Compare commits
8 Commits
e274f2626b
...
e6d54d693f
| Author | SHA1 | Date | |
|---|---|---|---|
| e6d54d693f | |||
| 07541e0bbe | |||
| dc6e94dbed | |||
| 37d92fd6a7 | |||
| c4a802b458 | |||
| b049b79911 | |||
| 7c724c00a9 | |||
| 4cb241ac83 |
63
API.md
63
API.md
@ -111,6 +111,14 @@ Query parameters:
|
||||
|
||||
Response: list of creator cards including follow state and avatar URL.
|
||||
|
||||
### Public Profile
|
||||
|
||||
```http
|
||||
GET /api/v1/profiles/{creatorId}
|
||||
```
|
||||
|
||||
Used by public creator profiles. The response contains display name, slug, bio, avatar URL, average rating and subscriber count.
|
||||
|
||||
### Update Profile
|
||||
|
||||
```http
|
||||
@ -142,7 +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, average rating, review count and access state.
|
||||
|
||||
### Feed
|
||||
|
||||
@ -150,7 +160,7 @@ Used by Marketplace. Supports sorting, search and category filtering.
|
||||
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
|
||||
|
||||
@ -177,6 +187,23 @@ Request:
|
||||
|
||||
Response: created prompt. The frontend redirects to `/post-detail?id={id}`.
|
||||
|
||||
### Own Prompts
|
||||
|
||||
```http
|
||||
GET /api/v1/prompts/mine
|
||||
```
|
||||
|
||||
Used by My Profile to show prompts created by the logged-in user.
|
||||
|
||||
### Update Prompt
|
||||
|
||||
```http
|
||||
PUT /api/v1/prompts/{id}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request body uses the same editable fields as prompt creation. Used by the edit prompt flow.
|
||||
|
||||
### Prompt Detail
|
||||
|
||||
```http
|
||||
@ -192,7 +219,21 @@ 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.
|
||||
|
||||
### Likes and Saves
|
||||
|
||||
```http
|
||||
PUT /api/v1/prompts/{id}/likes
|
||||
DELETE /api/v1/prompts/{id}/likes
|
||||
PUT /api/v1/prompts/{id}/saves
|
||||
DELETE /api/v1/prompts/{id}/saves
|
||||
```
|
||||
|
||||
Used by Dashboard, prompt detail and profile tabs to store user interactions.
|
||||
|
||||
### Reviews
|
||||
|
||||
@ -210,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
|
||||
|
||||
@ -240,8 +281,20 @@ Default categories are created automatically when the backend starts.
|
||||
### Follow or Subscribe to Creator
|
||||
|
||||
```http
|
||||
GET /api/v1/subscriptions/{creatorId}
|
||||
PUT /api/v1/subscriptions/{creatorId}
|
||||
DELETE /api/v1/subscriptions/{creatorId}
|
||||
```
|
||||
|
||||
Used by Community to follow or unfollow creators.
|
||||
Used by Community and public profiles to read follow state, follow creators or unfollow creators.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Common response codes used by the frontend:
|
||||
|
||||
- `200 OK`: request succeeded
|
||||
- `400 Bad Request`: validation error or invalid request data
|
||||
- `401 Unauthorized`: user is not logged in and should be redirected to login
|
||||
- `404 Not Found`: requested prompt, profile or subscription was not found
|
||||
|
||||
The frontend handles failed requests with visible error states or redirects to login for unauthorized users.
|
||||
|
||||
@ -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) {
|
||||
|
||||
59
README.md
59
README.md
@ -1,21 +1,22 @@
|
||||
# OnlyPrompt - AI Prompt Marketplace
|
||||
|
||||
## Description
|
||||
OnlyPrompt is a frontend web applications where people can publish and sell their high-quality AI prompts. User can browse, filter, rate and unlock premium features such as private chats with creators for exclusive AI tips.
|
||||
OnlyPrompt is a web application where users can create, publish, discover and interact with AI prompts. Users can edit their profiles, follow creators, like and save prompts, write reviews and browse free or paid prompt cards in a marketplace.
|
||||
|
||||
This project is built with with HTML, CSS and Java Script.
|
||||
This project is built with HTML, CSS and JavaScript.
|
||||
|
||||
## Special Features
|
||||
- 📝 Create and publish new prompts
|
||||
- 🔍 Live search and category filtering
|
||||
- 💬 Premium chat simulation with creators
|
||||
- 🔒 Unlock premium prompts (payment simulation)
|
||||
- ⭐ Rating system
|
||||
- ❤️ Save favorites (LocalStorage)
|
||||
- 📱 Fully responsive design (mobile & desktop)
|
||||
- 🌐 External API integration (e.g., RandomUser API for creator profiles)
|
||||
- 💾 Client-side data persistence
|
||||
- 🔄 Basic server communication (optional file-based backend)
|
||||
- 📝 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
|
||||
- 🌐 View own and public creator profiles
|
||||
- 📱 Responsive layout for desktop and mobile
|
||||
- 🔄 Server communication through a REST API
|
||||
- 💾 Shared data persistence with backend and database
|
||||
|
||||
|
||||
## Installment (for local use)
|
||||
@ -33,18 +34,36 @@ DB_PASSWORD=onlyprompt
|
||||
```
|
||||
|
||||
## Technologies, Libraries, Frameworks
|
||||
- HTML5 (Structure)
|
||||
- CSS3 + Bootstrap + PureCSS (Styling & responsive layout)
|
||||
- Vanilla JavaScript (Application logic)
|
||||
- JavaScript Libraries Axios, JQuery
|
||||
- ASP.NET Core (Backend)
|
||||
- Postgres (Backend Datastorage)
|
||||
- Docker (Deployment)
|
||||
- HTML5 for page structure
|
||||
- CSS3 with Flexbox/Grid for layout and responsive design
|
||||
- Bootstrap Icons for UI icons
|
||||
- Vanilla JavaScript for DOM manipulation, events and API calls
|
||||
- Fetch API for asynchronous server communication
|
||||
- ASP.NET Core as helper backend
|
||||
- PostgreSQL for shared data persistence
|
||||
- Docker for local development and deployment
|
||||
|
||||
## Technical Decisions
|
||||
The frontend is built with plain HTML, CSS and Vanilla JavaScript because the project focuses on understanding core frontend concepts without using a JavaScript framework. The backend is used as a helper service for authentication, shared data persistence and REST API communication. Docker is used so that the project can be started consistently on different machines.
|
||||
|
||||
## Use of AI Tools
|
||||
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, prompt creation and reviews.
|
||||
|
||||
Known limitations:
|
||||
- Payment and premium access are simulated and are not connected to a real payment provider.
|
||||
- User-generated content is displayed in the frontend, so XSS prevention is important. The project avoids intentionally executing user input as code, but further output sanitization would be needed for production.
|
||||
- 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, 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