feat: marketplace + post-detail dynamisch via API, syntax fix

This commit is contained in:
GeNii96 2026-05-30 11:18:16 +02:00
parent ba282613ac
commit cb11acd306
6 changed files with 1077 additions and 323 deletions

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); 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 ApiMinimalPrompt(Guid Id, string Title, DateTime TimeStamp, Guid CreatorId, string CreatorName, 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

@ -33,18 +33,64 @@ namespace OnlyPrompt.Backend.Controllers
); );
} }
[HttpGet]
public async Task<ApiMinimalPrompt[]> GetPromptsAsync(
[Range(0, int.MaxValue)][FromQuery] int offset = 0,
[Range(1, 100)][FromQuery] int limit = 20,
[FromQuery] FeedSortType sortBy = FeedSortType.Date,
[FromQuery] bool ascending = false,
[FromQuery] Identifier? category = null,
[FromQuery] string? search = null
)
{
var userId = User.GetUserId();
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);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(x => x.Title.Contains(search) || x.Description.Contains(search));
query = sortBy switch
{
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
FeedSortType.Rating => query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 2.5, ascending),
_ => query
};
var prompts = await query
.Skip(offset)
.Take(limit)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.SubscriptionTier.Level,
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();
return prompts;
}
[HttpGet("{id}")] [HttpGet("{id}")]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> GetPromptAsync(Identifier id) public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> GetPromptAsync(Identifier id)
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();
var prompt = await GetAccessiblePrompts(userId.Value)
var prompt = await _db.Prompts
.OfIdentifer(id) .OfIdentifer(id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (prompt is null) if (prompt is null)
return TypedResults.NotFound("Prompt not found or no permission"); return TypedResults.NotFound("Prompt not found");
var apiPrompt = _mapper.Map<ApiPrompt>(prompt); var canAccess = await GetAccessiblePrompts(userId.Value).AnyAsync(p => p.Id == prompt.Id);
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = canAccess ? prompt.Prompt : null, CanAccess = canAccess };
return TypedResults.Ok(apiPrompt); return TypedResults.Ok(apiPrompt);
} }

View File

@ -37,7 +37,8 @@ namespace OnlyPrompt.Backend.Utils
.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)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId) .MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating)); .MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.CanAccess, x => false);
config.CreateMap<PromptModel, ApiMinimalPrompt>() config.CreateMap<PromptModel, ApiMinimalPrompt>()
.MapCtorParamFrom(x => x.Id, x => x.Id) .MapCtorParamFrom(x => x.Id, x => x.Id)

View File

@ -99,15 +99,17 @@
.prompt-card { .prompt-card {
background: #fff; background: #fff;
border-radius: 18px; border-radius: 18px;
box-shadow: 0 2px 8px rgba(59,130,246,0.06); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
overflow: hidden; overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s; transition:
transform 0.2s,
box-shadow 0.2s;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.prompt-card:hover { .prompt-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59,130,246,0.12); box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
} }
.prompt-img { .prompt-img {
@ -171,7 +173,8 @@
gap: 12px; gap: 12px;
margin-top: 8px; margin-top: 8px;
} }
.buy-btn, .details-btn { .buy-btn,
.details-btn {
flex: 1; flex: 1;
border: none; border: none;
border-radius: 14px; border-radius: 14px;
@ -189,7 +192,8 @@
background: #f1f5f9; background: #f1f5f9;
color: #334155; color: #334155;
} }
.buy-btn:hover, .details-btn:hover { .buy-btn:hover,
.details-btn:hover {
opacity: 0.85; opacity: 0.85;
} }
@ -222,4 +226,28 @@
.prompt-actions { .prompt-actions {
flex-direction: column; flex-direction: column;
} }
} }
/* Payment method buttons */
.pay-method-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: #1e293b;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
text-align: left;
}
.pay-method-btn:hover {
border-color: #6366f1;
background: #f5f3ff;
}

View File

