Connect profile settings to user data

This commit is contained in:
Isabelle Nachbaur 2026-05-30 15:11:26 +02:00
parent 4f7e9bac68
commit d3b1d01058
13 changed files with 258 additions and 32 deletions

View File

@ -3,6 +3,6 @@ using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.ApiModels.UserProfile
{
public record ApiUpdateProfileRequest([MaxLength(100)] string? DisplayName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic);
public record ApiUpdateProfileRequest([MaxLength(100)] string? DisplayName, [MaxLength(100)][NoWhitespace] string? UserName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic);
public record ApiCreateReviewRequest(string? Comment, [Range(1, 5)] int Rating);
}

View File

@ -22,11 +22,41 @@ namespace OnlyPrompt.Backend.Controllers
{
{ nameof(UserProfileModel.Slug), new[] { "Slug already exists." } }
});
private static ValidationProblem UserNameExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(UserModel.UserName), new[] { "Username is already taken." } }
});
public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[HttpGet("self")]
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetSelfProfileAsync()
{
var userId = User.GetUserId();
if (userId is null)
return TypedResults.NotFound("Profile not found.");
var profile = await _db.UserProfiles
.Where(up => up.Id == userId.Value)
.Select(up => new ApiUserProfile(
up.DisplayName,
up.Slug,
up.Bio,
up.AvatarUrl,
up.Specialities,
_db.Reviews.Where(r => r.Prompt.CreatorId == up.Id).Average(r => (double?)r.Rating) ?? 0.0,
_db.Subscriptions.Count(s => s.SubscribedToId == up.Id)
))
.FirstOrDefaultAsync();
if (profile is null)
return TypedResults.NotFound("Profile not found.");
return TypedResults.Ok(profile);
}
[HttpGet("{id}")]
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetProfileAsync(Identifier id)
{
@ -81,6 +111,18 @@ namespace OnlyPrompt.Backend.Controllers
if (self is null)
return TypedResults.NotFound("Profile not found.");
var user = await GetUserAsync();
if (user is null)
return TypedResults.NotFound("User not found.");
if (string.IsNullOrEmpty(request.UserName) == false)
{
if (await _db.Users.AnyAsync(u => u.UserName == request.UserName && u.Id != user.Id))
return UserNameExistsProblem;
user.UserName = request.UserName;
}
if (string.IsNullOrEmpty(request.Slug) == false)
{
if (await _db.UserProfiles.AnyAsync(up => up.Slug == request.Slug && up.Id != self.Id))
@ -89,13 +131,13 @@ namespace OnlyPrompt.Backend.Controllers
self.Slug = request.Slug;
}
if(string.IsNullOrEmpty(request.AvatarUrl) == false)
if(request.AvatarUrl is not null)
self.AvatarUrl = request.AvatarUrl;
if(string.IsNullOrEmpty(request.Bio) == false)
if(request.Bio is not null)
self.Bio = request.Bio;
if(string.IsNullOrEmpty(request.Specialities) == false)
if(request.Specialities is not null)
self.Specialities = request.Specialities;
if (string.IsNullOrEmpty(request.DisplayName) == false)
@ -103,7 +145,15 @@ namespace OnlyPrompt.Backend.Controllers
self.IsPublic = request.IsPublic;
await _db.SaveChangesAsync();
var result = _mapper.Map<ApiUserProfile>(self);
var result = new ApiUserProfile(
self.DisplayName,
self.Slug,
self.Bio,
self.AvatarUrl,
self.Specialities,
await _db.Reviews.Where(r => r.Prompt.CreatorId == self.Id).AverageAsync(r => (double?)r.Rating) ?? 0.0,
await _db.Subscriptions.CountAsync(s => s.SubscribedToId == self.Id)
);
return TypedResults.Ok(result);
}

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/chats.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
@ -126,4 +127,4 @@
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>
</html>

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/community.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
@ -174,4 +175,4 @@
loadCreators();
</script>
</body>
</html>
</html>

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/create.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
@ -178,4 +179,4 @@
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>
</html>

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/dashboard.css" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
@ -25,7 +26,7 @@
>
<div id="sidebar-container"></div>
<div style="flex: 1; margin: 40px auto; max-width: 950px">
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="feed-main">

View File

@ -0,0 +1,68 @@
(function () {
let currentProfilePromise = null;
let currentProfile = null;
async function fetchJson(url) {
const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) {
location.href = "/login";
return null;
}
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
}
async function loadCurrentProfile(force = false) {
if (!force && currentProfilePromise) return currentProfilePromise;
currentProfilePromise = Promise.all([
fetchJson("/api/v1/profiles/self"),
fetchJson("/api/v1/auth/me"),
]).then(([profile, user]) => {
currentProfile = { ...profile, user };
return currentProfile;
});
return currentProfilePromise;
}
function applyTopbarProfile(profile) {
const avatar = document.getElementById("topbarAvatar");
if (!avatar || !profile) return;
if (profile.avatarUrl) avatar.src = profile.avatarUrl;
avatar.alt = profile.displayName || profile.user?.userName || "Profile";
avatar.title = profile.displayName || profile.user?.userName || "Profile";
}
async function hydrateTopbarProfile() {
try {
const profile = currentProfile || (await loadCurrentProfile());
applyTopbarProfile(profile);
} catch {
// Keep the fallback avatar if the profile cannot be loaded.
}
}
function setCurrentProfile(profile) {
currentProfile = profile;
currentProfilePromise = Promise.resolve(profile);
applyTopbarProfile(profile);
}
window.loadCurrentProfile = loadCurrentProfile;
window.hydrateTopbarProfile = hydrateTopbarProfile;
window.setCurrentProfile = setCurrentProfile;
const observer = new MutationObserver(() => {
if (document.getElementById("topbarAvatar")) hydrateTopbarProfile();
});
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
} else {
document.addEventListener("DOMContentLoaded", () => {
observer.observe(document.body, { childList: true, subtree: true });
});
}
})();

View File

@ -12,6 +12,7 @@
<link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/marketplace.css" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
@ -65,7 +66,7 @@
>
<div id="sidebar-container"></div>
<div style="flex: 1; margin: 40px auto; max-width: 950px">
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="marketplace-main">

View File

@ -12,6 +12,7 @@
<link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/post-detail.css" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/profile.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
@ -28,24 +29,27 @@
<section class="profile-header" style="display:flex;align-items:center;gap:32px;border-bottom:1px solid #e5e7eb;padding-bottom:24px;">
<img src="../images/content/cat.png" class="profile-avatar" style="width:110px;height:110px;border-radius:50%;object-fit:cover;">
<img id="profileAvatar" src="../images/content/cat.png" class="profile-avatar" style="width:110px;height:110px;border-radius:50%;object-fit:cover;">
<div class="profile-info" style="flex:1;">
<h1 style="font-size:2rem;font-weight:700;margin-bottom:4px;">Sunny the Cat</h1>
<div style="color:#64748b;margin-bottom:8px;">
@sunny_the_cat <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>
<h1 id="profileDisplayName" style="font-size:2rem;font-weight:700;margin-bottom:4px;">Loading...</h1>
<div id="profileSlug" style="color:#64748b;margin-bottom:8px;">
@profile <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>
</div>
<div style="margin-bottom:8px;">
🐾 Cat prompt creator | Nap enthusiast | AI Cat Content Curator<br>
Chasing lasers and building purrfect prompts.
<div id="profileBio" style="margin-bottom:8px;">
Loading profile...
</div>
<div style="color:#64748b;">Cat City, Dreamland 🐈</div>
<div id="profileSpecialities" style="color:#64748b;"></div>
<div id="profileStats" style="display:flex;gap:18px;color:#64748b;margin-top:12px;font-size:0.95rem;">
<span><strong id="profileRating" style="color:#111827;">0.0</strong> rating</span>
<span><strong id="profileSubscribers" style="color:#111827;">0</strong> subscribers</span>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<button class="login-button">Edit Profile</button>
<button class="login-button" onclick="location.href='settings.html'">Edit Profile</button>
<button class="login-button" style="background:#f3f4f6;color:#111;box-shadow:none;">Share Profile</button>
</div>
@ -106,6 +110,35 @@
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
const profileAvatar = document.getElementById('profileAvatar');
const profileDisplayName = document.getElementById('profileDisplayName');
const profileSlug = document.getElementById('profileSlug');
const profileBio = document.getElementById('profileBio');
const profileSpecialities = document.getElementById('profileSpecialities');
const profileRating = document.getElementById('profileRating');
const profileSubscribers = document.getElementById('profileSubscribers');
async function loadOwnProfile() {
try {
const profile = await window.loadCurrentProfile();
profileDisplayName.textContent = profile.displayName || 'My Profile';
profileSlug.innerHTML = `@${profile.user?.userName || profile.slug || 'profile'} <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>`;
profileBio.textContent = profile.bio || 'No bio yet.';
profileSpecialities.textContent = profile.specialities || 'No specialities added yet.';
profileRating.textContent = Number(profile.averageRating || 0).toFixed(1);
profileSubscribers.textContent = profile.subscribers || 0;
if (profile.avatarUrl) {
profileAvatar.src = profile.avatarUrl;
}
} catch (error) {
profileDisplayName.textContent = 'Profile unavailable';
profileBio.textContent = error.message;
}
}
loadOwnProfile();
</script>
</body>
</html>
</html>

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/settings.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
@ -40,7 +41,7 @@
<!-- Tab Content: Profile -->
<div id="profileTab" class="tab-content active">
<form class="settings-form">
<form class="settings-form" id="profileSettingsForm">
<div class="form-group">
<label for="avatar">Profile Picture</label>
<div class="avatar-upload">
@ -51,11 +52,11 @@
</div>
<div class="form-group">
<label for="displayName">Display Name</label>
<input type="text" id="displayName" placeholder="Sunny the Cat">
<input type="text" id="displayName" placeholder="Display name">
</div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" placeholder="@username">
<input type="text" id="username" placeholder="username" required>
</div>
<div class="form-group">
<label for="bio">Bio</label>
@ -63,10 +64,11 @@
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" placeholder="your@email.com">
<input type="email" id="email" placeholder="your@email.com" readonly>
</div>
<div class="form-actions">
<button type="submit" class="save-btn">Save Changes</button>
<p id="profileSaveStatus" style="margin-top:10px;color:#64748b;text-align:center;"></p>
</div>
</form>
</div>
@ -154,7 +156,29 @@
});
});
// Avatar preview (simple)
let currentAvatarUrl = '';
let currentSlug = '';
function setProfileForm(profile) {
document.getElementById('displayName').value = profile.displayName || '';
document.getElementById('username').value = profile.user?.userName || '';
document.getElementById('email').value = profile.user?.email || '';
document.getElementById('bio').value = profile.bio || '';
currentAvatarUrl = profile.avatarUrl || '../images/content/cat.png';
currentSlug = profile.slug || profile.user?.userName || '';
avatarPreview.src = currentAvatarUrl;
}
async function loadProfileSettings() {
try {
const profile = await window.loadCurrentProfile();
setProfileForm(profile);
} catch (error) {
document.getElementById('profileSaveStatus').textContent = 'Profile could not be loaded.';
}
}
// Avatar preview and save as data URL
const avatarUpload = document.getElementById('avatarUpload');
const avatarPreview = document.getElementById('avatarPreview');
if (avatarUpload) {
@ -163,15 +187,53 @@
if (file && (file.type === 'image/png' || file.type === 'image/jpeg')) {
const reader = new FileReader();
reader.onload = function(ev) {
avatarPreview.src = ev.target.result;
currentAvatarUrl = ev.target.result;
avatarPreview.src = currentAvatarUrl;
};
reader.readAsDataURL(file);
}
});
}
// Demo form submits for all forms
document.querySelectorAll('.settings-form').forEach(form => {
document.getElementById('profileSettingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('profileSaveStatus');
status.textContent = 'Saving...';
try {
const userName = document.getElementById('username').value.trim();
const response = await fetch('/api/v1/profiles', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
displayName: document.getElementById('displayName').value.trim(),
userName,
slug: userName || currentSlug,
bio: document.getElementById('bio').value.trim(),
avatarUrl: currentAvatarUrl,
specialities: null,
isPublic: false
})
});
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) throw new Error('Profile could not be saved.');
const savedProfile = await window.loadCurrentProfile(true);
window.setCurrentProfile(savedProfile);
setProfileForm(savedProfile);
status.textContent = 'Saved.';
} catch (error) {
status.textContent = error.message;
}
});
// Demo form submits for forms that are not connected yet
document.querySelectorAll('.settings-form:not(#profileSettingsForm)').forEach(form => {
form.addEventListener('submit', (e) => {
e.preventDefault();
alert('Settings saved (demo)');
@ -192,6 +254,8 @@
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
loadProfileSettings();
</script>
</body>
</html>
</html>

View File

@ -45,6 +45,11 @@
<input type="text" id="displayName" name="displayName" placeholder="Enter your display name" required>
</div>
<div class="form-group">
<label for="userName">Username</label>
<input type="text" id="userName" name="userName" placeholder="Choose a username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<!-- Password field with button to show/hide password -->
@ -77,4 +82,4 @@
<script type="module" src="js/signup.js"></script>
</body>
</html>
</html>

View File

@ -21,7 +21,7 @@
<!-- Profile avatar on the right (must be changed with backend) -->
<button class="topbar-avatar-btn" aria-label="Profile">
<img src="../images/content/cat.png" alt="Profile Picture" class="topbar-avatar">
<img id="topbarAvatar" src="../images/content/cat.png" alt="Profile Picture" class="topbar-avatar">
</button>
</div>
</header>
</header>