Social Features weiterentwickelt

This commit is contained in:
2026-03-04 16:12:04 +01:00
parent 21c276e96f
commit 6b90d2d88a
222 changed files with 6809 additions and 12532 deletions

View File

@@ -50,6 +50,11 @@ public class SecurityConfig {
.requestMatchers(AntPathRequestMatcher.antMatcher("/freunde.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/nachrichten.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/benutzer.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/gruppen.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/gruppe.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/feed.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/gruppen/**")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/feed/**")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.html")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/css/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/js/**")).permitAll()

View File

@@ -0,0 +1,38 @@
package de.oaa.xxx.config;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.List;
@Converter
public class StringListConverter implements AttributeConverter<List<String>, String> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<String> list) {
if (list == null || list.isEmpty()) return null;
try {
return mapper.writeValueAsString(list);
} catch (Exception e) {
return null;
}
}
@Override
public List<String> convertToEntityAttribute(String json) {
if (json == null || json.isBlank()) return List.of();
try {
if (!json.startsWith("[")) {
// Legacy: single base64 string
return List.of(json);
}
return mapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return List.of();
}
}
}

View File

@@ -32,6 +32,10 @@ public class ThemeController {
@Value("${app.theme.color-success:#2ecc71}")
private String colorSuccess;
/** Mobile breakpoint in px (unitless integer). Used by sidebar.js and lightbox layout. */
@Value("${app.theme.breakpoint-mobile:768}")
private int breakpointMobile;
@GetMapping(value = "/css/variables.css", produces = "text/css")
public String variables() {
return """
@@ -43,7 +47,8 @@ public class ThemeController {
--color-text: %s;
--color-muted: %s;
--color-success: %s;
--breakpoint-mobile: %d;
}
""".formatted(colorBg, colorCard, colorPrimary, colorSecondary, colorText, colorMuted, colorSuccess);
""".formatted(colorBg, colorCard, colorPrimary, colorSecondary, colorText, colorMuted, colorSuccess, breakpointMobile);
}
}

View File

@@ -0,0 +1,405 @@
package de.oaa.xxx.feed;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.feed.dto.FeedItemDto;
import de.oaa.xxx.feed.dto.FeedPostRequest;
import de.oaa.xxx.feed.entity.FeedPostEntity;
import de.oaa.xxx.feed.entity.FeedPostLikeEntity;
import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
import de.oaa.xxx.feed.repository.FeedPostLikeRepository;
import de.oaa.xxx.feed.repository.FeedPostOptionRepository;
import de.oaa.xxx.feed.repository.FeedPostRepository;
import de.oaa.xxx.feed.repository.FeedPostVoteRepository;
import de.oaa.xxx.gruppe.BeitragTyp;
import de.oaa.xxx.gruppe.dto.UmfrageOptionDto;
import de.oaa.xxx.gruppe.entity.GruppenbeitragEntity;
import de.oaa.xxx.gruppe.entity.UmfrageStimmeEntity;
import de.oaa.xxx.gruppe.repository.GruppeRepository;
import de.oaa.xxx.gruppe.repository.GruppenbeitragLikeRepository;
import de.oaa.xxx.gruppe.repository.GruppenbeitragRepository;
import de.oaa.xxx.gruppe.repository.GruppenmitgliedRepository;
import de.oaa.xxx.gruppe.repository.UmfrageOptionRepository;
import de.oaa.xxx.gruppe.repository.UmfrageStimmeRepository;
import de.oaa.xxx.social.entity.FriendshipEntity;
import de.oaa.xxx.social.repository.FriendshipRepository;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
@RestController
@RequestMapping("/feed")
public class FeedController {
private final FeedPostRepository feedPostRepository;
private final FeedPostLikeRepository feedPostLikeRepository;
private final FeedPostOptionRepository feedPostOptionRepository;
private final FeedPostVoteRepository feedPostVoteRepository;
private final FriendshipRepository friendshipRepository;
private final GruppenmitgliedRepository mitgliedRepository;
private final GruppenbeitragRepository gruppenbeitragRepository;
private final UmfrageOptionRepository umfrageOptionRepository;
private final UmfrageStimmeRepository umfrageStimmeRepository;
private final GruppenbeitragLikeRepository gruppenbeitragLikeRepository;
private final GruppeRepository gruppeRepository;
private final KommentarRepository kommentarRepository;
private final UserRepository userRepository;
public FeedController(FeedPostRepository feedPostRepository,
FeedPostLikeRepository feedPostLikeRepository,
FeedPostOptionRepository feedPostOptionRepository,
FeedPostVoteRepository feedPostVoteRepository,
FriendshipRepository friendshipRepository,
GruppenmitgliedRepository mitgliedRepository,
GruppenbeitragRepository gruppenbeitragRepository,
UmfrageOptionRepository umfrageOptionRepository,
UmfrageStimmeRepository umfrageStimmeRepository,
GruppenbeitragLikeRepository gruppenbeitragLikeRepository,
GruppeRepository gruppeRepository,
KommentarRepository kommentarRepository,
UserRepository userRepository) {
this.feedPostRepository = feedPostRepository;
this.feedPostLikeRepository = feedPostLikeRepository;
this.feedPostOptionRepository = feedPostOptionRepository;
this.feedPostVoteRepository = feedPostVoteRepository;
this.friendshipRepository = friendshipRepository;
this.mitgliedRepository = mitgliedRepository;
this.gruppenbeitragRepository = gruppenbeitragRepository;
this.umfrageOptionRepository = umfrageOptionRepository;
this.umfrageStimmeRepository = umfrageStimmeRepository;
this.gruppenbeitragLikeRepository = gruppenbeitragLikeRepository;
this.gruppeRepository = gruppeRepository;
this.kommentarRepository = kommentarRepository;
this.userRepository = userRepository;
}
record FeedPage(List<FeedItemDto> posts, boolean hasMore) {}
record VoteRequest(UUID optionId) {}
// ── POST /feed/posts ──
@PostMapping("/posts")
public ResponseEntity<FeedItemDto> createPost(@RequestBody FeedPostRequest req, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
BeitragTyp typ;
try {
typ = BeitragTyp.valueOf(req.beitragTyp());
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
FeedPostEntity post = new FeedPostEntity();
post.setPostId(UUID.randomUUID());
post.setAuthorId(myId);
post.setText(req.text().trim());
post.setBeitragTyp(typ);
post.setMultiChoice(typ == BeitragTyp.UMFRAGE ? req.multiChoice() : null);
post.setBilder(req.bilder() != null ? req.bilder() : List.of());
post.setPublic(req.isPublic());
post.setCreatedAt(LocalDateTime.now());
feedPostRepository.save(post);
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
for (int i = 0; i < req.optionen().size(); i++) {
String optText = req.optionen().get(i);
if (optText == null || optText.isBlank()) continue;
FeedPostOptionEntity opt = new FeedPostOptionEntity();
opt.setOptionId(UUID.randomUUID());
opt.setPostId(post.getPostId());
opt.setText(optText.trim());
opt.setReihenfolge(i);
feedPostOptionRepository.save(opt);
}
}
return ResponseEntity.status(201).body(toFeedItemDtoFromPost(post, myId));
}
// ── GET /feed/mine ──
@GetMapping("/mine")
public ResponseEntity<FeedPage> getMyFeed(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
// Collect friend IDs
List<UUID> friendIds = friendshipRepository
.findFriends(myId, FriendshipEntity.Status.ACCEPTED)
.stream()
.map(f -> f.getSenderId().equals(myId) ? f.getReceiverId() : f.getSenderId())
.toList();
List<UUID> authorIds = new ArrayList<>(friendIds);
authorIds.add(myId);
// Collect group IDs
List<UUID> gruppeIds = mitgliedRepository.findByUserId(myId)
.stream()
.map(m -> m.getGruppeId())
.toList();
LocalDateTime since = LocalDateTime.now().minusDays(90);
// Fetch feed posts from friends + self
List<FeedPostEntity> feedPosts = feedPostRepository
.findByAuthorIdInAndCreatedAtAfterOrderByCreatedAtDesc(authorIds, since);
// Fetch gruppe posts
List<GruppenbeitragEntity> gruppePosts = gruppeIds.isEmpty() ? List.of() :
gruppenbeitragRepository.findByGruppeIdInAndCreatedAtAfterOrderByCreatedAtDesc(gruppeIds, since);
// Merge, convert, sort
List<FeedItemDto> merged = Stream.concat(
feedPosts.stream().map(p -> toFeedItemDtoFromPost(p, myId)),
gruppePosts.stream().map(b -> toFeedItemDtoFromGruppe(b, myId))
).sorted(Comparator.comparing(FeedItemDto::createdAt).reversed()).toList();
int from = page * size;
int to = Math.min(from + size, merged.size());
List<FeedItemDto> items = from < merged.size() ? merged.subList(from, to) : List.of();
boolean hasMore = to < merged.size();
return ResponseEntity.ok(new FeedPage(items, hasMore));
}
// ── GET /feed/public ──
@GetMapping("/public")
public ResponseEntity<FeedPage> getPublicFeed(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
Slice<FeedPostEntity> slice = feedPostRepository
.findByIsPublicTrueOrderByCreatedAtDesc(PageRequest.of(page, size));
List<FeedItemDto> items = slice.getContent().stream()
.map(p -> toFeedItemDtoFromPost(p, myId))
.toList();
return ResponseEntity.ok(new FeedPage(items, slice.hasNext()));
}
// ── GET /feed/user/{userId} ──
@GetMapping("/user/{userId}")
public ResponseEntity<FeedPage> getUserPosts(@PathVariable UUID userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
PageRequest pageable = PageRequest.of(page, size);
List<FeedPostEntity> posts;
if (myId.equals(userId)) {
posts = feedPostRepository.findByAuthorIdOrderByCreatedAtDesc(userId, pageable);
} else {
posts = feedPostRepository.findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(userId, pageable);
}
// Check if there's a next page
PageRequest nextPageable = PageRequest.of(page + 1, size);
List<FeedPostEntity> nextPage;
if (myId.equals(userId)) {
nextPage = feedPostRepository.findByAuthorIdOrderByCreatedAtDesc(userId, nextPageable);
} else {
nextPage = feedPostRepository.findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(userId, nextPageable);
}
List<FeedItemDto> items = posts.stream()
.map(p -> toFeedItemDtoFromPost(p, myId))
.toList();
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
}
// ── POST /feed/posts/{id}/like ──
@PostMapping("/posts/{id}/like")
public ResponseEntity<Void> toggleLike(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (feedPostRepository.findById(id).isEmpty()) return ResponseEntity.notFound().build();
var existing = feedPostLikeRepository.findByPostIdAndUserId(id, myId);
if (existing.isPresent()) {
feedPostLikeRepository.delete(existing.get());
} else {
FeedPostLikeEntity like = new FeedPostLikeEntity();
like.setLikeId(UUID.randomUUID());
like.setPostId(id);
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
feedPostLikeRepository.save(like);
}
return ResponseEntity.ok().build();
}
// ── POST /feed/posts/{id}/vote ──
@PostMapping("/posts/{id}/vote")
public ResponseEntity<Void> vote(@PathVariable UUID id,
@RequestBody VoteRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
var postOpt = feedPostRepository.findById(id);
if (postOpt.isEmpty()) return ResponseEntity.notFound().build();
FeedPostEntity post = postOpt.get();
var optOpt = feedPostOptionRepository.findById(req.optionId());
if (optOpt.isEmpty() || !optOpt.get().getPostId().equals(id))
return ResponseEntity.badRequest().build();
boolean isMultiChoice = Boolean.TRUE.equals(post.getMultiChoice());
var existingVote = feedPostVoteRepository.findByOptionIdAndUserId(req.optionId(), myId);
if (existingVote.isPresent()) {
feedPostVoteRepository.delete(existingVote.get());
return ResponseEntity.ok().build();
}
if (!isMultiChoice) {
List<FeedPostVoteEntity> existing = feedPostVoteRepository.findByPostIdAndUserId(id, myId);
feedPostVoteRepository.deleteAll(existing);
}
FeedPostVoteEntity vote = new FeedPostVoteEntity();
vote.setStimmeId(UUID.randomUUID());
vote.setOptionId(req.optionId());
vote.setPostId(id);
vote.setUserId(myId);
feedPostVoteRepository.save(vote);
return ResponseEntity.ok().build();
}
// ── DELETE /feed/posts/{id} ──
@DeleteMapping("/posts/{id}")
public ResponseEntity<Void> deletePost(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
var postOpt = feedPostRepository.findById(id);
if (postOpt.isEmpty()) return ResponseEntity.notFound().build();
FeedPostEntity post = postOpt.get();
if (!post.getAuthorId().equals(myId)) return ResponseEntity.status(403).build();
feedPostVoteRepository.deleteByPostId(id);
feedPostOptionRepository.deleteByPostId(id);
feedPostLikeRepository.deleteByPostId(id);
var kommentare = kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("FEED_POST", id);
kommentarRepository.deleteAll(kommentare);
feedPostRepository.delete(post);
return ResponseEntity.noContent().build();
}
// ── Helpers ──
private UUID resolveMyId(Principal principal) {
if (principal == null) return null;
return userRepository.findByEmail(principal.getName())
.map(UserEntity::getUserId)
.orElse(null);
}
private FeedItemDto toFeedItemDtoFromPost(FeedPostEntity p, UUID myId) {
UserEntity author = userRepository.findById(p.getAuthorId()).orElse(null);
long likeCount = feedPostLikeRepository.countByPostId(p.getPostId());
boolean likedByMe = feedPostLikeRepository.findByPostIdAndUserId(p.getPostId(), myId).isPresent();
long kommentarCount = kommentarRepository.countByTargetTypeAndTargetId("FEED_POST", p.getPostId());
List<UmfrageOptionDto> optionen = List.of();
List<UUID> myVoteOptionIds = List.of();
if (p.getBeitragTyp() == BeitragTyp.UMFRAGE) {
optionen = feedPostOptionRepository.findByPostIdOrderByReihenfolge(p.getPostId())
.stream()
.map(o -> new UmfrageOptionDto(o.getOptionId(), o.getText(), o.getReihenfolge(),
feedPostVoteRepository.countByOptionId(o.getOptionId())))
.toList();
myVoteOptionIds = feedPostVoteRepository.findByPostIdAndUserId(p.getPostId(), myId)
.stream()
.map(FeedPostVoteEntity::getOptionId)
.toList();
}
return new FeedItemDto(
p.getPostId(), "FEED",
null, null,
p.getAuthorId(),
author != null ? author.getName() : "Unbekannt",
author != null ? author.getProfilePicture() : null,
p.getBeitragTyp().name(), p.getText(), p.getMultiChoice(), p.getBilder(),
p.getCreatedAt(),
likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds,
p.isPublic()
);
}
private FeedItemDto toFeedItemDtoFromGruppe(GruppenbeitragEntity b, UUID myId) {
UserEntity author = userRepository.findById(b.getAuthorId()).orElse(null);
long likeCount = gruppenbeitragLikeRepository.countByBeitragId(b.getBeitragId());
boolean likedByMe = gruppenbeitragLikeRepository.findByBeitragIdAndUserId(b.getBeitragId(), myId).isPresent();
long kommentarCount = kommentarRepository.countByTargetTypeAndTargetId("GROUP_POST", b.getBeitragId());
String gruppeName = gruppeRepository.findById(b.getGruppeId())
.map(g -> g.getName())
.orElse("Gruppe");
List<UmfrageOptionDto> optionen = List.of();
List<UUID> myVoteOptionIds = List.of();
if (b.getBeitragTyp() == BeitragTyp.UMFRAGE) {
optionen = umfrageOptionRepository.findByBeitragIdOrderByReihenfolge(b.getBeitragId())
.stream()
.map(o -> new UmfrageOptionDto(o.getOptionId(), o.getText(), o.getReihenfolge(),
umfrageStimmeRepository.countByOptionId(o.getOptionId())))
.toList();
myVoteOptionIds = umfrageStimmeRepository.findByBeitragIdAndUserId(b.getBeitragId(), myId)
.stream()
.map(UmfrageStimmeEntity::getOptionId)
.toList();
}
return new FeedItemDto(
b.getBeitragId(), "GROUP",
b.getGruppeId(), gruppeName,
b.getAuthorId(),
author != null ? author.getName() : "Unbekannt",
author != null ? author.getProfilePicture() : null,
b.getBeitragTyp().name(), b.getText(), b.getMultiChoice(), b.getBilder(),
b.getCreatedAt(),
likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds,
false
);
}
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.feed.dto;
import de.oaa.xxx.gruppe.dto.UmfrageOptionDto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public record FeedItemDto(
UUID postId,
String postType, // "FEED" | "GROUP"
UUID gruppeId,
String gruppeName,
UUID authorId,
String authorName,
String authorPicture,
String beitragTyp,
String text,
Boolean multiChoice,
List<String> bilder,
LocalDateTime createdAt,
long likeCount,
boolean likedByMe,
long kommentarCount,
List<UmfrageOptionDto> optionen,
List<UUID> myVoteOptionIds,
boolean isPublic
) {}

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.feed.dto;
import java.util.List;
public record FeedPostRequest(
String beitragTyp,
String text,
Boolean multiChoice,
List<String> optionen,
List<String> bilder,
boolean isPublic
) {}

View File

@@ -0,0 +1,64 @@
package de.oaa.xxx.feed.entity;
import de.oaa.xxx.config.StringListConverter;
import de.oaa.xxx.gruppe.BeitragTyp;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "feed_post")
public class FeedPostEntity {
@Id
@Column
private UUID postId;
@Column(nullable = false)
private UUID authorId;
@Column(columnDefinition = "TEXT")
private String text;
@Convert(converter = StringListConverter.class)
@Column(name = "bild", columnDefinition = "MEDIUMTEXT")
private List<String> bilder;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private BeitragTyp beitragTyp;
@Column
private Boolean multiChoice;
@Column(nullable = false)
private boolean isPublic;
@Column(nullable = false)
private LocalDateTime createdAt;
public UUID getPostId() { return postId; }
public void setPostId(UUID postId) { this.postId = postId; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public List<String> getBilder() { return bilder; }
public void setBilder(List<String> bilder) { this.bilder = bilder; }
public BeitragTyp getBeitragTyp() { return beitragTyp; }
public void setBeitragTyp(BeitragTyp beitragTyp) { this.beitragTyp = beitragTyp; }
public Boolean getMultiChoice() { return multiChoice; }
public void setMultiChoice(Boolean multiChoice) { this.multiChoice = multiChoice; }
public boolean isPublic() { return isPublic; }
public void setPublic(boolean aPublic) { isPublic = aPublic; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

View File

@@ -0,0 +1,37 @@
package de.oaa.xxx.feed.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "feed_post_like", uniqueConstraints = {
@UniqueConstraint(columnNames = {"postId", "userId"})
})
public class FeedPostLikeEntity {
@Id
@Column
private UUID likeId;
@Column(nullable = false)
private UUID postId;
@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 getPostId() { return postId; }
public void setPostId(UUID postId) { this.postId = postId; }
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,34 @@
package de.oaa.xxx.feed.entity;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "feed_post_option")
public class FeedPostOptionEntity {
@Id
@Column
private UUID optionId;
@Column(nullable = false)
private UUID postId;
@Column(nullable = false)
private String text;
@Column(nullable = false)
private int reihenfolge;
public UUID getOptionId() { return optionId; }
public void setOptionId(UUID optionId) { this.optionId = optionId; }
public UUID getPostId() { return postId; }
public void setPostId(UUID postId) { this.postId = postId; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public int getReihenfolge() { return reihenfolge; }
public void setReihenfolge(int reihenfolge) { this.reihenfolge = reihenfolge; }
}

View File

@@ -0,0 +1,34 @@
package de.oaa.xxx.feed.entity;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "feed_post_vote")
public class FeedPostVoteEntity {
@Id
@Column
private UUID stimmeId;
@Column(nullable = false)
private UUID optionId;
@Column(nullable = false)
private UUID postId;
@Column(nullable = false)
private UUID userId;
public UUID getStimmeId() { return stimmeId; }
public void setStimmeId(UUID stimmeId) { this.stimmeId = stimmeId; }
public UUID getOptionId() { return optionId; }
public void setOptionId(UUID optionId) { this.optionId = optionId; }
public UUID getPostId() { return postId; }
public void setPostId(UUID postId) { this.postId = postId; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
}

View File

@@ -0,0 +1,18 @@
package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostLikeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import java.util.UUID;
public interface FeedPostLikeRepository extends JpaRepository<FeedPostLikeEntity, UUID> {
Optional<FeedPostLikeEntity> findByPostIdAndUserId(UUID postId, UUID userId);
long countByPostId(UUID postId);
@Transactional
void deleteByPostId(UUID postId);
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
public interface FeedPostOptionRepository extends JpaRepository<FeedPostOptionEntity, UUID> {
List<FeedPostOptionEntity> findByPostIdOrderByReihenfolge(UUID postId);
@Transactional
void deleteByPostId(UUID postId);
}

View File

@@ -0,0 +1,25 @@
package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface FeedPostRepository extends JpaRepository<FeedPostEntity, UUID> {
Slice<FeedPostEntity> findByIsPublicTrueOrderByCreatedAtDesc(Pageable pageable);
List<FeedPostEntity> findByAuthorIdInAndCreatedAtAfterOrderByCreatedAtDesc(List<UUID> authorIds, LocalDateTime since);
List<FeedPostEntity> findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(UUID authorId, Pageable pageable);
List<FeedPostEntity> findByAuthorIdOrderByCreatedAtDesc(UUID authorId, Pageable pageable);
@Transactional
void deleteByAuthorId(UUID authorId);
}

View File

@@ -0,0 +1,21 @@
package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface FeedPostVoteRepository extends JpaRepository<FeedPostVoteEntity, UUID> {
List<FeedPostVoteEntity> findByPostIdAndUserId(UUID postId, UUID userId);
Optional<FeedPostVoteEntity> findByOptionIdAndUserId(UUID optionId, UUID userId);
long countByOptionId(UUID optionId);
@Transactional
void deleteByPostId(UUID postId);
}

View File

@@ -0,0 +1,5 @@
package de.oaa.xxx.gruppe;
public enum AnfrageStatus {
AUSSTEHEND, GENEHMIGT, ABGELEHNT
}

View File

@@ -0,0 +1,5 @@
package de.oaa.xxx.gruppe;
public enum BeitragTyp {
TEXT, UMFRAGE
}

View File

@@ -0,0 +1,482 @@
package de.oaa.xxx.gruppe;
import de.oaa.xxx.gruppe.dto.*;
import de.oaa.xxx.gruppe.entity.*;
import de.oaa.xxx.gruppe.repository.*;
import de.oaa.xxx.social.entity.KommentarEntity;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
@RestController
@RequestMapping("/gruppen")
public class GruppeController {
private final GruppeRepository gruppeRepository;
private final GruppenmitgliedRepository mitgliedRepository;
private final BeitrittsanfrageRepository anfrageRepository;
private final GruppenbeitragRepository beitragRepository;
private final UmfrageOptionRepository optionRepository;
private final UmfrageStimmeRepository stimmeRepository;
private final GruppenbeitragLikeRepository likeRepository;
private final BeitragMeldungRepository meldungRepository;
private final KommentarRepository kommentarRepository;
private final UserRepository userRepository;
public GruppeController(GruppeRepository gruppeRepository,
GruppenmitgliedRepository mitgliedRepository,
BeitrittsanfrageRepository anfrageRepository,
GruppenbeitragRepository beitragRepository,
UmfrageOptionRepository optionRepository,
UmfrageStimmeRepository stimmeRepository,
GruppenbeitragLikeRepository likeRepository,
BeitragMeldungRepository meldungRepository,
KommentarRepository kommentarRepository,
UserRepository userRepository) {
this.gruppeRepository = gruppeRepository;
this.mitgliedRepository = mitgliedRepository;
this.anfrageRepository = anfrageRepository;
this.beitragRepository = beitragRepository;
this.optionRepository = optionRepository;
this.stimmeRepository = stimmeRepository;
this.likeRepository = likeRepository;
this.meldungRepository = meldungRepository;
this.kommentarRepository = kommentarRepository;
this.userRepository = userRepository;
}
record CreateGruppeRequest(String name, String beschreibung, String bild, boolean isPrivate) {}
record JoinRequest(String nachricht) {}
record UpdateGruppeRequest(String name, String beschreibung, String bild, Boolean isPrivate) {}
// ── GET /gruppe/search?q= ──
@GetMapping("/search")
public ResponseEntity<List<GruppeDto>> search(@RequestParam String q, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
List<GruppeDto> result = gruppeRepository.findByNameContainingIgnoreCase(q)
.stream()
.limit(30)
.map(g -> toDto(g, myId))
.sorted((a, b) -> {
LocalDateTime la = beitragRepository.findFirstByGruppeIdOrderByCreatedAtDesc(a.gruppeId())
.map(GruppenbeitragEntity::getCreatedAt).orElse(a.createdAt());
LocalDateTime lb = beitragRepository.findFirstByGruppeIdOrderByCreatedAtDesc(b.gruppeId())
.map(GruppenbeitragEntity::getCreatedAt).orElse(b.createdAt());
return lb.compareTo(la);
})
.toList();
return ResponseEntity.ok(result);
}
// ── GET /gruppe/mine ──
@GetMapping("/mine")
public ResponseEntity<List<GruppeDto>> mine(Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
List<GruppeDto> result = mitgliedRepository.findByUserId(myId)
.stream()
.map(m -> gruppeRepository.findById(m.getGruppeId()).orElse(null))
.filter(Objects::nonNull)
.map(g -> toDto(g, myId))
.sorted((a, b) -> {
LocalDateTime la = beitragRepository.findFirstByGruppeIdOrderByCreatedAtDesc(a.gruppeId())
.map(GruppenbeitragEntity::getCreatedAt).orElse(a.createdAt());
LocalDateTime lb = beitragRepository.findFirstByGruppeIdOrderByCreatedAtDesc(b.gruppeId())
.map(GruppenbeitragEntity::getCreatedAt).orElse(b.createdAt());
return lb.compareTo(la);
})
.toList();
return ResponseEntity.ok(result);
}
// ── GET /gruppe/{id} ──
@GetMapping("/{id}")
public ResponseEntity<GruppeDto> getGruppe(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
return gruppeRepository.findById(id)
.map(g -> ResponseEntity.ok(toDto(g, myId)))
.orElse(ResponseEntity.notFound().build());
}
// ── POST /gruppe ──
@PostMapping
public ResponseEntity<GruppeDto> createGruppe(@RequestBody CreateGruppeRequest req, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build();
GruppeEntity gruppe = new GruppeEntity();
gruppe.setGruppeId(UUID.randomUUID());
gruppe.setName(req.name().trim());
gruppe.setBeschreibung(req.beschreibung());
gruppe.setBild(req.bild());
gruppe.setPrivate(req.isPrivate());
gruppe.setCreatedAt(LocalDateTime.now());
gruppe.setCreatedByUserId(myId);
gruppeRepository.save(gruppe);
GruppenmitgliedEntity admin = new GruppenmitgliedEntity();
admin.setMitgliedId(UUID.randomUUID());
admin.setGruppeId(gruppe.getGruppeId());
admin.setUserId(myId);
admin.setRolle(GruppenRolle.ADMIN);
admin.setJoinedAt(LocalDateTime.now());
mitgliedRepository.save(admin);
return ResponseEntity.status(201).body(toDto(gruppe, myId));
}
// ── PUT /gruppe/{id} ──
@PutMapping("/{id}")
public ResponseEntity<GruppeDto> updateGruppe(@PathVariable UUID id,
@RequestBody UpdateGruppeRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
var gruppeOpt = gruppeRepository.findById(id);
if (gruppeOpt.isEmpty()) return ResponseEntity.notFound().build();
GruppeEntity gruppe = gruppeOpt.get();
if (!isAdmin(id, myId)) return ResponseEntity.status(403).build();
if (req.name() != null && !req.name().isBlank()) gruppe.setName(req.name().trim());
if (req.beschreibung() != null) gruppe.setBeschreibung(req.beschreibung());
if (req.bild() != null) gruppe.setBild(req.bild());
if (req.isPrivate() != null) gruppe.setPrivate(req.isPrivate());
gruppeRepository.save(gruppe);
return ResponseEntity.ok(toDto(gruppe, myId));
}
// ── DELETE /gruppe/{id} ──
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteGruppe(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
var gruppeOpt = gruppeRepository.findById(id);
if (gruppeOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!isAdmin(id, myId)) return ResponseEntity.status(403).build();
// Cascade delete
List<GruppenbeitragEntity> beitraege = beitragRepository.findByGruppeIdOrderByCreatedAtDesc(id);
for (GruppenbeitragEntity b : beitraege) {
UUID bid = b.getBeitragId();
meldungRepository.deleteByBeitragId(bid);
stimmeRepository.deleteByBeitragId(bid);
optionRepository.deleteByBeitragId(bid);
likeRepository.deleteByBeitragId(bid);
// Kommentare on GROUP_POST
List<KommentarEntity> kommentare = kommentarRepository
.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("GROUP_POST", bid);
for (KommentarEntity k : kommentare) {
kommentarRepository.delete(k);
}
}
beitragRepository.deleteByGruppeId(id);
anfrageRepository.deleteByGruppeId(id);
mitgliedRepository.deleteByGruppeId(id);
gruppeRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
// ── POST /gruppe/{id}/join ──
@PostMapping("/{id}/join")
public ResponseEntity<Void> join(@PathVariable UUID id,
@RequestBody(required = false) JoinRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
var gruppeOpt = gruppeRepository.findById(id);
if (gruppeOpt.isEmpty()) return ResponseEntity.notFound().build();
GruppeEntity gruppe = gruppeOpt.get();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isPresent())
return ResponseEntity.status(409).build();
if (gruppe.isPrivate()) {
// Check no pending request already
var existingReq = anfrageRepository.findByGruppeIdAndUserId(id, myId);
if (existingReq.isPresent() && existingReq.get().getStatus() == AnfrageStatus.AUSSTEHEND)
return ResponseEntity.status(409).build();
BeitrittsanfrageEntity anfrage = new BeitrittsanfrageEntity();
anfrage.setAnfrageId(UUID.randomUUID());
anfrage.setGruppeId(id);
anfrage.setUserId(myId);
anfrage.setNachricht(req != null ? req.nachricht() : null);
anfrage.setAngefragtAt(LocalDateTime.now());
anfrage.setStatus(AnfrageStatus.AUSSTEHEND);
anfrageRepository.save(anfrage);
return ResponseEntity.status(201).build();
} else {
GruppenmitgliedEntity mitglied = new GruppenmitgliedEntity();
mitglied.setMitgliedId(UUID.randomUUID());
mitglied.setGruppeId(id);
mitglied.setUserId(myId);
mitglied.setRolle(GruppenRolle.MITGLIED);
mitglied.setJoinedAt(LocalDateTime.now());
mitgliedRepository.save(mitglied);
return ResponseEntity.status(201).build();
}
}
// ── DELETE /gruppe/{id}/leave ──
@DeleteMapping("/{id}/leave")
public ResponseEntity<Void> leave(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
mitgliedRepository.deleteByGruppeIdAndUserId(id, myId);
return ResponseEntity.noContent().build();
}
// ── GET /gruppe/{id}/members ──
@GetMapping("/{id}/members")
public ResponseEntity<List<Map<String, Object>>> getMembers(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty())
return ResponseEntity.status(403).build();
List<Map<String, Object>> result = mitgliedRepository.findByGruppeId(id).stream()
.map(m -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("mitgliedId", m.getMitgliedId());
map.put("userId", m.getUserId());
map.put("rolle", m.getRolle().name());
map.put("joinedAt", m.getJoinedAt());
userRepository.findById(m.getUserId()).ifPresent(u -> {
map.put("userName", u.getName());
map.put("userPicture", u.getProfilePicture());
});
return map;
})
.toList();
return ResponseEntity.ok(result);
}
// ── DELETE /gruppe/{id}/members/{userId} ──
@DeleteMapping("/{id}/members/{userId}")
public ResponseEntity<Void> removeMember(@PathVariable UUID id,
@PathVariable UUID userId,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (!isAdmin(id, myId)) return ResponseEntity.status(403).build();
mitgliedRepository.deleteByGruppeIdAndUserId(id, userId);
return ResponseEntity.noContent().build();
}
// ── POST /gruppe/{id}/members/{userId}/promote ──
@PostMapping("/{id}/members/{userId}/promote")
public ResponseEntity<Void> promote(@PathVariable UUID id,
@PathVariable UUID userId,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (!isAdmin(id, myId)) return ResponseEntity.status(403).build();
var m = mitgliedRepository.findFirstByGruppeIdAndUserId(id, userId);
if (m.isEmpty()) return ResponseEntity.notFound().build();
m.get().setRolle(GruppenRolle.ADMIN);
mitgliedRepository.save(m.get());
return ResponseEntity.ok().build();
}
// ── GET /gruppe/{id}/requests ──
@GetMapping("/{id}/requests")
public ResponseEntity<List<BeitrittsanfrageDto>> getRequests(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (!isAdmin(id, myId)) return ResponseEntity.status(403).build();
List<BeitrittsanfrageDto> dtos = anfrageRepository
.findByGruppeIdAndStatus(id, AnfrageStatus.AUSSTEHEND)
.stream()
.map(a -> {
UserEntity u = userRepository.findById(a.getUserId()).orElse(null);
return new BeitrittsanfrageDto(a.getAnfrageId(), a.getGruppeId(), a.getUserId(),
u != null ? u.getName() : "Unbekannt",
u != null ? u.getProfilePicture() : null,
a.getNachricht(), a.getAngefragtAt());
})
.toList();
return ResponseEntity.ok(dtos);
}
// ── POST /gruppe/{id}/requests/{reqId}/approve ──
@PostMapping("/{id}/requests/{reqId}/approve")
public ResponseEntity<Void> approveRequest(@PathVariable UUID id,
@PathVariable UUID reqId,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (!isAdmin(id, myId)) return ResponseEntity.status(403).build();
var anfOpt = anfrageRepository.findById(reqId);
if (anfOpt.isEmpty()) return ResponseEntity.notFound().build();
BeitrittsanfrageEntity anfrage = anfOpt.get();
anfrage.setStatus(AnfrageStatus.GENEHMIGT);
anfrageRepository.save(anfrage);
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, anfrage.getUserId()).isEmpty()) {
GruppenmitgliedEntity mitglied = new GruppenmitgliedEntity();
mitglied.setMitgliedId(UUID.randomUUID());
mitglied.setGruppeId(id);
mitglied.setUserId(anfrage.getUserId());
mitglied.setRolle(GruppenRolle.MITGLIED);
mitglied.setJoinedAt(LocalDateTime.now());
mitgliedRepository.save(mitglied);
}
return ResponseEntity.ok().build();
}
// ── DELETE /gruppe/{id}/requests/{reqId} ──
@DeleteMapping("/{id}/requests/{reqId}")
public ResponseEntity<Void> rejectRequest(@PathVariable UUID id,
@PathVariable UUID reqId,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (!isAdmin(id, myId)) return ResponseEntity.status(403).build();
var anfOpt = anfrageRepository.findById(reqId);
if (anfOpt.isEmpty()) return ResponseEntity.notFound().build();
BeitrittsanfrageEntity anfrage = anfOpt.get();
anfrage.setStatus(AnfrageStatus.ABGELEHNT);
anfrageRepository.save(anfrage);
return ResponseEntity.noContent().build();
}
// ── GET /gruppen/reports/pending/count ──
@GetMapping("/reports/pending/count")
public ResponseEntity<Long> pendingReportsCount(Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
long count = mitgliedRepository.findByUserId(myId).stream()
.filter(m -> m.getRolle() == GruppenRolle.ADMIN)
.mapToLong(m -> {
List<GruppenbeitragEntity> beitraege = beitragRepository.findByGruppeIdOrderByCreatedAtDesc(m.getGruppeId());
List<UUID> ids = beitraege.stream().map(GruppenbeitragEntity::getBeitragId).toList();
return ids.isEmpty() ? 0L : meldungRepository.findByBeitragIdIn(ids).size();
})
.sum();
return ResponseEntity.ok(count);
}
// ── GET /gruppen/requests/pending/count ──
@GetMapping("/requests/pending/count")
public ResponseEntity<Long> pendingRequestCount(Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
long count = mitgliedRepository.findByUserId(myId).stream()
.filter(m -> m.getRolle() == GruppenRolle.ADMIN)
.mapToLong(m -> anfrageRepository.findByGruppeIdAndStatus(m.getGruppeId(), AnfrageStatus.AUSSTEHEND).size())
.sum();
return ResponseEntity.ok(count);
}
// ── GET /gruppe/requests/mine ──
@GetMapping("/requests/mine")
public ResponseEntity<List<Map<String, Object>>> myRequests(Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
List<Map<String, Object>> result = anfrageRepository.findByUserIdAndStatus(myId, AnfrageStatus.AUSSTEHEND)
.stream()
.map(a -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("anfrageId", a.getAnfrageId());
map.put("gruppeId", a.getGruppeId());
map.put("nachricht", a.getNachricht());
map.put("angefragtAt", a.getAngefragtAt());
gruppeRepository.findById(a.getGruppeId()).ifPresent(g -> map.put("gruppeName", g.getName()));
return map;
})
.toList();
return ResponseEntity.ok(result);
}
// ── DELETE /gruppe/{id}/requests/mine (withdraw own request) ──
@DeleteMapping("/{id}/requests/mine")
public ResponseEntity<Void> withdrawRequest(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
anfrageRepository.findByGruppeIdAndUserId(id, myId).ifPresent(a -> {
if (a.getStatus() == AnfrageStatus.AUSSTEHEND) {
anfrageRepository.delete(a);
}
});
return ResponseEntity.noContent().build();
}
// ── Helpers ──
private UUID resolveMyId(Principal principal) {
if (principal == null) return null;
return userRepository.findByEmail(principal.getName())
.map(UserEntity::getUserId)
.orElse(null);
}
private boolean isAdmin(UUID gruppeId, UUID userId) {
return mitgliedRepository.findFirstByGruppeIdAndUserId(gruppeId, userId)
.map(m -> m.getRolle() == GruppenRolle.ADMIN)
.orElse(false);
}
GruppeDto toDto(GruppeEntity g, UUID myId) {
long memberCount = mitgliedRepository.countByGruppeId(g.getGruppeId());
long postCount = beitragRepository.findByGruppeIdOrderByCreatedAtDesc(g.getGruppeId()).size();
String myRole = mitgliedRepository.findFirstByGruppeIdAndUserId(g.getGruppeId(), myId)
.map(m -> m.getRolle().name())
.orElse(null);
String myRequestStatus = null;
if (myRole == null) {
myRequestStatus = anfrageRepository.findByGruppeIdAndUserId(g.getGruppeId(), myId)
.filter(a -> a.getStatus() == AnfrageStatus.AUSSTEHEND)
.map(a -> a.getStatus().name())
.orElse(null);
}
return new GruppeDto(g.getGruppeId(), g.getName(), g.getBeschreibung(), g.getBild(),
g.isPrivate(), g.getCreatedAt(), memberCount, postCount, myRole, myRequestStatus);
}
}

View File

@@ -0,0 +1,5 @@
package de.oaa.xxx.gruppe;
public enum GruppenRolle {
ADMIN, MITGLIED
}

View File

@@ -0,0 +1,348 @@
package de.oaa.xxx.gruppe;
import de.oaa.xxx.gruppe.dto.*;
import de.oaa.xxx.gruppe.entity.*;
import de.oaa.xxx.gruppe.repository.*;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
@RestController
public class GruppenbeitragController {
private final GruppeRepository gruppeRepository;
private final GruppenmitgliedRepository mitgliedRepository;
private final GruppenbeitragRepository beitragRepository;
private final UmfrageOptionRepository optionRepository;
private final UmfrageStimmeRepository stimmeRepository;
private final GruppenbeitragLikeRepository likeRepository;
private final BeitragMeldungRepository meldungRepository;
private final KommentarRepository kommentarRepository;
private final UserRepository userRepository;
public GruppenbeitragController(GruppeRepository gruppeRepository,
GruppenmitgliedRepository mitgliedRepository,
GruppenbeitragRepository beitragRepository,
UmfrageOptionRepository optionRepository,
UmfrageStimmeRepository stimmeRepository,
GruppenbeitragLikeRepository likeRepository,
BeitragMeldungRepository meldungRepository,
KommentarRepository kommentarRepository,
UserRepository userRepository) {
this.gruppeRepository = gruppeRepository;
this.mitgliedRepository = mitgliedRepository;
this.beitragRepository = beitragRepository;
this.optionRepository = optionRepository;
this.stimmeRepository = stimmeRepository;
this.likeRepository = likeRepository;
this.meldungRepository = meldungRepository;
this.kommentarRepository = kommentarRepository;
this.userRepository = userRepository;
}
record CreateBeitragRequest(String beitragTyp, String text, Boolean multiChoice, List<String> optionen, List<String> bilder) {}
record PostsPage(List<GruppenbeitragDto> posts, boolean hasMore) {}
record VoteRequest(UUID optionId) {}
record ReportRequest(String grund) {}
// ── GET /gruppen/{id}/posts/{postId} ──
@GetMapping("/gruppen/{id}/posts/{postId}")
public ResponseEntity<GruppenbeitragDto> getPost(@PathVariable UUID id,
@PathVariable UUID postId,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty())
return ResponseEntity.status(403).build();
return beitragRepository.findById(postId)
.map(b -> ResponseEntity.ok(toDto(b, myId)))
.orElse(ResponseEntity.notFound().build());
}
// ── GET /gruppen/{id}/posts ──
@GetMapping("/gruppen/{id}/posts")
public ResponseEntity<PostsPage> getPosts(@PathVariable UUID id,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty())
return ResponseEntity.status(403).build();
Slice<GruppenbeitragEntity> slice = beitragRepository
.findByGruppeIdOrderByCreatedAtDesc(id, PageRequest.of(page, size));
List<GruppenbeitragDto> posts = slice.getContent().stream()
.map(b -> toDto(b, myId))
.toList();
return ResponseEntity.ok(new PostsPage(posts, slice.hasNext()));
}
// ── POST /gruppen/{id}/posts ──
@PostMapping("/gruppen/{id}/posts")
public ResponseEntity<GruppenbeitragDto> createPost(@PathVariable UUID id,
@RequestBody CreateBeitragRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty())
return ResponseEntity.status(403).build();
if (gruppeRepository.findById(id).isEmpty()) return ResponseEntity.notFound().build();
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
BeitragTyp typ;
try {
typ = BeitragTyp.valueOf(req.beitragTyp());
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
GruppenbeitragEntity beitrag = new GruppenbeitragEntity();
beitrag.setBeitragId(UUID.randomUUID());
beitrag.setGruppeId(id);
beitrag.setAuthorId(myId);
beitrag.setBeitragTyp(typ);
beitrag.setText(req.text().trim());
beitrag.setMultiChoice(typ == BeitragTyp.UMFRAGE ? req.multiChoice() : null);
beitrag.setBilder(req.bilder() != null ? req.bilder() : List.of());
beitrag.setCreatedAt(LocalDateTime.now());
beitragRepository.save(beitrag);
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
for (int i = 0; i < req.optionen().size(); i++) {
String optText = req.optionen().get(i);
if (optText == null || optText.isBlank()) continue;
UmfrageOptionEntity opt = new UmfrageOptionEntity();
opt.setOptionId(UUID.randomUUID());
opt.setBeitragId(beitrag.getBeitragId());
opt.setText(optText.trim());
opt.setReihenfolge(i);
optionRepository.save(opt);
}
}
return ResponseEntity.status(201).body(toDto(beitrag, myId));
}
// ── DELETE /gruppen/{id}/posts/{postId} ──
@DeleteMapping("/gruppen/{id}/posts/{postId}")
public ResponseEntity<Void> deletePost(@PathVariable UUID id,
@PathVariable UUID postId,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
var bOpt = beitragRepository.findById(postId);
if (bOpt.isEmpty()) return ResponseEntity.notFound().build();
GruppenbeitragEntity beitrag = bOpt.get();
boolean isAuthor = beitrag.getAuthorId().equals(myId);
boolean isAdmin = mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId)
.map(m -> m.getRolle() == GruppenRolle.ADMIN)
.orElse(false);
if (!isAuthor && !isAdmin) return ResponseEntity.status(403).build();
deleteBeitragCascade(beitrag);
return ResponseEntity.noContent().build();
}
// ── POST /gruppen/{id}/posts/{postId}/like ──
@PostMapping("/gruppen/{id}/posts/{postId}/like")
public ResponseEntity<Void> toggleLike(@PathVariable UUID id,
@PathVariable UUID postId,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty())
return ResponseEntity.status(403).build();
var existing = likeRepository.findByBeitragIdAndUserId(postId, myId);
if (existing.isPresent()) {
likeRepository.delete(existing.get());
} else {
GruppenbeitragLikeEntity like = new GruppenbeitragLikeEntity();
like.setLikeId(UUID.randomUUID());
like.setBeitragId(postId);
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
likeRepository.save(like);
}
return ResponseEntity.ok().build();
}
// ── POST /gruppen/{id}/posts/{postId}/vote ──
@PostMapping("/gruppen/{id}/posts/{postId}/vote")
public ResponseEntity<Void> vote(@PathVariable UUID id,
@PathVariable UUID postId,
@RequestBody VoteRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty())
return ResponseEntity.status(403).build();
var bOpt = beitragRepository.findById(postId);
if (bOpt.isEmpty()) return ResponseEntity.notFound().build();
GruppenbeitragEntity beitrag = bOpt.get();
var optOpt = optionRepository.findById(req.optionId());
if (optOpt.isEmpty() || !optOpt.get().getBeitragId().equals(postId))
return ResponseEntity.badRequest().build();
boolean isMultiChoice = Boolean.TRUE.equals(beitrag.getMultiChoice());
var existingVote = stimmeRepository.findByOptionIdAndUserId(req.optionId(), myId);
if (existingVote.isPresent()) {
// Toggle: remove vote
stimmeRepository.delete(existingVote.get());
return ResponseEntity.ok().build();
}
if (!isMultiChoice) {
// Single-choice: remove all existing votes on this poll
List<UmfrageStimmeEntity> existing = stimmeRepository.findByBeitragIdAndUserId(postId, myId);
stimmeRepository.deleteAll(existing);
}
UmfrageStimmeEntity stimme = new UmfrageStimmeEntity();
stimme.setStimmeId(UUID.randomUUID());
stimme.setOptionId(req.optionId());
stimme.setBeitragId(postId);
stimme.setUserId(myId);
stimmeRepository.save(stimme);
return ResponseEntity.ok().build();
}
// ── POST /gruppen/{id}/posts/{postId}/report ──
@PostMapping("/gruppen/{id}/posts/{postId}/report")
public ResponseEntity<Void> report(@PathVariable UUID id,
@PathVariable UUID postId,
@RequestBody ReportRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty())
return ResponseEntity.status(403).build();
if (beitragRepository.findById(postId).isEmpty()) return ResponseEntity.notFound().build();
var existing = meldungRepository.findByBeitragIdAndMelderId(postId, myId);
if (existing.isPresent()) return ResponseEntity.status(409).build();
BeitragMeldungEntity meldung = new BeitragMeldungEntity();
meldung.setMeldungId(UUID.randomUUID());
meldung.setBeitragId(postId);
meldung.setMelderId(myId);
meldung.setGrund(req.grund());
meldung.setGemeldetAt(LocalDateTime.now());
meldungRepository.save(meldung);
return ResponseEntity.status(201).build();
}
// ── GET /gruppen/{id}/reports ──
@GetMapping("/gruppen/{id}/reports")
public ResponseEntity<List<BeitragMeldungDto>> getReports(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId)
.map(m -> m.getRolle() != GruppenRolle.ADMIN).orElse(true))
return ResponseEntity.status(403).build();
List<UUID> beitragIds = beitragRepository.findByGruppeIdOrderByCreatedAtDesc(id)
.stream().map(GruppenbeitragEntity::getBeitragId).toList();
List<BeitragMeldungDto> dtos = meldungRepository.findByBeitragIdIn(beitragIds)
.stream()
.map(m -> {
UserEntity melder = userRepository.findById(m.getMelderId()).orElse(null);
return new BeitragMeldungDto(m.getMeldungId(), m.getBeitragId(), m.getMelderId(),
melder != null ? melder.getName() : "Unbekannt",
m.getGrund(), m.getGemeldetAt());
})
.toList();
return ResponseEntity.ok(dtos);
}
// ── DELETE /gruppen/{id}/reports/{meldungId} ──
@DeleteMapping("/gruppen/{id}/reports/{meldungId}")
public ResponseEntity<Void> dismissReport(@PathVariable UUID id,
@PathVariable UUID meldungId,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId)
.map(m -> m.getRolle() != GruppenRolle.ADMIN).orElse(true))
return ResponseEntity.status(403).build();
meldungRepository.deleteById(meldungId);
return ResponseEntity.noContent().build();
}
// ── Helpers ──
private UUID resolveMyId(Principal principal) {
if (principal == null) return null;
return userRepository.findByEmail(principal.getName())
.map(UserEntity::getUserId)
.orElse(null);
}
private void deleteBeitragCascade(GruppenbeitragEntity beitrag) {
UUID bid = beitrag.getBeitragId();
meldungRepository.deleteByBeitragId(bid);
stimmeRepository.deleteByBeitragId(bid);
optionRepository.deleteByBeitragId(bid);
likeRepository.deleteByBeitragId(bid);
var kommentare = kommentarRepository
.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("GROUP_POST", bid);
kommentarRepository.deleteAll(kommentare);
beitragRepository.delete(beitrag);
}
private GruppenbeitragDto toDto(GruppenbeitragEntity b, UUID myId) {
UserEntity author = userRepository.findById(b.getAuthorId()).orElse(null);
long likeCount = likeRepository.countByBeitragId(b.getBeitragId());
boolean likedByMe = likeRepository.findByBeitragIdAndUserId(b.getBeitragId(), myId).isPresent();
long kommentarCount = kommentarRepository
.countByTargetTypeAndTargetId("GROUP_POST", b.getBeitragId());
boolean reported = meldungRepository.findByBeitragIdAndMelderId(b.getBeitragId(), myId).isPresent();
List<UmfrageOptionDto> optionen = List.of();
List<UUID> myVoteOptionIds = List.of();
if (b.getBeitragTyp() == BeitragTyp.UMFRAGE) {
optionen = optionRepository.findByBeitragIdOrderByReihenfolge(b.getBeitragId())
.stream()
.map(o -> new UmfrageOptionDto(o.getOptionId(), o.getText(), o.getReihenfolge(),
stimmeRepository.countByOptionId(o.getOptionId())))
.toList();
myVoteOptionIds = stimmeRepository.findByBeitragIdAndUserId(b.getBeitragId(), myId)
.stream()
.map(UmfrageStimmeEntity::getOptionId)
.toList();
}
return new GruppenbeitragDto(
b.getBeitragId(), b.getGruppeId(), b.getAuthorId(),
author != null ? author.getName() : "Unbekannt",
author != null ? author.getProfilePicture() : null,
b.getBeitragTyp().name(), b.getText(), b.getMultiChoice(), b.getBilder(), b.getCreatedAt(),
likeCount, likedByMe, kommentarCount, optionen, myVoteOptionIds, reported);
}
}

View File

@@ -0,0 +1,13 @@
package de.oaa.xxx.gruppe.dto;
import java.time.LocalDateTime;
import java.util.UUID;
public record BeitragMeldungDto(
UUID meldungId,
UUID beitragId,
UUID melderId,
String melderName,
String grund,
LocalDateTime gemeldetAt
) {}

View File

@@ -0,0 +1,14 @@
package de.oaa.xxx.gruppe.dto;
import java.time.LocalDateTime;
import java.util.UUID;
public record BeitrittsanfrageDto(
UUID anfrageId,
UUID gruppeId,
UUID userId,
String userName,
String userPicture,
String nachricht,
LocalDateTime angefragtAt
) {}

View File

@@ -0,0 +1,17 @@
package de.oaa.xxx.gruppe.dto;
import java.time.LocalDateTime;
import java.util.UUID;
public record GruppeDto(
UUID gruppeId,
String name,
String beschreibung,
String bild,
boolean isPrivate,
LocalDateTime createdAt,
long memberCount,
long postCount,
String myRole,
String myRequestStatus
) {}

View File

@@ -0,0 +1,24 @@
package de.oaa.xxx.gruppe.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public record GruppenbeitragDto(
UUID beitragId,
UUID gruppeId,
UUID authorId,
String authorName,
String authorPicture,
String beitragTyp,
String text,
Boolean multiChoice,
List<String> bilder,
LocalDateTime createdAt,
long likeCount,
boolean likedByMe,
long kommentarCount,
List<UmfrageOptionDto> optionen,
List<UUID> myVoteOptionIds,
boolean reported
) {}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.gruppe.dto;
import java.util.UUID;
public record UmfrageOptionDto(
UUID optionId,
String text,
int reihenfolge,
long stimmenCount
) {}

View File

@@ -0,0 +1,42 @@
package de.oaa.xxx.gruppe.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "beitrag_meldung",
uniqueConstraints = @UniqueConstraint(columnNames = {"beitragId", "melderId"}))
public class BeitragMeldungEntity {
@Id
@Column
private UUID meldungId;
@Column(nullable = false)
private UUID beitragId;
@Column(nullable = false)
private UUID melderId;
@Column(columnDefinition = "TEXT")
private String grund;
@Column(nullable = false)
private LocalDateTime gemeldetAt;
public UUID getMeldungId() { return meldungId; }
public void setMeldungId(UUID meldungId) { this.meldungId = meldungId; }
public UUID getBeitragId() { return beitragId; }
public void setBeitragId(UUID beitragId) { this.beitragId = beitragId; }
public UUID getMelderId() { return melderId; }
public void setMelderId(UUID melderId) { this.melderId = melderId; }
public String getGrund() { return grund; }
public void setGrund(String grund) { this.grund = grund; }
public LocalDateTime getGemeldetAt() { return gemeldetAt; }
public void setGemeldetAt(LocalDateTime gemeldetAt) { this.gemeldetAt = gemeldetAt; }
}

View File

@@ -0,0 +1,49 @@
package de.oaa.xxx.gruppe.entity;
import de.oaa.xxx.gruppe.AnfrageStatus;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "beitrittsanfrage")
public class BeitrittsanfrageEntity {
@Id
@Column
private UUID anfrageId;
@Column(nullable = false)
private UUID gruppeId;
@Column(nullable = false)
private UUID userId;
@Column(columnDefinition = "TEXT")
private String nachricht;
@Column(nullable = false)
private LocalDateTime angefragtAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 15)
private AnfrageStatus status;
public UUID getAnfrageId() { return anfrageId; }
public void setAnfrageId(UUID anfrageId) { this.anfrageId = anfrageId; }
public UUID getGruppeId() { return gruppeId; }
public void setGruppeId(UUID gruppeId) { this.gruppeId = gruppeId; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public String getNachricht() { return nachricht; }
public void setNachricht(String nachricht) { this.nachricht = nachricht; }
public LocalDateTime getAngefragtAt() { return angefragtAt; }
public void setAngefragtAt(LocalDateTime angefragtAt) { this.angefragtAt = angefragtAt; }
public AnfrageStatus getStatus() { return status; }
public void setStatus(AnfrageStatus status) { this.status = status; }
}

View File

@@ -0,0 +1,53 @@
package de.oaa.xxx.gruppe.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "gruppe")
public class GruppeEntity {
@Id
@Column
private UUID gruppeId;
@Column(length = 100, nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String beschreibung;
@Column(columnDefinition = "MEDIUMTEXT")
private String bild;
@Column(nullable = false)
private boolean isPrivate = false;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private UUID createdByUserId;
public UUID getGruppeId() { return gruppeId; }
public void setGruppeId(UUID gruppeId) { this.gruppeId = gruppeId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getBeschreibung() { return beschreibung; }
public void setBeschreibung(String beschreibung) { this.beschreibung = beschreibung; }
public String getBild() { return bild; }
public void setBild(String bild) { this.bild = bild; }
public boolean isPrivate() { return isPrivate; }
public void setPrivate(boolean aPrivate) { isPrivate = aPrivate; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public UUID getCreatedByUserId() { return createdByUserId; }
public void setCreatedByUserId(UUID createdByUserId) { this.createdByUserId = createdByUserId; }
}

View File

@@ -0,0 +1,64 @@
package de.oaa.xxx.gruppe.entity;
import de.oaa.xxx.config.StringListConverter;
import de.oaa.xxx.gruppe.BeitragTyp;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "gruppe_beitrag")
public class GruppenbeitragEntity {
@Id
@Column
private UUID beitragId;
@Column(nullable = false)
private UUID gruppeId;
@Column(nullable = false)
private UUID authorId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private BeitragTyp beitragTyp;
@Column(columnDefinition = "TEXT", nullable = false)
private String text;
@Column
private Boolean multiChoice;
@Convert(converter = StringListConverter.class)
@Column(name = "bild", columnDefinition = "MEDIUMTEXT")
private List<String> bilder;
@Column(nullable = false)
private LocalDateTime createdAt;
public UUID getBeitragId() { return beitragId; }
public void setBeitragId(UUID beitragId) { this.beitragId = beitragId; }
public UUID getGruppeId() { return gruppeId; }
public void setGruppeId(UUID gruppeId) { this.gruppeId = gruppeId; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public BeitragTyp getBeitragTyp() { return beitragTyp; }
public void setBeitragTyp(BeitragTyp beitragTyp) { this.beitragTyp = beitragTyp; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public Boolean getMultiChoice() { return multiChoice; }
public void setMultiChoice(Boolean multiChoice) { this.multiChoice = multiChoice; }
public List<String> getBilder() { return bilder; }
public void setBilder(List<String> bilder) { this.bilder = bilder; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

View File

@@ -0,0 +1,36 @@
package de.oaa.xxx.gruppe.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "gruppe_beitrag_like",
uniqueConstraints = @UniqueConstraint(columnNames = {"beitragId", "userId"}))
public class GruppenbeitragLikeEntity {
@Id
@Column
private UUID likeId;
@Column(nullable = false)
private UUID beitragId;
@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 getBeitragId() { return beitragId; }
public void setBeitragId(UUID beitragId) { this.beitragId = beitragId; }
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,44 @@
package de.oaa.xxx.gruppe.entity;
import de.oaa.xxx.gruppe.GruppenRolle;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "gruppe_mitglied",
uniqueConstraints = @UniqueConstraint(columnNames = {"gruppeId", "userId"}))
public class GruppenmitgliedEntity {
@Id
@Column
private UUID mitgliedId;
@Column(nullable = false)
private UUID gruppeId;
@Column(nullable = false)
private UUID userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private GruppenRolle rolle;
@Column(nullable = false)
private LocalDateTime joinedAt;
public UUID getMitgliedId() { return mitgliedId; }
public void setMitgliedId(UUID mitgliedId) { this.mitgliedId = mitgliedId; }
public UUID getGruppeId() { return gruppeId; }
public void setGruppeId(UUID gruppeId) { this.gruppeId = gruppeId; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public GruppenRolle getRolle() { return rolle; }
public void setRolle(GruppenRolle rolle) { this.rolle = rolle; }
public LocalDateTime getJoinedAt() { return joinedAt; }
public void setJoinedAt(LocalDateTime joinedAt) { this.joinedAt = joinedAt; }
}

View File

@@ -0,0 +1,34 @@
package de.oaa.xxx.gruppe.entity;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "umfrage_option")
public class UmfrageOptionEntity {
@Id
@Column
private UUID optionId;
@Column(nullable = false)
private UUID beitragId;
@Column(length = 200, nullable = false)
private String text;
@Column(nullable = false)
private int reihenfolge;
public UUID getOptionId() { return optionId; }
public void setOptionId(UUID optionId) { this.optionId = optionId; }
public UUID getBeitragId() { return beitragId; }
public void setBeitragId(UUID beitragId) { this.beitragId = beitragId; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public int getReihenfolge() { return reihenfolge; }
public void setReihenfolge(int reihenfolge) { this.reihenfolge = reihenfolge; }
}

View File

@@ -0,0 +1,35 @@
package de.oaa.xxx.gruppe.entity;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "umfrage_stimme",
uniqueConstraints = @UniqueConstraint(columnNames = {"optionId", "userId"}))
public class UmfrageStimmeEntity {
@Id
@Column
private UUID stimmeId;
@Column(nullable = false)
private UUID optionId;
@Column(nullable = false)
private UUID beitragId;
@Column(nullable = false)
private UUID userId;
public UUID getStimmeId() { return stimmeId; }
public void setStimmeId(UUID stimmeId) { this.stimmeId = stimmeId; }
public UUID getOptionId() { return optionId; }
public void setOptionId(UUID optionId) { this.optionId = optionId; }
public UUID getBeitragId() { return beitragId; }
public void setBeitragId(UUID beitragId) { this.beitragId = beitragId; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
}

View File

@@ -0,0 +1,22 @@
package de.oaa.xxx.gruppe.repository;
import de.oaa.xxx.gruppe.entity.BeitragMeldungEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface BeitragMeldungRepository extends JpaRepository<BeitragMeldungEntity, UUID> {
List<BeitragMeldungEntity> findByBeitragId(UUID beitragId);
List<BeitragMeldungEntity> findByBeitragIdIn(Collection<UUID> beitragIds);
Optional<BeitragMeldungEntity> findByBeitragIdAndMelderId(UUID beitragId, UUID melderId);
@Transactional
void deleteByBeitragId(UUID beitragId);
}

View File

@@ -0,0 +1,22 @@
package de.oaa.xxx.gruppe.repository;
import de.oaa.xxx.gruppe.AnfrageStatus;
import de.oaa.xxx.gruppe.entity.BeitrittsanfrageEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface BeitrittsanfrageRepository extends JpaRepository<BeitrittsanfrageEntity, UUID> {
List<BeitrittsanfrageEntity> findByGruppeIdAndStatus(UUID gruppeId, AnfrageStatus status);
List<BeitrittsanfrageEntity> findByUserIdAndStatus(UUID userId, AnfrageStatus status);
Optional<BeitrittsanfrageEntity> findByGruppeIdAndUserId(UUID gruppeId, UUID userId);
@Transactional
void deleteByGruppeId(UUID gruppeId);
}

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.gruppe.repository;
import de.oaa.xxx.gruppe.entity.GruppeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface GruppeRepository extends JpaRepository<GruppeEntity, UUID> {
List<GruppeEntity> findByNameContainingIgnoreCase(String name);
}

View File

@@ -0,0 +1,18 @@
package de.oaa.xxx.gruppe.repository;
import de.oaa.xxx.gruppe.entity.GruppenbeitragLikeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import java.util.UUID;
public interface GruppenbeitragLikeRepository extends JpaRepository<GruppenbeitragLikeEntity, UUID> {
Optional<GruppenbeitragLikeEntity> findByBeitragIdAndUserId(UUID beitragId, UUID userId);
long countByBeitragId(UUID beitragId);
@Transactional
void deleteByBeitragId(UUID beitragId);
}

View File

@@ -0,0 +1,29 @@
package de.oaa.xxx.gruppe.repository;
import de.oaa.xxx.gruppe.entity.GruppenbeitragEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface GruppenbeitragRepository extends JpaRepository<GruppenbeitragEntity, UUID> {
Slice<GruppenbeitragEntity> findByGruppeIdOrderByCreatedAtDesc(UUID gruppeId, Pageable pageable);
List<GruppenbeitragEntity> findByGruppeIdOrderByCreatedAtDesc(UUID gruppeId);
Optional<GruppenbeitragEntity> findFirstByGruppeIdOrderByCreatedAtDesc(UUID gruppeId);
List<GruppenbeitragEntity> findByGruppeIdInAndCreatedAtAfterOrderByCreatedAtDesc(List<UUID> gruppeIds, LocalDateTime since);
@Transactional
void deleteByGruppeId(UUID gruppeId);
@Transactional
void deleteByAuthorId(UUID authorId);
}

View File

@@ -0,0 +1,26 @@
package de.oaa.xxx.gruppe.repository;
import de.oaa.xxx.gruppe.entity.GruppenmitgliedEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface GruppenmitgliedRepository extends JpaRepository<GruppenmitgliedEntity, UUID> {
Optional<GruppenmitgliedEntity> findFirstByGruppeIdAndUserId(UUID gruppeId, UUID userId);
List<GruppenmitgliedEntity> findByGruppeId(UUID gruppeId);
List<GruppenmitgliedEntity> findByUserId(UUID userId);
@Transactional
void deleteByGruppeIdAndUserId(UUID gruppeId, UUID userId);
long countByGruppeId(UUID gruppeId);
@Transactional
void deleteByGruppeId(UUID gruppeId);
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.gruppe.repository;
import de.oaa.xxx.gruppe.entity.UmfrageOptionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
public interface UmfrageOptionRepository extends JpaRepository<UmfrageOptionEntity, UUID> {
List<UmfrageOptionEntity> findByBeitragIdOrderByReihenfolge(UUID beitragId);
@Transactional
void deleteByBeitragId(UUID beitragId);
}

View File

@@ -0,0 +1,21 @@
package de.oaa.xxx.gruppe.repository;
import de.oaa.xxx.gruppe.entity.UmfrageStimmeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface UmfrageStimmeRepository extends JpaRepository<UmfrageStimmeEntity, UUID> {
List<UmfrageStimmeEntity> findByBeitragIdAndUserId(UUID beitragId, UUID userId);
Optional<UmfrageStimmeEntity> findByOptionIdAndUserId(UUID optionId, UUID userId);
long countByOptionId(UUID optionId);
@Transactional
void deleteByBeitragId(UUID beitragId);
}

View File

@@ -0,0 +1,132 @@
package de.oaa.xxx.social;
import de.oaa.xxx.social.dto.KommentarDto;
import de.oaa.xxx.social.entity.KommentarEntity;
import de.oaa.xxx.social.entity.KommentarLikeEntity;
import de.oaa.xxx.social.repository.KommentarLikeRepository;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.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/kommentare")
public class KommentarController {
private final KommentarRepository kommentarRepository;
private final KommentarLikeRepository likeRepository;
private final UserRepository userRepository;
public KommentarController(KommentarRepository kommentarRepository,
KommentarLikeRepository likeRepository,
UserRepository userRepository) {
this.kommentarRepository = kommentarRepository;
this.likeRepository = likeRepository;
this.userRepository = userRepository;
}
record CreateKommentarRequest(String targetType, UUID targetId, String text) {}
@GetMapping
public ResponseEntity<List<KommentarDto>> getKommentare(
@RequestParam String targetType,
@RequestParam UUID targetId,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<KommentarDto> dtos = kommentarRepository
.findByTargetTypeAndTargetIdOrderByCreatedAtAsc(targetType, targetId)
.stream()
.map(k -> toDto(k, myId))
.toList();
return ResponseEntity.ok(dtos);
}
@PostMapping
public ResponseEntity<KommentarDto> createKommentar(@RequestBody CreateKommentarRequest request, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
if (request.text() == null || request.text().isBlank()) return ResponseEntity.badRequest().build();
if (request.text().length() > 500) return ResponseEntity.badRequest().build();
if (!List.of("PINNWAND", "IMAGE", "KOMMENTAR", "GROUP_POST").contains(request.targetType())) {
return ResponseEntity.badRequest().build();
}
KommentarEntity entity = new KommentarEntity();
entity.setKommentarId(UUID.randomUUID());
entity.setAuthorId(myId);
entity.setTargetType(request.targetType());
entity.setTargetId(request.targetId());
entity.setText(request.text().trim());
entity.setCreatedAt(LocalDateTime.now());
kommentarRepository.save(entity);
return ResponseEntity.status(201).body(toDto(entity, myId));
}
@DeleteMapping("/{kommentarId}")
public ResponseEntity<Void> deleteKommentar(@PathVariable UUID kommentarId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var kOpt = kommentarRepository.findById(kommentarId);
if (kOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!kOpt.get().getAuthorId().equals(myId)) return ResponseEntity.status(403).build();
// Delete nested replies and their likes
List<KommentarEntity> replies = kommentarRepository
.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("KOMMENTAR", kommentarId);
for (KommentarEntity reply : replies) {
likeRepository.deleteByKommentarId(reply.getKommentarId());
kommentarRepository.delete(reply);
}
likeRepository.deleteByKommentarId(kommentarId);
kommentarRepository.delete(kOpt.get());
return ResponseEntity.noContent().build();
}
@PostMapping("/{kommentarId}/like")
public ResponseEntity<Void> toggleLike(@PathVariable UUID kommentarId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
if (kommentarRepository.findById(kommentarId).isEmpty()) return ResponseEntity.notFound().build();
var existing = likeRepository.findByKommentarIdAndUserId(kommentarId, myId);
if (existing.isPresent()) {
likeRepository.delete(existing.get());
} else {
KommentarLikeEntity like = new KommentarLikeEntity();
like.setLikeId(UUID.randomUUID());
like.setKommentarId(kommentarId);
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
likeRepository.save(like);
}
return ResponseEntity.ok().build();
}
private KommentarDto toDto(KommentarEntity k, UUID myId) {
UserEntity author = userRepository.findById(k.getAuthorId()).orElse(null);
String authorName = author != null ? author.getName() : "Unbekannt";
String authorPic = author != null ? author.getProfilePicture() : null;
long likeCount = likeRepository.countByKommentarId(k.getKommentarId());
boolean likedByMe = likeRepository.findByKommentarIdAndUserId(k.getKommentarId(), myId).isPresent();
long replyCount = kommentarRepository.countByTargetTypeAndTargetId("KOMMENTAR", k.getKommentarId());
return new KommentarDto(k.getKommentarId(), k.getAuthorId(), authorName, authorPic,
k.getTargetType(), k.getTargetId(), k.getText(), k.getCreatedAt(),
likeCount, likedByMe, replyCount);
}
}

View File

@@ -0,0 +1,129 @@
package de.oaa.xxx.social;
import de.oaa.xxx.social.dto.PinnwandEintragDto;
import de.oaa.xxx.social.entity.PinnwandEintragEntity;
import de.oaa.xxx.social.entity.PinnwandLikeEntity;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.social.repository.PinnwandEintragRepository;
import de.oaa.xxx.social.repository.PinnwandLikeRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.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/pinnwand")
public class PinnwandController {
private final PinnwandEintragRepository eintragRepository;
private final PinnwandLikeRepository likeRepository;
private final KommentarRepository kommentarRepository;
private final UserRepository userRepository;
public PinnwandController(PinnwandEintragRepository eintragRepository,
PinnwandLikeRepository likeRepository,
KommentarRepository kommentarRepository,
UserRepository userRepository) {
this.eintragRepository = eintragRepository;
this.likeRepository = likeRepository;
this.kommentarRepository = kommentarRepository;
this.userRepository = userRepository;
}
record CreateEintragRequest(UUID profilUserId, String text) {}
@GetMapping
public ResponseEntity<List<PinnwandEintragDto>> getEintraege(@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<PinnwandEintragDto> dtos = eintragRepository
.findByProfilUserIdOrderByCreatedAtDesc(userId)
.stream()
.map(e -> toDto(e, myId))
.toList();
return ResponseEntity.ok(dtos);
}
@PostMapping
public ResponseEntity<PinnwandEintragDto> createEintrag(@RequestBody CreateEintragRequest request, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
if (request.text() == null || request.text().isBlank()) return ResponseEntity.badRequest().build();
if (request.text().length() > 1000) return ResponseEntity.badRequest().build();
PinnwandEintragEntity entity = new PinnwandEintragEntity();
entity.setEintragId(UUID.randomUUID());
entity.setProfilUserId(request.profilUserId());
entity.setAuthorId(myId);
entity.setText(request.text().trim());
entity.setCreatedAt(LocalDateTime.now());
eintragRepository.save(entity);
return ResponseEntity.status(201).body(toDto(entity, myId));
}
@DeleteMapping("/{eintragId}")
public ResponseEntity<Void> deleteEintrag(@PathVariable UUID eintragId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var eintragOpt = eintragRepository.findById(eintragId);
if (eintragOpt.isEmpty()) return ResponseEntity.notFound().build();
var eintrag = eintragOpt.get();
// Author or profile owner may delete
if (!eintrag.getAuthorId().equals(myId) && !eintrag.getProfilUserId().equals(myId)) {
return ResponseEntity.status(403).build();
}
likeRepository.deleteByEintragId(eintragId);
// Delete comments on this entry (via KommentarRepository)
kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("PINNWAND", eintragId)
.forEach(k -> kommentarRepository.deleteById(k.getKommentarId()));
eintragRepository.delete(eintrag);
return ResponseEntity.noContent().build();
}
@PostMapping("/{eintragId}/like")
public ResponseEntity<Void> toggleLike(@PathVariable UUID eintragId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
if (eintragRepository.findById(eintragId).isEmpty()) return ResponseEntity.notFound().build();
var existing = likeRepository.findByEintragIdAndUserId(eintragId, myId);
if (existing.isPresent()) {
likeRepository.delete(existing.get());
} else {
PinnwandLikeEntity like = new PinnwandLikeEntity();
like.setLikeId(UUID.randomUUID());
like.setEintragId(eintragId);
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
likeRepository.save(like);
}
return ResponseEntity.ok().build();
}
private PinnwandEintragDto toDto(PinnwandEintragEntity e, UUID myId) {
UserEntity author = userRepository.findById(e.getAuthorId()).orElse(null);
String authorName = author != null ? author.getName() : "Unbekannt";
String authorPic = author != null ? author.getProfilePicture() : null;
long likeCount = likeRepository.countByEintragId(e.getEintragId());
boolean likedByMe = likeRepository.findByEintragIdAndUserId(e.getEintragId(), myId).isPresent();
long kommentarCount = kommentarRepository.countByTargetTypeAndTargetId("PINNWAND", e.getEintragId());
return new PinnwandEintragDto(e.getEintragId(), e.getProfilUserId(), e.getAuthorId(),
authorName, authorPic, e.getText(), e.getCreatedAt(), likeCount, likedByMe, kommentarCount);
}
}

View File

@@ -121,6 +121,21 @@ public class SocialController {
return ResponseEntity.noContent().build();
}
@GetMapping("/friends/user/{userId}")
public ResponseEntity<List<UserProfile>> getFriendsOfUser(@PathVariable UUID userId, Principal principal) {
if (userRepository.findByEmail(principal.getName()).isEmpty()) return ResponseEntity.status(401).build();
List<UserProfile> profiles = friendshipRepository.findFriends(userId, Status.ACCEPTED).stream()
.map(f -> {
UUID friendId = f.getSenderId().equals(userId) ? f.getReceiverId() : f.getSenderId();
return userRepository.findById(friendId)
.map(u -> new UserProfile(u.getUserId(), u.getName(), u.getProfilePicture(), u.getProfilePictureHq(), null))
.orElse(null);
})
.filter(Objects::nonNull)
.toList();
return ResponseEntity.ok(profiles);
}
@GetMapping("/friends")
public ResponseEntity<List<FriendshipDto>> getFriends(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
@@ -259,7 +274,9 @@ public class SocialController {
status = "PENDING_RECEIVED";
}
}
return new UserProfile(user.getUserId(), user.getName(), user.getProfilePicture(), user.getProfilePictureHq(), status);
return new UserProfile(user.getUserId(), user.getName(), user.getProfilePicture(), user.getProfilePictureHq(),
status, user.getAlter(), user.getGroesse(), user.getGewicht(),
user.getGeschlecht(), user.getNeigung(), user.getBeziehungsstatus(), user.getBeschreibung());
}
private MessageDto toMessageDto(MessageEntity m) {

View File

@@ -0,0 +1,19 @@
package de.oaa.xxx.social.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public record KommentarDto(
UUID kommentarId,
UUID authorId,
String authorName,
String authorPicture,
String targetType,
UUID targetId,
String text,
LocalDateTime createdAt,
long likeCount,
boolean likedByMe,
long replyCount
) {}

View File

@@ -0,0 +1,17 @@
package de.oaa.xxx.social.dto;
import java.time.LocalDateTime;
import java.util.UUID;
public record PinnwandEintragDto(
UUID eintragId,
UUID profilUserId,
UUID authorId,
String authorName,
String authorPicture,
String text,
LocalDateTime createdAt,
long likeCount,
boolean likedByMe,
long kommentarCount
) {}

View File

@@ -1,5 +1,27 @@
package de.oaa.xxx.social.dto;
import de.oaa.xxx.user.Beziehungsstatus;
import de.oaa.xxx.user.Geschlecht;
import de.oaa.xxx.user.Neigung;
import java.util.UUID;
public record UserProfile(UUID userId, String name, String profilePicture, String profilePictureHq, String friendStatus) {}
public record UserProfile(
UUID userId,
String name,
String profilePicture,
String profilePictureHq,
String friendStatus,
Integer alter,
Integer groesse,
Integer gewicht,
Geschlecht geschlecht,
Neigung neigung,
Beziehungsstatus beziehungsstatus,
String beschreibung
) {
/** Compact constructor for contexts where profile details are not needed (friend list etc.) */
public UserProfile(UUID userId, String name, String profilePicture, String profilePictureHq, String friendStatus) {
this(userId, name, profilePicture, profilePictureHq, friendStatus, null, null, null, null, null, null, null);
}
}

View File

@@ -0,0 +1,48 @@
package de.oaa.xxx.social.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "kommentar")
public class KommentarEntity {
/** targetType values: PINNWAND, IMAGE, KOMMENTAR */
@Id
@Column
private UUID kommentarId;
@Column(nullable = false)
private UUID authorId;
@Column(length = 20, nullable = false)
private String targetType;
@Column(nullable = false)
private UUID targetId;
@Column(columnDefinition = "TEXT", nullable = false)
private String text;
@Column(nullable = false)
private LocalDateTime createdAt;
public UUID getKommentarId() { return kommentarId; }
public void setKommentarId(UUID kommentarId) { this.kommentarId = kommentarId; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public String getTargetType() { return targetType; }
public void setTargetType(String targetType) { this.targetType = targetType; }
public UUID getTargetId() { return targetId; }
public void setTargetId(UUID targetId) { this.targetId = targetId; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

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 = "kommentar_like")
public class KommentarLikeEntity {
@Id
@Column
private UUID likeId;
@Column(nullable = false)
private UUID kommentarId;
@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 getKommentarId() { return kommentarId; }
public void setKommentarId(UUID kommentarId) { this.kommentarId = kommentarId; }
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,41 @@
package de.oaa.xxx.social.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "pinnwand_eintrag")
public class PinnwandEintragEntity {
@Id
@Column
private UUID eintragId;
@Column(nullable = false)
private UUID profilUserId;
@Column(nullable = false)
private UUID authorId;
@Column(columnDefinition = "TEXT", nullable = false)
private String text;
@Column(nullable = false)
private LocalDateTime createdAt;
public UUID getEintragId() { return eintragId; }
public void setEintragId(UUID eintragId) { this.eintragId = eintragId; }
public UUID getProfilUserId() { return profilUserId; }
public void setProfilUserId(UUID profilUserId) { this.profilUserId = profilUserId; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

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 = "pinnwand_like")
public class PinnwandLikeEntity {
@Id
@Column
private UUID likeId;
@Column(nullable = false)
private UUID eintragId;
@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 getEintragId() { return eintragId; }
public void setEintragId(UUID eintragId) { this.eintragId = eintragId; }
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,18 @@
package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.KommentarLikeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface KommentarLikeRepository extends JpaRepository<KommentarLikeEntity, UUID> {
long countByKommentarId(UUID kommentarId);
Optional<KommentarLikeEntity> findByKommentarIdAndUserId(UUID kommentarId, UUID userId);
void deleteByKommentarId(UUID kommentarId);
void deleteByUserId(UUID userId);
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.KommentarEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface KommentarRepository extends JpaRepository<KommentarEntity, UUID> {
List<KommentarEntity> findByTargetTypeAndTargetIdOrderByCreatedAtAsc(String targetType, UUID targetId);
long countByTargetTypeAndTargetId(String targetType, UUID targetId);
void deleteByAuthorId(UUID authorId);
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.PinnwandEintragEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface PinnwandEintragRepository extends JpaRepository<PinnwandEintragEntity, UUID> {
List<PinnwandEintragEntity> findByProfilUserIdOrderByCreatedAtDesc(UUID profilUserId);
void deleteByProfilUserId(UUID profilUserId);
void deleteByAuthorId(UUID authorId);
}

View File

@@ -0,0 +1,18 @@
package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.PinnwandLikeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface PinnwandLikeRepository extends JpaRepository<PinnwandLikeEntity, UUID> {
long countByEintragId(UUID eintragId);
Optional<PinnwandLikeEntity> findByEintragIdAndUserId(UUID eintragId, UUID userId);
void deleteByEintragId(UUID eintragId);
void deleteByUserId(UUID userId);
}

View File

@@ -0,0 +1,15 @@
package de.oaa.xxx.user;
public enum Beziehungsstatus {
SINGLE("single"),
IN_EINER_BEZIEHUNG("in einer Beziehung"),
VERHEIRATET("verheiratet"),
IN_EINER_OFFENEN_BEZIEHUNG("in einer offenen Beziehung"),
IN_EINER_OFFENEN_EHE("in einer offenen Ehe");
private final String label;
Beziehungsstatus(String label) { this.label = label; }
public String getLabel() { return label; }
}

View File

@@ -0,0 +1,13 @@
package de.oaa.xxx.user;
public enum Geschlecht {
WEIBLICH("weiblich"),
DIVERS("divers"),
MAENNLICH("männlich");
private final String label;
Geschlecht(String label) { this.label = label; }
public String getLabel() { return label; }
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.user;
public enum Neigung {
DEVOT("devot"),
EHER_DEVOT("eher devot"),
SWITCHER("Switcher"),
EHER_DOMINANT("eher dominant"),
DOMINANT("dominant"),
KEINES("keines");
private final String label;
Neigung(String label) { this.label = label; }
public String getLabel() { return label; }
}

View File

@@ -9,6 +9,13 @@ public class User {
private String email;
private String password;
private String profilePicture;
private Integer alter;
private Integer groesse;
private Integer gewicht;
private Geschlecht geschlecht;
private Neigung neigung;
private Beziehungsstatus beziehungsstatus;
private String beschreibung;
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
@@ -25,6 +32,27 @@ public class User {
public String getProfilePicture() { return profilePicture; }
public void setProfilePicture(String profilePicture) { this.profilePicture = profilePicture; }
public Integer getAlter() { return alter; }
public void setAlter(Integer alter) { this.alter = alter; }
public Integer getGroesse() { return groesse; }
public void setGroesse(Integer groesse) { this.groesse = groesse; }
public Integer getGewicht() { return gewicht; }
public void setGewicht(Integer gewicht) { this.gewicht = gewicht; }
public Geschlecht getGeschlecht() { return geschlecht; }
public void setGeschlecht(Geschlecht geschlecht) { this.geschlecht = geschlecht; }
public Neigung getNeigung() { return neigung; }
public void setNeigung(Neigung neigung) { this.neigung = neigung; }
public Beziehungsstatus getBeziehungsstatus() { return beziehungsstatus; }
public void setBeziehungsstatus(Beziehungsstatus beziehungsstatus) { this.beziehungsstatus = beziehungsstatus; }
public String getBeschreibung() { return beschreibung; }
public void setBeschreibung(String beschreibung) { this.beschreibung = beschreibung; }
@Override
public String toString() {
return "User[userId=" + userId + ", name=" + name + ", email=" + email + "]";

View File

@@ -33,6 +33,10 @@ 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 de.oaa.xxx.social.repository.PinnwandEintragRepository;
import de.oaa.xxx.social.repository.PinnwandLikeRepository;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.social.repository.KommentarLikeRepository;
import jakarta.transaction.Transactional;
@RestController
@@ -57,6 +61,10 @@ public class UserController {
private final PasswordResetRepository passwordResetRepository;
private final ProfileImageRepository profileImageRepository;
private final ProfileImageLikeRepository profileImageLikeRepository;
private final PinnwandEintragRepository pinnwandEintragRepository;
private final PinnwandLikeRepository pinnwandLikeRepository;
private final KommentarRepository kommentarRepository;
private final KommentarLikeRepository kommentarLikeRepository;
public UserController(UserRepository userRepository,
RegistrationRepository registrationRepository,
@@ -73,7 +81,11 @@ public class UserController {
EmailChangeRepository emailChangeRepository,
PasswordResetRepository passwordResetRepository,
ProfileImageRepository profileImageRepository,
ProfileImageLikeRepository profileImageLikeRepository) {
ProfileImageLikeRepository profileImageLikeRepository,
PinnwandEintragRepository pinnwandEintragRepository,
PinnwandLikeRepository pinnwandLikeRepository,
KommentarRepository kommentarRepository,
KommentarLikeRepository kommentarLikeRepository) {
this.userRepository = userRepository;
this.registrationRepository = registrationRepository;
this.aufgabenGruppeRepository = aufgabenGruppeRepository;
@@ -90,10 +102,16 @@ public class UserController {
this.passwordResetRepository = passwordResetRepository;
this.profileImageRepository = profileImageRepository;
this.profileImageLikeRepository = profileImageLikeRepository;
this.pinnwandEintragRepository = pinnwandEintragRepository;
this.pinnwandLikeRepository = pinnwandLikeRepository;
this.kommentarRepository = kommentarRepository;
this.kommentarLikeRepository = kommentarLikeRepository;
}
record ProfilePictureRequest(String picture, String pictureHq) {}
record NameChangeRequest(String name) {}
record ProfileRequest(Integer alter, Integer groesse, Integer gewicht,
Geschlecht geschlecht, Neigung neigung, Beziehungsstatus beziehungsstatus, String beschreibung) {}
@PutMapping("/me/picture")
public ResponseEntity<Void> updateProfilePicture(@RequestBody ProfilePictureRequest request, Principal principal) {
@@ -105,6 +123,25 @@ public class UserController {
return ResponseEntity.ok().build();
}
@PutMapping("/me/profile")
public ResponseEntity<Void> updateProfile(@RequestBody ProfileRequest request, Principal principal) {
var userOpt = userRepository.findByEmail(principal.getName());
if (userOpt.isEmpty()) return ResponseEntity.status(401).build();
var user = userOpt.get();
if (request.beschreibung() != null && request.beschreibung().length() > 600) {
return ResponseEntity.badRequest().build();
}
user.setAlter(request.alter());
user.setGroesse(request.groesse());
user.setGewicht(request.gewicht());
user.setGeschlecht(request.geschlecht());
user.setNeigung(request.neigung());
user.setBeziehungsstatus(request.beziehungsstatus());
user.setBeschreibung(request.beschreibung());
userRepository.save(user);
return ResponseEntity.ok().build();
}
@PutMapping("/me/name")
public ResponseEntity<Void> updateName(@RequestBody NameChangeRequest request, Principal principal) {
String newName = request.name();
@@ -169,10 +206,31 @@ public class UserController {
var profileImages = profileImageRepository.findByUserIdOrderByUploadedAtDesc(userId);
for (var img : profileImages) {
profileImageLikeRepository.deleteByImageId(img.getImageId());
kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("IMAGE", img.getImageId())
.forEach(k -> {
kommentarLikeRepository.deleteByKommentarId(k.getKommentarId());
kommentarRepository.delete(k);
});
}
profileImageRepository.deleteAll(profileImages);
profileImageLikeRepository.deleteByUserId(userId);
// 5c. Delete pinnwand entries (authored by or on user's wall) + their likes/comments
var ownWallEntries = pinnwandEintragRepository.findByProfilUserIdOrderByCreatedAtDesc(userId);
for (var e : ownWallEntries) {
pinnwandLikeRepository.deleteByEintragId(e.getEintragId());
kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("PINNWAND", e.getEintragId())
.forEach(k -> {
kommentarLikeRepository.deleteByKommentarId(k.getKommentarId());
kommentarRepository.delete(k);
});
}
pinnwandEintragRepository.deleteAll(ownWallEntries);
pinnwandEintragRepository.deleteByAuthorId(userId);
pinnwandLikeRepository.deleteByUserId(userId);
kommentarRepository.deleteByAuthorId(userId);
kommentarLikeRepository.deleteByUserId(userId);
// 6. Delete user
userRepository.delete(user);

View File

@@ -1,9 +1,6 @@
package de.oaa.xxx.user;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.*;
import java.util.UUID;
@@ -27,6 +24,30 @@ public class UserEntity {
@Column(columnDefinition = "MEDIUMTEXT")
private String profilePictureHq;
@Column(name = "benutzer_alter")
private Integer alter;
@Column
private Integer groesse;
@Column
private Integer gewicht;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private Geschlecht geschlecht;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private Neigung neigung;
@Enumerated(EnumType.STRING)
@Column(length = 30)
private Beziehungsstatus beziehungsstatus;
@Column(columnDefinition = "TEXT")
private String beschreibung;
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
@@ -45,6 +66,27 @@ public class UserEntity {
public String getProfilePictureHq() { return profilePictureHq; }
public void setProfilePictureHq(String profilePictureHq) { this.profilePictureHq = profilePictureHq; }
public Integer getAlter() { return alter; }
public void setAlter(Integer alter) { this.alter = alter; }
public Integer getGroesse() { return groesse; }
public void setGroesse(Integer groesse) { this.groesse = groesse; }
public Integer getGewicht() { return gewicht; }
public void setGewicht(Integer gewicht) { this.gewicht = gewicht; }
public Geschlecht getGeschlecht() { return geschlecht; }
public void setGeschlecht(Geschlecht geschlecht) { this.geschlecht = geschlecht; }
public Neigung getNeigung() { return neigung; }
public void setNeigung(Neigung neigung) { this.neigung = neigung; }
public Beziehungsstatus getBeziehungsstatus() { return beziehungsstatus; }
public void setBeziehungsstatus(Beziehungsstatus beziehungsstatus) { this.beziehungsstatus = beziehungsstatus; }
public String getBeschreibung() { return beschreibung; }
public void setBeschreibung(String beschreibung) { this.beschreibung = beschreibung; }
@Override
public String toString() {
return "UserEntity[userId=" + userId + ", name=" + name + ", email=" + email + "]";
@@ -56,6 +98,13 @@ public class UserEntity {
user.setName(name);
user.setUserId(userId);
user.setProfilePicture(profilePicture);
user.setAlter(alter);
user.setGroesse(groesse);
user.setGewicht(gewicht);
user.setGeschlecht(geschlecht);
user.setNeigung(neigung);
user.setBeziehungsstatus(beziehungsstatus);
user.setBeschreibung(beschreibung);
return user;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,655 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feed 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; }
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; transition:border-color 0.15s; }
.post-compose.drag-over { border-color:var(--color-primary); background:rgba(var(--color-primary-rgb,180,0,60),0.06); }
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
.post-compose textarea:focus { border-color:var(--color-primary); }
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; width:18px; height:18px; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
.umfrage-options { margin-top:0.5rem; }
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
.umfrage-option-row input { flex:1; }
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.privacy-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
label.compose-action-btn { display:inline-flex; align-items:center; }
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-meta { font-size:0.75rem; color:var(--color-muted); }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
.post-action-btn.active { color:var(--color-primary); }
.post-delete { margin-left:auto; }
.post-delete:hover { color:#c0392b !important; }
/* Carousel */
.post-carousel { position:relative; margin-top:0.5rem; }
.car-slide { display:none; }
.car-slide.active { display:block; }
.car-btn { position:absolute; top:50%; transform:translateY(-50%); background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:2.2rem; width:auto; min-width:2.4rem; height:3.2rem; border-radius:8px; cursor:pointer; z-index:5; display:flex; align-items:center; justify-content:center; padding:0 0.5rem; margin:0; line-height:1; }
.car-prev { left:0.3rem; }
.car-next { right:0.3rem; }
.car-indicator { text-align:center; font-size:0.75rem; color:var(--color-muted); margin-top:0.25rem; }
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); transition:width 0.4s; }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
.gruppe-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; color:var(--color-muted); background:var(--color-secondary); border-radius:4px; padding:0.15rem 0.45rem; margin-top:0.1rem; }
.gruppe-badge a { color:inherit; text-decoration:none; }
.gruppe-badge a:hover { color:var(--color-primary); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.sentinel { height:1px; }
/* Lightbox */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:920px; width:95vw; max-height:90vh; background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; gap:0.5rem; flex-shrink:0; align-items:center; }
.lb-comment-compose input { flex:1; font-size:0.85rem; padding:0.35rem 0.6rem; height:auto; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; max-height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
.comment-item { display:flex; gap:0.5rem; margin-bottom:0.5rem; }
.comment-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.75rem; flex-shrink:0; overflow:hidden; }
.comment-avatar img { width:100%; height:100%; object-fit:cover; }
.comment-body { flex:1; background:rgba(255,255,255,0.04); border-radius:6px; padding:0.5rem 0.65rem; }
.comment-author { font-size:0.8rem; font-weight:600; }
.comment-text { font-size:0.85rem; white-space:pre-wrap; word-break:break-word; margin-top:0.2rem; }
.comment-date { font-size:0.72rem; color:var(--color-muted); margin-left:0.4rem; }
.comment-actions { display:flex; gap:0.4rem; margin-top:0.3rem; align-items:center; }
.btn-like { background:none; border:1px solid rgba(255,255,255,0.15); border-radius:20px; padding:0.2rem 0.65rem; color:var(--color-muted); font-size:0.78rem; cursor:pointer; display:inline-flex; align-items:center; gap:0.3rem; margin:0; width:auto; transition:border-color 0.15s, color 0.15s; }
.btn-like:hover, .btn-like.liked { border-color:var(--color-primary); color:var(--color-primary); }
.btn-delete-small { background:none; border:none; color:rgba(200,50,50,0.6); font-size:0.78rem; cursor:pointer; margin:0; width:auto; padding:0; }
.btn-delete-small:hover { color:var(--color-primary); }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="tabs">
<button class="tab-btn active" id="tabMine" onclick="switchTab('mine', this)">Mein Feed</button>
<button class="tab-btn" id="tabPublic" onclick="switchTab('public', this)">Öffentlicher Feed</button>
</div>
<!-- Mein Feed -->
<div class="tab-panel active" id="tab-mine">
<div class="post-compose" id="compose">
<div class="compose-type">
<label><input type="radio" name="beitragTyp" value="TEXT" checked onchange="toggleUmfrage()"> Text</label>
<label><input type="radio" name="beitragTyp" value="UMFRAGE" onchange="toggleUmfrage()"> Umfrage</label>
</div>
<textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="composeThumbs"></div>
<div class="umfrage-options" id="umfrageOptions" style="display:none;">
<div id="optionList"></div>
<button onclick="addOption()" style="width:auto; margin:0; padding:0.3rem 0.75rem; font-size:0.8rem; margin-top:0.4rem;">+ Option</button>
</div>
<div class="compose-footer">
<div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;">
<label class="multi-toggle" id="multiChoiceRow" style="display:none;">
<input type="checkbox" id="multiChoice"> Multi-Choice
</label>
<label class="privacy-toggle">
<input type="checkbox" id="isPublic"> Öffentlich
</label>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji einfügen">😊</button>
<label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)">
</label>
<button onclick="submitPost()" style="width:auto; margin:0;">Veröffentlichen</button>
</div>
</div>
</div>
<div id="mineFeed"></div>
<p class="empty-hint" id="mineEmpty" style="display:none;">Noch keine Beiträge. Schreib den ersten!</p>
<div class="sentinel" id="mineSentinel"></div>
</div>
<!-- Öffentlicher Feed -->
<div class="tab-panel" id="tab-public">
<div id="publicFeed"></div>
<p class="empty-hint" id="publicEmpty" style="display:none;">Noch keine öffentlichen Beiträge.</p>
<div class="sentinel" id="publicSentinel"></div>
</div>
</div>
</div>
<!-- Post Lightbox -->
<div class="lightbox" id="postLightbox">
<div class="lb-layout">
<button class="lb-close" onclick="closeLb()"></button>
<div class="lb-post-side" id="lbPostBody"></div>
<div class="lb-comments-panel">
<div class="lb-comments-header">Kommentare</div>
<div class="lb-comments-list" id="lbCommentsList"></div>
<div class="lb-comment-compose">
<input type="text" id="lbCommentInput" placeholder="Kommentar schreiben…" maxlength="500"
onkeydown="if(event.key==='Enter') postLbComment()">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="postLbComment()">Senden</button>
</div>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
// ── State ──
let myUserId = null;
let activeLbPostId = null;
let activeLbPostType = null;
const feedState = {
mine: { page:0, hasMore:true, loading:false, loaded:false },
public: { page:0, hasMore:true, loading:false, loaded:false }
};
let composeBilderArr = [];
// ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
if (user) {
myUserId = user.userId;
loadFeed('mine');
}
}).catch(() => {});
// ── Tab switching ──
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
if (!feedState[name].loaded) loadFeed(name);
}
// ── Feed loading ──
async function loadFeed(tab) {
const state = feedState[tab];
if (state.loading || !state.hasMore) return;
state.loading = true;
state.loaded = true;
try {
const endpoint = tab === 'mine' ? '/feed/mine' : '/feed/public';
const res = await fetch(`${endpoint}?page=${state.page}&size=10`);
if (!res.ok) return;
const data = await res.json();
const feedEl = document.getElementById(tab + 'Feed');
if (state.page === 0 && data.posts.length === 0) {
document.getElementById(tab + 'Empty').style.display = '';
}
data.posts.forEach(p => feedEl.insertAdjacentHTML('beforeend', renderPostCard(p, tab)));
state.hasMore = data.hasMore;
state.page++;
} finally {
state.loading = false;
}
}
// ── Infinite Scroll ──
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
if (e.target.id === 'mineSentinel') loadFeed('mine');
if (e.target.id === 'publicSentinel') loadFeed('public');
});
}, { threshold: 0.5 });
observer.observe(document.getElementById('mineSentinel'));
observer.observe(document.getElementById('publicSentinel'));
// ── Carousel ──
function bilderCarousel(bilder, postId) {
if (!bilder || bilder.length === 0) return '';
if (bilder.length === 1) {
return `<div style="margin-top:0.5rem;"><img class="post-bild" src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>`;
}
const slides = bilder.map((b, i) =>
`<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>`
).join('');
return `<div class="post-carousel">
${slides}
<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button>
<div class="car-indicator"><span class="car-cur">1</span>/${bilder.length}</div>
</div>`;
}
function carNav(btn, dir) {
const car = btn.closest('.post-carousel');
const slides = Array.from(car.querySelectorAll('.car-slide'));
const cur = slides.findIndex(s => s.classList.contains('active'));
slides[cur].classList.remove('active');
const next = (cur + dir + slides.length) % slides.length;
slides[next].classList.add('active');
const ind = car.querySelector('.car-cur');
if (ind) ind.textContent = next + 1;
}
// ── Render post card ──
function renderPostCard(p, tab) {
const avatarHtml = p.authorPicture
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">`
: '◉';
const privacyLabel = !p.isPublic ? ' <span style="font-size:0.7rem;color:var(--color-muted);">🔒</span>' : '';
const groupBadge = p.postType === 'GROUP' && p.gruppeId
? `<span class="gruppe-badge" onclick="event.stopPropagation()">👥 <a href="/gruppe.html?id=${p.gruppeId}" onclick="event.stopPropagation()">${esc(p.gruppeName)}</a></span>`
: '';
const bildHtml = bilderCarousel(p.bilder, p.postId);
let umfrageHtml = '';
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) {
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
umfrageHtml = '<div style="margin-top:0.5rem;">' + p.optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar${voted ? ' voted' : ''}" onclick="event.stopPropagation(); votePost('${p.postId}','${o.optionId}','${tab}','${p.postType}')">
<div class="umfrage-bar-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
</div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`;
}
const canDelete = p.postType === 'FEED' && p.authorId === myUserId;
const deleteBtn = canDelete
? `<button class="post-action-btn post-delete" onclick="event.stopPropagation(); deletePost('${p.postId}')">🗑</button>`
: '';
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="openLb('${p.postId}','${p.postType}')" style="cursor:pointer;">
<div class="post-header">
<div class="post-avatar">${avatarHtml}</div>
<div>
<div class="post-author"><a href="/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div>
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
</div>
${deleteBtn}
</div>
<div class="post-text">${esc(p.text)}</div>
${bildHtml}
${umfrageHtml}
<div class="post-actions">
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="lk-${p.postId}" onclick="event.stopPropagation(); likePost('${p.postId}','${p.postType}')">
♥ <span id="lkc-${p.postId}">${p.likeCount}</span>
</button>
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')">
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span>
</button>
</div>
</div>`;
}
// ── Compose ──
function toggleUmfrage() {
const isUmfrage = document.querySelector('input[name="beitragTyp"]:checked').value === 'UMFRAGE';
document.getElementById('umfrageOptions').style.display = isUmfrage ? '' : 'none';
document.getElementById('multiChoiceRow').style.display = isUmfrage ? '' : 'none';
if (isUmfrage && document.getElementById('optionList').children.length === 0) {
addOption(); addOption();
}
}
function addOption() {
const list = document.getElementById('optionList');
const idx = list.children.length;
const row = document.createElement('div');
row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${idx + 1}" maxlength="100">
<button onclick="this.parentElement.remove()">✕</button>`;
list.appendChild(row);
}
function selectComposeBilder(input) {
[...input.files].forEach(f => { if (f.type.startsWith('image/')) processImageFile(f); });
input.value = '';
}
function processImageFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const maxSize = 1024;
const canvas = document.createElement('canvas');
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
const data = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
composeBilderArr.push(data);
renderComposeThumbs();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function renderComposeThumbs() {
const container = document.getElementById('composeThumbs');
container.innerHTML = '';
composeBilderArr.forEach((b, i) => {
const div = document.createElement('div');
div.className = 'compose-thumb';
div.innerHTML = `<img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="removeThumb(${i})" title="Entfernen">✕</button>`;
container.appendChild(div);
});
container.style.display = composeBilderArr.length > 0 ? 'flex' : 'none';
}
function removeThumb(idx) {
composeBilderArr.splice(idx, 1);
renderComposeThumbs();
}
// ── Drag & Drop ──
const compose = document.getElementById('compose');
compose.addEventListener('dragover', e => {
e.preventDefault();
if ([...e.dataTransfer.items].some(i => i.type.startsWith('image/')))
compose.classList.add('drag-over');
});
compose.addEventListener('dragleave', e => {
if (!compose.contains(e.relatedTarget)) compose.classList.remove('drag-over');
});
compose.addEventListener('drop', e => {
e.preventDefault();
compose.classList.remove('drag-over');
[...e.dataTransfer.files]
.filter(f => f.type.startsWith('image/'))
.forEach(f => processImageFile(f));
});
async function submitPost() {
const text = document.getElementById('composeText').value.trim();
if (!text) return;
const beitragTyp = document.querySelector('input[name="beitragTyp"]:checked').value;
const multiChoice = document.getElementById('multiChoice').checked;
const isPublic = document.getElementById('isPublic').checked;
let optionen = [];
if (beitragTyp === 'UMFRAGE') {
optionen = Array.from(document.getElementById('optionList').querySelectorAll('input'))
.map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
}
const res = await fetch('/feed/posts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beitragTyp, text, multiChoice, optionen, bilder: [...composeBilderArr], isPublic })
});
if (!res.ok) return;
const post = await res.json();
// Reset compose
document.getElementById('composeText').value = '';
composeBilderArr = [];
renderComposeThumbs();
document.querySelector('input[name="beitragTyp"][value="TEXT"]').checked = true;
toggleUmfrage();
document.getElementById('multiChoice').checked = false;
document.getElementById('isPublic').checked = false;
document.getElementById('optionList').innerHTML = '';
// Prepend to mine feed
document.getElementById('mineEmpty').style.display = 'none';
document.getElementById('mineFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'mine'));
if (isPublic) {
document.getElementById('publicEmpty').style.display = 'none';
document.getElementById('publicFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'public'));
}
}
// ── Like ──
async function likePost(postId, postType) {
let likeEndpoint;
if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId);
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return;
likeEndpoint = `/gruppen/${gruppeId}/posts/${postId}/like`;
} else {
likeEndpoint = `/feed/posts/${postId}/like`;
}
await fetch(likeEndpoint, { method: 'POST' });
const btn = document.getElementById('lk-' + postId);
const lc = document.getElementById('lkc-' + postId);
const was = btn.classList.contains('active');
btn.classList.toggle('active', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
// ── Vote ──
async function votePost(postId, optionId, tab, postType) {
if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId);
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return;
await fetch(`/gruppen/${gruppeId}/posts/${postId}/vote`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
} else {
await fetch('/feed/posts/' + postId + '/vote', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
}
reloadPost(postId, tab);
}
async function reloadPost(postId, tab) {
const state = feedState[tab];
state.page = 0; state.hasMore = true; state.loaded = false;
document.getElementById(tab + 'Feed').innerHTML = '';
document.getElementById(tab + 'Empty').style.display = 'none';
await loadFeed(tab);
}
// ── Delete ──
async function deletePost(postId) {
if (!confirm('Post löschen?')) return;
const res = await fetch('/feed/posts/' + postId, { method: 'DELETE' });
if (res.ok) {
document.getElementById('pc-' + postId)?.remove();
}
}
// ── Lightbox ──
function openLb(postId, postType) {
activeLbPostId = postId;
activeLbPostType = postType;
const card = document.getElementById('pc-' + postId);
if (card) {
const clone = card.cloneNode(true);
clone.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = clone.innerHTML;
}
loadLbComments(postId, postType);
document.getElementById('postLightbox').classList.add('open');
}
function closeLb() {
document.getElementById('postLightbox').classList.remove('open');
activeLbPostId = null;
activeLbPostType = null;
}
document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postLightbox')) closeLb();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
});
async function loadLbComments(postId, postType) {
const targetType = postType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${postId}`);
const comments = await res.json();
document.getElementById('lbCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, targetType, postId)).join('');
}
async function postLbComment() {
if (!activeLbPostId) return;
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const targetType = activeLbPostType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType, targetId: activeLbPostId, text })
});
input.value = '';
await loadLbComments(activeLbPostId, activeLbPostType);
const kcEl = document.getElementById('kc-' + activeLbPostId);
if (kcEl) kcEl.textContent = parseInt(kcEl.textContent) + 1;
}
function renderKommentarHtml(k, targetType, targetId) {
const avatarHtml = k.authorPicture
? `<img src="data:image/png;base64,${k.authorPicture}" alt="">`
: '◉';
const canDelete = k.authorId === myUserId;
return `<div class="comment-item" id="kom-${k.kommentarId}">
<div class="comment-avatar">${avatarHtml}</div>
<div class="comment-body">
<span class="comment-author">${esc(k.authorName)}</span>
<span class="comment-date">${fmtDate(k.createdAt)}</span>
<div class="comment-text">${esc(k.text)}</div>
<div class="comment-actions">
<button class="btn-like${k.likedByMe ? ' liked' : ''}" onclick="likeKommentar('${k.kommentarId}')">
♥ <span id="lkk-${k.kommentarId}">${k.likeCount}</span>
</button>
${canDelete ? `<button class="btn-delete-small" onclick="deleteKommentar('${k.kommentarId}','${targetType}','${targetId}')">✕</button>` : ''}
</div>
</div>
</div>`;
}
async function likeKommentar(kommentarId) {
await fetch('/social/kommentare/' + kommentarId + '/like', { method: 'POST' });
const btn = document.getElementById('lkk-' + kommentarId)?.parentElement;
const lc = document.getElementById('lkk-' + kommentarId);
if (!btn || !lc) return;
const was = btn.classList.contains('liked');
btn.classList.toggle('liked', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
async function deleteKommentar(kommentarId, targetType, targetId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, activeLbPostType);
}
// ── Emoji Picker ──
const EMOJIS = ['😊','😂','❤️','😍','🔥','👍','🥰','😎','🤔','😘','💕','🎉','✨','💋','😈','🫦','🍑','🍆','🔞','🥵','😭','😢','😤','🙄','🤦','🤷','🙏','💪','😏','🤩'];
let emojiTarget = null;
function toggleEmojiPicker(btn, targetId) {
emojiTarget = document.getElementById(targetId);
let picker = document.getElementById('emojiPicker');
if (!picker) {
picker = document.createElement('div');
picker.id = 'emojiPicker';
picker.style.cssText = 'position:fixed;z-index:1000;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:0.5rem;display:flex;flex-wrap:wrap;gap:0.2rem;max-width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
EMOJIS.forEach(em => {
const b = document.createElement('button');
b.textContent = em;
b.style.cssText = 'background:none;border:none;font-size:1.3rem;cursor:pointer;padding:0.2rem;margin:0;width:auto;line-height:1;';
b.onclick = e => { e.stopPropagation(); insertEmoji(em); };
picker.appendChild(b);
});
document.body.appendChild(picker);
}
if (picker.style.display === 'flex') { picker.style.display = 'none'; return; }
picker.style.display = 'flex';
requestAnimationFrame(() => {
const rect = btn.getBoundingClientRect();
const ph = picker.offsetHeight, pw = picker.offsetWidth;
let top = rect.top - ph - 8;
let left = rect.left;
if (top < 8) top = rect.bottom + 8;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
});
}
function insertEmoji(emoji) {
if (!emojiTarget) return;
const start = emojiTarget.selectionStart ?? emojiTarget.value.length;
const end = emojiTarget.selectionEnd ?? start;
emojiTarget.value = emojiTarget.value.slice(0, start) + emoji + emojiTarget.value.slice(end);
emojiTarget.selectionStart = emojiTarget.selectionEnd = start + emoji.length;
emojiTarget.focus();
}
document.addEventListener('click', e => {
const picker = document.getElementById('emojiPicker');
if (picker && picker.style.display === 'flex' && !picker.contains(e.target) && !e.target.closest('[onclick*="toggleEmojiPicker"]')) {
picker.style.display = 'none';
}
});
// ── Helpers ──
function esc(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/\n/g,'<br>');
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
}
</script>
</body>
</html>

View File

@@ -88,6 +88,31 @@
.empty-hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
/* ── Confirm dialog ── */
.dialog-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.dialog-backdrop.visible { display: flex; }
.dialog {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.75rem;
width: 100%;
max-width: 340px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
}
.dialog h3 { color: var(--color-primary); font-size: 1.05rem; margin-bottom: 0.75rem; }
.dialog p { color: var(--color-muted); font-size: 0.88rem; margin-bottom: 1.25rem; }
.dialog-actions { display: flex; gap: 0.75rem; }
.dialog-actions button { flex: 1; margin-top: 0; }
.tab-badge {
display: inline-block;
background: var(--color-primary);
@@ -128,6 +153,18 @@
</div>
</div>
<!-- Confirm remove dialog -->
<div class="dialog-backdrop" id="removeDialog">
<div class="dialog">
<h3>Freundschaft beenden</h3>
<p id="removeDialogText">Möchtest du diese Freundschaft wirklich beenden?</p>
<div class="dialog-actions">
<button class="secondary" onclick="closeRemoveDialog()">Abbrechen</button>
<button id="removeConfirmBtn" style="background:#c0392b;" onclick="confirmRemove()">Entfernen</button>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
@@ -253,24 +290,52 @@
}
}
async function removeFriend(friendshipId, btn) {
let pendingRemoveFriendshipId = null;
let pendingRemoveBtn = null;
function removeFriend(friendshipId, btn) {
pendingRemoveFriendshipId = friendshipId;
pendingRemoveBtn = btn;
document.getElementById('removeDialogText').textContent =
'Möchtest du ' + (btn.closest('.user-item').querySelector('.user-name')?.textContent || 'diese Person') + ' wirklich aus deiner Freundesliste entfernen?';
document.getElementById('removeDialog').classList.add('visible');
}
function closeRemoveDialog() {
document.getElementById('removeDialog').classList.remove('visible');
pendingRemoveFriendshipId = null;
pendingRemoveBtn = null;
}
async function confirmRemove() {
if (!pendingRemoveFriendshipId) return;
const btn = document.getElementById('removeConfirmBtn');
btn.disabled = true;
btn.textContent = '…';
try {
await fetch('/social/friends/reject', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId })
body: JSON.stringify({ friendshipId: pendingRemoveFriendshipId })
});
document.getElementById('friend-' + friendshipId)?.remove();
document.getElementById('friend-' + pendingRemoveFriendshipId)?.remove();
const list = document.getElementById('friendsList');
if (list.children.length === 0) {
document.getElementById('friendsEmpty').style.display = '';
}
closeRemoveDialog();
} catch (e) {
if (pendingRemoveBtn) pendingRemoveBtn.disabled = false;
} finally {
btn.disabled = false;
btn.textContent = 'Entfernen';
}
}
document.getElementById('removeDialog').addEventListener('click', e => {
if (e.target === document.getElementById('removeDialog')) closeRemoveDialog();
});
loadFriends();
loadPending();
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,403 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gruppen 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; }
.gruppe-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1rem; margin-top:0.5rem; }
.gruppe-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
.gruppe-card-img { width:100%; height:110px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:2.5rem; }
.gruppe-card-img img { width:100%; height:100%; object-fit:cover; }
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
.gruppe-card-name { font-weight:700; font-size:1rem; }
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
.gruppe-card-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:auto; padding-top:0.5rem; }
.gruppe-card-actions button, .gruppe-card-actions a.btn { margin-top:0; padding:0.3rem 0.7rem; font-size:0.8rem; width:auto; }
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
.search-row { display:flex; gap:0.75rem; margin-bottom:1rem; }
.search-row input { flex:1; }
.search-row button { white-space:nowrap; width:auto; margin-top:0; }
.anfrage-list { list-style:none; margin:0; padding:0; }
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
.anfrage-item:last-child { border-bottom:none; }
.anfrage-name { font-weight:600; }
.anfrage-status { font-size:0.8rem; color:var(--color-muted); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
/* Dialog */
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
.dialog-backdrop.visible { display:flex; }
.dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; }
.dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
.dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; }
.dialog input, .dialog textarea { width:100%; box-sizing:border-box; }
.dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; }
.dialog textarea:focus { border-color:var(--color-primary); }
.dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
.dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; }
.toggle-row { display:flex; align-items:center; gap:0.75rem; margin-top:0.75rem; }
.toggle-row label { margin:0; font-size:0.9rem; color:var(--color-text); }
.img-preview { width:100%; max-height:140px; object-fit:cover; border-radius:6px; margin-top:0.5rem; display:none; }
.card-notif { font-size:0.75rem; font-weight:700; color:#fff; background:#e67e22; border-radius:4px; padding:0.15rem 0.45rem; display:none; width:fit-content; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
<h1 style="margin:0;">Gruppen</h1>
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('mine', this)">Meine Gruppen</button>
<button class="tab-btn" onclick="switchTab('discover', this)">Entdecken</button>
<button class="tab-btn" onclick="switchTab('requests', this)">Meine Anfragen</button>
</div>
<!-- Meine Gruppen -->
<div class="tab-panel active" id="tab-mine">
<div class="gruppe-grid" id="mineGrid"></div>
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
</div>
<!-- Entdecken -->
<div class="tab-panel" id="tab-discover">
<div class="search-row">
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" onkeydown="if(event.key==='Enter')doSearch()">
<button onclick="doSearch()">Suchen</button>
</div>
<div class="gruppe-grid" id="discoverGrid"></div>
<p class="empty-hint" id="discoverHint">Gib einen Suchbegriff ein.</p>
</div>
<!-- Meine Anfragen -->
<div class="tab-panel" id="tab-requests">
<ul class="anfrage-list" id="requestsList"></ul>
<p class="empty-hint" id="requestsEmpty" style="display:none;">Keine ausstehenden Anfragen.</p>
</div>
</div>
</div>
<!-- Gruppe erstellen Dialog -->
<div class="dialog-backdrop" id="createDialog">
<div class="dialog">
<h3>Gruppe erstellen</h3>
<label>Name *</label>
<input type="text" id="createName" maxlength="100" placeholder="Gruppenname">
<label>Beschreibung</label>
<textarea id="createDesc" maxlength="1000" placeholder="Worum geht es in dieser Gruppe?"></textarea>
<label>Bild (optional)</label>
<input type="file" id="createBildFile" accept="image/*" onchange="previewBild(this,'createBildPreview','createBildData')">
<img id="createBildPreview" class="img-preview" alt="">
<input type="hidden" id="createBildData">
<div class="toggle-row">
<input type="checkbox" id="createPrivate">
<label for="createPrivate">Private Gruppe (Beitritt nur per Anfrage)</label>
</div>
<p class="message error" id="createError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" onclick="closeCreateDialog()">Abbrechen</button>
<button onclick="createGruppe()">Erstellen</button>
</div>
</div>
</div>
<!-- Beitrittsanfrage Dialog -->
<div class="dialog-backdrop" id="joinDialog">
<div class="dialog">
<h3>Beitrittsanfrage senden</h3>
<p id="joinDialogGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
<label>Nachricht (optional)</label>
<textarea id="joinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
<p class="message error" id="joinError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" onclick="closeJoinDialog()">Abbrechen</button>
<button onclick="sendJoinRequest()">Anfrage senden</button>
</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');
if (name === 'mine') loadMine();
if (name === 'requests') loadRequests();
}
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
function gruppeCard(g, showJoin = false) {
const img = g.bild
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
: `<div class="gruppe-card-img">👥</div>`;
const roleBadge = g.myRole
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
: '';
const privBadge = g.isPrivate ? ' 🔒' : '';
let actions = '';
if (showJoin && !g.myRole) {
if (g.myRequestStatus === 'AUSSTEHEND') {
actions = `<button disabled style="opacity:0.6;" onclick="event.stopPropagation()">Anfrage ausstehend</button>`;
} else if (g.isPrivate) {
actions = `<button onclick="event.stopPropagation(); openJoinDialog('${g.gruppeId}','${esc(g.name)}')">Anfrage senden</button>`;
} else {
actions = `<button onclick="event.stopPropagation(); joinGruppe('${g.gruppeId}', this)">Beitreten</button>`;
}
}
return `
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
${img}
<div class="gruppe-card-body">
<div class="gruppe-card-name">${esc(g.name)}${privBadge} ${roleBadge}</div>
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
<div class="card-notif" id="notif-${g.gruppeId}"></div>
${actions ? `<div class="gruppe-card-actions">${actions}</div>` : ''}
</div>
</div>`;
}
async function loadMine() {
try {
const res = await fetch('/gruppen/mine');
if (!res.ok) return;
const data = await res.json();
const grid = document.getElementById('mineGrid');
grid.innerHTML = '';
if (data.length === 0) { document.getElementById('mineEmpty').style.display = ''; return; }
document.getElementById('mineEmpty').style.display = 'none';
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g)));
loadAdminBadges(data);
} catch(e) { console.error(e); }
}
async function loadAdminBadges(groups) {
const adminGroups = groups.filter(g => g.myRole === 'ADMIN');
await Promise.all(adminGroups.map(async g => {
const [reqRes, repRes] = await Promise.all([
fetch('/gruppen/' + g.gruppeId + '/requests'),
fetch('/gruppen/' + g.gruppeId + '/reports')
]);
const reqs = reqRes.ok ? await reqRes.json() : [];
const reps = repRes.ok ? await repRes.json() : [];
const total = reqs.length + reps.length;
if (total === 0) return;
const el = document.getElementById('notif-' + g.gruppeId);
if (!el) return;
const parts = [];
if (reqs.length > 0) parts.push(reqs.length + ' Anfrage' + (reqs.length !== 1 ? 'n' : ''));
if (reps.length > 0) parts.push(reps.length + ' Meldung' + (reps.length !== 1 ? 'en' : ''));
el.textContent = '🔔 ' + parts.join(' · ');
el.style.display = '';
}));
}
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
try {
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
const data = await res.json();
const grid = document.getElementById('discoverGrid');
const hint = document.getElementById('discoverHint');
grid.innerHTML = '';
if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; }
hint.style.display = 'none';
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true)));
} catch(e) { console.error(e); }
}
async function joinGruppe(gruppeId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
if (res.ok || res.status === 201) {
btn.textContent = 'Beigetreten ✓';
setTimeout(loadMine, 500);
} else {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
} catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; }
}
async function loadRequests() {
try {
const reqRes = await fetch('/gruppen/requests/mine');
if (!reqRes.ok) { document.getElementById('requestsEmpty').style.display = ''; return; }
const data = await reqRes.json();
const list = document.getElementById('requestsList');
list.innerHTML = '';
if (data.length === 0) { document.getElementById('requestsEmpty').style.display = ''; return; }
document.getElementById('requestsEmpty').style.display = 'none';
data.forEach(r => {
list.insertAdjacentHTML('beforeend', `
<li class="anfrage-item" id="req-${r.anfrageId}">
<div>
<div class="anfrage-name">${esc(r.gruppeName)}</div>
<div class="anfrage-status">Ausstehend seit ${fmtDate(r.angefragtAt)}</div>
${r.nachricht ? `<div style="font-size:0.82rem;color:var(--color-muted);margin-top:0.2rem;">"${esc(r.nachricht)}"</div>` : ''}
</div>
<button class="secondary" onclick="withdrawRequest('${r.gruppeId}', '${r.anfrageId}', this)">Zurückziehen</button>
</li>`);
});
} catch(e) { console.error(e); }
}
async function withdrawRequest(gruppeId, anfrageId, btn) {
btn.disabled = true;
try {
await fetch('/gruppen/' + gruppeId + '/requests/mine', { method: 'DELETE' });
document.getElementById('req-' + anfrageId)?.remove();
if (document.getElementById('requestsList').children.length === 0)
document.getElementById('requestsEmpty').style.display = '';
} catch(e) { btn.disabled = false; }
}
function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
}
// ── Create dialog ──
function openCreateDialog() {
document.getElementById('createName').value = '';
document.getElementById('createDesc').value = '';
document.getElementById('createPrivate').checked = false;
document.getElementById('createBildData').value = '';
document.getElementById('createBildPreview').style.display = 'none';
document.getElementById('createBildFile').value = '';
document.getElementById('createDialog').classList.add('visible');
}
function closeCreateDialog() { document.getElementById('createDialog').classList.remove('visible'); document.getElementById('createError').style.display = 'none'; }
function showCreateError(text) {
const el = document.getElementById('createError');
el.textContent = text;
el.style.display = 'block';
}
async function createGruppe() {
const name = document.getElementById('createName').value.trim();
if (!name) { showCreateError('Bitte einen Namen eingeben.'); return; }
document.getElementById('createError').style.display = 'none';
const body = {
name,
beschreibung: document.getElementById('createDesc').value.trim() || null,
bild: document.getElementById('createBildData').value || null,
isPrivate: document.getElementById('createPrivate').checked
};
try {
const res = await fetch('/gruppen', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (res.ok || res.status === 201) {
const g = await res.json();
closeCreateDialog();
window.location.href = '/gruppe.html?gruppeId=' + g.gruppeId;
} else {
showCreateError('Fehler beim Erstellen der Gruppe.');
}
} catch(e) { showCreateError('Fehler: ' + e.message); }
}
// ── Join dialog ──
let pendingJoinGruppeId = null;
function openJoinDialog(gruppeId, name) {
pendingJoinGruppeId = gruppeId;
document.getElementById('joinDialogGroupName').textContent = name;
document.getElementById('joinNachricht').value = '';
document.getElementById('joinDialog').classList.add('visible');
}
function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; }
async function sendJoinRequest() {
if (!pendingJoinGruppeId) return;
document.getElementById('joinError').style.display = 'none';
const nachricht = document.getElementById('joinNachricht').value.trim() || null;
try {
const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nachricht })
});
if (res.ok || res.status === 201) {
closeJoinDialog();
doSearch();
} else {
const el = document.getElementById('joinError');
el.textContent = 'Fehler beim Senden der Anfrage.';
el.style.display = 'block';
}
} catch(e) {
const el = document.getElementById('joinError');
el.textContent = 'Fehler: ' + e.message;
el.style.display = 'block';
}
}
// ── Image preview ──
function previewBild(input, previewId, dataId) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const original = new Image();
original.onload = () => {
const MAX = 256;
let w = original.width, h = original.height;
if (w > MAX || h > MAX) {
if (w > h) { h = Math.round(h * MAX / w); w = MAX; }
else { w = Math.round(w * MAX / h); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(original, 0, 0, w, h);
const scaled = canvas.toDataURL('image/jpeg', 0.85);
const img = document.getElementById(previewId);
img.src = scaled;
img.style.display = 'block';
document.getElementById(dataId).value = scaled.split(',')[1];
};
original.src = e.target.result;
};
reader.readAsDataURL(file);
}
document.getElementById('createDialog').addEventListener('click', e => {
if (e.target === document.getElementById('createDialog')) closeCreateDialog();
});
document.getElementById('joinDialog').addEventListener('click', e => {
if (e.target === document.getElementById('joinDialog')) closeJoinDialog();
});
loadMine();
</script>
</body>
</html>

View File

@@ -136,7 +136,7 @@
);
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(); })
l.addEventListener('click', () => { if (window.innerWidth <= (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--breakpoint-mobile').trim()) || 768)) closeMenu(); })
);
// Social sidebar auf allen App-Seiten nachladen

View File

@@ -5,9 +5,11 @@
const path = window.location.pathname;
const links = [
{ href: '/feed.html', icon: '📰', label: 'Feed', badgeId: null, mobileBadgeId: null },
{ href: '/personen-suchen.html', icon: '⊕', label: 'Personen suchen', badgeId: null, mobileBadgeId: null },
{ href: '/freunde.html', icon: '♡', label: 'Freunde', badgeId: 'socialFriendsBadge', mobileBadgeId: 'socialMobileFriendsBadge' },
{ href: '/nachrichten.html', icon: '✉', label: 'Nachrichten', badgeId: 'socialMsgBadge', mobileBadgeId: 'socialMobileMsgBadge' },
{ href: '/gruppen.html', icon: '👥', label: 'Gruppen', badgeId: 'socialGruppenBadge', mobileBadgeId: 'socialMobileGruppenBadge' },
];
const profileActive = (path === '/benutzer.html' || path === '/profile.html') ? ' class="active"' : '';
@@ -108,4 +110,10 @@
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialMsgBadge', 'socialMobileMsgBadge'], n))
.catch(() => {});
Promise.all([
fetch('/gruppen/requests/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/gruppen/reports/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
]).then(([joins, reports]) => setBadge(['socialGruppenBadge', 'socialMobileGruppenBadge'], joins + reports))
.catch(() => {});
})();

View File

@@ -168,6 +168,60 @@
color: var(--color-primary);
}
/* ── Profile extras ── */
.profile-extras-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1rem;
margin-bottom: 1rem;
}
.profile-extras-grid .full-col {
grid-column: 1 / -1;
}
select {
width: 100%;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
appearance: none;
-webkit-appearance: none;
}
select:focus { border-color: var(--color-primary); }
textarea {
width: 100%;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 1rem;
outline: none;
resize: vertical;
min-height: 100px;
transition: border-color 0.2s;
font-family: inherit;
}
textarea:focus { border-color: var(--color-primary); }
.char-count {
font-size: 0.75rem;
color: var(--color-muted);
text-align: right;
margin-top: 0.25rem;
}
.char-count.over { color: var(--color-primary); }
/* ── Modal ── */
.modal-backdrop {
display: none;
@@ -237,6 +291,60 @@
</div>
</div>
<!-- Freiwillige Profilangaben -->
<div class="gallery-section-label" style="margin-top:1.25rem;">Freiwillige Angaben</div>
<div class="profile-extras-grid">
<div>
<label>Alter</label>
<input type="number" id="profileAlter" min="18" max="99" placeholder="—">
</div>
<div>
<label>Größe (cm)</label>
<input type="number" id="profileGroesse" min="100" max="250" placeholder="—">
</div>
<div>
<label>Gewicht (kg)</label>
<input type="number" id="profileGewicht" min="30" max="300" placeholder="—">
</div>
<div>
<label>Geschlecht</label>
<select id="profileGeschlecht">
<option value="">— keine Angabe —</option>
<option value="WEIBLICH">weiblich</option>
<option value="DIVERS">divers</option>
<option value="MAENNLICH">männlich</option>
</select>
</div>
<div class="full-col">
<label>Neigung</label>
<select id="profileNeigung">
<option value="">— keine Angabe —</option>
<option value="DEVOT">devot</option>
<option value="EHER_DEVOT">eher devot</option>
<option value="SWITCHER">Switcher</option>
<option value="EHER_DOMINANT">eher dominant</option>
<option value="DOMINANT">dominant</option>
<option value="KEINES">keines</option>
</select>
</div>
<div class="full-col">
<label>Beziehungsstatus</label>
<select id="profileBeziehungsstatus">
<option value="">— keine Angabe —</option>
<option value="SINGLE">single</option>
<option value="IN_EINER_BEZIEHUNG">in einer Beziehung</option>
<option value="VERHEIRATET">verheiratet</option>
<option value="IN_EINER_OFFENEN_BEZIEHUNG">in einer offenen Beziehung</option>
<option value="IN_EINER_OFFENEN_EHE">in einer offenen Ehe</option>
</select>
</div>
<div class="full-col">
<label>Über mich</label>
<textarea id="profileBeschreibung" maxlength="600" placeholder="Erzähl etwas über dich…" oninput="updateCharCount()"></textarea>
<div class="char-count" id="charCount">0 / 600</div>
</div>
</div>
<div class="btn-delete-row">
<button class="btn-delete" onclick="openDeleteDialog()">Konto löschen</button>
</div>
@@ -303,6 +411,7 @@
<script>
let currentPicture = null;
let currentPictureHq = null;
let pictureDirty = false;
fetch('/login/me')
.then(r => {
@@ -317,6 +426,17 @@
currentPicture = user.profilePicture;
renderPicture(currentPicture);
}
// Fill optional profile fields
if (user.alter) document.getElementById('profileAlter').value = user.alter;
if (user.groesse) document.getElementById('profileGroesse').value = user.groesse;
if (user.gewicht) document.getElementById('profileGewicht').value = user.gewicht;
if (user.geschlecht) document.getElementById('profileGeschlecht').value = user.geschlecht;
if (user.neigung) document.getElementById('profileNeigung').value = user.neigung;
if (user.beziehungsstatus) document.getElementById('profileBeziehungsstatus').value = user.beziehungsstatus;
if (user.beschreibung) {
document.getElementById('profileBeschreibung').value = user.beschreibung;
updateCharCount();
}
myUserId = user.userId;
loadOwnGallery();
})
@@ -331,6 +451,7 @@
const file = e.target.files[0];
if (!file) return;
[currentPicture, currentPictureHq] = await Promise.all([toBase64(file, 96), toBase64(file, 1024)]);
pictureDirty = true;
renderPicture(currentPicture);
});
@@ -339,30 +460,69 @@
el.innerHTML = `<img src="data:image/png;base64,${base64}" alt="Profilbild">`;
}
function updateCharCount() {
const ta = document.getElementById('profileBeschreibung');
const el = document.getElementById('charCount');
const len = ta.value.length;
el.textContent = len + ' / 600';
el.classList.toggle('over', len > 600);
}
async function saveProfile() {
const btn = document.getElementById('saveBtn');
btn.disabled = true;
btn.textContent = 'Wird gespeichert…';
hideMessage();
const beschreibung = document.getElementById('profileBeschreibung').value.trim();
if (beschreibung.length > 600) {
showMessage('Die Beschreibung darf maximal 600 Zeichen lang sein.', 'error');
btn.disabled = false;
btn.textContent = 'Profil speichern';
return;
}
const toNullOrInt = id => {
const v = document.getElementById(id).value;
return v ? parseInt(v, 10) : null;
};
const toNullOrStr = id => {
const v = document.getElementById(id).value;
return v || null;
};
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';
const [picRes, profileRes] = await Promise.all([
pictureDirty ? fetch('/user/me/picture', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ picture: currentPicture, pictureHq: currentPictureHq })
}) : Promise.resolve({ ok: true }),
fetch('/user/me/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
alter: toNullOrInt('profileAlter'),
groesse: toNullOrInt('profileGroesse'),
gewicht: toNullOrInt('profileGewicht'),
geschlecht: toNullOrStr('profileGeschlecht'),
neigung: toNullOrStr('profileNeigung'),
beziehungsstatus: toNullOrStr('profileBeziehungsstatus'),
beschreibung: beschreibung || null
})
})
]);
if (picRes.ok && profileRes.ok) {
showMessage('Gespeichert!', 'success');
} else {
showMessage(`Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Profil speichern';
showMessage(`Fehler beim Speichern.`, 'error');
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
console.error(err);
} finally {
btn.disabled = false;
btn.textContent = 'Profil speichern';
console.error(err);
}
}