@ -1,237 +1,699 @@
<!-- OnlyPrompt - Marketplace page: <!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort, crypto payment modal -->
- Browse and filter AI prompts with buy/view details buttons and pricing
- Added sort dropdown (Best Rating, Price Low to High, Price High to Low) - UI only -->
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Marketplace</title> <title>OnlyPrompt - Marketplace</title>
<link rel="stylesheet" href="../css/variables.css"> <link rel="stylesheet" href="../css/variables.css" />
<link rel="stylesheet" href="../css/base.css"> <link rel="stylesheet" href="../css/base.css" />
<link rel="stylesheet" href="../css/sidebar.css"> <link rel="stylesheet" href="../css/sidebar.css" />
<link rel="stylesheet" href="../css/login.css"> <link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.css"> <link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/marketplace.css"> <link rel="stylesheet" href="../css/marketplace.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> <link
<style> rel="stylesheet"
/* Additional inline style for sort dropdown – can be moved to marketplace.css */ href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
.filter-sort-row { />
display: flex; <style>
justify-content: space-between; /* Additional inline style for sort dropdown – can be moved to marketplace.css */
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 32px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 16px;
}
.filter-buttons {
margin-bottom: 0;
border-bottom: none;
padding-bottom: 0;
}
.sort-dropdown {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 30px;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
color: #334155;
cursor: pointer;
outline: none;
}
.sort-dropdown:hover {
border-color: #94a3b8;
}
@media (max-width: 700px) {
.filter-sort-row { .filter-sort-row {
flex-direction: column; display: flex;
align-items: stretch; justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 32px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 16px;
}
.filter-buttons {
margin-bottom: 0;
border-bottom: none;
padding-bottom: 0;
} }
.sort-dropdown { .sort-dropdown {
align-self: flex-start; background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 30px;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
color: #334155;
cursor: pointer;
outline: none;
} }
} .sort-dropdown:hover {
</style> border-color: #94a3b8;
</head> }
<body> @media (max-width: 700px) {
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);"> .filter-sort-row {
flex-direction: column;
<div id="sidebar-container"></div> align-items: stretch;
}
.sort-dropdown {
align-self: flex-start;
}
}
</style>
</head>
<body>
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<div id="sidebar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;"> <div style="flex: 1; margin: 40px auto; max-width: 950px">
<div id="topbar-container"></div>
<div id="topbar-container"></div>
<main class="marketplace-main"> <main class="marketplace-main">
<!-- Header -->
<!-- Header --> <div class="marketplace-header">
<div class="marketplace-header"> <h1>Marketplace</h1>
<h1>Marketplace</h1> <p>Browse and discover high-quality AI prompts</p>
<p>Browse and discover high-quality AI prompts</p>
</div>
<!-- Filter + Sort Row -->
<div class="filter-sort-row">
<div class="filter-buttons">
<button class="filter-btn active">All</button>
<button class="filter-btn">Writing</button>
<button class="filter-btn">Coding</button>
<button class="filter-btn">Art</button>
<button class="filter-btn">Marketing</button>
<button class="filter-btn">Video</button>
<button class="filter-btn">Data</button>
</div> </div>
<select class="sort-dropdown" aria-label="Sort prompts">
<option value="best">Best Rating</option>
<option value="price_low">Price: Low to High</option>
<option value="price_high">Price: High to Low</option>
</select>
</div>
<!-- Prompts Grid --> <!-- Filter + Sort Row -->
<div class="prompts-grid"> <div class="filter-sort-row">
<div class="filter-buttons" id="category-filters">
<!-- Prompt Card 1 --> <button class="filter-btn active" data-category="">All</button>
<div class="prompt-card">
<img src="../images/content/market1.png" alt="Creative Blog Post Generator" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Creative Blog Post Generator</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Generate engaging blog posts in minutes to captivate your audience.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.8</span>
<span>(124 reviews)</span>
</div>
<div class="prompt-price">$19.99</div>
<div class="prompt-actions">
<button class="buy-btn">Buy Now</button>
<button class="details-btn">View Details</button>
</div>
</div> </div>
<select
class="sort-dropdown"
id="sort-select"
aria-label="Sort prompts"
>
<option value="date|false">Newest</option>
<option value="date|true">Oldest</option>
<option value="rating|false">Best Rating</option>
<option value="rating|true">Lowest Rating</option>
</select>
</div> </div>
<!-- Prompt Card 2 --> <!-- Search -->
<div class="prompt-card"> <div style="margin-bottom: 24px">
<img src="../images/content/market2.png" alt="Python Code Assistant" class="prompt-img"> <input
<div class="prompt-info"> id="search-input"
<h3 class="prompt-title">Python Code Assistant</h3> type="text"
<div class="prompt-author">@JaneDoe</div> placeholder="Search prompts..."
<p class="prompt-description">Efficiently debug and write Python code with AI assistance.</p> style="
<div class="prompt-rating"> width: 100%;
<span><i class="bi bi-star-fill"></i> 4.7</span> padding: 10px 16px;
<span>(98 reviews)</span> border: 1px solid #e2e8f0;
</div> border-radius: 30px;
<div class="prompt-price">$19.99</div> font-size: 0.95rem;
<div class="prompt-actions"> outline: none;
<button class="buy-btn">Buy Now</button> background: #f8fafc;
<button class="details-btn">View Details</button> "
</div> />
</div>
</div> </div>
<!-- Prompt Card 3 --> <!-- Prompts Grid -->
<div class="prompt-card"> <div class="prompts-grid" id="prompts-grid"></div>
<img src="../images/content/market3.png" alt="Digital Art Style Guide" class="prompt-img">
<div class="prompt-info"> <!-- Empty State -->
<h3 class="prompt-title">Digital Art Style Guide</h3> <div
<div class="prompt-author">@JaneDoe</div> id="market-empty"
<p class="prompt-description">Create stunning digital art styles with this comprehensive guide.</p> style="
<div class="prompt-rating"> display: none;
<span><i class="bi bi-star-fill"></i> 4.8</span> text-align: center;
<span>(156 reviews)</span> padding: 60px 20px;
</div> color: #64748b;
<div class="prompt-price">$19.99</div> "
<div class="prompt-actions"> >
<button class="buy-btn">Buy Now</button> <i
<button class="details-btn">View Details</button> class="bi bi-bag-x"
</div> style="font-size: 3rem; display: block; margin-bottom: 16px"
</div> ></i>
<h3 style="margin-bottom: 8px">No prompts found</h3>
<p>Try a different category or search term.</p>
</div> </div>
<!-- Prompt Card 4 --> <!-- Error State -->
<div class="prompt-card"> <div
<img src="../images/content/market4.png" alt="Marketing Copywriter" class="prompt-img"> id="market-error"
<div class="prompt-info"> style="
<h3 class="prompt-title">Marketing Copywriter</h3> display: none;
<div class="prompt-author">@JaneDoe</div> text-align: center;
<p class="prompt-description">Generate engaging marketing copy in minutes.</p> padding: 60px 20px;
<div class="prompt-rating"> color: #ef4444;
<span><i class="bi bi-star-fill"></i> 4.8</span> "
<span>(112 reviews)</span> >
</div> <i
<div class="prompt-price">$19.99</div> class="bi bi-exclamation-circle"
<div class="prompt-actions"> style="font-size: 3rem; display: block; margin-bottom: 16px"
<button class="buy-btn">Buy Now</button> ></i>
<button class="details-btn">View Details</button> <h3 style="margin-bottom: 8px">Could not load prompts</h3>
</div> <p id="market-error-msg"></p>
</div>
</div> </div>
</main>
<!-- Prompt Card 5 --> </div>
<div class="prompt-card">
<img src="../images/content/market5.png" alt="Midjourney Image Prompts" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Midjourney Image Prompts</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Curated prompts for stunning Midjourney images.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.7</span>
<span>(203 reviews)</span>
</div>
<div class="prompt-price">$19.99</div>
<div class="prompt-actions">
<button class="buy-btn">Buy Now</button>
<button class="details-btn">View Details</button>
</div>
</div>
</div>
<!-- Prompt Card 6 -->
<div class="prompt-card">
<img src="../images/content/market6.png" alt="UI Design Assistant" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">UI Design Assistant</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Generate UI components and design systems with AI.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.9</span>
<span>(87 reviews)</span>
</div>
<div class="prompt-price">$19.99</div>
<div class="prompt-actions">
<button class="buy-btn">Buy Now</button>
<button class="details-btn">View Details</button>
</div>
</div>
</div>
</div>
</main>
</div> </div>
</div>
<script> <!-- ── Payment Modal ─────────────────────────────────────────────── -->
fetch('/sidebar.html') <div
.then(r => r.text()) id="payment-overlay"
.then(data => { style="
document.getElementById('sidebar-container').innerHTML = data; display: none;
// Remove 'active' from all sidebar links position: fixed;
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => { inset: 0;
link.classList.remove('active'); background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
"
>
<div
style="
background: #fff;
border-radius: 16px;
padding: 32px;
max-width: 460px;
width: 90%;
position: relative;
max-height: 90vh;
overflow-y: auto;
"
>
<button
onclick="closePayment()"
style="
position: absolute;
top: 14px;
right: 18px;
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
color: #64748b;
"
>
&#x2715;
</button>
<!-- Step 1: Choose method -->
<div id="pay-step-1">
<h2 style="margin-bottom: 4px">Subscribe to access</h2>
<p
id="pay-prompt-title"
style="color: #6366f1; font-weight: 600; margin-bottom: 16px"
></p>
<p style="color: #64748b; margin-bottom: 24px">
Choose a payment method to unlock this prompt:
</p>
<div style="display: flex; flex-direction: column; gap: 12px">
<button class="pay-method-btn" onclick="selectCrypto('btc')">
<span style="font-size: 1.4rem">₿</span> Bitcoin (BTC)
<span
id="price-btc"
style="margin-left: auto; font-size: 0.85rem; color: #94a3b8"
></span>
</button>
<button class="pay-method-btn" onclick="selectCrypto('eth')">
<span style="font-size: 1.4rem">Ξ</span> Ethereum (ETH)
<span
id="price-eth"
style="margin-left: auto; font-size: 0.85rem; color: #94a3b8"
></span>
</button>
<button class="pay-method-btn" onclick="selectCrypto('sol')">
<span style="font-size: 1.4rem">◎</span> Solana (SOL)
<span
id="price-sol"
style="margin-left: auto; font-size: 0.85rem; color: #94a3b8"
></span>
</button>
<button class="pay-method-btn" onclick="selectCrypto('usdt')">
<span style="font-size: 1.4rem">₮</span> USDT (TRC-20)
<span
id="price-usdt"
style="margin-left: auto; font-size: 0.85rem; color: #94a3b8"
></span>
</button>
</div>
<p
style="
margin-top: 20px;
font-size: 0.8rem;
color: #94a3b8;
text-align: center;
"
>
<i class="bi bi-shield-lock-fill"></i> Payments are processed
on-chain. No account needed.
</p>
</div>
<!-- Step 2: Send payment -->
<div id="pay-step-2" style="display: none">
<button
onclick="backToStep1()"
style="
background: none;
border: none;
color: #6366f1;
cursor: pointer;
margin-bottom: 16px;
font-size: 0.9rem;
"
>
&#8592; Back
</button>
<h2 id="pay-crypto-title" style="margin-bottom: 8px"></h2>
<p style="color: #64748b; margin-bottom: 8px">
Send exactly <strong id="pay-amount"></strong> to:
</p>
<div
style="
background: #f1f5f9;
border-radius: 10px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
"
>
<code
id="pay-address"
style="font-size: 0.85rem; word-break: break-all; flex: 1"
></code>
<button
onclick="copyAddress()"
title="Copy"
style="
background: none;
border: none;
cursor: pointer;
color: #6366f1;
font-size: 1.1rem;
"
>
<i class="bi bi-clipboard"></i>
</button>
</div>
<p style="font-size: 0.78rem; color: #94a3b8; margin-bottom: 20px">
⚠️ Only send the exact amount. Payments are non-refundable.
</p>
<div
style="
background: #fef9c3;
border: 1px solid #fde68a;
border-radius: 10px;
padding: 12px 14px;
font-size: 0.82rem;
color: #92400e;
margin-bottom: 20px;
"
>
<i class="bi bi-info-circle-fill"></i> After sending, click the
button below to confirm. Access will be granted once the transaction
is verified.
</div>
<button
onclick="confirmPayment()"
style="
width: 100%;
padding: 12px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
"
>
I've sent the payment ✓
</button>
</div>
<!-- Step 3: Success -->
<div
id="pay-step-3"
style="display: none; text-align: center; padding: 20px 0"
>
<i
class="bi bi-check-circle-fill"
style="
font-size: 3.5rem;
color: #10b981;
display: block;
margin-bottom: 16px;
"
></i>
<h2 style="margin-bottom: 8px">Payment received!</h2>
<p style="color: #64748b; margin-bottom: 24px">
Your access is being activated. This usually takes 1–2 minutes.
</p>
<button
onclick="closePayment()"
style="
padding: 12px 28px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
"
>
Done
</button>
</div>
</div>
</div>
<script type="module">
// ── Sidebar & Topbar ──────────────────────────────────────────────
fetch("/sidebar.html")
.then((r) => r.text())
.then((data) => {
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((l) => l.classList.remove("active"));
const link = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[1];
if (link) link.classList.add("active");
}); });
// Set 'active' on the second link (Marketplace) - index 1 fetch("/topbar.html")
const secondLink = document.querySelectorAll('#sidebar-container .sidebar li a')[1]; .then((r) => r.text())
if (secondLink) secondLink.classList.add('active'); .then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
// ── State ─────────────────────────────────────────────────────────
let activeCategory = "";
let searchTimeout;
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function renderStars(rating) {
if (rating == null)
return '<span style="color:#94a3b8;font-size:0.8rem;">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>`;
}
function tierPrice(level) {
if (!level) return "Free";
return `$${(level * 4.99).toFixed(2)}/mo`;
}
const MARKET_IMAGES = [
"/images/content/market1.png",
"/images/content/market2.png",
"/images/content/market3.png",
"/images/content/market4.png",
"/images/content/market5.png",
"/images/content/market6.png",
];
let cardIndex = 0;
function renderCard(p) {
const locked = !p.canAccess && p.tierLevel != null;
const img = p._img || MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
return `
<div class="prompt-card">
<img src="${img}" alt="${p.title}" class="prompt-img">
<div class="prompt-info">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<div style="width:34px;height:34px;border-radius:50%;background:#6366f1;color:#fff;font-weight:700;display:flex;align-items:center;justify-content:center;font-size:0.95rem;flex-shrink:0;">${p.creatorName.charAt(0).toUpperCase()}</div>
<span class="prompt-author">@${p.creatorName}</span>
<span style="margin-left:auto;font-size:0.75rem;color:#94a3b8;">${timeAgo(p.timeStamp)}</span>
</div>
<h3 class="prompt-title">${p.title}</h3>
${p.tierName ? `<span style="display:inline-block;background:#f1f5f9;color:#475569;border-radius:20px;padding:2px 10px;font-size:0.75rem;margin-bottom:8px;"><i class="bi bi-lock-fill"></i> ${p.tierName}</span>` : '<span style="display:inline-block;background:#dcfce7;color:#166534;border-radius:20px;padding:2px 10px;font-size:0.75rem;margin-bottom:8px;">Free</span>'}
<div style="margin-bottom:12px;">${renderStars(p.averageRating)}</div>
<div class="prompt-price">${tierPrice(p.tierLevel)}</div>
<div class="prompt-actions">
${
locked
? `<button class="buy-btn" onclick='openPayment(${JSON.stringify(p)})'>Subscribe</button>`
: `<button class="buy-btn" style="background:#10b981;" onclick="location.href='/post-detail?id=${p.id}'">Access <i class="bi bi-unlock-fill"></i></button>`
}
<button class="details-btn" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>
</div>
</div>
</div>`;
}
async function loadPrompts() {
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 [sortBy, ascending] = document
.getElementById("sort-select")
.value.split("|");
grid.innerHTML = "";
emptyEl.style.display = "none";
errorEl.style.display = "none";
cardIndex = 0;
const DEMO_PROMPTS = [
{
id: "demo-1",
title: "Creative Blog Post Generator",
creatorName: "JaneDoe",
timeStamp: new Date(Date.now() - 7200000).toISOString(),
tierLevel: null,
tierName: null,
averageRating: 4.8,
canAccess: true,
},
{
id: "demo-2",
title: "Python Code Assistant",
creatorName: "AlexDev",
timeStamp: new Date(Date.now() - 86400000).toISOString(),
tierLevel: 1,
tierName: "Basic",
averageRating: 4.7,
canAccess: false,
},
{
id: "demo-3",
title: "Digital Art Style Guide",
creatorName: "MiaArt",
timeStamp: new Date(Date.now() - 172800000).toISOString(),
tierLevel: 2,
tierName: "Pro",
averageRating: 4.9,
canAccess: false,
},
{
id: "demo-4",
title: "Marketing Copywriter",
creatorName: "TomCopy",
timeStamp: new Date(Date.now() - 259200000).toISOString(),
tierLevel: null,
tierName: null,
averageRating: 4.6,
canAccess: true,
},
{
id: "demo-5",
title: "Midjourney Image Prompts",
creatorName: "SarahViz",
timeStamp: new Date(Date.now() - 432000000).toISOString(),
tierLevel: 1,
tierName: "Basic",
averageRating: 4.7,
canAccess: false,
},
{
id: "demo-6",
title: "UI Design Assistant",
creatorName: "LilyUI",
timeStamp: new Date(Date.now() - 604800000).toISOString(),
tierLevel: 3,
tierName: "Premium",
averageRating: 4.9,
canAccess: false,
_img: "/images/content/market6.png",
},
];
// pre-assign images to demo prompts
DEMO_PROMPTS.forEach((p, i) => (p._img = MARKET_IMAGES[i]));
try {
let url = `/api/v1/prompts?sortBy=${sortBy}&ascending=${ascending}&limit=50`;
if (activeCategory) url += `&category=${activeCategory}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const res = await fetch(url);
if (res.status === 401) {
location.href = "/login";
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
let prompts = await res.json();
// If API is empty and no filters active, show demo content
if (prompts.length === 0 && !activeCategory && !search) {
grid.innerHTML =
`<div style="grid-column:1/-1;text-align:center;padding:8px 0 20px;color:#94a3b8;font-size:0.85rem;"><i class="bi bi-info-circle"></i> Showing example prompts — no real prompts yet.</div>` +
DEMO_PROMPTS.map(renderCard).join("");
return;
}
if (prompts.length === 0) {
emptyEl.style.display = "block";
return;
}
grid.innerHTML = prompts.map(renderCard).join("");
} catch (e) {
// On error, show demo content as fallback
grid.innerHTML =
`<div style="grid-column:1/-1;text-align:center;padding:8px 0 20px;color:#94a3b8;font-size:0.85rem;"><i class="bi bi-wifi-off"></i> Could not connect to server — showing demo content.</div>` +
DEMO_PROMPTS.map(renderCard).join("");
}
}
async function loadCategories() {
try {
const res = await fetch("/api/v1/categories/minimal");
if (!res.ok) return;
const cats = await res.json();
const container = document.getElementById("category-filters");
cats.forEach((c) => {
const btn = document.createElement("button");
btn.className = "filter-btn";
btn.dataset.category = c.slug;
btn.textContent = c.name;
btn.addEventListener("click", () => {
document
.querySelectorAll("#category-filters .filter-btn")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
activeCategory = c.slug;
loadPrompts();
});
container.appendChild(btn);
});
} catch {}
}
// ── Category filter (All) ──────────────────────────────────────────
document
.querySelector("#category-filters .filter-btn")
.addEventListener("click", function () {
document
.querySelectorAll("#category-filters .filter-btn")
.forEach((b) => b.classList.remove("active"));
this.classList.add("active");
activeCategory = "";
loadPrompts();
});
document
.getElementById("sort-select")
.addEventListener("change", loadPrompts);
document.getElementById("search-input").addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadPrompts, 400);
}); });
fetch('/topbar.html') // Make openPayment global
.then(r => r.text()) window.openPayment = openPayment;
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script> // ── Payment Modal ──────────────────────────────────────────────────
</body> const CRYPTO_ADDRESSES = {
</html> btc: "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf1N",
eth: "0x742d35Cc6634C0532925a3b8D4C9B8E4D8F2b1a",
sol: "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV1",
usdt: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
};
const CRYPTO_NAMES = {
btc: "Bitcoin (BTC)",
eth: "Ethereum (ETH)",
sol: "Solana (SOL)",
usdt: "USDT TRC-20",
};
let currentPrompt = null;
let currentCrypto = null;
function openPayment(prompt) {
currentPrompt = prompt;
const usd = prompt.tierLevel ? prompt.tierLevel * 4.99 : 0;
document.getElementById("pay-prompt-title").textContent = prompt.title;
document.getElementById("price-btc").textContent =
`≈ ${(usd / 67000).toFixed(6)} BTC`;
document.getElementById("price-eth").textContent =
`≈ ${(usd / 3200).toFixed(5)} ETH`;
document.getElementById("price-sol").textContent =
`≈ ${(usd / 145).toFixed(3)} SOL`;
document.getElementById("price-usdt").textContent =
`${usd.toFixed(2)} USDT`;
document.getElementById("pay-step-1").style.display = "block";
document.getElementById("pay-step-2").style.display = "none";
document.getElementById("pay-step-3").style.display = "none";
document.getElementById("payment-overlay").style.display = "flex";
}
window.selectCrypto = function (crypto) {
currentCrypto = crypto;
const usd = currentPrompt.tierLevel
? currentPrompt.tierLevel * 4.99
: 0;
const amounts = {
btc: `${(usd / 67000).toFixed(6)} BTC`,
eth: `${(usd / 3200).toFixed(5)} ETH`,
sol: `${(usd / 145).toFixed(3)} SOL`,
usdt: `${usd.toFixed(2)} USDT`,
};
document.getElementById("pay-crypto-title").textContent =
CRYPTO_NAMES[crypto];
document.getElementById("pay-amount").textContent = amounts[crypto];
document.getElementById("pay-address").textContent =
CRYPTO_ADDRESSES[crypto];
document.getElementById("pay-step-1").style.display = "none";
document.getElementById("pay-step-2").style.display = "block";
};
window.backToStep1 = function () {
document.getElementById("pay-step-1").style.display = "block";
document.getElementById("pay-step-2").style.display = "none";
};
window.copyAddress = function () {
navigator.clipboard.writeText(CRYPTO_ADDRESSES[currentCrypto]);
};
window.confirmPayment = function () {
document.getElementById("pay-step-2").style.display = "none";
document.getElementById("pay-step-3").style.display = "block";
};
window.closePayment = function () {
document.getElementById("payment-overlay").style.display = "none";
};
document
.getElementById("payment-overlay")
.addEventListener("click", function (e) {
if (e.target === this) closePayment();
});
// ── Init ───────────────────────────────────────────────────────────
await loadCategories();
await loadPrompts();
</script>
</body>
</html>

View File

@ -1,114 +1,331 @@
<!-- OnlyPrompt - Settings page: <!-- OnlyPrompt - Post Detail: dynamic prompt loaded via API -->
- User preferences with tabs for profile, security, and notifications -->
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Post Detail</title> <title>OnlyPrompt - Post Detail</title>
<link rel="stylesheet" href="../css/variables.css"> <link rel="stylesheet" href="../css/variables.css" />
<link rel="stylesheet" href="../css/base.css"> <link rel="stylesheet" href="../css/base.css" />
<link rel="stylesheet" href="../css/sidebar.css"> <link rel="stylesheet" href="../css/sidebar.css" />
<link rel="stylesheet" href="../css/login.css"> <link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.css"> <link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/post-detail.css"> <link rel="stylesheet" href="../css/post-detail.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> <link
</head> rel="stylesheet"
<body> href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);"> />
</head>
<div id="sidebar-container"></div> <body>
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<div id="sidebar-container"></div>
<div style="flex:1; display: flex; flex-direction: column;"> <div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<div id="topbar-container"></div>
<main class="post-detail-main"> <main class="post-detail-main">
<div class="post-detail-container"> <div class="post-detail-container" id="detail-content">
<!-- Header --> <!-- Loading -->
<div class="post-header"> <div
<h1 class="post-title">Marketing Guru: Campaign Strategist</h1> id="detail-loading"
<div class="post-meta"> style="text-align: center; padding: 60px 20px; color: #64748b"
<span class="category">Marketing</span> >
<span class="category">Business</span> <i
<span class="category">Copywriting</span> class="bi bi-hourglass-split"
<span class="updated">Updated: Oct 26, 2023</span> style="font-size: 2.5rem; display: block; margin-bottom: 12px"
></i>
<p>Loading prompt...</p>
</div> </div>
<div class="post-stats">
<span><i class="bi bi-bookmark-star"></i> 2,109 Saves</span>
<span><i class="bi bi-eye"></i> 14.5k Views</span>
</div>
</div>
<!-- Prompt Content Section --> <!-- Error -->
<div class="prompt-section"> <div
<h2>PROMPT</h2> id="detail-error"
<div class="prompt-content"> style="
<p>You are an expert marketing strategist and copywriter. I will provide a product name, target audience, and key benefits. Your goal is to create a comprehensive marketing campaign plan, including:</p> display: none;
<ul> text-align: center;
<li>Campaign objectives</li> padding: 60px 20px;
<li>Target Audience: [User Input]</li> color: #ef4444;
<li>Expected Output: A detailed marketing plan with channel strategy, messaging, and sample copy</li> "
<li>Budget considerations and KPIs</li> >
</ul> <i
class="bi bi-exclamation-circle"
style="font-size: 2.5rem; display: block; margin-bottom: 12px"
></i>
<h3 id="detail-error-title">Prompt not found</h3>
<p
id="detail-error-msg"
style="color: #64748b; margin-top: 8px"
></p>
<button
onclick="history.back()"
style="
margin-top: 20px;
padding: 10px 24px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
"
>
Go Back
</button>
</div> </div>
</div>
<!-- Rating & Like --> <!-- Content (hidden until loaded) -->
<div class="rating-section"> <div id="detail-body" style="display: none">
<div class="rating-stars"> <!-- Header -->
<span><i class="bi bi-star-fill"></i> 4.9</span> <div class="post-header">
<span><i class="bi bi-star-fill"></i> 4.8 (241 Ratings)</span> <div
</div> style="
<button class="like-btn"><i class="bi bi-heart"></i> Like (212)</button> display: flex;
</div> align-items: center;
gap: 10px;
<!-- Example Output Section --> margin-bottom: 16px;
<div class="example-section"> "
<h2>EXAMPLE OUTPUT</h2> >
<div class="example-content"> <div
<h3>Generated Strategy 16px</h3> id="creator-avatar"
<div class="example-output-text"> style="
<p><strong>Output: EcoWare Campaign</strong></p> width: 42px;
<p><strong>Campaign Overview</strong><br>Target Persona: Sarah Jenkins, 28, Eco-conscious</p> height: 42px;
<p><strong>Key Messaging</strong><br>Channel Strategy<br>Sample Copy<br>Social<br>Email</p> border-radius: 50%;
background: #6366f1;
color: #fff;
font-weight: 700;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
"
></div>
<div>
<span
id="creator-name"
style="font-weight: 600; font-size: 0.95rem"
></span>
<span
id="prompt-date"
style="display: block; font-size: 0.8rem; color: #94a3b8"
></span>
</div>
<div style="margin-left: auto">
<span id="tier-badge"></span>
</div>
</div>
<h1 class="post-title" id="prompt-title"></h1>
<div class="post-meta">
<span class="category" id="prompt-category"></span>
<span class="updated" id="prompt-updated"></span>
</div>
<div class="post-stats">
<span id="prompt-rating-stat"></span>
</div>
</div> </div>
<!-- Optional example image (if uploaded in create) -->
<div class="example-image" style="display: none;"> <!-- Description -->
<img src="#" alt="Example Output Image"> <div class="prompt-section" id="desc-section">
<h2>DESCRIPTION</h2>
<div class="prompt-content">
<p id="prompt-description"></p>
</div>
</div>
<!-- Prompt Content (only if accessible) -->
<div class="prompt-section" id="prompt-content-section">
<h2>PROMPT</h2>
<div
class="prompt-content"
id="prompt-body"
style="
white-space: pre-wrap;
font-family: monospace;
background: #f8fafc;
border-radius: 10px;
padding: 16px;
font-size: 0.9rem;
line-height: 1.7;
"
></div>
</div>
<!-- Locked section (shown instead of prompt if no access) -->
<div
id="locked-section"
style="
display: none;
text-align: center;
padding: 40px 20px;
background: #f8fafc;
border-radius: 12px;
margin-bottom: 28px;
"
>
<i
class="bi bi-lock-fill"
style="
font-size: 2.5rem;
color: #94a3b8;
display: block;
margin-bottom: 12px;
"
></i>
<h3 style="margin-bottom: 8px">
This prompt requires a subscription
</h3>
<p style="color: #64748b; margin-bottom: 20px">
Subscribe to <strong id="locked-creator"></strong> to access
this prompt.
</p>
<button
id="locked-subscribe-btn"
style="
padding: 12px 28px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
"
>
Subscribe <span id="locked-tier-name"></span>
</button>
</div>
<!-- Rating -->
<div class="rating-section" id="rating-section">
<div class="rating-stars" id="rating-display"></div>
</div> </div>
</div> </div>
</div> </div>
</main>
<!-- Unlock / Buy Section --> </div>
<div class="unlock-section">
<button class="unlock-btn">Unlock Full Prompt • $19.99</button>
</div>
</div>
</main>
</div> </div>
</div>
<script> <script type="module">
// Fetch sidebar and topbar fetch("/sidebar.html")
fetch('/sidebar.html') .then((r) => r.text())
.then(r => r.text()) .then((data) => {
.then(data => { document.getElementById("sidebar-container").innerHTML = data;
document.getElementById('sidebar-container').innerHTML = data; document
// Remove active class from all sidebar links .querySelectorAll("#sidebar-container .sidebar a")
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => { .forEach((l) => l.classList.remove("active"));
link.classList.remove('active');
}); });
// Optionally set active on a relevant link (e.g., Marketplace) fetch("/topbar.html")
const marketplaceLink = document.querySelector('#sidebar-container a[href="marketplace.html"]'); .then((r) => r.text())
if (marketplaceLink) marketplaceLink.classList.add('active'); .then(
}); (data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
fetch('/topbar.html') const params = new URLSearchParams(location.search);
.then(r => r.text()) const id = params.get("id");
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script> if (!id) {
</body> showError("No prompt ID provided", "Add ?id=... to the URL.");
</html> } else {
loadPrompt(id);
}
async function loadPrompt(id) {
try {
const res = await fetch(`/api/v1/prompts/${id}`);
if (res.status === 401) {
location.href = "/login";
return;
}
if (res.status === 404) {
showError(
"Prompt not found",
"This prompt does not exist or you do not have access.",
);
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
const p = await res.json();
renderPrompt(p);
} catch (e) {
showError("Could not load prompt", e.message);
}
}
function renderPrompt(p) {
document.title = `${p.title} — OnlyPrompt`;
document.getElementById("creator-avatar").textContent = p.creatorName
.charAt(0)
.toUpperCase();
document.getElementById("creator-name").textContent = p.creatorName;
document.getElementById("prompt-date").textContent = new Date(
p.timeStamp,
).toLocaleDateString("de-CH", {
year: "numeric",
month: "long",
day: "numeric",
});
document.getElementById("prompt-title").textContent = p.title;
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;
// Tier badge
const badge = document.getElementById("tier-badge");
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>`;
}
// Rating
if (p.averageRating != null) {
const stars = Math.round(p.averageRating);
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>`;
document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi bi-star-fill" style="color:#f59e0b;"></i> ${p.averageRating.toFixed(1)} rating`;
} else {
document.getElementById("rating-display").innerHTML =
'<span style="color:#94a3b8;font-size:0.9rem;">No ratings yet</span>';
}
// Content visibility
if (p.content) {
document.getElementById("prompt-content-section").style.display =
"block";
document.getElementById("locked-section").style.display = "none";
} else {
document.getElementById("prompt-content-section").style.display =
"none";
document.getElementById("locked-section").style.display = "block";
document.getElementById("locked-creator").textContent = p.creatorName;
document.getElementById("locked-tier-name").textContent = p.tierName
? `— ${p.tierName}`
: "";
}
document.getElementById("detail-loading").style.display = "none";
document.getElementById("detail-body").style.display = "block";
}
function showError(title, msg) {
document.getElementById("detail-loading").style.display = "none";
document.getElementById("detail-error").style.display = "block";
document.getElementById("detail-error-title").textContent = title;
document.getElementById("detail-error-msg").textContent = msg;
}
</script>
</body>
</html>