2026-06-02 10:36:02 +02:00

489 lines
18 KiB
HTML

<!-- OnlyPrompt - Post Detail: dynamic prompt loaded via API -->
<!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" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
</head>
<body>
<div class="layout">
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<main class="post-detail-main">
<div class="post-detail-container" id="detail-content">
<!-- Loading -->
<div id="detail-loading">
<i class="bi bi-hourglass-split state-icon"></i>
<p>Loading prompt...</p>
</div>
<!-- Error -->
<div id="detail-error" class="state-error">
<i class="bi bi-exclamation-circle state-icon"></i>
<h3 id="detail-error-title">Prompt not found</h3>
<p id="detail-error-msg"></p>
<button onclick="history.back()" class="detail-back-btn">
Go Back
</button>
</div>
<!-- Content (hidden until loaded) -->
<div id="detail-body">
<!-- Header -->
<div class="post-header">
<div class="detail-creator-row">
<div id="creator-avatar"></div>
<div>
<span id="creator-name"></span>
<span id="prompt-date"></span>
</div>
<div class="detail-actions-right">
<span id="tier-badge"></span>
<button id="edit-prompt-btn">Edit</button>
</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>
<!-- 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"></div>
</div>
<!-- Example Output -->
<div class="example-section" id="example-section">
<h2>EXAMPLE OUTPUT</h2>
<div class="example-content">
<div
id="example-output-text"
class="example-output-text"
></div>
<div id="example-image" class="example-image">
<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">
<i class="bi bi-lock-fill locked-icon"></i>
<h3 class="locked-title">
This prompt requires a subscription
</h3>
<p class="locked-desc">
Subscribe to <strong id="locked-creator"></strong> to access
this prompt.
</p>
<button id="locked-subscribe-btn">
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>
<!-- Reviews -->
<div class="reviews-section" id="reviews-section">
<h2>REVIEWS</h2>
<div class="review-form" id="review-form">
<h3>Your review</h3>
<div
class="review-star-input"
id="review-star-input"
aria-label="Select rating"
>
<button type="button" data-rating="1"></button>
<button type="button" data-rating="2"></button>
<button type="button" data-rating="3"></button>
<button type="button" data-rating="4"></button>
<button type="button" data-rating="5"></button>
</div>
<textarea
id="review-comment"
maxlength="200"
rows="3"
placeholder="Write a short comment..."
></textarea>
<button type="button" id="submit-review-btn">
Submit Review
</button>
<p id="review-message"></p>
</div>
<div class="reviews-list" id="reviews-list">
<p class="detail-loading-text">Loading reviews...</p>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<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"));
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
const params = new URLSearchParams(location.search);
const id = params.get("id");
let selectedReviewRating = 0;
if (!id) {
showError("No prompt ID provided", "Add ?id=... to the URL.");
} 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-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;
document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi ${p.isLiked ? "bi-heart-fill" : "bi-heart"} detail-heart-icon"></i> ${p.likeCount || 0} likes
<span class="detail-bookmark-span"><i class="bi ${p.isSaved ? "bi-bookmark-fill" : "bi-bookmark"} detail-bookmark-icon"></i> ${p.saveCount || 0} saves</span>`;
renderExamples(p);
renderOwnerActions(p);
setupReviewSection(p);
loadReviews(p.id);
// Tier badge
const badge = document.getElementById("tier-badge");
if (p.price != null && Number(p.price) > 0) {
badge.innerHTML = `<span class="tier-badge-paid">$${Number(p.price).toFixed(2)}</span>`;
} else if (p.tierName) {
badge.innerHTML = `<span class="tier-badge-tier"><i class="bi bi-lock-fill"></i> ${p.tierName}</span>`;
} else {
badge.innerHTML = `<span class="tier-badge-free">Free</span>`;
}
// Rating
if (p.averageRating != null) {
const stars = Math.round(p.averageRating);
document.getElementById("rating-display").innerHTML =
`<span class="rating-stars-display">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span>
<span class="rating-value">${p.averageRating.toFixed(1)}</span>
<span class="rating-count">/ 5.0 (${p.reviewCount || 0} ${(p.reviewCount || 0) === 1 ? "review" : "reviews"})</span>`;
document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi ${p.isLiked ? "bi-heart-fill" : "bi-heart"} detail-heart-icon"></i> ${p.likeCount || 0} likes
<span class="detail-bookmark-span"><i class="bi ${p.isSaved ? "bi-bookmark-fill" : "bi-bookmark"} detail-bookmark-icon"></i> ${p.saveCount || 0} saves</span>
<span class="detail-bookmark-span"><i class="bi bi-star-fill detail-bookmark-icon"></i> ${p.averageRating.toFixed(1)} (${p.reviewCount || 0})</span>`;
} else {
document.getElementById("rating-display").innerHTML =
'<span class="rating-none">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";
scrollToHashSection();
}
function scrollToHashSection() {
if (!location.hash) return;
const target = document.querySelector(location.hash);
if (!target) return;
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
function setReviewRating(rating) {
selectedReviewRating = rating;
document
.querySelectorAll("#review-star-input button")
.forEach((button) => {
const value = Number(button.dataset.rating);
button.textContent = value <= rating ? "★" : "☆";
});
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function setupReviewSection(prompt) {
const form = document.getElementById("review-form");
const message = document.getElementById("review-message");
const submitBtn = document.getElementById("submit-review-btn");
selectedReviewRating = 0;
setReviewRating(0);
document.getElementById("review-star-input").style.display = "flex";
document.getElementById("review-comment").style.display = "block";
submitBtn.style.display = "inline-block";
document.getElementById("review-comment").value = "";
message.textContent = "";
form.style.display = "block";
if (!prompt.content) {
document.getElementById("review-star-input").style.display = "none";
document.getElementById("review-comment").style.display = "none";
submitBtn.style.display = "none";
message.textContent = "Unlock this prompt before writing a review.";
return;
}
try {
const response = await fetch("/api/v1/auth/me", {
credentials: "same-origin",
});
if (!response.ok) return;
const user = await response.json();
if (user.id === prompt.creatorId) {
document.getElementById("review-star-input").style.display = "none";
document.getElementById("review-comment").style.display = "none";
submitBtn.style.display = "none";
message.textContent = "You cannot review your own prompt.";
return;
}
} catch {
// Keep the review form visible; the API will reject unauthenticated users.
}
document
.querySelectorAll("#review-star-input button")
.forEach((button) => {
button.onclick = () =>
setReviewRating(Number(button.dataset.rating));
});
submitBtn.onclick = async () => {
if (!selectedReviewRating) {
message.textContent = "Please select a star rating.";
return;
}
submitBtn.disabled = true;
message.textContent = "Saving review...";
const response = await fetch(`/api/v1/prompts/${prompt.id}/reviews`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
rating: selectedReviewRating,
comment:
document.getElementById("review-comment").value.trim() || null,
}),
});
if (response.status === 401) {
location.href = "/login";
return;
}
submitBtn.disabled = false;
if (!response.ok) {
message.textContent = await response.text();
return;
}
message.textContent = "Review saved.";
await loadPrompt(prompt.id);
};
}
async function loadReviews(promptId) {
const list = document.getElementById("reviews-list");
try {
const response = await fetch(`/api/v1/prompts/${promptId}/reviews`, {
credentials: "same-origin",
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) throw new Error(`Server error ${response.status}`);
const reviews = await response.json();
if (reviews.length === 0) {
list.innerHTML =
'<p class="detail-loading-text">No reviews yet.</p>';
return;
}
list.innerHTML = reviews
.map((review) => {
const stars =
"★".repeat(review.rating) + "☆".repeat(5 - review.rating);
return `
<article class="review-card">
<div class="review-card-header">
<span class="review-card-user">@${escapeHtml(review.creatorName)}</span>
<span class="review-card-stars">${stars}</span>
</div>
<p class="review-card-comment">${escapeHtml(review.comment || "No comment.")}</p>
</article>`;
})
.join("");
} catch (error) {
list.innerHTML = `<p class="detail-error-text">${error.message}</p>`;
}
}
async function renderOwnerActions(p) {
try {
const response = await fetch("/api/v1/auth/me", {
credentials: "same-origin",
});
if (!response.ok) return;
const user = await response.json();
if (user.id !== p.creatorId) return;
const editBtn = document.getElementById("edit-prompt-btn");
editBtn.style.display = "inline-block";
editBtn.onclick = () => {
location.href = `/create?id=${p.id}`;
};
} catch {
// Keep edit hidden if the current user cannot be loaded.
}
}
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";
document.getElementById("detail-error-title").textContent = title;
document.getElementById("detail-error-msg").textContent = msg;
}
</script>
</body>
</html>