feat: community.html dynamisch via API, GET /api/v1/profiles, follow/unfollow fix
This commit is contained in:
parent
cb11acd306
commit
4f7e9bac68
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user