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] Public user profile page (profile/public.html)
|
||||||
- [x] User profile display (avatar, bio, display name)
|
- [x] User profile display (avatar, bio, display name)
|
||||||
- [x] User's activity list on profile with pagination
|
- [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] Profile edit page (profile/edit.html)
|
||||||
- [x] Avatar URL input
|
- [x] Avatar URL input
|
||||||
- [x] Profile settings form with validation
|
- [x] Profile settings form with validation
|
||||||
|
|
@ -718,9 +718,20 @@ For ActivityPub federated posts and thumbnails:
|
||||||
- [x] Settings page placeholder (settings.html)
|
- [x] Settings page placeholder (settings.html)
|
||||||
- [x] Client-side authentication checks for protected pages
|
- [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** ✅
|
**Navigation & Layout** ✅
|
||||||
- [x] Top navigation bar with logo
|
- [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] User menu dropdown (Profile, Settings, Logout)
|
||||||
- [x] Footer with app info
|
- [x] Footer with app info
|
||||||
- [x] Mobile hamburger menu (Bootstrap responsive navbar)
|
- [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] Activity image generation with track overlay (ActivityImageService)
|
||||||
- [x] FIT epoch timestamp fix (631065600 second offset for proper date handling)
|
- [x] FIT epoch timestamp fix (631065600 second offset for proper date handling)
|
||||||
- [x] Web Mercator projection for accurate track-to-map alignment
|
- [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)
|
- [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
|
- [ ] Notifications system
|
||||||
- [ ] Enhanced privacy controls UI
|
- [ ] Enhanced privacy controls UI
|
||||||
- [ ] Follow/unfollow buttons on user profiles
|
- [ ] Follow/unfollow buttons on user profiles
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ public class SecurityConfig {
|
||||||
// Public endpoints - Web UI pages
|
// Public endpoints - Web UI pages
|
||||||
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
|
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
|
||||||
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
|
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
|
||||||
|
.requestMatchers("/discover").permitAll() // User discovery page
|
||||||
|
|
||||||
// Public endpoints - ActivityPub federation
|
// Public endpoints - ActivityPub federation
|
||||||
.requestMatchers("/.well-known/**").permitAll()
|
.requestMatchers("/.well-known/**").permitAll()
|
||||||
|
|
@ -110,6 +111,10 @@ public class SecurityConfig {
|
||||||
.requestMatchers(HttpMethod.PUT, "/api/users/me").authenticated()
|
.requestMatchers(HttpMethod.PUT, "/api/users/me").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/users/{username}").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/users/{username}").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/users/id/*").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
|
// All other requests require authentication
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
|
|
|
||||||
|
|
@ -94,4 +94,17 @@ public class ProfileViewController {
|
||||||
model.addAttribute("username", username);
|
model.addAttribute("username", username);
|
||||||
return "profile/following";
|
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
|
<i class="bi bi-globe"></i> Timeline
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link" th:href="@{/activities}" id="myActivitiesLink" style="display: none;">
|
<a class="nav-link" th:href="@{/activities}" id="myActivitiesLink" style="display: none;">
|
||||||
<i class="bi bi-list-task"></i> My Activities
|
<i class="bi bi-list-task"></i> My Activities
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,15 @@
|
||||||
const joinedDate = new Date(user.createdAt);
|
const joinedDate = new Date(user.createdAt);
|
||||||
document.getElementById('joinedDate').textContent = joinedDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
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)
|
// Stats (activities count will be loaded separately)
|
||||||
// Followers/Following counts TODO: implement when federation is ready
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRecentActivities() {
|
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