From d42f9b5339c85b375809571c9de44a77a10f40e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Sun, 30 Nov 2025 10:33:28 +0100 Subject: [PATCH] More fixes --- .../fitpub/controller/TimelineController.java | 13 +- .../fitpub/model/dto/TimelineActivityDTO.java | 5 + .../fitpub/service/TimelineService.java | 50 ++- src/main/resources/static/js/timeline.js | 97 +++++- .../templates/activities/detail.html | 316 ++++++++++++++++++ 5 files changed, 466 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/operaton/fitpub/controller/TimelineController.java b/src/main/java/org/operaton/fitpub/controller/TimelineController.java index 5b02cc0..1fef6d8 100644 --- a/src/main/java/org/operaton/fitpub/controller/TimelineController.java +++ b/src/main/java/org/operaton/fitpub/controller/TimelineController.java @@ -72,22 +72,31 @@ public class TimelineController { /** * Get the public timeline. * Shows all public activities from all users. + * Optionally authenticated - if user is logged in, will show which activities they've liked. * * GET /api/timeline/public?page=0&size=20 * + * @param userDetails the authenticated user details (optional) * @param page page number (default: 0) * @param size page size (default: 20) * @return page of timeline activities */ @GetMapping("/public") public ResponseEntity> getPublicTimeline( + @AuthenticationPrincipal(errorOnInvalidType = false) UserDetails userDetails, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - log.debug("Public timeline request"); + UUID userId = null; + if (userDetails != null) { + userId = getUserId(userDetails); + log.debug("Public timeline request from authenticated user: {}", userId); + } else { + log.debug("Public timeline request (unauthenticated)"); + } Pageable pageable = PageRequest.of(page, size); - Page timeline = timelineService.getPublicTimeline(pageable); + Page timeline = timelineService.getPublicTimeline(userId, pageable); return ResponseEntity.ok(timeline); } diff --git a/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java b/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java index 4a1b8d1..e8c282c 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/TimelineActivityDTO.java @@ -38,6 +38,11 @@ public class TimelineActivityDTO { private String avatarUrl; private boolean isLocal; + // Social interaction counts + private Long likesCount; + private Long commentsCount; + private Boolean likedByCurrentUser; + // Metrics summary private ActivityMetricsSummary metrics; diff --git a/src/main/java/org/operaton/fitpub/service/TimelineService.java b/src/main/java/org/operaton/fitpub/service/TimelineService.java index 38d15c7..2575dac 100644 --- a/src/main/java/org/operaton/fitpub/service/TimelineService.java +++ b/src/main/java/org/operaton/fitpub/service/TimelineService.java @@ -33,6 +33,8 @@ public class TimelineService { private final ActivityRepository activityRepository; private final FollowRepository followRepository; private final UserRepository userRepository; + private final org.operaton.fitpub.repository.LikeRepository likeRepository; + private final org.operaton.fitpub.repository.CommentRepository commentRepository; @Value("${fitpub.base-url}") private String baseUrl; @@ -74,12 +76,19 @@ public class TimelineService { if (activityUser == null) { return null; } - return TimelineActivityDTO.fromActivity( + TimelineActivityDTO dto = TimelineActivityDTO.fromActivity( activity, activityUser.getUsername(), activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(), activityUser.getAvatarUrl() ); + + // Add social interaction counts + dto.setLikesCount(likeRepository.countByActivityId(activity.getId())); + dto.setCommentsCount(commentRepository.countByActivityIdAndNotDeleted(activity.getId())); + dto.setLikedByCurrentUser(likeRepository.existsByActivityIdAndUserId(activity.getId(), userId)); + + return dto; }) .filter(dto -> dto != null) .collect(Collectors.toList()); @@ -91,11 +100,12 @@ public class TimelineService { * Get the public timeline. * Shows all public activities from all users. * + * @param userId optional user ID for checking liked status (null for unauthenticated) * @param pageable pagination parameters * @return page of timeline activities */ @Transactional(readOnly = true) - public Page getPublicTimeline(Pageable pageable) { + public Page getPublicTimeline(UUID userId, Pageable pageable) { log.debug("Fetching public timeline"); // Fetch all public activities @@ -111,12 +121,25 @@ public class TimelineService { if (activityUser == null) { return null; } - return TimelineActivityDTO.fromActivity( + TimelineActivityDTO dto = TimelineActivityDTO.fromActivity( activity, activityUser.getUsername(), activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(), activityUser.getAvatarUrl() ); + + // Add social interaction counts + dto.setLikesCount(likeRepository.countByActivityId(activity.getId())); + dto.setCommentsCount(commentRepository.countByActivityIdAndNotDeleted(activity.getId())); + + // Check if current user liked this activity (if authenticated) + if (userId != null) { + dto.setLikedByCurrentUser(likeRepository.existsByActivityIdAndUserId(activity.getId(), userId)); + } else { + dto.setLikedByCurrentUser(false); + } + + return dto; }) .filter(dto -> dto != null) .collect(Collectors.toList()); @@ -141,12 +164,21 @@ public class TimelineService { Page activities = activityRepository.findByUserIdOrderByStartedAtDesc(userId, pageable); List timelineActivities = activities.getContent().stream() - .map(activity -> TimelineActivityDTO.fromActivity( - activity, - user.getUsername(), - user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(), - user.getAvatarUrl() - )) + .map(activity -> { + TimelineActivityDTO dto = TimelineActivityDTO.fromActivity( + activity, + user.getUsername(), + user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(), + user.getAvatarUrl() + ); + + // Add social interaction counts + dto.setLikesCount(likeRepository.countByActivityId(activity.getId())); + dto.setCommentsCount(commentRepository.countByActivityIdAndNotDeleted(activity.getId())); + dto.setLikedByCurrentUser(likeRepository.existsByActivityIdAndUserId(activity.getId(), userId)); + + return dto; + }) .collect(Collectors.toList()); return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements()); diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js index 1463832..d0c5830 100644 --- a/src/main/resources/static/js/timeline.js +++ b/src/main/resources/static/js/timeline.js @@ -44,6 +44,8 @@ const FitPubTimeline = { switch (this.timelineType) { case 'public': endpoint = `/api/timeline/public?page=${page}&size=20`; + // Public timeline is optionally authenticated + fetchOptions = { useAuth: FitPubAuth.isAuthenticated() }; break; case 'federated': endpoint = `/api/timeline/federated?page=${page}&size=20`; @@ -178,13 +180,24 @@ const FitPubTimeline = { -
+
+ View Details - - - ${activity.visibility} + + ${activity.commentsCount > 0 ? ` ${activity.commentsCount}` : ''} + + + ${activity.visibility} +
@@ -198,6 +211,82 @@ const FitPubTimeline = { this.renderPreviewMap(activity); }); }, 100); + + // Setup like button handlers + this.setupLikeButtons(); + }, + + /** + * Setup like button click handlers + */ + setupLikeButtons: function() { + const likeButtons = document.querySelectorAll('.like-btn'); + + likeButtons.forEach(btn => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + + // Check if user is authenticated + if (!FitPubAuth.isAuthenticated()) { + window.location.href = '/login'; + return; + } + + const activityId = btn.dataset.activityId; + const isLiked = btn.dataset.liked === 'true'; + const icon = btn.querySelector('i'); + const countSpan = btn.querySelector('.like-count'); + + try { + // Disable button during request + btn.disabled = true; + + if (isLiked) { + // Unlike + const response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/likes`, + { method: 'DELETE' } + ); + + if (response.ok) { + // Update UI + btn.classList.remove('btn-danger'); + btn.classList.add('btn-outline-danger'); + icon.classList.remove('bi-heart-fill'); + icon.classList.add('bi-heart'); + btn.dataset.liked = 'false'; + + // Update count + const currentCount = parseInt(countSpan.textContent) || 0; + countSpan.textContent = Math.max(0, currentCount - 1); + } + } else { + // Like + const response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/likes`, + { method: 'POST' } + ); + + if (response.ok) { + // Update UI + btn.classList.remove('btn-outline-danger'); + btn.classList.add('btn-danger'); + icon.classList.remove('bi-heart'); + icon.classList.add('bi-heart-fill'); + btn.dataset.liked = 'true'; + + // Update count + const currentCount = parseInt(countSpan.textContent) || 0; + countSpan.textContent = currentCount + 1; + } + } + } catch (error) { + console.error('Error toggling like:', error); + } finally { + btn.disabled = false; + } + }); + }); }, /** diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 5235cab..50c9d4e 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -166,6 +166,76 @@ + +
+
+
+
+
+ Social +
+
+ +
+
+
+ +
+
+ + Liked by 0 +
+
+ +
+
+ + +
+
+ + Comments (0) +
+ + + + + +
+ +
+ + + +
+
+
+
+
+
@@ -287,6 +357,11 @@ // Additional metrics renderAdditionalMetrics(activity); + + // Load social interactions + loadLikes(); + loadComments(); + setupSocialInteractions(activity); } function renderMap(simplifiedTrack) { @@ -415,6 +490,247 @@ } } + // Social interactions functionality + async function loadLikes() { + try { + const response = await fetch(`/api/activities/${activityId}/likes`); + if (response.ok) { + const likes = await response.json(); + renderLikes(likes); + } + } catch (error) { + console.error('Error loading likes:', error); + } + } + + function renderLikes(likes) { + const likesList = document.getElementById('likesList'); + const likeCount = document.getElementById('likeCount'); + const likesCountText = document.getElementById('likesCountText'); + + likeCount.textContent = likes.length; + likesCountText.textContent = likes.length; + + if (likes.length === 0) { + likesList.innerHTML = 'No likes yet'; + return; + } + + likesList.innerHTML = likes.map(like => { + const displayName = like.displayName || like.username || 'Unknown'; + const avatarHtml = like.avatarUrl + ? `${displayName}` + : ``; + + return ` +
+ ${avatarHtml} + ${displayName} +
+ `; + }).join(''); + } + + async function loadComments() { + try { + const response = await fetch(`/api/activities/${activityId}/comments`); + if (response.ok) { + const commentsPage = await response.json(); + renderComments(commentsPage.content || []); + } + } catch (error) { + console.error('Error loading comments:', error); + } + } + + function renderComments(comments) { + const commentsList = document.getElementById('commentsList'); + const commentsCount = document.getElementById('commentsCount'); + + commentsCount.textContent = comments.length; + + if (comments.length === 0) { + commentsList.innerHTML = '

No comments yet. Be the first to comment!

'; + return; + } + + commentsList.innerHTML = comments.map(comment => { + const displayName = comment.displayName || comment.username || 'Unknown'; + const avatarHtml = comment.avatarUrl + ? `${displayName}` + : ``; + + const createdAt = new Date(comment.createdAt).toLocaleString(); + const deleteBtn = comment.canDelete + ? `` + : ''; + + return ` +
+
+ ${avatarHtml} +
+
+
+
+ ${displayName} + ${createdAt} +
+ ${deleteBtn} +
+

