More fixes

This commit is contained in:
Tim Zöller 2025-11-30 10:33:28 +01:00
parent 96cf1fe5ad
commit d42f9b5339
5 changed files with 466 additions and 15 deletions

View file

@ -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<Page<TimelineActivityDTO>> 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<TimelineActivityDTO> timeline = timelineService.getPublicTimeline(pageable);
Page<TimelineActivityDTO> timeline = timelineService.getPublicTimeline(userId, pageable);
return ResponseEntity.ok(timeline);
}

View file

@ -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;

View file

@ -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<TimelineActivityDTO> getPublicTimeline(Pageable pageable) {
public Page<TimelineActivityDTO> 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<Activity> activities = activityRepository.findByUserIdOrderByStartedAtDesc(userId, pageable);
List<TimelineActivityDTO> 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());

View file

@ -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 = {
</div>
<!-- Activity Actions -->
<div class="d-flex gap-2">
<div class="d-flex gap-2 align-items-center">
<button
class="btn btn-sm ${activity.likedByCurrentUser ? 'btn-danger' : 'btn-outline-danger'} like-btn"
data-activity-id="${activity.id}"
data-liked="${activity.likedByCurrentUser || false}"
>
<i class="bi bi-heart${activity.likedByCurrentUser ? '-fill' : ''}"></i>
<span class="like-count">${activity.likesCount || 0}</span>
</button>
<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View Details
</a>
<span class="ms-auto text-muted small">
<i class="bi bi-${this.getVisibilityIcon(activity.visibility)}"></i>
${activity.visibility}
<span class="ms-auto text-muted small d-flex align-items-center gap-2">
${activity.commentsCount > 0 ? `<span><i class="bi bi-chat-left-text"></i> ${activity.commentsCount}</span>` : ''}
<span>
<i class="bi bi-${this.getVisibilityIcon(activity.visibility)}"></i>
${activity.visibility}
</span>
</span>
</div>
</div>
@ -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;
}
});
});
},
/**

View file

@ -166,6 +166,76 @@
</div>
</div>
<!-- Social Interactions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-chat-heart"></i> Social
</h5>
<div>
<button id="likeBtn" class="btn btn-sm btn-outline-danger me-2">
<i class="bi bi-heart"></i>
<span id="likeBtnText">Like</span>
(<span id="likeCount">0</span>)
</button>
</div>
</div>
<div class="card-body">
<!-- Likes Section -->
<div class="mb-4" id="likesSection">
<h6 class="text-muted mb-3">
<i class="bi bi-heart-fill text-danger"></i>
Liked by <span id="likesCountText">0</span>
</h6>
<div id="likesList" class="d-flex flex-wrap gap-2">
<!-- Likes will be populated here -->
</div>
</div>
<!-- Comments Section -->
<div>
<h6 class="text-muted mb-3">
<i class="bi bi-chat-left-text"></i>
Comments (<span id="commentsCount">0</span>)
</h6>
<!-- Comment Form -->
<div id="commentForm" class="mb-4" style="display: none;">
<form id="addCommentForm">
<div class="mb-3">
<textarea
id="commentContent"
class="form-control"
rows="3"
placeholder="Write a comment..."
required
></textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send"></i> Post Comment
</button>
</form>
</div>
<!-- Comments List -->
<div id="commentsList">
<!-- Comments will be populated here -->
</div>
<!-- Login prompt for non-authenticated users -->
<div id="loginPrompt" style="display: none;">
<p class="text-muted">
<a href="/login">Log in</a> to like or comment on this activity.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Back Button -->
<div class="row">
<div class="col-12">
@ -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 = '<span class="text-muted">No likes yet</span>';
return;
}
likesList.innerHTML = likes.map(like => {
const displayName = like.displayName || like.username || 'Unknown';
const avatarHtml = like.avatarUrl
? `<img src="${like.avatarUrl}" alt="${displayName}" class="rounded-circle me-2" width="32" height="32">`
: `<i class="bi bi-person-circle me-2" style="font-size: 32px;"></i>`;
return `
<div class="d-flex align-items-center p-2 border rounded">
${avatarHtml}
<span>${displayName}</span>
</div>
`;
}).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 = '<p class="text-muted">No comments yet. Be the first to comment!</p>';
return;
}
commentsList.innerHTML = comments.map(comment => {
const displayName = comment.displayName || comment.username || 'Unknown';
const avatarHtml = comment.avatarUrl
? `<img src="${comment.avatarUrl}" alt="${displayName}" class="rounded-circle" width="40" height="40">`
: `<i class="bi bi-person-circle" style="font-size: 40px;"></i>`;
const createdAt = new Date(comment.createdAt).toLocaleString();
const deleteBtn = comment.canDelete
? `<button class="btn btn-sm btn-outline-danger delete-comment-btn" data-comment-id="${comment.id}">
<i class="bi bi-trash"></i>
</button>`
: '';
return `
<div class="d-flex mb-3 pb-3 border-bottom" data-comment-id="${comment.id}">
<div class="me-3">
${avatarHtml}
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${displayName}</strong>
<small class="text-muted ms-2">${createdAt}</small>
</div>
${deleteBtn}
</div>
<p class="mb-0 mt-1">${escapeHtml(comment.content)}</p>
</div>
</div>
`;
}).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'));