Connect prompt creation flow and document API

This commit is contained in:
Isabelle Nachbaur 2026-05-30 17:11:42 +02:00
parent 863cf3e0ef
commit 691bc72681
16 changed files with 567 additions and 54 deletions

247
API.md Normal file
View File

@ -0,0 +1,247 @@
# OnlyPrompt API
This file documents the backend endpoints used by the frontend. The backend is a helper service for server communication, authentication and shared data persistence.
Base URL in local Docker setup:
```text
http://localhost:1801
```
Authentication uses a `jwt` cookie set by the login endpoint. Protected endpoints require a logged-in user.
## Auth
### Register
```http
POST /api/v1/auth/register
Content-Type: application/json
```
Request:
```json
{
"displayName": "Isabelle",
"userName": "its_isabelle",
"email": "isabelle@test.com",
"password": "1234"
}
```
Response: created user data or validation errors.
### Login
```http
POST /api/v1/auth/login
Content-Type: application/json
```
Request:
```json
{
"userNameOrEmail": "isabelle@test.com",
"password": "1234"
}
```
Response: sets the auth cookie.
### Current User
```http
GET /api/v1/auth/me
```
Response:
```json
{
"id": "uuid",
"userName": "its_isabelle",
"email": "isabelle@test.com",
"roles": ["User"]
}
```
### Logout
```http
POST /api/v1/auth/logout
```
Deletes the auth cookie and redirects to login.
## Profiles
### Current Profile
```http
GET /api/v1/profiles/self
```
Response:
```json
{
"displayName": "Isabelle",
"slug": "its_isabelle",
"bio": "AI creator",
"avatarUrl": "data:image/png;base64,...",
"specialities": null,
"averageRating": 0,
"subscribers": 0
}
```
### Creator List
```http
GET /api/v1/profiles?sort=popular&limit=50&search=belle
```
Query parameters:
- `sort`: `popular`, `prompts`, `new`, `rating`
- `limit`: number of creators
- `search`: optional search term
Response: list of creator cards including follow state and avatar URL.
### Update Profile
```http
PUT /api/v1/profiles
Content-Type: application/json
```
Request:
```json
{
"displayName": "Belle",
"userName": "lady_belle",
"slug": "lady_belle",
"bio": "Prompt creator",
"avatarUrl": "data:image/png;base64,...",
"specialities": null,
"isPublic": true
}
```
Updates profile data used on My Profile, Community and the topbar.
## Prompts
### List Prompts
```http
GET /api/v1/prompts?sortBy=date&ascending=false&limit=50&search=cat
```
Used by Marketplace. Supports sorting, search and category filtering.
### Feed
```http
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.
### Create Prompt
```http
POST /api/v1/prompts
Content-Type: application/json
```
Request:
```json
{
"title": "Luxury Cat Portrait",
"description": "Create a premium cat portrait prompt.",
"content": "Write the full prompt instructions here.",
"category": "art",
"subscriptionTier": null,
"slug": null,
"exampleOutput": "Example output text",
"exampleImageUrl": "data:image/png;base64,...",
"price": null
}
```
Response: created prompt. The frontend redirects to `/post-detail?id={id}`.
### Prompt Detail
```http
GET /api/v1/prompts/{id}
```
Response includes:
- title and description
- prompt content if accessible
- category
- creator information
- price or free state
- example output
- example image
- rating data
### Reviews
```http
GET /api/v1/prompts/{id}/reviews
PUT /api/v1/prompts/{id}/reviews
```
`PUT` request:
```json
{
"comment": "Helpful prompt",
"rating": 5
}
```
Used for user feedback on prompts.
## Categories
### Minimal Categories
```http
GET /api/v1/categories/minimal
```
Used by Create New and Marketplace filters.
Response:
```json
[
{
"name": "Art",
"slug": "art"
}
]
```
Default categories are created automatically when the backend starts.
## Subscriptions
### Follow or Subscribe to Creator
```http
PUT /api/v1/subscriptions/{creatorId}
DELETE /api/v1/subscriptions/{creatorId}
```
Used by Community to follow or unfollow creators.

View File

@ -1,6 +1,6 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt namespace OnlyPrompt.Backend.ApiModels.Prompt
{ {
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, 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? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
public record ApiMinimalPrompt(Guid Id, string Title, DateTime TimeStamp, Guid CreatorId, string CreatorName, 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, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating); public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
} }

View File

@ -1,6 +1,4 @@
using OnlyPrompt.Backend.Utils; namespace OnlyPrompt.Backend.ApiModels.Prompt
namespace OnlyPrompt.Backend.ApiModels.Prompt
{ {
public record ApiCreatePromptRequest(string Title, string Description, string Content, Identifier Category, int? SubscriptionTier, string Slug); public record ApiCreatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? Slug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
} }

View File

@ -15,7 +15,7 @@ namespace OnlyPrompt.Backend.Controllers
{ {
[ApiController] [ApiController]
[Route("api/v1/categories")] [Route("api/v1/categories")]
[Authorize(Roles = ModelConstants.AdminRole, AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class CategoryController : BaseController public class CategoryController : BaseController
{ {
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]> private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
@ -53,6 +53,7 @@ namespace OnlyPrompt.Backend.Controllers
} }
[HttpPost] [HttpPost]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<Ok<ApiCategory>, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request) public async Task<Results<Ok<ApiCategory>, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request)
{ {
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug); var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug);
@ -69,6 +70,7 @@ namespace OnlyPrompt.Backend.Controllers
} }
[HttpPut("{id}")] [HttpPut("{id}")]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<Ok<ApiCategory>, NotFound<string>, ValidationProblem>> UpdateCategoryAsync(Identifier id, [FromBody] ApiUpdateCategoryRequest request) public async Task<Results<Ok<ApiCategory>, NotFound<string>, ValidationProblem>> UpdateCategoryAsync(Identifier id, [FromBody] ApiUpdateCategoryRequest request)
{ {
var category = await _db.Categories.FindByIdentifierAsync(id); var category = await _db.Categories.FindByIdentifierAsync(id);
@ -95,6 +97,7 @@ namespace OnlyPrompt.Backend.Controllers
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<NoContent, BadRequest<string>, NotFound<string>>> DeleteCategoryAsync(Identifier id, [FromQuery] Identifier? replaceWith = null) public async Task<Results<NoContent, BadRequest<string>, NotFound<string>>> DeleteCategoryAsync(Identifier id, [FromQuery] Identifier? replaceWith = null)
{ {
var hasPrompts = await _db.Prompts.AnyAsync(p => p.CategoryId == id.Id); var hasPrompts = await _db.Prompts.AnyAsync(p => p.CategoryId == id.Id);

View File

@ -31,11 +31,7 @@ namespace OnlyPrompt.Backend.Controllers
) )
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();
var query = _db.Prompts var query = _db.Prompts.AsQueryable();
.Where(
x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId)
&& x.CreatorId != userId
);
if (category.HasValue) if (category.HasValue)
query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug); query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug);
@ -58,11 +54,14 @@ namespace OnlyPrompt.Backend.Controllers
.Select(x => new ApiMinimalPrompt( .Select(x => new ApiMinimalPrompt(
x.Id, x.Id,
x.Title, x.Title,
x.Description,
x.UpdatedAt, x.UpdatedAt,
x.CreatorId, x.CreatorId,
x.Creator.Profile.DisplayName, x.Creator.Profile.DisplayName,
x.SubscriptionTier.Level, x.Creator.Profile.AvatarUrl,
x.SubscriptionTier.Name, x.ExampleImageUrl,
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating), x.Reviews.Average(r => (double?)r.Rating),
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level) x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
)).ToArrayAsync(); )).ToArrayAsync();

View File

@ -65,11 +65,14 @@ namespace OnlyPrompt.Backend.Controllers
.Select(x => new ApiMinimalPrompt( .Select(x => new ApiMinimalPrompt(
x.Id, x.Id,
x.Title, x.Title,
x.Description,
x.UpdatedAt, x.UpdatedAt,
x.CreatorId, x.CreatorId,
x.Creator.Profile.DisplayName, x.Creator.Profile.DisplayName,
x.SubscriptionTier.Level, x.Creator.Profile.AvatarUrl,
x.SubscriptionTier.Name, x.ExampleImageUrl,
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating), x.Reviews.Average(r => (double?)r.Rating),
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level) x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
)).ToArrayAsync(); )).ToArrayAsync();
@ -115,7 +118,7 @@ namespace OnlyPrompt.Backend.Controllers
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();
var category = await _db.Categories.FindByIdentifierAsync(request.Category); var category = await _db.Categories.FindByIdentifierAsync(new Identifier(request.Category));
if (category is null) if (category is null)
return TypedResults.NotFound("Category not found"); return TypedResults.NotFound("Category not found");
@ -141,6 +144,9 @@ namespace OnlyPrompt.Backend.Controllers
Title = request.Title, Title = request.Title,
Description = request.Description, Description = request.Description,
Prompt = request.Content, Prompt = request.Content,
ExampleOutput = request.ExampleOutput,
ExampleImageUrl = request.ExampleImageUrl,
Price = request.Price,
CreatorId = userId.Value, CreatorId = userId.Value,
SubscriptionTier = subscriptionTier, SubscriptionTier = subscriptionTier,
Category = category, Category = category,

View File

@ -31,6 +31,13 @@ namespace OnlyPrompt.Backend.Database.Models
[MaxLength(1000)] [MaxLength(1000)]
public required string Description { get; set; } public required string Description { get; set; }
[MaxLength(4000)]
public string? ExampleOutput { get; set; }
public string? ExampleImageUrl { get; set; }
public decimal? Price { get; set; }
[MaxLength(ModelConstants.MaxSlugLength)] [MaxLength(ModelConstants.MaxSlugLength)]
[Column(TypeName = "citext")] [Column(TypeName = "citext")]
public required string Slug { get; set; } public required string Slug { get; set; }

View File

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class PromptCreateDetails : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExampleImageUrl",
table: "Prompts",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExampleOutput",
table: "Prompts",
type: "character varying(4000)",
maxLength: 4000,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Price",
table: "Prompts",
type: "numeric",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExampleImageUrl",
table: "Prompts");
migrationBuilder.DropColumn(
name: "ExampleOutput",
table: "Prompts");
migrationBuilder.DropColumn(
name: "Price",
table: "Prompts");
}
}
}

View File

@ -79,6 +79,16 @@ namespace OnlyPrompt.Backend.Migrations
.HasMaxLength(1000) .HasMaxLength(1000)
.HasColumnType("character varying(1000)"); .HasColumnType("character varying(1000)");
b.Property<string>("ExampleImageUrl")
.HasColumnType("text");
b.Property<string>("ExampleOutput")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<decimal?>("Price")
.HasColumnType("numeric");
b.Property<string>("Prompt") b.Property<string>("Prompt")
.IsRequired() .IsRequired()
.HasMaxLength(4000) .HasMaxLength(4000)

View File

@ -109,5 +109,54 @@ app.MapFallbackToFile("/login.html");
using var scope = app.Services.CreateScope(); using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>(); var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
await EnsurePromptDetailColumnsAsync(db);
await SeedDefaultCategoriesAsync(db);
app.Run(); app.Run();
static async Task EnsurePromptDetailColumnsAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "ExampleImageUrl" text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "ExampleOutput" character varying(4000);
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "Price" numeric;
""");
}
static async Task SeedDefaultCategoriesAsync(OnlyPromptContext db)
{
var defaults = new[]
{
("creative-writing", "Creative Writing"),
("coding", "Coding"),
("art", "Art"),
("marketing", "Marketing"),
("video", "Video"),
("data", "Data")
};
foreach (var (slug, name) in defaults)
{
if (await db.Categories.AnyAsync(c => c.Slug == slug))
continue;
db.Categories.Add(new CategoryModel
{
Id = Guid.CreateVersion7(),
Slug = slug,
Name = name,
Description = $"{name} prompts"
});
}
await db.SaveChangesAsync();
}

View File

@ -33,6 +33,11 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.Description, x => x.Description) .MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.Content, x => x.Prompt) .MapCtorParamFrom(x => x.Content, x => x.Prompt)
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt) .MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.MapCtorParamFrom(x => x.CategoryName, x => x.Category.Name)
.MapCtorParamFrom(x => x.CategorySlug, x => x.Category.Slug)
.MapCtorParamFrom(x => x.ExampleOutput, x => x.ExampleOutput)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.Price, x => x.Price)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level) .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.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName) .MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
@ -43,9 +48,12 @@ namespace OnlyPrompt.Backend.Utils
config.CreateMap<PromptModel, ApiMinimalPrompt>() config.CreateMap<PromptModel, ApiMinimalPrompt>()
.MapCtorParamFrom(x => x.Id, x => x.Id) .MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Title, x => x.Title) .MapCtorParamFrom(x => x.Title, x => x.Title)
.MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName) .MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.CreatorAvatarUrl, x => x.Creator.Profile.AvatarUrl)
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt) .MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId) .MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level) .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.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating)) .MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))

View File

@ -99,6 +99,7 @@
<button type="submit" class="submit-btn">Publish Prompt</button> <button type="submit" class="submit-btn">Publish Prompt</button>
<button type="button" class="cancel-btn">Cancel</button> <button type="button" class="cancel-btn">Cancel</button>
</div> </div>
<p id="create-status" style="text-align:center;color:#64748b;margin:0;"></p>
</form> </form>
</div> </div>
</main> </main>
@ -129,6 +130,7 @@
const imageInput = document.getElementById('exampleImage'); const imageInput = document.getElementById('exampleImage');
const imagePreview = document.getElementById('imagePreview'); const imagePreview = document.getElementById('imagePreview');
const previewImg = document.getElementById('previewImg'); const previewImg = document.getElementById('previewImg');
let exampleImageUrl = '';
if (imageInput) { if (imageInput) {
imageInput.addEventListener('change', function(event) { imageInput.addEventListener('change', function(event) {
@ -136,25 +138,99 @@
if (file && (file.type === 'image/png' || file.type === 'image/jpeg' || file.type === 'image/jpg')) { if (file && (file.type === 'image/png' || file.type === 'image/jpeg' || file.type === 'image/jpg')) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function(e) { reader.onload = function(e) {
previewImg.src = e.target.result; exampleImageUrl = e.target.result;
previewImg.src = exampleImageUrl;
imagePreview.style.display = 'block'; imagePreview.style.display = 'block';
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} else { } else {
imagePreview.style.display = 'none'; imagePreview.style.display = 'none';
previewImg.src = '#'; previewImg.src = '#';
exampleImageUrl = '';
if (file) alert('Please upload a PNG or JPG image.'); if (file) alert('Please upload a PNG or JPG image.');
} }
}); });
} }
// Handle form submission (demo only) async function loadCategories() {
document.getElementById('createPromptForm').addEventListener('submit', (e) => { const categorySelect = document.getElementById('category');
try {
const response = await fetch('/api/v1/categories/minimal');
if (!response.ok) return;
const categories = await response.json();
if (!categories.length) return;
categorySelect.innerHTML = categories
.map((category) => `<option value="${category.slug}">${category.name}</option>`)
.join('');
} catch {
// Keep the static fallback categories.
}
}
// Handle form submission
document.getElementById('createPromptForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
alert('Prompt published! (Demo)'); const status = document.getElementById('create-status');
// Here you would normally send data to a backend (including the image file) const submitBtn = document.querySelector('.submit-btn');
status.textContent = 'Publishing...';
submitBtn.disabled = true;
try {
const isPaid = paidBtn.classList.contains('active');
const price = isPaid ? Number(priceInput.value || 0) : null;
const payload = {
title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(),
category: document.getElementById('category').value,
content: document.getElementById('promptContent').value.trim(),
exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
exampleImageUrl: exampleImageUrl || null,
price,
subscriptionTier: null,
slug: null
};
const response = await fetch('/api/v1/prompts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
}); });
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) {
const error = await response.text();
throw new Error(getCreateErrorMessage(error, response.status));
}
const prompt = await response.json();
location.href = `/post-detail?id=${prompt.id}`;
} catch (error) {
status.textContent = error.message;
submitBtn.disabled = false;
}
});
function getCreateErrorMessage(errorText, status) {
if (!errorText) return `Server error ${status}`;
try {
const error = JSON.parse(errorText);
const messages = error.errors
? Object.values(error.errors).flat()
: [error.title || errorText];
return messages.join(' ');
} catch {
return errorText;
}
}
// Cancel button (go back) // Cancel button (go back)
document.querySelector('.cancel-btn').addEventListener('click', () => { document.querySelector('.cancel-btn').addEventListener('click', () => {
window.history.back(); window.history.back();
@ -177,6 +253,8 @@
fetch('/topbar.html') fetch('/topbar.html')
.then(r => r.text()) .then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data); .then(data => document.getElementById('topbar-container').innerHTML = data);
loadCategories();
</script> </script>
</body> </body>
</html> </html>

View File

@ -167,6 +167,15 @@
.action-btn i { .action-btn i {
font-size: 1.1rem; font-size: 1.1rem;
} }
.action-btn.active {
font-weight: 700;
}
.like-btn.active {
color: #ef4444;
}
.save-btn.active {
color: #f59e0b;
}
.like-btn:hover { .like-btn:hover {
color: #ef4444; color: #ef4444;
} }

View File

@ -151,33 +151,51 @@
function feedImg(id) { function feedImg(id) {
return `/images/content/feed${(parseInt(id.slice(-1), 16) % 4) + 1}.png`; 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) { function renderCard(prompt) {
const locked = !prompt.canAccess; const locked = !prompt.canAccess;
const liked = localStorage.getItem(`prompt-liked-${prompt.id}`) === "true";
const saved = localStorage.getItem(`prompt-saved-${prompt.id}`) === "true";
return ` return `
<div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='/post-detail?id=${prompt.id}'"> <div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='/post-detail?id=${prompt.id}'">
<div class="post-header"> <div class="post-header">
<img class="post-avatar" src="${creatorImg(prompt.creatorId)}" alt="${prompt.creatorName}"> <img class="post-avatar" src="${prompt.creatorAvatarUrl || '../images/content/cat.png'}" alt="${prompt.creatorName}">
<div class="post-author"> <div class="post-author">
<span class="post-name">${prompt.creatorName}</span> <span class="post-name">${prompt.creatorName}</span>
</div> </div>
<span class="post-date">${timeAgo(prompt.timeStamp)}</span> <span class="post-date">${timeAgo(prompt.timeStamp)}</span>
</div> </div>
<div class="post-content"> <div class="post-content">
<img class="post-image${locked ? ' post-image-locked' : ''}" src="${feedImg(prompt.id)}" alt="${prompt.title}"> ${prompt.exampleImageUrl ? `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<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> <h3 class="post-title" style="margin-top:10px">${prompt.title}</h3>
<p class="post-description">${prompt.description || ''}</p>
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? 'Paid'} tier required</p>` : ''} ${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? 'Paid'} tier required</p>` : ''}
${renderStars(prompt.averageRating)} ${renderStars(prompt.averageRating)}
</div> </div>
<div class="post-actions"> <div class="post-actions">
<button class="action-btn share-btn" onclick="event.stopPropagation(); navigator.clipboard.writeText(location.origin+'/post-detail?id=${prompt.id}')"><i class="bi bi-share"></i></button> <button class="action-btn like-btn ${liked ? 'active' : ''}" onclick="toggleFeedState(event, 'liked', '${prompt.id}')"><i class="bi ${liked ? 'bi-heart-fill' : 'bi-heart'}"></i> <span>${liked ? 'Liked' : 'Like'}</span></button>
<button class="action-btn comment-btn" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat"></i> <span>Review</span></button>
<button class="action-btn share-btn" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share"></i> <span>Share</span></button>
<button class="action-btn save-btn ${saved ? 'active' : ''}" onclick="toggleFeedState(event, 'saved', '${prompt.id}')"><i class="bi ${saved ? 'bi-bookmark-fill' : 'bi-bookmark'}"></i> <span>${saved ? 'Saved' : 'Save'}</span></button>
</div> </div>
</div>`; </div>`;
} }
window.toggleFeedState = function(event, type, id) {
event.stopPropagation();
const key = `prompt-${type}-${id}`;
const next = localStorage.getItem(key) !== "true";
localStorage.setItem(key, next);
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
window.sharePrompt = function(event, id) {
event.stopPropagation();
navigator.clipboard.writeText(`${location.origin}/post-detail?id=${id}`);
};
async function loadFeed(sortBy = "date", ascending = false) { async function loadFeed(sortBy = "date", ascending = false) {
grid.innerHTML = ""; grid.innerHTML = "";
emptyEl.style.display = "none"; emptyEl.style.display = "none";

View File

@ -93,24 +93,6 @@
</select> </select>
</div> </div>
<!-- Search -->
<div style="margin-bottom: 24px">
<input
id="search-input"
type="text"
placeholder="Search prompts..."
style="
width: 100%;
padding: 10px 16px;
border: 1px solid #e2e8f0;
border-radius: 30px;
font-size: 0.95rem;
outline: none;
background: #f8fafc;
"
/>
</div>
<!-- Prompts Grid --> <!-- Prompts Grid -->
<div class="prompts-grid" id="prompts-grid"></div> <div class="prompts-grid" id="prompts-grid"></div>
@ -426,7 +408,7 @@
function renderCard(p) { function renderCard(p) {
const locked = !p.canAccess && p.tierLevel != null; const locked = !p.canAccess && p.tierLevel != null;
const img = p._img || MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length]; const img = p.exampleImageUrl || p._img || MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
return ` return `
<div class="prompt-card"> <div class="prompt-card">
<img src="${img}" alt="${p.title}" class="prompt-img"> <img src="${img}" alt="${p.title}" class="prompt-img">
@ -456,7 +438,7 @@
const grid = document.getElementById("prompts-grid"); const grid = document.getElementById("prompts-grid");
const emptyEl = document.getElementById("market-empty"); const emptyEl = document.getElementById("market-empty");
const errorEl = document.getElementById("market-error"); const errorEl = document.getElementById("market-error");
const search = document.getElementById("search-input").value.trim(); const search = document.getElementById("topbarSearchInput")?.value.trim() || "";
const [sortBy, ascending] = document const [sortBy, ascending] = document
.getElementById("sort-select") .getElementById("sort-select")
.value.split("|"); .value.split("|");
@ -605,10 +587,20 @@
document document
.getElementById("sort-select") .getElementById("sort-select")
.addEventListener("change", loadPrompts); .addEventListener("change", loadPrompts);
document.getElementById("search-input").addEventListener("input", () => { function wireMarketplaceTopbarSearch() {
const input = document.getElementById("topbarSearchInput");
if (!input || input.dataset.marketplaceSearchReady === "true") return;
input.dataset.marketplaceSearchReady = "true";
input.addEventListener("input", () => {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadPrompts, 400); searchTimeout = setTimeout(loadPrompts, 400);
}); });
}
const topbarObserver = new MutationObserver(wireMarketplaceTopbarSearch);
topbarObserver.observe(document.body, { childList: true, subtree: true });
wireMarketplaceTopbarSearch();
// Make openPayment global // Make openPayment global
window.openPayment = openPayment; window.openPayment = openPayment;

View File

@ -156,6 +156,17 @@
></div> ></div>
</div> </div>
<!-- Example Output -->
<div class="example-section" id="example-section" style="display: none">
<h2>EXAMPLE OUTPUT</h2>
<div class="example-content">
<div id="example-output-text" class="example-output-text" style="white-space: pre-wrap"></div>
<div id="example-image" class="example-image" style="display: none">
<img id="example-image-img" src="" alt="Example output image">
</div>
</div>
</div>
<!-- Locked section (shown instead of prompt if no access) --> <!-- Locked section (shown instead of prompt if no access) -->
<div <div
id="locked-section" id="locked-section"
@ -274,15 +285,20 @@
day: "numeric", day: "numeric",
}); });
document.getElementById("prompt-title").textContent = p.title; document.getElementById("prompt-title").textContent = p.title;
document.getElementById("prompt-category").textContent =
p.categoryName || "Uncategorized";
document.getElementById("prompt-updated").textContent = document.getElementById("prompt-updated").textContent =
`Updated: ${new Date(p.timeStamp).toLocaleDateString()}`; `Updated: ${new Date(p.timeStamp).toLocaleDateString()}`;
document.getElementById("prompt-description").textContent = document.getElementById("prompt-description").textContent =
p.description; p.description;
document.getElementById("prompt-body").textContent = p.content; document.getElementById("prompt-body").textContent = p.content;
renderExamples(p);
// Tier badge // Tier badge
const badge = document.getElementById("tier-badge"); const badge = document.getElementById("tier-badge");
if (p.tierName) { if (p.price != null && Number(p.price) > 0) {
badge.innerHTML = `<span style="background:#fef3c7;color:#92400e;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">$${Number(p.price).toFixed(2)}</span>`;
} else if (p.tierName) {
badge.innerHTML = `<span style="background:#f1f5f9;color:#475569;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;"><i class="bi bi-lock-fill"></i> ${p.tierName}</span>`; badge.innerHTML = `<span style="background:#f1f5f9;color:#475569;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;"><i class="bi bi-lock-fill"></i> ${p.tierName}</span>`;
} else { } else {
badge.innerHTML = `<span style="background:#dcfce7;color:#166534;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">Free</span>`; badge.innerHTML = `<span style="background:#dcfce7;color:#166534;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">Free</span>`;
@ -321,6 +337,30 @@
document.getElementById("detail-body").style.display = "block"; document.getElementById("detail-body").style.display = "block";
} }
function renderExamples(p) {
const section = document.getElementById("example-section");
const text = document.getElementById("example-output-text");
const imageWrap = document.getElementById("example-image");
const image = document.getElementById("example-image-img");
if (!p.exampleOutput && !p.exampleImageUrl) {
section.style.display = "none";
return;
}
section.style.display = "block";
text.textContent = p.exampleOutput || "";
text.style.display = p.exampleOutput ? "block" : "none";
if (p.exampleImageUrl) {
image.src = p.exampleImageUrl;
imageWrap.style.display = "block";
} else {
image.removeAttribute("src");
imageWrap.style.display = "none";
}
}
function showError(title, msg) { function showError(title, msg) {
document.getElementById("detail-loading").style.display = "none"; document.getElementById("detail-loading").style.display = "none";
document.getElementById("detail-error").style.display = "block"; document.getElementById("detail-error").style.display = "block";