596 lines
26 KiB
HTML
596 lines
26 KiB
HTML
<!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 and across the Fediverse</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Remote User Discovery -->
|
|
<div class="row mb-4">
|
|
<div class="col-12 col-md-8 col-lg-6">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title mb-3">
|
|
<i class="bi bi-globe"></i> Follow Remote Users
|
|
</h5>
|
|
<p class="text-muted small mb-3">
|
|
Connect with users from other FitPub instances or ActivityPub-compatible platforms like Mastodon
|
|
</p>
|
|
<form id="remoteUserSearchForm">
|
|
<div class="input-group">
|
|
<span class="input-group-text">@</span>
|
|
<input type="text"
|
|
class="form-control"
|
|
id="remoteUserHandle"
|
|
placeholder="username@domain.com or username@instance.local:8080"
|
|
pattern="[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+(:[0-9]+)?"
|
|
autocomplete="off"
|
|
required>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-search"></i> Search
|
|
</button>
|
|
</div>
|
|
<div class="form-text">
|
|
Enter a handle like: alice@fitpub.example or bob@mastodon.social
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Remote User Result -->
|
|
<div id="remoteUserResult" class="mt-3 d-none">
|
|
<!-- Will be populated by JavaScript -->
|
|
</div>
|
|
|
|
<!-- Remote User Error -->
|
|
<div id="remoteUserError" class="alert alert-danger mt-3 d-none" role="alert">
|
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
|
<span id="remoteUserErrorText"></span>
|
|
</div>
|
|
|
|
<!-- Remote User Loading -->
|
|
<div id="remoteUserLoading" class="text-center mt-3 d-none">
|
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
|
<span class="visually-hidden">Searching...</span>
|
|
</div>
|
|
<span class="ms-2 text-muted">Discovering remote user...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Local User Search -->
|
|
<div class="row mb-3">
|
|
<div class="col-12">
|
|
<h5 class="mb-3">
|
|
<i class="bi bi-house-door"></i> Local Users
|
|
</h5>
|
|
</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="empty-state empty-state-users d-none">
|
|
<div class="empty-state-icon">
|
|
<i class="bi bi-person-x"></i>
|
|
</div>
|
|
<h4 class="empty-state-title">No Users Found</h4>
|
|
<p class="empty-state-message">Try adjusting your search or browse all users in the community</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() {
|
|
// Remote user search form handler
|
|
const remoteUserSearchForm = document.getElementById('remoteUserSearchForm');
|
|
remoteUserSearchForm.addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
await searchRemoteUser();
|
|
});
|
|
|
|
// 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 searchRemoteUser() {
|
|
const handle = document.getElementById('remoteUserHandle').value.trim();
|
|
|
|
if (!handle) {
|
|
return;
|
|
}
|
|
|
|
// Show loading, hide result and error
|
|
document.getElementById('remoteUserLoading').classList.remove('d-none');
|
|
document.getElementById('remoteUserResult').classList.add('d-none');
|
|
document.getElementById('remoteUserError').classList.add('d-none');
|
|
|
|
try {
|
|
const response = await FitPubAuth.authenticatedFetch(
|
|
`/api/users/discover-remote?handle=${encodeURIComponent(handle)}`
|
|
);
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 400) {
|
|
throw new Error('Invalid handle format. Please use format: username@domain.com');
|
|
} else if (response.status === 404) {
|
|
throw new Error('User not found. Please check the handle and try again.');
|
|
} else {
|
|
throw new Error('Failed to discover remote user. Please try again.');
|
|
}
|
|
}
|
|
|
|
const actor = await response.json();
|
|
|
|
// Hide loading
|
|
document.getElementById('remoteUserLoading').classList.add('d-none');
|
|
|
|
// Display remote user
|
|
displayRemoteUser(actor);
|
|
|
|
} catch (error) {
|
|
console.error('Error discovering remote user:', error);
|
|
|
|
// Hide loading
|
|
document.getElementById('remoteUserLoading').classList.add('d-none');
|
|
|
|
// Show error
|
|
document.getElementById('remoteUserErrorText').textContent = error.message;
|
|
document.getElementById('remoteUserError').classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
function displayRemoteUser(actor) {
|
|
const resultDiv = document.getElementById('remoteUserResult');
|
|
|
|
const avatarHtml = actor.avatarUrl
|
|
? `<img src="${escapeHtml(actor.avatarUrl)}"
|
|
alt="${escapeHtml(actor.username)}"
|
|
class="rounded-circle"
|
|
width="60"
|
|
height="60"
|
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">`
|
|
: '';
|
|
|
|
const avatarPlaceholder = `
|
|
<div class="avatar-placeholder ${actor.avatarUrl ? 'd-none' : ''}"
|
|
style="width: 60px; height: 60px;">
|
|
<i class="bi bi-person-circle"></i>
|
|
</div>
|
|
`;
|
|
|
|
resultDiv.innerHTML = `
|
|
<div class="card border-primary">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center mb-3">
|
|
<div class="me-3">
|
|
${avatarHtml}
|
|
${avatarPlaceholder}
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<h6 class="mb-1">${escapeHtml(actor.displayName || actor.username)}</h6>
|
|
<p class="text-muted small mb-0">
|
|
@${escapeHtml(actor.handle)}
|
|
<span class="badge bg-info ms-2">Remote</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
${actor.bio
|
|
? `<p class="card-text small mb-3">${sanitizeHtml(actor.bio)}</p>`
|
|
: '<p class="card-text small text-muted mb-3 fst-italic">No bio</p>'
|
|
}
|
|
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-primary" onclick="followRemoteUser('${escapeHtml(actor.handle)}')">
|
|
<i class="bi bi-person-plus"></i> Follow
|
|
</button>
|
|
${actor.actorUri
|
|
? `<a href="${escapeHtml(actor.actorUri)}"
|
|
target="_blank"
|
|
class="btn btn-outline-secondary">
|
|
<i class="bi bi-box-arrow-up-right"></i> View Profile
|
|
</a>`
|
|
: ''
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
resultDiv.classList.remove('d-none');
|
|
}
|
|
|
|
async function followRemoteUser(handle) {
|
|
try {
|
|
const response = await FitPubAuth.authenticatedFetch(
|
|
`/api/users/${encodeURIComponent(handle)}/follow`,
|
|
{ method: 'POST' }
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to follow user');
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Show success message
|
|
FitPub.showAlert('success', result.message || `Follow request sent to ${handle}`);
|
|
|
|
// Clear the search form
|
|
document.getElementById('remoteUserHandle').value = '';
|
|
document.getElementById('remoteUserResult').classList.add('d-none');
|
|
|
|
} catch (error) {
|
|
console.error('Error following remote user:', error);
|
|
FitPub.showAlert('error', 'Failed to follow user. Please try again.');
|
|
}
|
|
}
|
|
|
|
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">${sanitizeHtml(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;
|
|
}
|
|
|
|
function sanitizeHtml(html) {
|
|
if (!html) return '';
|
|
// Use DOMPurify to sanitize HTML, allowing safe tags like p, br, a
|
|
return DOMPurify.sanitize(html, {
|
|
ALLOWED_TAGS: ['p', 'br', 'a', 'strong', 'em', 'b', 'i', 'span'],
|
|
ALLOWED_ATTR: ['href', 'class', 'rel', 'target']
|
|
});
|
|
}
|
|
</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>
|