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
{
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 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}")]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> GetPromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await GetAccessiblePrompts(userId.Value)
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
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);
}

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.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating));
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.CanAccess, x => false);
config.CreateMap<PromptModel, ApiMinimalPrompt>()
.MapCtorParamFrom(x => x.Id, x => x.Id)

View File

@ -99,15 +99,17 @@
.prompt-card {
background: #fff;
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;
transition: transform 0.2s, box-shadow 0.2s;
transition:
transform 0.2s,
box-shadow 0.2s;
display: flex;
flex-direction: column;
}
.prompt-card:hover {
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 {
@ -171,7 +173,8 @@
gap: 12px;
margin-top: 8px;
}
.buy-btn, .details-btn {
.buy-btn,
.details-btn {
flex: 1;
border: none;
border-radius: 14px;
@ -189,7 +192,8 @@
background: #f1f5f9;
color: #334155;
}
.buy-btn:hover, .details-btn:hover {
.buy-btn:hover,
.details-btn:hover {
opacity: 0.85;
}
@ -222,4 +226,28 @@
.prompt-actions {
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:
- 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 -->
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort, crypto payment modal -->
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Marketplace</title>
<link rel="stylesheet" href="../css/variables.css">
<link rel="stylesheet" href="../css/base.css">
<link rel="stylesheet" href="../css/sidebar.css">
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.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">
<style>
/* Additional inline style for sort dropdown can be moved to marketplace.css */
.filter-sort-row {
display: flex;
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 {
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) {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Marketplace</title>
<link rel="stylesheet" href="../css/variables.css" />
<link rel="stylesheet" href="../css/base.css" />
<link rel="stylesheet" href="../css/sidebar.css" />
<link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.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"
/>
<style>
/* Additional inline style for sort dropdown can be moved to marketplace.css */
.filter-sort-row {
flex-direction: column;
align-items: stretch;
display: flex;
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 {
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;
}
}
</style>
</head>
<body>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
.sort-dropdown:hover {
border-color: #94a3b8;
}
@media (max-width: 700px) {
.filter-sort-row {
flex-direction: column;
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 id="topbar-container"></div>
<div style="flex: 1; margin: 40px auto; max-width: 950px">
<div id="topbar-container"></div>
<main class="marketplace-main">
<!-- Header -->
<div class="marketplace-header">
<h1>Marketplace</h1>
<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>
<main class="marketplace-main">
<!-- Header -->
<div class="marketplace-header">
<h1>Marketplace</h1>
<p>Browse and discover high-quality AI prompts</p>
</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 -->
<div class="prompts-grid">
<!-- Prompt Card 1 -->
<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>
<!-- Filter + Sort Row -->
<div class="filter-sort-row">
<div class="filter-buttons" id="category-filters">
<button class="filter-btn active" data-category="">All</button>
</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>
<!-- Prompt Card 2 -->
<div class="prompt-card">
<img src="../images/content/market2.png" alt="Python Code Assistant" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Python Code Assistant</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Efficiently debug and write Python code with AI assistance.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.7</span>
<span>(98 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>
<!-- 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>
<!-- Prompt Card 3 -->
<div class="prompt-card">
<img src="../images/content/market3.png" alt="Digital Art Style Guide" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Digital Art Style Guide</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Create stunning digital art styles with this comprehensive guide.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.8</span>
<span>(156 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>
<!-- Prompts Grid -->
<div class="prompts-grid" id="prompts-grid"></div>
<!-- Empty State -->
<div
id="market-empty"
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #64748b;
"
>
<i
class="bi bi-bag-x"
style="font-size: 3rem; display: block; margin-bottom: 16px"
></i>
<h3 style="margin-bottom: 8px">No prompts found</h3>
<p>Try a different category or search term.</p>
</div>
<!-- Prompt Card 4 -->
<div class="prompt-card">
<img src="../images/content/market4.png" alt="Marketing Copywriter" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Marketing Copywriter</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Generate engaging marketing copy in minutes.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.8</span>
<span>(112 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>
<!-- Error State -->
<div
id="market-error"
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #ef4444;
"
>
<i
class="bi bi-exclamation-circle"
style="font-size: 3rem; display: block; margin-bottom: 16px"
></i>
<h3 style="margin-bottom: 8px">Could not load prompts</h3>
<p id="market-error-msg"></p>
</div>
<!-- Prompt Card 5 -->
<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>
</main>
</div>
</div>
</div>
<script>
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
// Remove 'active' from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active');
<!-- ── Payment Modal ─────────────────────────────────────────────── -->
<div
id="payment-overlay"
style="
display: none;
position: fixed;
inset: 0;
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 12 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
const secondLink = document.querySelectorAll('#sidebar-container .sidebar li a')[1];
if (secondLink) secondLink.classList.add('active');
fetch("/topbar.html")
.then((r) => r.text())
.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')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>
// Make openPayment global
window.openPayment = openPayment;
// ── Payment Modal ──────────────────────────────────────────────────
const CRYPTO_ADDRESSES = {
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:
- User preferences with tabs for profile, security, and notifications -->
<!-- OnlyPrompt - Post Detail: dynamic prompt loaded via API -->
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Post Detail</title>
<link rel="stylesheet" href="../css/variables.css">
<link rel="stylesheet" href="../css/base.css">
<link rel="stylesheet" href="../css/sidebar.css">
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.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">
</head>
<body>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Post Detail</title>
<link rel="stylesheet" href="../css/variables.css" />
<link rel="stylesheet" href="../css/base.css" />
<link rel="stylesheet" href="../css/sidebar.css" />
<link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.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"
/>
</head>
<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 id="topbar-container"></div>
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="post-detail-main">
<div class="post-detail-container">
<!-- Header -->
<div class="post-header">
<h1 class="post-title">Marketing Guru: Campaign Strategist</h1>
<div class="post-meta">
<span class="category">Marketing</span>
<span class="category">Business</span>
<span class="category">Copywriting</span>
<span class="updated">Updated: Oct 26, 2023</span>
<main class="post-detail-main">
<div class="post-detail-container" id="detail-content">
<!-- Loading -->
<div
id="detail-loading"
style="text-align: center; padding: 60px 20px; color: #64748b"
>
<i
class="bi bi-hourglass-split"
style="font-size: 2.5rem; display: block; margin-bottom: 12px"
></i>
<p>Loading prompt...</p>
</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 -->
<div class="prompt-section">
<h2>PROMPT</h2>
<div class="prompt-content">
<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>
<ul>
<li>Campaign objectives</li>
<li>Target Audience: [User Input]</li>
<li>Expected Output: A detailed marketing plan with channel strategy, messaging, and sample copy</li>
<li>Budget considerations and KPIs</li>
</ul>
<!-- Error -->
<div
id="detail-error"
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #ef4444;
"
>
<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>
<!-- Rating & Like -->
<div class="rating-section">
<div class="rating-stars">
<span><i class="bi bi-star-fill"></i> 4.9</span>
<span><i class="bi bi-star-fill"></i> 4.8 (241 Ratings)</span>
</div>
<button class="like-btn"><i class="bi bi-heart"></i> Like (212)</button>
</div>
<!-- Example Output Section -->
<div class="example-section">
<h2>EXAMPLE OUTPUT</h2>
<div class="example-content">
<h3>Generated Strategy 16px</h3>
<div class="example-output-text">
<p><strong>Output: EcoWare Campaign</strong></p>
<p><strong>Campaign Overview</strong><br>Target Persona: Sarah Jenkins, 28, Eco-conscious</p>
<p><strong>Key Messaging</strong><br>Channel Strategy<br>Sample Copy<br>Social<br>Email</p>
<!-- Content (hidden until loaded) -->
<div id="detail-body" style="display: none">
<!-- Header -->
<div class="post-header">
<div
style="
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
"
>
<div
id="creator-avatar"
style="
width: 42px;
height: 42px;
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>
<!-- Optional example image (if uploaded in create) -->
<div class="example-image" style="display: none;">
<img src="#" alt="Example Output Image">
<!-- Description -->
<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>
<!-- Unlock / Buy Section -->
<div class="unlock-section">
<button class="unlock-btn">Unlock Full Prompt • $19.99</button>
</div>
</div>
</main>
</main>
</div>
</div>
</div>
<script>
// Fetch sidebar and topbar
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
// Remove active class from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active');
<script type="module">
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"));
});
// Optionally set active on a relevant link (e.g., Marketplace)
const marketplaceLink = document.querySelector('#sidebar-container a[href="marketplace.html"]');
if (marketplaceLink) marketplaceLink.classList.add('active');
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>
const params = new URLSearchParams(location.search);
const id = params.get("id");
if (!id) {
showError("No prompt ID provided", "Add ?id=... to the URL.");
} 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>