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);
|
model.addAttribute("username", username);
|
||||||
return "profile/public";
|
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;
|
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 {
|
.stat-value {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
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>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="stat-card">
|
<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-value" id="followersCount">0</div>
|
||||||
<div class="stat-label">Followers</div>
|
<div class="stat-label">Followers</div>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="stat-card">
|
<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-value" id="followingCount">0</div>
|
||||||
<div class="stat-label">Following</div>
|
<div class="stat-label">Following</div>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -179,6 +183,14 @@
|
||||||
document.getElementById('avatarPlaceholder').classList.add('d-none');
|
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
|
// Joined date
|
||||||
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' });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue