489 lines
18 KiB
HTML
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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>
|