Add prompt reviews and rating sorting

This commit is contained in:
Thuvaraka Yogarajah 2026-06-01 22:10:38 +02:00
parent c4a802b458
commit 37d92fd6a7
7 changed files with 296 additions and 14 deletions

View File

@ -1,7 +1,7 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
public record ApiLikeState(int LikeCount, bool IsLiked);
public record ApiSaveState(int SaveCount, bool IsSaved);

View File

@ -47,8 +47,8 @@ namespace OnlyPrompt.Backend.Controllers
query = sortBy switch {
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
FeedSortType.Rating => ascending
? query.OrderBy(x => x.Likes.Count).ThenBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0)
: query.OrderByDescending(x => x.Likes.Count).ThenByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0),
? query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenBy(x => x.Reviews.Count)
: query.OrderByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenByDescending(x => x.Reviews.Count),
_ => query
};
@ -72,6 +72,7 @@ namespace OnlyPrompt.Backend.Controllers
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
)).ToArrayAsync();

View File

@ -57,8 +57,8 @@ namespace OnlyPrompt.Backend.Controllers
{
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
FeedSortType.Rating => ascending
? query.OrderBy(x => x.Likes.Count).ThenBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0)
: query.OrderByDescending(x => x.Likes.Count).ThenByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0),
? query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenBy(x => x.Reviews.Count)
: query.OrderByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenByDescending(x => x.Reviews.Count),
_ => query
};
@ -82,6 +82,7 @@ namespace OnlyPrompt.Backend.Controllers
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
x.CreatorId == userId || ((x.Price == null || x.Price <= 0) && (x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)))
)).ToArrayAsync();
@ -112,6 +113,7 @@ namespace OnlyPrompt.Backend.Controllers
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
true
)).ToArrayAsync();
@ -331,7 +333,10 @@ namespace OnlyPrompt.Backend.Controllers
{
var userId = User.GetUserId();
var accessiblePrompts = GetAccessiblePrompts(userId!.Value);
var reviews = await accessiblePrompts.Select(x => x.Reviews)
var reviews = await accessiblePrompts
.OfIdentifer(id)
.SelectMany(x => x.Reviews)
.OrderByDescending(x => x.UpdatedAt)
.Skip(offset)
.Take(limit)
.ProjectTo<ApiReview>(_mapper.ConfigurationProvider)

View File

@ -47,6 +47,7 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
.MapCtorParamFrom(x => x.CanAccess, x => false);
config.CreateMap<PromptModel, ApiMinimalPrompt>()
@ -66,6 +67,7 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
.MapCtorParamFrom(x => x.CanAccess, x => true);
config.CreateMap<ReviewModel, ApiReview>()

View File

@ -134,6 +134,110 @@
margin-right: 6px;
}
/* Reviews */
.reviews-section {
margin-top: 18px;
}
.reviews-section h2 {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 28px;
}
.review-form {
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: 16px;
padding: 22px;
margin-bottom: 28px;
}
.review-form h3 {
font-size: 1.2rem;
margin-bottom: 16px;
}
.review-star-input {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.review-star-input button {
border: none;
background: transparent;
color: #f59e0b;
cursor: pointer;
font-family: inherit;
font-size: 2.4rem;
line-height: 1;
padding: 0 2px;
}
.review-form textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 12px;
color: #111827;
display: block;
font: inherit;
font-size: 1rem;
line-height: 1.5;
margin-bottom: 16px;
min-height: 96px;
padding: 14px 16px;
resize: vertical;
}
.review-form textarea:focus {
border-color: #a855f7;
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.14);
outline: none;
}
.review-form button#submit-review-btn {
background: var(--gradient);
border: none;
border-radius: 14px;
color: #fff;
cursor: pointer;
font: inherit;
font-size: 1rem;
font-weight: 700;
padding: 12px 24px;
}
.review-form button#submit-review-btn:disabled {
cursor: wait;
opacity: 0.7;
}
#review-message {
color: #64748b;
font-size: 0.9rem;
margin-top: 12px;
}
.reviews-list {
display: grid;
gap: 14px;
}
.review-card {
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 16px 18px;
}
.review-card-header {
align-items: center;
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.review-card-user {
font-weight: 700;
}
.review-card-stars {
color: #f59e0b;
font-size: 1.2rem;
letter-spacing: 1px;
margin-left: auto;
}
.review-card-comment {
color: #334155;
line-height: 1.45;
margin: 0;
}
/* Example Output Section */
.example-section {
margin-bottom: 32px;
@ -214,4 +318,4 @@
.prompt-content {
padding: 16px;
}
}
}

View File

@ -387,11 +387,15 @@
return `${Math.floor(h / 24)}d ago`;
}
function renderStars(rating) {
function renderStars(rating, reviewCount = 0, promptId = null, locked = false) {
const target = promptId && !locked
? ` onclick="location.href='/post-detail?id=${promptId}#rating-section'" title="View reviews" style="cursor:pointer;"`
: "";
if (rating == null)
return '<span style="color:#94a3b8;font-size:0.8rem;">No reviews yet</span>';
return `<span${target} style="color:#94a3b8;font-size:0.8rem;${promptId && !locked ? 'cursor:pointer;' : ''}">No reviews yet</span>`;
const stars = Math.round(rating);
return `<span class="prompt-rating"><span style="color:#f59e0b">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> ${rating.toFixed(1)}</span>`;
const label = reviewCount === 1 ? "review" : "reviews";
return `<span class="prompt-rating"${target}><span style="color:#f59e0b">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> ${rating.toFixed(1)} (${reviewCount} ${label})</span>`;
}
function promptPrice(prompt) {
@ -451,7 +455,7 @@
</div>
<h3 class="prompt-title">${p.title}</h3>
<p class="prompt-description">${p.description || 'No description yet.'}</p>
<div style="margin-bottom:12px;">${renderStars(p.averageRating)}</div>
<div style="margin-bottom:12px;">${renderStars(p.averageRating, p.reviewCount || 0, p.id, locked)}</div>
<div class="prompt-price">${promptPrice(p)}</div>
<div class="prompt-actions">
${

View File

@ -232,6 +232,27 @@
<div class="rating-section" id="rating-section">
<div class="rating-stars" id="rating-display"></div>
</div>
<!-- Reviews -->
<div class="reviews-section" id="reviews-section">
<h2>REVIEWS</h2>
<div class="review-form" id="review-form">
<h3>Your review</h3>
<div class="review-star-input" id="review-star-input" aria-label="Select rating">
<button type="button" data-rating="1"></button>
<button type="button" data-rating="2"></button>
<button type="button" data-rating="3"></button>
<button type="button" data-rating="4"></button>
<button type="button" data-rating="5"></button>
</div>
<textarea id="review-comment" maxlength="200" rows="3" placeholder="Write a short comment..."></textarea>
<button type="button" id="submit-review-btn">Submit Review</button>
<p id="review-message"></p>
</div>
<div class="reviews-list" id="reviews-list">
<p style="color:#94a3b8;">Loading reviews...</p>
</div>
</div>
</div>
</div>
</main>
@ -256,6 +277,7 @@
const params = new URLSearchParams(location.search);
const id = params.get("id");
let selectedReviewRating = 0;
if (!id) {
showError("No prompt ID provided", "Add ?id=... to the URL.");
@ -313,6 +335,8 @@
<span style="margin-left:12px;"><i class="bi ${p.isSaved ? 'bi-bookmark-fill' : 'bi-bookmark'}" style="color:#f59e0b;"></i> ${p.saveCount || 0} saves</span>`;
renderExamples(p);
renderOwnerActions(p);
setupReviewSection(p);
loadReviews(p.id);
// Tier badge
const badge = document.getElementById("tier-badge");
@ -330,11 +354,11 @@
document.getElementById("rating-display").innerHTML =
`<span style="color:#f59e0b;font-size:1.1rem;">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span>
<span style="margin-left:8px;font-weight:600;">${p.averageRating.toFixed(1)}</span>
<span style="color:#94a3b8;font-size:0.85rem;margin-left:4px;">/ 5.0</span>`;
<span style="color:#94a3b8;font-size:0.85rem;margin-left:4px;">/ 5.0 (${p.reviewCount || 0} ${(p.reviewCount || 0) === 1 ? "review" : "reviews"})</span>`;
document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes
<span style="margin-left:12px;"><i class="bi ${p.isSaved ? 'bi-bookmark-fill' : 'bi-bookmark'}" style="color:#f59e0b;"></i> ${p.saveCount || 0} saves</span>
<span style="margin-left:12px;"><i class="bi bi-star-fill" style="color:#f59e0b;"></i> ${p.averageRating.toFixed(1)} rating</span>`;
<span style="margin-left:12px;"><i class="bi bi-star-fill" style="color:#f59e0b;"></i> ${p.averageRating.toFixed(1)} (${p.reviewCount || 0})</span>`;
} else {
document.getElementById("rating-display").innerHTML =
'<span style="color:#94a3b8;font-size:0.9rem;">No ratings yet</span>';
@ -357,6 +381,148 @@
document.getElementById("detail-loading").style.display = "none";
document.getElementById("detail-body").style.display = "block";
scrollToHashSection();
}
function scrollToHashSection() {
if (!location.hash) return;
const target = document.querySelector(location.hash);
if (!target) return;
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
function setReviewRating(rating) {
selectedReviewRating = rating;
document.querySelectorAll("#review-star-input button").forEach((button) => {
const value = Number(button.dataset.rating);
button.textContent = value <= rating ? "★" : "☆";
});
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function setupReviewSection(prompt) {
const form = document.getElementById("review-form");
const message = document.getElementById("review-message");
const submitBtn = document.getElementById("submit-review-btn");
selectedReviewRating = 0;
setReviewRating(0);
document.getElementById("review-star-input").style.display = "flex";
document.getElementById("review-comment").style.display = "block";
submitBtn.style.display = "inline-block";
document.getElementById("review-comment").value = "";
message.textContent = "";
form.style.display = "block";
if (!prompt.content) {
document.getElementById("review-star-input").style.display = "none";
document.getElementById("review-comment").style.display = "none";
submitBtn.style.display = "none";
message.textContent = "Unlock this prompt before writing a review.";
return;
}
try {
const response = await fetch("/api/v1/auth/me", {
credentials: "same-origin",
});
if (!response.ok) return;
const user = await response.json();
if (user.id === prompt.creatorId) {
document.getElementById("review-star-input").style.display = "none";
document.getElementById("review-comment").style.display = "none";
submitBtn.style.display = "none";
message.textContent = "You cannot review your own prompt.";
return;
}
} catch {
// Keep the review form visible; the API will reject unauthenticated users.
}
document.querySelectorAll("#review-star-input button").forEach((button) => {
button.onclick = () => setReviewRating(Number(button.dataset.rating));
});
submitBtn.onclick = async () => {
if (!selectedReviewRating) {
message.textContent = "Please select a star rating.";
return;
}
submitBtn.disabled = true;
message.textContent = "Saving review...";
const response = await fetch(`/api/v1/prompts/${prompt.id}/reviews`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
rating: selectedReviewRating,
comment: document.getElementById("review-comment").value.trim() || null,
}),
});
if (response.status === 401) {
location.href = "/login";
return;
}
submitBtn.disabled = false;
if (!response.ok) {
message.textContent = await response.text();
return;
}
message.textContent = "Review saved.";
await loadPrompt(prompt.id);
};
}
async function loadReviews(promptId) {
const list = document.getElementById("reviews-list");
try {
const response = await fetch(`/api/v1/prompts/${promptId}/reviews`, {
credentials: "same-origin",
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) throw new Error(`Server error ${response.status}`);
const reviews = await response.json();
if (reviews.length === 0) {
list.innerHTML = '<p style="color:#94a3b8;">No reviews yet.</p>';
return;
}
list.innerHTML = reviews.map((review) => {
const stars = "★".repeat(review.rating) + "☆".repeat(5 - review.rating);
return `
<article class="review-card">
<div class="review-card-header">
<span class="review-card-user">@${escapeHtml(review.creatorName)}</span>
<span class="review-card-stars">${stars}</span>
</div>
<p class="review-card-comment">${escapeHtml(review.comment || "No comment.")}</p>
</article>`;
}).join("");
} catch (error) {
list.innerHTML = `<p style="color:#ef4444;">${error.message}</p>`;
}
}
async function renderOwnerActions(p) {