Follower UI

This commit is contained in:
Tim Zöller 2025-12-04 08:09:44 +01:00
parent 8545a3f43b
commit 37d0e3132b
6 changed files with 429 additions and 5 deletions

View file

@ -698,7 +698,7 @@ For ActivityPub federated posts and thumbnails:
- [x] Public user profile page (profile/public.html)
- [x] User profile display (avatar, bio, display name)
- [x] User's activity list on profile with pagination
- [x] Follower/following counts display (static for now)
- [x] Follower/following counts display (real data from backend)
- [x] Profile edit page (profile/edit.html)
- [x] Avatar URL input
- [x] Profile settings form with validation
@ -718,9 +718,20 @@ For ActivityPub federated posts and thumbnails:
- [x] Settings page placeholder (settings.html)
- [x] Client-side authentication checks for protected pages
**User Discovery UI** ✅
- [x] Discover users page (users/discover.html)
- [x] User search functionality with live search bar
- [x] Browse all users with pagination
- [x] User cards grid layout with avatar, bio, and stats
- [x] Responsive design for mobile and desktop
- [x] Empty state for no results
- [x] Loading indicators
- [x] View controller route (GET /discover)
- [x] Integration with backend search and browse APIs
**Navigation & Layout** ✅
- [x] Top navigation bar with logo
- [x] Navigation links (Timeline, My Activities, Upload, Profile)
- [x] Navigation links (Timeline, Discover, My Activities, Upload, Profile)
- [x] User menu dropdown (Profile, Settings, Logout)
- [x] Footer with app info
- [x] Mobile hamburger menu (Bootstrap responsive navbar)
@ -761,9 +772,10 @@ For ActivityPub federated posts and thumbnails:
- [x] Activity image generation with track overlay (ActivityImageService)
- [x] FIT epoch timestamp fix (631065600 second offset for proper date handling)
- [x] Web Mercator projection for accurate track-to-map alignment
- [x] User search and discovery (UserRepository.searchUsers, UserRepository.findAllEnabledUsers, GET /api/users/search, GET /api/users/browse)
- [x] User search and discovery backend (UserRepository.searchUsers, UserRepository.findAllEnabledUsers, GET /api/users/search, GET /api/users/browse)
- [x] User search and discovery UI (users/discover.html, /discover route, search bar with live filtering, user cards grid, pagination)
- [x] Followers/following lists (ActorDTO, GET /api/users/{username}/followers, GET /api/users/{username}/following)
- [ ] Follower/following counts (populate with real data)
- [x] Follower/following counts (UserController.populateSocialCounts, UserDTO with followersCount/followingCount, frontend displays real counts)
- [ ] Notifications system
- [ ] Enhanced privacy controls UI
- [ ] Follow/unfollow buttons on user profiles

View file

@ -58,6 +58,7 @@ public class SecurityConfig {
// Public endpoints - Web UI pages
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
.requestMatchers("/discover").permitAll() // User discovery page
// Public endpoints - ActivityPub federation
.requestMatchers("/.well-known/**").permitAll()
@ -110,6 +111,10 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.PUT, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/users/{username}").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search
.requestMatchers(HttpMethod.GET, "/api/users/browse").permitAll() // Browse all users
.requestMatchers(HttpMethod.GET, "/api/users/*/followers").permitAll() // User followers list
.requestMatchers(HttpMethod.GET, "/api/users/*/following").permitAll() // User following list
// All other requests require authentication
.anyRequest().authenticated()

View file

@ -94,4 +94,17 @@ public class ProfileViewController {
model.addAttribute("username", username);
return "profile/following";
}
/**
* User discovery page.
* Allows searching and browsing all users.
*
* @param model the model
* @return discover template
*/
@GetMapping("/discover")
public String discoverUsers(Model model) {
model.addAttribute("pageTitle", "Discover Users");
return "users/discover";
}
}

View file

@ -55,6 +55,11 @@
<i class="bi bi-globe"></i> Timeline
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/discover}">
<i class="bi bi-people"></i> Discover
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/activities}" id="myActivitiesLink" style="display: none;">
<i class="bi bi-list-task"></i> My Activities

View file

