Follower UI
This commit is contained in:
parent
301364b8a7
commit
8545a3f43b
5 changed files with 339 additions and 8 deletions
|
|
@ -64,4 +64,34 @@ public class ProfileViewController {
|
|||
model.addAttribute("username", username);
|
||||
return "profile/public";
|
||||
}
|
||||
|
||||
/**
|
||||
* User followers page.
|
||||
* Shows list of people who follow this user.
|
||||
*
|
||||
* @param username the username
|
||||
* @param model the model
|
||||
* @return followers template
|
||||
*/
|
||||
@GetMapping("/profile/{username}/followers")
|
||||
public String userFollowers(@PathVariable String username, Model model) {
|
||||
model.addAttribute("pageTitle", "Followers - @" + username);
|
||||
model.addAttribute("username", username);
|
||||
return "profile/followers";
|
||||
}
|
||||
|
||||
/**
|
||||
* User following page.
|
||||
* Shows list of people this user follows.
|
||||
*
|
||||
* @param username the username
|
||||
* @param model the model
|
||||
* @return following template
|
||||
*/
|
||||
@GetMapping("/profile/{username}/following")
|
||||
public String userFollowing(@PathVariable String username, Model model) {
|
||||
model.addAttribute("pageTitle", "Following - @" + username);
|
||||
model.addAttribute("username", username);
|
||||
return "profile/following";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,6 +199,17 @@ body {
|
|||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-card-hover {
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.stat-card-hover:hover {
|
||||
background-color: var(--light-color);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
139
src/main/resources/templates/profile/followers.html
Normal file
139
src/main/resources/templates/profile/followers.html
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<!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>Followers</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<!-- 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 followers...</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>
|
||||
|
||||
<!-- Followers Content -->
|
||||
<div id="followersContent" class="d-none">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2>
|
||||
<a th:href="@{/profile/{username}(username=${username})}" class="text-decoration-none text-muted">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
Followers
|
||||
</h2>
|
||||
<p class="text-muted mb-0" id="followersSubtitle">People who follow @<span id="usernameDisplay"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Followers List -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<!-- List -->
|
||||
<div id="followersList">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="followersEmpty" class="text-center py-5 d-none">
|
||||
<i class="bi bi-people" style="font-size: 3rem; color: #d1d5db;"></i>
|
||||
<p class="text-muted mt-3">No followers yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:inline="javascript">
|
||||
const targetUsername = /*[[${username}]]*/ '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('usernameDisplay').textContent = targetUsername;
|
||||
loadFollowers();
|
||||
|
||||
async function loadFollowers() {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${targetUsername}/followers`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load followers');
|
||||
}
|
||||
|
||||
const followers = await response.json();
|
||||
|
||||
// Hide loading, show content
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('followersContent').classList.remove('d-none');
|
||||
|
||||
if (followers.length > 0) {
|
||||
renderFollowers(followers);
|
||||
} else {
|
||||
document.getElementById('followersEmpty').classList.remove('d-none');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading followers:', error);
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('errorMessage').textContent = 'Failed to load followers.';
|
||||
document.getElementById('errorAlert').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFollowers(followers) {
|
||||
const list = document.getElementById('followersList');
|
||||
list.innerHTML = followers.map(follower => `
|
||||
<div class="d-flex align-items-center py-3 border-bottom">
|
||||
<div class="me-3">
|
||||
${follower.avatarUrl ?
|
||||
`<img src="${escapeHtml(follower.avatarUrl)}" alt="${escapeHtml(follower.displayName || follower.username)}" class="rounded-circle" width="48" height="48">` :
|
||||
`<div class="avatar-placeholder rounded-circle" style="width: 48px; height: 48px;">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-0">
|
||||
${follower.local ?
|
||||
`<a href="/profile/${escapeHtml(follower.username)}" class="text-decoration-none">${escapeHtml(follower.displayName || follower.username)}</a>` :
|
||||
`<span>${escapeHtml(follower.displayName || follower.username)}</span>`
|
||||
}
|
||||
${!follower.local ? '<i class="bi bi-globe text-muted small" title="Remote user"></i>' : ''}
|
||||
</h6>
|
||||
<p class="text-muted small mb-0">
|
||||
@${escapeHtml(follower.handle)}
|
||||
</p>
|
||||
${follower.bio ? `<p class="small mt-1 mb-0 text-muted">${escapeHtml(follower.bio)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
139
src/main/resources/templates/profile/following.html
Normal file
139
src/main/resources/templates/profile/following.html
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<!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>Following</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<!-- 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 following...</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>
|
||||
|
||||
<!-- Following Content -->
|
||||
<div id="followingContent" class="d-none">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2>
|
||||
<a th:href="@{/profile/{username}(username=${username})}" class="text-decoration-none text-muted">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
</a>
|
||||
Following
|
||||
</h2>
|
||||
<p class="text-muted mb-0" id="followingSubtitle">People that @<span id="usernameDisplay"></span> follows</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Following List -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<!-- List -->
|
||||
<div id="followingList">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="followingEmpty" class="text-center py-5 d-none">
|
||||
<i class="bi bi-people" style="font-size: 3rem; color: #d1d5db;"></i>
|
||||
<p class="text-muted mt-3">Not following anyone yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:inline="javascript">
|
||||
const targetUsername = /*[[${username}]]*/ '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('usernameDisplay').textContent = targetUsername;
|
||||
loadFollowing();
|
||||
|
||||
async function loadFollowing() {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${targetUsername}/following`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load following');
|
||||
}
|
||||
|
||||
const following = await response.json();
|
||||
|
||||
// Hide loading, show content
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('followingContent').classList.remove('d-none');
|
||||
|
||||
if (following.length > 0) {
|
||||
renderFollowing(following);
|
||||
} else {
|
||||
document.getElementById('followingEmpty').classList.remove('d-none');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading following:', error);
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('errorMessage').textContent = 'Failed to load following.';
|
||||
document.getElementById('errorAlert').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFollowing(following) {
|
||||
const list = document.getElementById('followingList');
|
||||
list.innerHTML = following.map(user => `
|
||||
<div class="d-flex align-items-center py-3 border-bottom">
|
||||
<div class="me-3">
|
||||
${user.avatarUrl ?
|
||||
`<img src="${escapeHtml(user.avatarUrl)}" alt="${escapeHtml(user.displayName || user.username)}" class="rounded-circle" width="48" height="48">` :
|
||||
`<div class="avatar-placeholder rounded-circle" style="width: 48px; height: 48px;">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-0">
|
||||
${user.local ?
|
||||
`<a href="/profile/${escapeHtml(user.username)}" class="text-decoration-none">${escapeHtml(user.displayName || user.username)}</a>` :
|
||||
`<span>${escapeHtml(user.displayName || user.username)}</span>`
|
||||
}
|
||||
${!user.local ? '<i class="bi bi-globe text-muted small" title="Remote user"></i>' : ''}
|
||||
</h6>
|
||||
<p class="text-muted small mb-0">
|
||||
@${escapeHtml(user.handle)}
|
||||
</p>
|
||||
${user.bio ? `<p class="small mt-1 mb-0 text-muted">${escapeHtml(user.bio)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -64,16 +64,20 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="followersCount">0</div>
|
||||
<div class="stat-label">Followers</div>
|
||||
</div>
|
||||
<a th:href="@{/profile/{username}/followers(username=${username})}" class="text-decoration-none text-dark">
|
||||
<div class="stat-card stat-card-hover">
|
||||
<div class="stat-value" id="followersCount">0</div>
|
||||
<div class="stat-label">Followers</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="followingCount">0</div>
|
||||
<div class="stat-label">Following</div>
|
||||
</div>
|
||||
<a th:href="@{/profile/{username}/following(username=${username})}" class="text-decoration-none text-dark">
|
||||
<div class="stat-card stat-card-hover">
|
||||
<div class="stat-value" id="followingCount">0</div>
|
||||
<div class="stat-label">Following</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -179,6 +183,14 @@
|
|||
document.getElementById('avatarPlaceholder').classList.add('d-none');
|
||||
}
|
||||
|
||||
// Follower/following counts
|
||||
if (user.followersCount !== undefined) {
|
||||
document.getElementById('followersCount').textContent = user.followersCount;
|
||||
}
|
||||
if (user.followingCount !== undefined) {
|
||||
document.getElementById('followingCount').textContent = user.followingCount;
|
||||
}
|
||||
|
||||
// Joined date
|
||||
const joinedDate = new Date(user.createdAt);
|
||||
document.getElementById('joinedDate').textContent = joinedDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue