Compare commits
8 Commits
e274f2626b
...
e6d54d693f
| Author | SHA1 | Date | |
|---|---|---|---|
| e6d54d693f | |||
| 07541e0bbe | |||
| dc6e94dbed | |||
| 37d92fd6a7 | |||
| c4a802b458 | |||
| b049b79911 | |||
| 7c724c00a9 | |||
| 4cb241ac83 |
10
API.md
10
API.md
@ -150,9 +150,9 @@ Updates profile data used on My Profile, Community and the topbar.
|
||||
GET /api/v1/prompts?sortBy=date&ascending=false&limit=50&search=cat
|
||||
```
|
||||
|
||||
Used by Marketplace. Supports sorting, search and category filtering.
|
||||
Used by Marketplace. Supports sorting, search and category filtering. The marketplace excludes prompts created by the logged-in user.
|
||||
|
||||
Response items include prompt title, description, creator id, creator name, creator avatar, example image, price, like/save counts and access state.
|
||||
Response items include prompt title, description, creator id, creator name, creator avatar, example image, price, like/save counts, average rating, review count and access state.
|
||||
|
||||
### Feed
|
||||
|
||||
@ -160,7 +160,7 @@ Response items include prompt title, description, creator id, creator name, crea
|
||||
GET /api/v1/feed?sortBy=date&ascending=false&limit=20
|
||||
```
|
||||
|
||||
Used by Dashboard. Returns prompt cards with title, description, creator info, avatar and example image.
|
||||
Used by Dashboard. Returns prompt cards from followed creators, excluding the logged-in user's own prompts.
|
||||
|
||||
### Create Prompt
|
||||
|
||||
@ -219,7 +219,7 @@ Response includes:
|
||||
- price or free state
|
||||
- example output
|
||||
- example image
|
||||
- rating data
|
||||
- average rating and review count
|
||||
- like/save state and counts
|
||||
|
||||
Paid prompts return no detail content for users without access.
|
||||
@ -251,7 +251,7 @@ PUT /api/v1/prompts/{id}/reviews
|
||||
}
|
||||
```
|
||||
|
||||
Used for user feedback on prompts.
|
||||
Used on the prompt detail page. Users can review prompts created by other users. Each user can have one review per prompt; submitting again updates the existing review. Reviews are displayed with username, star rating and comment.
|
||||
|
||||
## Categories
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -31,7 +31,9 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var query = _db.Prompts.AsQueryable();
|
||||
var query = _db.Prompts
|
||||
.Where(x => x.CreatorId != userId)
|
||||
.Where(x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId));
|
||||
|
||||
if (category.HasValue)
|
||||
query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug);
|
||||
@ -45,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
|
||||
};
|
||||
|
||||
@ -70,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();
|
||||
|
||||
|
||||
@ -44,7 +44,8 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var query = _db.Prompts.AsQueryable();
|
||||
var query = _db.Prompts
|
||||
.Where(x => x.CreatorId != userId);
|
||||
|
||||
if (category.HasValue)
|
||||
query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug);
|
||||
@ -56,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
|
||||
};
|
||||
|
||||
@ -81,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();
|
||||
|
||||
@ -111,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();
|
||||
|
||||
@ -330,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)
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,6 +59,7 @@
|
||||
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
if (location.pathname.includes("marketplace")) return;
|
||||
|
||||
const search = input.value.trim();
|
||||
location.href = search
|
||||
|
||||
@ -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) {
|
||||
@ -450,7 +454,8 @@
|
||||
<span style="margin-left:auto;font-size:0.75rem;color:#94a3b8;">${timeAgo(p.timeStamp)}</span>
|
||||
</div>
|
||||
<h3 class="prompt-title">${p.title}</h3>
|
||||
<div style="margin-bottom:12px;">${renderStars(p.averageRating)}</div>
|
||||
<p class="prompt-description">${p.description || 'No description yet.'}</p>
|
||||
<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">
|
||||
${
|
||||
@ -558,6 +563,12 @@
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(loadPrompts, 400);
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
loadPrompts();
|
||||
});
|
||||
}
|
||||
|
||||
const topbarObserver = new MutationObserver(wireMarketplaceTopbarSearch);
|
||||
|
||||
@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@ -9,6 +9,7 @@ This project is built with HTML, CSS and JavaScript.
|
||||
- 📝 Create, edit and publish AI prompts
|
||||
- 🔍 Browse prompts in a marketplace with category, search and price filters
|
||||
- 📄 View prompt detail pages with examples, ratings and access states
|
||||
- ⭐ Write reviews with star ratings and comments
|
||||
- ❤️ Like and save prompts
|
||||
- 👥 Follow and discover other creators
|
||||
- 👤 Edit user profiles with display name, username, bio and profile picture
|
||||
@ -49,7 +50,7 @@ The frontend is built with plain HTML, CSS and Vanilla JavaScript because the pr
|
||||
AI tools were used as support during development, mainly for debugging, comparing implementation approaches and improving documentation wording. Generated suggestions were reviewed, adapted and tested by the team before being included in the project.
|
||||
|
||||
## Security Considerations
|
||||
The project uses authentication with a JWT cookie so that protected pages and API endpoints require a logged-in user. User input is validated on the backend for important operations such as registration, profile updates and prompt creation.
|
||||
The project uses authentication with a JWT cookie so that protected pages and API endpoints require a logged-in user. User input is validated on the backend for important operations such as registration, profile updates, prompt creation and reviews.
|
||||
|
||||
Known limitations:
|
||||
- Payment and premium access are simulated and are not connected to a real payment provider.
|
||||
@ -57,12 +58,12 @@ Known limitations:
|
||||
- Authentication is implemented for local project use and would need additional hardening for production.
|
||||
|
||||
## Reflection
|
||||
A main challenge was connecting static frontend pages with dynamic backend data while keeping the application usable and consistent. During development, the project evolved from demo pages into a connected application with real profiles, prompts, likes, saves and creator interactions. We learned how important clear API structures, consistent data models and regular browser testing are when multiple pages depend on the same shared data.
|
||||
A main challenge was connecting static frontend pages with dynamic backend data while keeping the application usable and consistent. During development, the project evolved from demo pages into a connected application with real profiles, prompts, reviews, likes, saves and creator interactions. We learned how important clear API structures, consistent data models and regular browser testing are when multiple pages depend on the same shared data.
|
||||
|
||||
## Group members and their roles
|
||||
| Name | Role |
|
||||
| --- | --- |
|
||||
| **Thuvaraka Yogarajah** | Initial project structure, HTML layout and API backend scaffold |
|
||||
| **Thuvaraka Yogarajah** | Initial project structure, HTML layout, marketplace/review functions and API backend scaffold |
|
||||
| **Isabelle Nachbaur** | Frontend pages, UI design, prompt creation flow, marketplace/profile features |
|
||||
| **Florian Klessascheck** | Backend setup, authentication, database integration, Docker setup and utility/PWA experiments |
|
||||
| **Abdul Geylani Semiz** | Dynamic API integration for dashboard feed, marketplace, post detail and community creator features |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user