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
{
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 ApiMinimalPrompt(Guid Id, string Title, 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, 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);
}

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

View File

@ -31,11 +31,7 @@ namespace OnlyPrompt.Backend.Controllers
)
{
var userId = User.GetUserId();
var query = _db.Prompts
.Where(
x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId)
&& x.CreatorId != userId
);
var query = _db.Prompts.AsQueryable();
if (category.HasValue)
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(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.SubscriptionTier.Level,
x.SubscriptionTier.Name,
x.Creator.Profile.AvatarUrl,
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.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
)).ToArrayAsync();

View File

@ -65,11 +65,14 @@ namespace OnlyPrompt.Backend.Controllers
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.SubscriptionTier.Level,
x.SubscriptionTier.Name,
x.Creator.Profile.AvatarUrl,
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.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
)).ToArrayAsync();
@ -115,7 +118,7 @@ namespace OnlyPrompt.Backend.Controllers
{
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)
return TypedResults.NotFound("Category not found");
@ -141,6 +144,9 @@ namespace OnlyPrompt.Backend.Controllers
Title = request.Title,
Description = request.Description,
Prompt = request.Content,
ExampleOutput = request.ExampleOutput,
ExampleImageUrl = request.ExampleImageUrl,
Price = request.Price,
CreatorId = userId.Value,
SubscriptionTier = subscriptionTier,
Category = category,

View File

@ -31,6 +31,13 @@ namespace OnlyPrompt.Backend.Database.Models
[MaxLength(1000)]
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)]
[Column(TypeName = "citext")]
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)
.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")
.IsRequired()
.HasMaxLength(4000)

View File

@ -109,5 +109,54 @@ app.MapFallbackToFile("/login.html");
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
await db.Database.MigrateAsync();
await EnsurePromptDetailColumnsAsync(db);
await SeedDefaultCategoriesAsync(db);
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.Content, x => x.Prompt)
.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.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
@ -43,9 +48,12 @@ namespace OnlyPrompt.Backend.Utils
config.CreateMap<PromptModel, ApiMinimalPrompt>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.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.CreatorAvatarUrl, x => x.Creator.Profile.AvatarUrl)
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.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.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.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="button" class="cancel-btn">Cancel</button>
</div>
<p id="create-status" style="text-align:center;color:#64748b;margin:0;"></p>
</form>
</div>
</main>
@ -129,6 +130,7 @@
const imageInput = document.getElementById('exampleImage');
const imagePreview = document.getElementById('imagePreview');
const previewImg = document.getElementById('previewImg');
let exampleImageUrl = '';
if (imageInput) {
imageInput.addEventListener('change', function(event) {
@ -136,25 +138,99 @@
if (file && (file.type === 'image/png' || file.type === 'image/jpeg' || file.type === 'image/jpg')) {
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
exampleImageUrl = e.target.result;
previewImg.src = exampleImageUrl;
imagePreview.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
imagePreview.style.display = 'none';
previewImg.src = '#';
exampleImageUrl = '';
if (file) alert('Please upload a PNG or JPG image.');
}
});
}
// Handle form submission (demo only)
document.getElementById('createPromptForm').addEventListener('submit', (e) => {
async function loadCategories() {
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();
alert('Prompt published! (Demo)');
// Here you would normally send data to a backend (including the image file)
const status = document.getElementById('create-status');
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)
document.querySelector('.cancel-btn').addEventListener('click', () => {
window.history.back();
@ -177,6 +253,8 @@
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
loadCategories();
</script>
</body>
</html>

View File

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

View File

@ -151,33 +151,51 @@
function feedImg(id) {
return `/images/content/feed${(parseInt(id.slice(-1), 16) % 4) + 1}.png`;
}
function creatorImg(id) {
return `/images/content/creator${(parseInt(id.slice(-1), 16) % 6) + 1}.png`;
}
function renderCard(prompt) {
const locked = !prompt.canAccess;
const liked = localStorage.getItem(`prompt-liked-${prompt.id}`) === "true";
const saved = localStorage.getItem(`prompt-saved-${prompt.id}`) === "true";
return `
<div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='/post-detail?id=${prompt.id}'">
<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">
<span class="post-name">${prompt.creatorName}</span>
</div>
<span class="post-date">${timeAgo(prompt.timeStamp)}</span>
</div>
<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>
<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>` : ''}
${renderStars(prompt.averageRating)}
</div>
<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>`;
}
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) {
grid.innerHTML = "";
emptyEl.style.display = "none";

View File

@ -93,24 +93,6 @@
</select>
</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 -->
<div class="prompts-grid" id="prompts-grid"></div>
@ -426,7 +408,7 @@
function renderCard(p) {
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 `
<div class="prompt-card">
<img src="${img}" alt="${p.title}" class="prompt-img">
@ -456,7 +438,7 @@
const grid = document.getElementById("prompts-grid");
const emptyEl = document.getElementById("market-empty");
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
.getElementById("sort-select")
.value.split("|");
@ -605,10 +587,20 @@
document
.getElementById("sort-select")
.addEventListener("change", loadPrompts);
document.getElementById("search-input").addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadPrompts, 400);
});
function wireMarketplaceTopbarSearch() {
const input = document.getElementById("topbarSearchInput");
if (!input || input.dataset.marketplaceSearchReady === "true") return;
input.dataset.marketplaceSearchReady = "true";
input.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadPrompts, 400);
});
}
const topbarObserver = new MutationObserver(wireMarketplaceTopbarSearch);
topbarObserver.observe(document.body, { childList: true, subtree: true });
wireMarketplaceTopbarSearch();
// Make openPayment global
window.openPayment = openPayment;

View File

@ -156,6 +156,17 @@
></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) -->
<div
id="locked-section"
@ -274,15 +285,20 @@
day: "numeric",
});
document.getElementById("prompt-title").textContent = p.title;
document.getElementById("prompt-category").textContent =
p.categoryName || "Uncategorized";
document.getElementById("prompt-updated").textContent =
`Updated: ${new Date(p.timeStamp).toLocaleDateString()}`;
document.getElementById("prompt-description").textContent =
p.description;
document.getElementById("prompt-body").textContent = p.content;
renderExamples(p);
// 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>`;
} else {
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";
}
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) {
document.getElementById("detail-loading").style.display = "none";
document.getElementById("detail-error").style.display = "block";