Follower UI
This commit is contained in:
parent
8545a3f43b
commit
37d0e3132b
6 changed files with 429 additions and 5 deletions
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
382
src/main/resources/templates/users/discover.html
Normal file
382
src/main/resources/templates/users/discover.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue