feat: community.html dynamisch via API, GET /api/v1/profiles, follow/unfollow fix

This commit is contained in:
GeNii96 2026-05-30 13:28:48 +02:00
parent cb11acd306
commit 4f7e9bac68
7 changed files with 188 additions and 116 deletions

View File

@ -1,4 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.UserProfile
{
public record ApiUserProfile(string DisplayName, string Slug, string? Bio, string AvatarUrl, string? Specialities, double AverageRating, int Subscribers);
public record ApiCreatorCard(Guid UserId, string DisplayName, string Slug, string? Bio, double AverageRating, int Subscribers, int PromptCount, bool IsFollowing);
}

View File

@ -9,6 +9,7 @@ using OnlyPrompt.Backend.ApiModels.UserProfile;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.Controllers
{
@ -41,6 +42,38 @@ namespace OnlyPrompt.Backend.Controllers
return TypedResults.Ok(profile);
}
[HttpGet]
public async Task<ApiCreatorCard[]> GetCreatorsAsync(
[Range(0, int.MaxValue)] int offset = 0,
[Range(1, 100)] int limit = 20,
[FromQuery] string sort = "popular"
)
{
var userId = User.GetUserId();
var query = _db.UserProfiles.Where(up => up.IsPublic && up.Id != userId);
var projected = query.Select(up => new ApiCreatorCard(
up.Id,
up.DisplayName,
up.Slug,
up.Bio,
_db.Reviews.Where(r => r.Prompt.CreatorId == up.Id).Average(r => (double?)r.Rating) ?? 0.0,
_db.Subscriptions.Count(s => s.SubscribedToId == up.Id),
_db.Prompts.Count(p => p.CreatorId == up.Id),
_db.Subscriptions.Any(s => s.SubscribedToId == up.Id && s.SubscriberId == userId)
));
var allCreators = await projected.ToArrayAsync();
return (sort switch
{
"rating" => allCreators.OrderByDescending(c => c.AverageRating),
"new" => allCreators.OrderByDescending(c => c.UserId),
"prompts" => allCreators.OrderByDescending(c => c.PromptCount),
_ => allCreators.OrderByDescending(c => c.Subscribers),
}).Skip(offset).Take(limit).ToArray();
}
[HttpPut]
public async Task<Results<ValidationProblem, NotFound<string>, Ok<ApiUserProfile>>> UpdateProfileAsync([FromBody] ApiUpdateProfileRequest request)
{

View File

@ -26,8 +26,8 @@ namespace OnlyPrompt.Backend.Controllers
{
}
[HttpPut("{userId}/{level}")]
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> SubscribeAsync(Identifier subscribeToId, int? level = null)
[HttpPut("{userId}/{level?}")]
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> SubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId, int? level = null)
{
var userId = User.GetUserId();
var subscribeTo = await _db.Users.Include(x => x.SubscriptionTiers.Where(st => st.Level == level))
@ -86,7 +86,7 @@ namespace OnlyPrompt.Backend.Controllers
}
[HttpGet("{userId}")]
public async Task<ApiSubscription?> GetCurrentSubscriptionAsync(Identifier subscribeToId)
public async Task<ApiSubscription?> GetCurrentSubscriptionAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
{
var userId = User.GetUserId();
var subscription = await _db.Subscriptions
@ -101,7 +101,7 @@ namespace OnlyPrompt.Backend.Controllers
}
[HttpDelete("{userId}")]
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync(Identifier subscribeToId)
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
{
var userId = User.GetUserId();
var count = await _db.Subscriptions

View File

@ -1,5 +1,5 @@
<!-- OnlyPrompt - Marketplace page:
- Browse and filter AI prompts with buy/view details buttons and pricing -->
<!-- OnlyPrompt - Community page:
- Discover creators, follow/unfollow, dynamic via API -->
<!DOCTYPE html>
<html lang="en">
@ -17,145 +17,161 @@
</head>
<body>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<div id="topbar-container"></div>
<main class="creators-main">
<!-- Header / Titel -->
<div class="creators-header">
<h1>Discover Creators</h1>
<p>Follow your favorite prompt artists and get inspired.</p>
</div>
<!-- Filter Buttons -->
<div class="filter-buttons">
<button class="filter-btn active">Popular</button>
<button class="filter-btn">Rising</button>
<button class="filter-btn">New</button>
<button class="filter-btn">Top Rated</button>
<button class="filter-btn active" data-sort="popular">Popular</button>
<button class="filter-btn" data-sort="prompts">Rising</button>
<button class="filter-btn" data-sort="new">New</button>
<button class="filter-btn" data-sort="rating">Top Rated</button>
</div>
<!-- Creators Grid -->
<div class="creators-grid">
<!-- Creator Card 1 -->
<div class="creator-card">
<img src="../images/content/creator1.png" alt="Sarah Jenkins" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Sarah Jenkins</h3>
<div class="creator-handle">@sarahj</div>
<p class="creator-bio">AI Explorer | Prompt Curator | Exploring creativity through generative AI.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 42 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.9</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<div class="creators-grid" id="creators-grid"></div>
<!-- Creator Card 2 -->
<div class="creator-card">
<img src="../images/content/creator2.png" alt="Alex Chen" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Alex Chen</h3>
<div class="creator-handle">@alexchen</div>
<p class="creator-bio">Digital artist & prompt engineer. Creating surreal landscapes.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 87 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.8</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<!-- Creator Card 3 -->
<div class="creator-card">
<img src="../images/content/creator3.png" alt="Mia Wong" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Mia Wong</h3>
<div class="creator-handle">@miawong</div>
<p class="creator-bio">Midjourney master | UI/UX prompts | Design systems.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 124 prompts</span>
<span><i class="bi bi-star-fill"></i> 5.0</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<!-- Creator Card 4 -->
<div class="creator-card">
<img src="../images/content/creator4.png" alt="Tom Rivera" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Tom Rivera</h3>
<div class="creator-handle">@tomrivera</div>
<p class="creator-bio">3D artist | Character design | Sci-fi & fantasy prompts.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 33 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.7</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<!-- Creator Card 5 -->
<div class="creator-card">
<img src="../images/content/creator5.png" alt="Emma Watson" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Emma Watson</h3>
<div class="creator-handle">@emmawatson</div>
<p class="creator-bio">Watercolor & pet portraits | Whimsical art prompts.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 56 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.9</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<!-- Creator Card 6 -->
<div class="creator-card">
<img src="../images/content/creator6.png" alt="Liam O'Brien" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Liam O'Brien</h3>
<div class="creator-handle">@liamob</div>
<p class="creator-bio">Minimalist logo designer | Brand identity prompts.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 28 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.6</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<div id="creators-empty" style="display:none; text-align:center; padding:60px 20px; color:#64748b;">
<i class="bi bi-people" style="font-size:3rem; display:block; margin-bottom:16px;"></i>
<h3 style="margin-bottom:8px;">No creators found</h3>
<p>Check back later for new creators to follow.</p>
</div>
<div id="creators-error" style="display:none; text-align:center; padding:60px 20px; color:#ef4444;">
<i class="bi bi-exclamation-circle" style="font-size:3rem; display:block; margin-bottom:16px;"></i>
<h3 style="margin-bottom:8px;">Could not load creators</h3>
<p id="creators-error-msg"></p>
</div>
</main>
</div>
</div>
<script>
<script type="module">
// ── Sidebar & Topbar ─────────────────────────────────────────────
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
// Remove 'active' from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active');
});
// Set 'active' on the third link (Community)
document.querySelectorAll('#sidebar-container .sidebar a').forEach(l => l.classList.remove('active'));
const thirdLink = document.querySelectorAll('#sidebar-container .sidebar li a')[2];
if (thirdLink) thirdLink.classList.add('active');
});
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
// ── Helpers ──────────────────────────────────────────────────────
function creatorImg(id) {
return `/images/content/creator${(parseInt(id.slice(-1), 16) % 6) + 1}.png`;
}
function renderStars(rating) {
if (!rating) return '';
const stars = Math.round(rating);
return `<span style="color:#f59e0b">${'★'.repeat(stars)}${'☆'.repeat(5 - stars)}</span> <span style="color:#64748b;font-size:0.8rem">${rating.toFixed(1)}</span>`;
}
function renderCard(c) {
return `
<div class="creator-card">
<img class="creator-avatar"
src="${creatorImg(c.userId)}"
alt="${c.displayName}"
style="cursor:pointer"
onclick="location.href='/profile?id=${c.userId}'">
<div class="creator-info">
<h3 class="creator-name"
style="cursor:pointer"
onclick="location.href='/profile?id=${c.userId}'">${c.displayName}</h3>
<div class="creator-handle">@${c.slug}</div>
<p class="creator-bio">${c.bio ?? 'No bio yet.'}</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> ${c.promptCount} prompts</span>
<span><i class="bi bi-people"></i> ${c.subscribers}</span>
${c.averageRating > 0 ? `<span>${renderStars(c.averageRating)}</span>` : ''}
</div>
<button class="follow-btn ${c.isFollowing ? 'following' : ''}"
data-userid="${c.userId}"
data-following="${c.isFollowing}">
${c.isFollowing ? 'Following' : 'Follow'}
</button>
</div>
</div>`;
}
// ── Follow / Unfollow ────────────────────────────────────────────
async function toggleFollow(btn) {
const userId = btn.dataset.userid;
const isFollowing = btn.dataset.following === 'true';
btn.disabled = true;
const res = await fetch(`/api/v1/subscriptions/${userId}`, {
method: isFollowing ? 'DELETE' : 'PUT',
credentials: 'same-origin'
});
if (res.status === 401) { location.href = '/login'; return; }
if (res.ok) {
const nowFollowing = !isFollowing;
btn.dataset.following = nowFollowing;
btn.textContent = nowFollowing ? 'Following' : 'Follow';
btn.classList.toggle('following', nowFollowing);
}
btn.disabled = false;
}
// ── Load Creators ────────────────────────────────────────────────
const grid = document.getElementById('creators-grid');
const emptyEl = document.getElementById('creators-empty');
const errorEl = document.getElementById('creators-error');
const errorMsg = document.getElementById('creators-error-msg');
async function loadCreators(sort = 'popular') {
grid.innerHTML = '';
emptyEl.style.display = 'none';
errorEl.style.display = 'none';
try {
const res = await fetch(`/api/v1/profiles?sort=${sort}&limit=50`);
if (res.status === 401) { location.href = '/login'; return; }
if (!res.ok) throw new Error(`Server error ${res.status}`);
const creators = await res.json();
if (creators.length === 0) { emptyEl.style.display = 'block'; return; }
grid.innerHTML = creators.map(renderCard).join('');
grid.querySelectorAll('.follow-btn').forEach(btn => {
btn.addEventListener('click', () => toggleFollow(btn));
});
} catch (e) {
errorEl.style.display = 'block';
errorMsg.textContent = e.message;
}
}
// ── Filter buttons ───────────────────────────────────────────────
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadCreators(btn.dataset.sort);
});
});
loadCreators();
</script>
</body>
</html>

View File

@ -137,6 +137,16 @@
.follow-btn:hover {
opacity: 0.85;
}
.follow-btn.following {
background: transparent;
border: 2px solid #94a3b8;
color: #64748b;
}
.follow-btn.following:hover {
border-color: #ef4444;
color: #ef4444;
opacity: 1;
}
/* Responsive */
@media (max-width: 768px) {

View File

@ -219,6 +219,10 @@
.post-locked {
opacity: 0.75;
}
.post-image-locked {
filter: blur(6px);
pointer-events: none;
}
.post-locked-msg {
color: #94a3b8;
font-size: 0.85rem;

View File

@ -147,20 +147,28 @@
</span>`;
}
function feedImg(id) {
return `/images/content/feed${(parseInt(id.slice(-1), 16) % 4) + 1}.png`;
}
function creatorImg(id) {
return `/images/content/creator${(parseInt(id.slice(-1), 16) % 6) + 1}.png`;
}
function renderCard(prompt) {
const locked = !prompt.canAccess;
return `
<div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='/post-detail?id=${prompt.id}'">
<div class="post-header">
<div class="post-avatar post-avatar-initials">${prompt.creatorName.charAt(0).toUpperCase()}</div>
<img class="post-avatar" src="${creatorImg(prompt.creatorId)}" alt="${prompt.creatorName}">
<div class="post-author">
<span class="post-name">${prompt.creatorName}</span>
</div>
<span class="post-date">${timeAgo(prompt.timeStamp)}</span>
</div>
<div class="post-content">
<h3 class="post-title">${prompt.title}</h3>
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? "Paid"} tier required</p>` : ""}
<img class="post-image${locked ? ' post-image-locked' : ''}" src="${feedImg(prompt.id)}" alt="${prompt.title}">
<h3 class="post-title" style="margin-top:10px">${prompt.title}</h3>
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? 'Paid'} tier required</p>` : ''}
${renderStars(prompt.averageRating)}
</div>
<div class="post-actions">