@ -194,8 +194,15 @@
const joinedDate = new Date(user.createdAt);
document.getElementById('joinedDate').textContent = joinedDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
// Followers/Following counts
if (user.followersCount !== undefined) {
document.getElementById('followersCount').textContent = user.followersCount;
}
if (user.followingCount !== undefined) {
document.getElementById('followingCount').textContent = user.followingCount;
}
// Stats (activities count will be loaded separately)
// Followers/Following counts TODO: implement when federation is ready
}
async function loadRecentActivities() {

View file

@ -0,0 +1,382 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Discover Users</title>
</head>
<body>
<div layout:fragment="content">
<div class="container-fluid">
<!-- Page Header -->
<div class="row mb-4">
<div class="col-12">
<h2 class="mb-1">
<i class="bi bi-people"></i> Discover Users
</h2>
<p class="text-muted">Find and connect with athletes on FitPub</p>
</div>
</div>
<!-- Search Bar -->
<div class="row mb-4">
<div class="col-12 col-md-8 col-lg-6">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-search"></i>
</span>
<input type="text"
class="form-control"
id="searchInput"
placeholder="Search by username or display name..."
autocomplete="off">
<button class="btn btn-outline-secondary"
type="button"
id="clearSearch"
style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="form-text">
Press Enter to search, or leave empty to browse all users
</div>
</div>
</div>
<!-- Search Info -->
<div id="searchInfo" class="row mb-3 d-none">
<div class="col-12">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<span id="searchInfoText"></span>
<button type="button" class="btn-close float-end" id="closeSearchInfo"></button>
</div>
</div>
</div>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading users...</p>
</div>
<!-- Error Alert -->
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage"></span>
</div>
<!-- Users Grid -->
<div id="usersContent" class="d-none">
<div class="row g-3" id="usersList">
<!-- Will be populated by JavaScript -->
</div>
<!-- Pagination -->
<nav id="pagination" aria-label="Users pagination" class="mt-4 d-none">
<ul class="pagination justify-content-center" id="paginationList">
<!-- Will be populated by JavaScript -->
</ul>
</nav>
</div>
<!-- Empty State -->
<div id="emptyState" class="text-center py-5 d-none">
<i class="bi bi-person-x" style="font-size: 4rem; color: #d1d5db;"></i>
<h4 class="mt-3 text-muted">No users found</h4>
<p class="text-muted">Try adjusting your search or browse all users</p>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
let currentPage = 0;
let currentQuery = '';
const pageSize = 12;
document.addEventListener('DOMContentLoaded', function() {
// Load initial users (browse mode)
loadUsers();
// Search input handler
const searchInput = document.getElementById('searchInput');
const clearSearch = document.getElementById('clearSearch');
searchInput.addEventListener('keyup', function(e) {
if (e.key === 'Enter') {
performSearch();
}
// Show/hide clear button
clearSearch.style.display = searchInput.value ? 'block' : 'none';
});
// Clear search button
clearSearch.addEventListener('click', function() {
searchInput.value = '';
clearSearch.style.display = 'none';
currentQuery = '';
currentPage = 0;
hideSearchInfo();
loadUsers();
});
// Close search info
document.getElementById('closeSearchInfo').addEventListener('click', function() {
hideSearchInfo();
});
});
function performSearch() {
const query = document.getElementById('searchInput').value.trim();
currentQuery = query;
currentPage = 0;
if (query) {
showSearchInfo(`Searching for "${query}"`);
} else {
hideSearchInfo();
}
loadUsers();
}
function showSearchInfo(text) {
document.getElementById('searchInfoText').textContent = text;
document.getElementById('searchInfo').classList.remove('d-none');
}
function hideSearchInfo() {
document.getElementById('searchInfo').classList.add('d-none');
}
async function loadUsers() {
try {
// Show loading
document.getElementById('loadingIndicator').classList.remove('d-none');
document.getElementById('usersContent').classList.add('d-none');
document.getElementById('emptyState').classList.add('d-none');
document.getElementById('errorAlert').classList.add('d-none');
// Build URL based on search or browse
let url;
if (currentQuery) {
url = `/api/users/search?q=${encodeURIComponent(currentQuery)}&page=${currentPage}&size=${pageSize}`;
} else {
url = `/api/users/browse?page=${currentPage}&size=${pageSize}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to load users');
}
const data = await response.json();
// Hide loading
document.getElementById('loadingIndicator').classList.add('d-none');
if (data.content && data.content.length > 0) {
renderUsers(data.content);
document.getElementById('usersContent').classList.remove('d-none');
if (data.totalPages > 1) {
renderPagination(data);
document.getElementById('pagination').classList.remove('d-none');
} else {
document.getElementById('pagination').classList.add('d-none');
}
} else {
document.getElementById('emptyState').classList.remove('d-none');
}
} catch (error) {
console.error('Error loading users:', error);
document.getElementById('loadingIndicator').classList.add('d-none');
document.getElementById('errorMessage').textContent = 'Failed to load users. Please try again.';
document.getElementById('errorAlert').classList.remove('d-none');
}
}
function renderUsers(users) {
const usersList = document.getElementById('usersList');
usersList.innerHTML = users.map(user => `
<div class="col-12 col-sm-6 col-md-4 col-lg-3">
<div class="card h-100 user-card">
<div class="card-body text-center">
<!-- Avatar -->
<div class="mb-3">
${user.avatarUrl
? `<img src="${escapeHtml(user.avatarUrl)}"
alt="${escapeHtml(user.username)}"
class="rounded-circle"
width="80"
height="80"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">`
: ''
}
<div class="avatar-placeholder ${user.avatarUrl ? 'd-none' : ''}"
style="width: 80px; height: 80px; margin: 0 auto;">
<i class="bi bi-person-circle"></i>
</div>
</div>
<!-- Display Name -->
<h6 class="card-title mb-1">
<a href="/users/${user.username}" class="text-decoration-none text-dark">
${escapeHtml(user.displayName || user.username)}
</a>
</h6>
<!-- Username -->
<p class="text-muted small mb-2">@${escapeHtml(user.username)}</p>
<!-- Bio -->
${user.bio
? `<p class="card-text small text-muted mb-3 bio-preview">${escapeHtml(user.bio)}</p>`
: '<p class="card-text small text-muted mb-3 fst-italic">No bio</p>'
}
<!-- Stats -->
<div class="d-flex justify-content-around mb-3 text-muted small">
<div>
<strong>${user.followersCount || 0}</strong>
<div>Followers</div>
</div>
<div>
<strong>${user.followingCount || 0}</strong>
<div>Following</div>
</div>
</div>
<!-- View Profile Button -->
<a href="/users/${user.username}" class="btn btn-sm btn-outline-primary w-100">
<i class="bi bi-person"></i> View Profile
</a>
</div>
</div>
</div>
`).join('');
}
function renderPagination(data) {
const paginationList = document.getElementById('paginationList');
let html = '';
// Previous button
html += `
<li class="page-item ${data.first ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="changePage(${data.number - 1}); return false;">
<i class="bi bi-chevron-left"></i>
</a>
</li>
`;
// Page numbers
const startPage = Math.max(0, data.number - 2);
const endPage = Math.min(data.totalPages - 1, data.number + 2);
if (startPage > 0) {
html += `
<li class="page-item">
<a class="page-link" href="#" onclick="changePage(0); return false;">1</a>
</li>
`;
if (startPage > 1) {
html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
}
}
for (let i = startPage; i <= endPage; i++) {
html += `
<li class="page-item ${i === data.number ? 'active' : ''}">
<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i + 1}</a>
</li>
`;
}
if (endPage < data.totalPages - 1) {
if (endPage < data.totalPages - 2) {
html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
}
html += `
<li class="page-item">
<a class="page-link" href="#" onclick="changePage(${data.totalPages - 1}); return false;">${data.totalPages}</a>
</li>
`;
}
// Next button
html += `
<li class="page-item ${data.last ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="changePage(${data.number + 1}); return false;">
<i class="bi bi-chevron-right"></i>
</a>
</li>
`;
paginationList.innerHTML = html;
}
window.changePage = function(page) {
currentPage = page;
loadUsers();
window.scrollTo({ top: 0, behavior: 'smooth' });
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
<style>
.user-card {
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.user-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.bio-preview {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
max-height: 3em;
line-height: 1.5em;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
font-size: 2.5rem;
color: white;
}
#searchInput:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.input-group-text {
background-color: white;
}
</style>
</th:block>
</body>
</html>