${escapeHtml(comment.content)}

+
+
+ `; + }).join(''); + + // Add delete event listeners + document.querySelectorAll('.delete-comment-btn').forEach(btn => { + btn.addEventListener('click', async function() { + if (confirm('Delete this comment?')) { + await deleteComment(this.dataset.commentId); + } + }); + }); + } + + function setupSocialInteractions(activity) { + const isAuthenticated = FitPubAuth.isAuthenticated(); + + if (isAuthenticated) { + // Show comment form for authenticated users + document.getElementById('commentForm').style.display = 'block'; + + // Update like button based on activity data + if (activity.likedByCurrentUser) { + updateLikeButton(true); + } + + // Setup like button click handler + document.getElementById('likeBtn').addEventListener('click', handleLikeClick); + + // Setup comment form submit handler + document.getElementById('addCommentForm').addEventListener('submit', handleCommentSubmit); + } else { + // Show login prompt for non-authenticated users + document.getElementById('loginPrompt').style.display = 'block'; + document.getElementById('likeBtn').disabled = true; + } + } + + async function handleLikeClick(event) { + event.preventDefault(); + const btn = event.currentTarget; + const isLiked = btn.classList.contains('btn-danger'); + + try { + if (isLiked) { + // Unlike + const response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/likes`, + { method: 'DELETE' } + ); + + if (response.ok) { + updateLikeButton(false); + loadLikes(); // Reload likes list + } + } else { + // Like + const response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/likes`, + { method: 'POST' } + ); + + if (response.ok) { + updateLikeButton(true); + loadLikes(); // Reload likes list + } + } + } catch (error) { + console.error('Error toggling like:', error); + FitPub.showAlert('Failed to update like. Please try again.', 'danger'); + } + } + + function updateLikeButton(isLiked) { + const btn = document.getElementById('likeBtn'); + const btnText = document.getElementById('likeBtnText'); + const icon = btn.querySelector('i'); + + if (isLiked) { + btn.classList.remove('btn-outline-danger'); + btn.classList.add('btn-danger'); + icon.classList.remove('bi-heart'); + icon.classList.add('bi-heart-fill'); + btnText.textContent = 'Liked'; + } else { + btn.classList.remove('btn-danger'); + btn.classList.add('btn-outline-danger'); + icon.classList.remove('bi-heart-fill'); + icon.classList.add('bi-heart'); + btnText.textContent = 'Like'; + } + } + + async function handleCommentSubmit(event) { + event.preventDefault(); + + const contentInput = document.getElementById('commentContent'); + const content = contentInput.value.trim(); + + if (!content) return; + + try { + const response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/comments`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }) + } + ); + + if (response.ok) { + contentInput.value = ''; + loadComments(); // Reload comments list + FitPub.showAlert('Comment posted successfully', 'success'); + } else { + throw new Error('Failed to post comment'); + } + } catch (error) { + console.error('Error posting comment:', error); + FitPub.showAlert('Failed to post comment. Please try again.', 'danger'); + } + } + + async function deleteComment(commentId) { + try { + const response = await FitPubAuth.authenticatedFetch( + `/api/activities/${activityId}/comments/${commentId}`, + { method: 'DELETE' } + ); + + if (response.ok) { + loadComments(); // Reload comments list + FitPub.showAlert('Comment deleted successfully', 'success'); + } else { + throw new Error('Failed to delete comment'); + } + } catch (error) { + console.error('Error deleting comment:', error); + FitPub.showAlert('Failed to delete comment. Please try again.', 'danger'); + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + // Delete functionality document.getElementById('deleteBtn').addEventListener('click', function() { const modal = new bootstrap.Modal(document.getElementById('deleteModal'));