Compare commits

..

No commits in common. "main" and "isabelle" have entirely different histories.

36 changed files with 1667 additions and 3476 deletions

51
API.md
View File

@ -152,7 +152,7 @@ GET /api/v1/prompts?sortBy=date&ascending=false&limit=50&search=cat
Used by Marketplace. Supports sorting, search and category filtering. The marketplace excludes prompts created by the logged-in user.
Response items include prompt title, description, creator id, creator name, creator avatar, example image, tier data, like/save counts, average rating, review count and access state.
Response items include prompt title, description, creator id, creator name, creator avatar, example image, price, like/save counts, average rating, review count and access state.
### Feed
@ -187,8 +187,6 @@ Request:
Response: created prompt. The frontend redirects to `/post-detail?id={id}`.
`subscriptionTier` is the creator's tier level. `null` means the prompt is public/free. `price` is kept as `null` because prompt access is handled through monthly creator tiers.
### Own Prompts
```http
@ -204,7 +202,7 @@ PUT /api/v1/prompts/{id}
Content-Type: application/json
```
Request body uses the same editable fields as prompt creation, including `subscriptionTier`. Used by the edit prompt flow.
Request body uses the same editable fields as prompt creation. Used by the edit prompt flow.
### Prompt Detail
@ -218,13 +216,13 @@ Response includes:
- prompt content if accessible
- category
- creator information
- tier or free state
- price or free state
- example output
- example image
- average rating and review count
- like/save state and counts
Tier prompts return no detail content for users without a matching creator subscription.
Paid prompts return no detail content for users without access.
### Likes and Saves
@ -285,49 +283,10 @@ Default categories are created automatically when the backend starts.
```http
GET /api/v1/subscriptions/{creatorId}
PUT /api/v1/subscriptions/{creatorId}
PUT /api/v1/subscriptions/{creatorId}/{level}
DELETE /api/v1/subscriptions/{creatorId}
```
Used by Community, public profiles and locked prompt details to read follow state, follow creators, subscribe to a monthly tier or unfollow creators.
- `PUT /api/v1/subscriptions/{creatorId}` follows a creator without a paid tier.
- `PUT /api/v1/subscriptions/{creatorId}/{level}` subscribes to one of the creator's tiers. A higher tier gives access to prompts from the same level and lower levels.
### Subscription Tiers
```http
GET /api/v1/subscriptions/tiers
GET /api/v1/subscriptions/tiers/{creatorId}
POST /api/v1/subscriptions/tiers
PUT /api/v1/subscriptions/tiers/{tierId}
DELETE /api/v1/subscriptions/tiers/{tierId}
```
Used by the Subscription Tiers page, Create Prompt and public creator profiles.
Create request:
```json
{
"name": "Supporter",
"monthlyPrice": 4.99,
"level": 1,
"description": "Access to basic premium prompts."
}
```
Response:
```json
{
"id": "uuid",
"name": "Supporter",
"level": 1,
"monthlyPrice": 4.99,
"description": "Access to basic premium prompts."
}
```
Used by Community and public profiles to read follow state, follow creators or unfollow creators.
## Error Handling

View File

@ -1,7 +1,7 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, decimal? TierMonthlyPrice, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, decimal? TierMonthlyPrice, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, int ReviewCount, bool CanAccess);
public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
public record ApiLikeState(int LikeCount, bool IsLiked);
public record ApiSaveState(int SaveCount, bool IsSaved);

View File

@ -1,5 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiCreatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? Slug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
public record ApiUpdatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
public record ApiUpdatePromptRequest(string Title, string Description, string Content, string Category, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
}

View File

@ -71,10 +71,9 @@ namespace OnlyPrompt.Backend.Controllers
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level <= s.SubscriptionTier.Level)
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
)).ToArrayAsync();
return prompts;

View File

@ -81,10 +81,9 @@ namespace OnlyPrompt.Backend.Controllers
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
x.CreatorId == userId || x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level <= s.SubscriptionTier.Level)
x.CreatorId == userId || ((x.Price == null || x.Price <= 0) && (x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)))
)).ToArrayAsync();
return prompts;
@ -113,7 +112,6 @@ namespace OnlyPrompt.Backend.Controllers
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
true
@ -134,6 +132,9 @@ namespace OnlyPrompt.Backend.Controllers
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
if (prompt.CreatorId != userId && prompt.Price.HasValue && prompt.Price.Value > 0)
return TypedResults.NotFound("Prompt not found or requires payment");
var canAccess = await GetAccessiblePrompts(userId.Value).AnyAsync(p => p.Id == prompt.Id);
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with
{
@ -168,20 +169,7 @@ namespace OnlyPrompt.Backend.Controllers
prompt.Category = category;
prompt.ExampleOutput = request.ExampleOutput;
prompt.ExampleImageUrl = request.ExampleImageUrl;
prompt.Price = null;
prompt.SubscriptionTier = null;
if (request.SubscriptionTier.HasValue)
{
var subscriptionTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(
t => t.Level == request.SubscriptionTier.Value
&& t.UserId == userId
);
if (subscriptionTier is null)
return TypedResults.NotFound("Subscription tier not found");
prompt.SubscriptionTier = subscriptionTier;
}
prompt.Price = request.Price;
await _db.SaveChangesAsync();
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = prompt.Prompt, CanAccess = true };
@ -327,7 +315,7 @@ namespace OnlyPrompt.Backend.Controllers
Prompt = request.Content,
ExampleOutput = request.ExampleOutput,
ExampleImageUrl = request.ExampleImageUrl,
Price = null,
Price = request.Price,
CreatorId = userId.Value,
SubscriptionTier = subscriptionTier,
Category = category,

View File

@ -100,35 +100,6 @@ namespace OnlyPrompt.Backend.Controllers
return subscription;
}
[HttpGet("tiers")]
public async Task<ApiSubscriptionTier[]> GetOwnSubscriptionTiersAsync()
{
var userId = User.GetUserId();
return await _db.SubscriptionTiers
.Where(t => t.UserId == userId)
.OrderBy(t => t.Level)
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
.ToArrayAsync();
}
[HttpGet("tiers/{userId}")]
public async Task<Results<Ok<ApiSubscriptionTier[]>, NotFound<string>>> GetCreatorSubscriptionTiersAsync([FromRoute(Name = "userId")] Identifier creatorId)
{
var creatorExists = await _db.Users.AnyAsync(
user => creatorId.Id.HasValue ? user.Id == creatorId.Id.Value : user.Profile.Slug == creatorId.Slug
);
if (creatorExists == false)
return TypedResults.NotFound($"No user found with identifier {creatorId}");
var tiers = await _db.SubscriptionTiers
.Where(t => creatorId.Id.HasValue ? t.UserId == creatorId.Id.Value : t.User.Profile.Slug == creatorId.Slug)
.OrderBy(t => t.Level)
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return TypedResults.Ok(tiers);
}
[HttpDelete("{userId}")]
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
{

View File

@ -44,7 +44,6 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
.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))
@ -67,7 +66,6 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
.MapCtorParamFrom(x => x.CanAccess, x => true);

View File

@ -1,381 +1,130 @@
<!-- OnlyPrompt - Chats page:
- Direct messaging interface with conversation list and active chat window -->
<!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 - Chats</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/chats.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>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Chats</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/chats.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" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<div style="flex:1; display: flex; flex-direction: column;">
<div id="topbar-container"></div>
<main class="chats-main" id="main-content" tabindex="-1">
<!-- Chat Container: Left column (list) + Right column (active chat) -->
<div class="chat-container">
<!-- Left Column: Chat Overview -->
<div class="chat-list">
<div class="chat-list-header">
<h2>Messages</h2>
<button type="button" class="new-chat-btn" id="newChatBtn" aria-label="New chat" aria-expanded="false" aria-controls="new-chat-panel">
<i class="bi bi-pencil-square" aria-hidden="true"></i>
</button>
</div>
<div class="new-chat-panel" id="new-chat-panel" hidden>
<label for="creatorSearch" class="sr-only">Search creator</label>
<input type="search" id="creatorSearch" placeholder="Search creator..." autocomplete="off" />
<div class="creator-search-results" id="creatorSearchResults" role="listbox" aria-label="Creator results"></div>
</div>
<div class="chat-list-items" id="chatList" role="list" aria-label="Conversations">
</div>
<main class="chats-main">
<!-- Chat Container: Left column (list) + Right column (active chat) -->
<div class="chat-container">
<!-- Left Column: Chat Overview -->
<div class="chat-list">
<div class="chat-list-header">
<h2>Messages</h2>
<button class="new-chat-btn"><i class="bi bi-pencil-square"></i></button>
</div>
<!-- Right Column: Active Chat -->
<div class="chat-active">
<div class="chat-header">
<img
src="../images/content/cat.png"
alt=""
class="chat-avatar-large"
id="activeChatAvatar"
/>
<div class="chat-header-info">
<div class="chat-header-name" id="activeChatName">Select a chat</div>
<div class="chat-header-status">
<span class="online-dot" aria-hidden="true"></span>
<span id="activeChatStatus">Ready</span>
</div>
<div class="chat-list-items">
<!-- Chat Entry 1 (active) -->
<div class="chat-item active">
<img src="../images/content/creator2.png" alt="Alex Chen" class="chat-avatar">
<div class="chat-item-info">
<div class="chat-name">Alex Chen</div>
<div class="chat-last-msg">Hey Sarah! Really loved your last video on minimalism...</div>
</div>
<div class="chat-time">10:17 AM</div>
</div>
<div class="chat-messages" id="chatMessages" aria-live="polite" aria-label="Conversation">
<!-- Chat Entry 2 -->
<div class="chat-item">
<img src="../images/content/creator3.png" alt="Mia Wong" class="chat-avatar">
<div class="chat-item-info">
<div class="chat-name">Mia Wong</div>
<div class="chat-last-msg">Thanks for the prompt tips! They worked perfectly.</div>
</div>
<div class="chat-time">Yesterday</div>
</div>
<!-- Chat Entry 3 -->
<div class="chat-item">
<img src="../images/content/creator4.png" alt="Tom Rivera" class="chat-avatar">
<div class="chat-item-info">
<div class="chat-name">Tom Rivera</div>
<div class="chat-last-msg">Let's schedule a call for the collab?</div>
</div>
<div class="chat-time">Yesterday</div>
</div>
<form class="chat-input-area" id="chatForm">
<input type="text" id="messageInput" placeholder="Type your message..." aria-label="Message" autocomplete="off" />
<button type="submit" class="send-btn">Send</button>
</form>
</div>
</div>
</main>
</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");
link.removeAttribute("aria-current");
});
// Set 'active' on the Chats link (4th link, index 3)
const chatsLink = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[3];
if (chatsLink) {
chatsLink.classList.add("active");
chatsLink.setAttribute("aria-current", "page");
}
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
const STORAGE_KEY = "onlyprompt-chat-conversations";
const chatList = document.getElementById("chatList");
const chatMessages = document.getElementById("chatMessages");
const chatForm = document.getElementById("chatForm");
const messageInput = document.getElementById("messageInput");
const activeChatAvatar = document.getElementById("activeChatAvatar");
const activeChatName = document.getElementById("activeChatName");
const activeChatStatus = document.getElementById("activeChatStatus");
const newChatBtn = document.getElementById("newChatBtn");
const newChatPanel = document.getElementById("new-chat-panel");
const creatorSearch = document.getElementById("creatorSearch");
const creatorSearchResults = document.getElementById("creatorSearchResults");
let conversations = loadConversations();
let creators = [];
let activeConversationId = null;
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function loadConversations() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
return Array.isArray(saved) && saved.length ? saved : demoConversations();
} catch {
return demoConversations();
}
}
function saveConversations() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
}
function demoConversations() {
const now = Date.now();
return [
{
id: "demo-alex",
userId: "demo-alex",
name: "Alex Chen",
avatar: "../images/content/creator2.png",
updatedAt: now - 60000,
messages: [
{ from: "them", text: "Hey, I liked your last prompt idea.", createdAt: now - 180000 },
{ from: "me", text: "Thanks. Which part was useful?", createdAt: now - 120000 },
{ from: "them", text: "The structure was easy to adapt.", createdAt: now - 60000 },
],
},
{
id: "demo-mia",
userId: "demo-mia",
name: "Mia Wong",
avatar: "../images/content/creator3.png",
updatedAt: now - 86400000,
messages: [
{ from: "them", text: "Thanks for the prompt tips.", createdAt: now - 86400000 },
],
},
];
}
function formatTime(timestamp) {
const date = new Date(timestamp);
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
function getLastMessage(conversation) {
return conversation.messages.at(-1)?.text || "No messages yet.";
}
function renderChatList() {
conversations.sort((a, b) => b.updatedAt - a.updatedAt);
if (!conversations.length) {
chatList.innerHTML = '<div class="chat-empty">Start a chat with a creator.</div>';
return;
}
chatList.innerHTML = conversations
.map((conversation) => `
<button type="button"
class="chat-item ${conversation.id === activeConversationId ? "active" : ""}"
data-chat-id="${escapeHtml(conversation.id)}"
aria-label="Open chat with ${escapeHtml(conversation.name)}"
${conversation.id === activeConversationId ? 'aria-current="true"' : ""}>
<img src="${escapeHtml(conversation.avatar)}" alt="" class="chat-avatar" />
<div class="chat-item-info">
<div class="chat-name">${escapeHtml(conversation.name)}</div>
<div class="chat-last-msg">${escapeHtml(getLastMessage(conversation))}</div>
<!-- Right Column: Active Chat (with Alex Chen) -->
<div class="chat-active">
<div class="chat-header">
<img src="../images/content/creator2.png" alt="Alex Chen" class="chat-avatar-large">
<div class="chat-header-info">
<div class="chat-header-name">Alex Chen</div>
<div class="chat-header-status"><span class="online-dot"></span> Online</div>
</div>
<time class="chat-time" datetime="${new Date(conversation.updatedAt).toISOString()}">${formatTime(conversation.updatedAt)}</time>
</button>
`)
.join("");
chatList.querySelectorAll("[data-chat-id]").forEach((button) => {
button.addEventListener("click", () => selectConversation(button.dataset.chatId));
});
}
function renderMessages() {
const conversation = conversations.find((item) => item.id === activeConversationId);
if (!conversation) {
activeChatAvatar.src = "../images/content/cat.png";
activeChatName.textContent = "Select a chat";
activeChatStatus.textContent = "Ready";
chatMessages.innerHTML = '<div class="chat-empty">Choose a conversation or start a new chat.</div>';
messageInput.disabled = true;
chatForm.querySelector("button").disabled = true;
return;
}
activeChatAvatar.src = conversation.avatar;
activeChatName.textContent = conversation.name;
activeChatStatus.textContent = "Online";
chatMessages.setAttribute("aria-label", `Conversation with ${conversation.name}`);
messageInput.disabled = false;
chatForm.querySelector("button").disabled = false;
chatMessages.innerHTML = conversation.messages
.map((message) => `
<div class="message ${message.from === "me" ? "sent" : "received"}">
<div class="message-bubble">${escapeHtml(message.text)}</div>
<time class="message-time" datetime="${new Date(message.createdAt).toISOString()}">${formatTime(message.createdAt)}</time>
</div>
`)
.join("");
chatMessages.scrollTop = chatMessages.scrollHeight;
}
<div class="chat-messages">
<!-- Message from Alex -->
<div class="message received">
<div class="message-bubble">Hey Sarah! Really loved your last video on minimalism. Quick question about your workspace layout?</div>
<div class="message-time">10:15 AM</div>
</div>
<!-- Reply from Sarah -->
<div class="message sent">
<div class="message-bubble">Thanks Alex! Appreciate it. Yes, happy to share! The desk is from Article, and the shelving unit is custom-built. Highly recommend a clean setup!</div>
<div class="message-time">10:16 AM</div>
</div>
<!-- Alex replies -->
<div class="message received">
<div class="message-bubble">Thanks so much! Your aesthetic is exactly what I'm aiming for. Can't wait for your next piece!</div>
<div class="message-time">10:17 AM</div>
</div>
<!-- Sarah replies -->
<div class="message sent">
<div class="message-bubble">Awesome! Let me know if you need more tips. Enjoy the process! 😊</div>
<div class="message-time">10:18 AM</div>
</div>
</div>
<div class="chat-input-area">
<input type="text" placeholder="Type your message...">
<button class="send-btn">Send</button>
</div>
</div>
function selectConversation(id) {
activeConversationId = id;
renderChatList();
renderMessages();
}
</div>
</main>
</div>
</div>
function startConversation(creator) {
const id = `user-${creator.userId}`;
let conversation = conversations.find((item) => item.id === id);
if (!conversation) {
conversation = {
id,
userId: creator.userId,
name: creator.displayName,
avatar: creator.avatarUrl || "../images/content/cat.png",
updatedAt: Date.now(),
messages: [],
};
conversations.push(conversation);
saveConversations();
}
activeConversationId = id;
newChatPanel.hidden = true;
newChatBtn.setAttribute("aria-expanded", "false");
creatorSearch.value = "";
renderCreatorResults();
renderChatList();
renderMessages();
messageInput.focus();
}
function addMessage(text) {
const conversation = conversations.find((item) => item.id === activeConversationId);
if (!conversation || !text.trim()) return;
conversation.messages.push({
from: "me",
text: text.trim(),
createdAt: Date.now(),
<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');
});
conversation.updatedAt = Date.now();
saveConversations();
renderChatList();
renderMessages();
}
async function loadCreators() {
try {
const response = await fetch("/api/v1/profiles?limit=100", {
credentials: "same-origin",
});
if (!response.ok) throw new Error("Creators could not be loaded.");
creators = await response.json();
} catch {
creators = [
{ userId: "demo-alex", displayName: "Alex Chen", slug: "alex", avatarUrl: "../images/content/creator2.png" },
{ userId: "demo-mia", displayName: "Mia Wong", slug: "mia", avatarUrl: "../images/content/creator3.png" },
{ userId: "demo-tom", displayName: "Tom Rivera", slug: "tom", avatarUrl: "../images/content/creator4.png" },
];
}
renderCreatorResults();
}
function renderCreatorResults() {
const search = creatorSearch.value.trim().toLowerCase();
const results = creators
.filter((creator) =>
`${creator.displayName || ""} ${creator.slug || ""}`.toLowerCase().includes(search),
)
.slice(0, 8);
if (!results.length) {
creatorSearchResults.innerHTML = '<div class="creator-result-empty">No creators found.</div>';
return;
}
creatorSearchResults.innerHTML = results
.map((creator) => `
<button type="button" class="creator-result" data-user-id="${escapeHtml(creator.userId)}" role="option">
<img src="${escapeHtml(creator.avatarUrl || "../images/content/cat.png")}" alt="" />
<span>
<strong>${escapeHtml(creator.displayName)}</strong>
<small>@${escapeHtml(creator.slug || "creator")}</small>
</span>
</button>
`)
.join("");
creatorSearchResults.querySelectorAll("[data-user-id]").forEach((button) => {
button.addEventListener("click", () => {
const creator = creators.find((item) => item.userId === button.dataset.userId);
if (creator) startConversation(creator);
});
});
}
function openConversationFromUrl() {
const params = new URLSearchParams(location.search);
const userId = params.get("userId");
if (!userId) return false;
startConversation({
userId,
displayName: params.get("name") || "Creator",
slug: "creator",
avatarUrl: params.get("avatar") || "../images/content/cat.png",
});
history.replaceState(null, "", "/chats.html");
return true;
}
newChatBtn.addEventListener("click", () => {
const willOpen = newChatPanel.hidden;
newChatPanel.hidden = !willOpen;
newChatBtn.setAttribute("aria-expanded", String(willOpen));
if (willOpen) creatorSearch.focus();
// Set 'active' on the Chats link (4th link, index 3)
const chatsLink = document.querySelectorAll('#sidebar-container .sidebar li a')[3];
if (chatsLink) chatsLink.classList.add('active');
});
creatorSearch.addEventListener("input", renderCreatorResults);
chatForm.addEventListener("submit", (event) => {
event.preventDefault();
addMessage(messageInput.value);
messageInput.value = "";
});
loadCreators().then(() => {
if (!openConversationFromUrl()) {
activeConversationId = conversations[0]?.id || null;
renderChatList();
renderMessages();
}
});
</script>
</body>
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>

View File

@ -1,247 +1,204 @@
<!-- OnlyPrompt - Community page:
- Discover creators, follow/unfollow, dynamic 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 - Discover Creators</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/community.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>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Discover Creators</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/community.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" style="display: flex; min-height: 100vh; background: var(--bg);">
<div class="page-body">
<div id="topbar-container"></div>
<div id="sidebar-container"></div>
<main class="creators-main" id="main-content" tabindex="-1">
<div class="creators-header">
<h1>Discover Creators</h1>
<p>Follow your favorite prompt artists and get inspired.</p>
</div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<div class="filter-buttons" role="group" aria-label="Sort creators">
<button type="button" class="filter-btn active" data-sort="popular" aria-pressed="true">
Popular
</button>
<button type="button" class="filter-btn" data-sort="prompts" aria-pressed="false">Rising</button>
<button type="button" class="filter-btn" data-sort="new" aria-pressed="false">New</button>
<button type="button" class="filter-btn" data-sort="rating" aria-pressed="false">Top Rated</button>
</div>
<div id="topbar-container"></div>
<div class="creators-grid" id="creators-grid" aria-live="polite"></div>
<main class="creators-main">
<div id="creators-empty" class="state-empty" role="status" aria-live="polite">
<i class="bi bi-people state-icon" aria-hidden="true"></i>
<h3 id="creators-empty-title" class="state-title">
No creators found
</h3>
<p id="creators-empty-text">
Check back later for new creators to follow.
</p>
</div>
<div class="creators-header">
<h1>Discover Creators</h1>
<p>Follow your favorite prompt artists and get inspired.</p>
</div>
<div id="creators-error" class="state-error" role="alert" aria-live="assertive">
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
<h3 class="state-title">Could not load creators</h3>
<p id="creators-error-msg"></p>
</div>
</main>
</div>
<div class="filter-buttons">
<button class="filter-btn active" data-sort="popular">Popular</button>
<button class="filter-btn" data-sort="prompts">Rising</button>
<button class="filter-btn" data-sort="new">New</button>
<button class="filter-btn" data-sort="rating">Top Rated</button>
</div>
<div class="creators-grid" id="creators-grid"></div>
<div id="creators-empty" style="display:none; text-align:center; padding:60px 20px; color:#64748b;">
<i class="bi bi-people" style="font-size:3rem; display:block; margin-bottom:16px;"></i>
<h3 id="creators-empty-title" style="margin-bottom:8px;">No creators found</h3>
<p id="creators-empty-text">Check back later for new creators to follow.</p>
</div>
<div id="creators-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 creators</h3>
<p id="creators-error-msg"></p>
</div>
</main>
</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");
l.removeAttribute("aria-current");
});
const thirdLink = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[2];
if (thirdLink) {
thirdLink.classList.add("active");
thirdLink.setAttribute("aria-current", "page");
}
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
<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 thirdLink = document.querySelectorAll('#sidebar-container .sidebar li a')[2];
if (thirdLink) thirdLink.classList.add('active');
});
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
// ── Helpers ──────────────────────────────────────────────────────
function renderStars(rating) {
if (!rating) return "";
const stars = Math.round(rating);
return `<span class="creator-stars">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> <span class="creator-stars-value">${rating.toFixed(1)}</span>`;
}
// ── Helpers ──────────────────────────────────────────────────────
function renderStars(rating) {
if (!rating) return '';
const stars = Math.round(rating);
return `<span style="color:#f59e0b">${'★'.repeat(stars)}${'☆'.repeat(5 - stars)}</span> <span style="color:#64748b;font-size:0.8rem">${rating.toFixed(1)}</span>`;
}
function renderCard(c) {
const profileHref = `/profile?id=${encodeURIComponent(c.userId)}`;
const chatHref = `/chats.html?userId=${encodeURIComponent(c.userId)}&name=${encodeURIComponent(c.displayName)}&avatar=${encodeURIComponent(c.avatarUrl || "../images/content/cat.png")}`;
return `
function renderCard(c) {
return `
<div class="creator-card">
<a class="creator-avatar-link" href="${profileHref}" aria-label="Open profile for ${c.displayName}">
<img class="creator-avatar"
src="${c.avatarUrl || "../images/content/cat.png"}"
alt="${c.displayName}">
</a>
<img class="creator-avatar"
src="${c.avatarUrl || '../images/content/cat.png'}"
alt="${c.displayName}"
style="cursor:pointer"
onclick="location.href='/profile?id=${c.userId}'">
<div class="creator-info">
<h3 class="creator-name"><a href="${profileHref}">${c.displayName}</a></h3>
<h3 class="creator-name"
style="cursor:pointer"
onclick="location.href='/profile?id=${c.userId}'">${c.displayName}</h3>
<div class="creator-handle">@${c.slug}</div>
<p class="creator-bio">${c.bio ?? "No bio yet."}</p>
<p class="creator-bio">${c.bio ?? 'No bio yet.'}</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle" aria-hidden="true"></i> ${c.promptCount} prompts</span>
<span><i class="bi bi-people" aria-hidden="true"></i> ${c.subscribers} subscribers</span>
${c.averageRating > 0 ? `<span>${renderStars(c.averageRating)}</span>` : ""}
</div>
<div class="creator-actions">
<button type="button" class="follow-btn ${c.isFollowing ? "following" : ""}"
data-userid="${c.userId}"
data-following="${c.isFollowing}"
aria-pressed="${c.isFollowing}"
aria-label="${c.isFollowing ? "Unfollow" : "Follow"} ${c.displayName}">
${c.isFollowing ? "Following" : "Follow"}
</button>
<a class="creator-chat-btn" href="${chatHref}" aria-label="Chat with ${c.displayName}">
<i class="bi bi-chat-dots" aria-hidden="true"></i>
Chat
</a>
<span><i class="bi bi-puzzle"></i> ${c.promptCount} prompts</span>
<span><i class="bi bi-people"></i> ${c.subscribers}</span>
${c.averageRating > 0 ? `<span>${renderStars(c.averageRating)}</span>` : ''}
</div>
<button class="follow-btn ${c.isFollowing ? 'following' : ''}"
data-userid="${c.userId}"
data-following="${c.isFollowing}">
${c.isFollowing ? 'Following' : 'Follow'}
</button>
</div>
</div>`;
}
// ── Follow / Unfollow ────────────────────────────────────────────
async function toggleFollow(btn) {
const userId = btn.dataset.userid;
const isFollowing = btn.dataset.following === 'true';
btn.disabled = true;
const res = await fetch(`/api/v1/subscriptions/${userId}`, {
method: isFollowing ? 'DELETE' : 'PUT',
credentials: 'same-origin'
});
if (res.status === 401) { location.href = '/login'; return; }
if (res.ok) {
const nowFollowing = !isFollowing;
btn.dataset.following = nowFollowing;
btn.textContent = nowFollowing ? 'Following' : 'Follow';
btn.classList.toggle('following', nowFollowing);
}
// ── Follow / Unfollow ────────────────────────────────────────────
async function toggleFollow(btn) {
const userId = btn.dataset.userid;
const isFollowing = btn.dataset.following === "true";
btn.disabled = true;
btn.disabled = false;
}
const res = await fetch(`/api/v1/subscriptions/${userId}`, {
method: isFollowing ? "DELETE" : "PUT",
credentials: "same-origin",
// ── Load Creators ────────────────────────────────────────────────
const grid = document.getElementById('creators-grid');
const emptyEl = document.getElementById('creators-empty');
const emptyTitle = document.getElementById('creators-empty-title');
const emptyText = document.getElementById('creators-empty-text');
const errorEl = document.getElementById('creators-error');
const errorMsg = document.getElementById('creators-error-msg');
let activeSort = 'popular';
let currentSearch = new URLSearchParams(location.search).get('search') || '';
function getSearchTerm() {
return currentSearch.trim();
}
async function loadCreators(sort = activeSort) {
activeSort = sort;
grid.innerHTML = '';
emptyEl.style.display = 'none';
errorEl.style.display = 'none';
try {
const params = new URLSearchParams({
sort,
limit: '50'
});
const search = getSearchTerm();
if (search) params.set('search', search);
if (res.status === 401) {
location.href = "/login";
const res = await fetch(`/api/v1/profiles?${params}`);
if (res.status === 401) { location.href = '/login'; return; }
if (!res.ok) throw new Error(`Server error ${res.status}`);
const creators = await res.json();
if (creators.length === 0) {
const search = getSearchTerm();
emptyTitle.textContent = search ? 'No matching creators' : 'No creators found';
emptyText.textContent = search
? `No creator matches "${search}". Try another name or clear the search.`
: 'Create another local user to see creators here.';
emptyEl.style.display = 'block';
return;
}
if (res.ok) {
const nowFollowing = !isFollowing;
btn.dataset.following = nowFollowing;
btn.textContent = nowFollowing ? "Following" : "Follow";
btn.setAttribute("aria-pressed", String(nowFollowing));
btn.classList.toggle("following", nowFollowing);
}
grid.innerHTML = creators.map(renderCard).join('');
btn.disabled = false;
}
// ── Load Creators ────────────────────────────────────────────────
const grid = document.getElementById("creators-grid");
const emptyEl = document.getElementById("creators-empty");
const emptyTitle = document.getElementById("creators-empty-title");
const emptyText = document.getElementById("creators-empty-text");
const errorEl = document.getElementById("creators-error");
const errorMsg = document.getElementById("creators-error-msg");
let activeSort = "popular";
let currentSearch =
new URLSearchParams(location.search).get("search") || "";
function getSearchTerm() {
return currentSearch.trim();
}
async function loadCreators(sort = activeSort) {
activeSort = sort;
grid.innerHTML = "";
emptyEl.style.display = "none";
errorEl.style.display = "none";
try {
const params = new URLSearchParams({
sort,
limit: "50",
});
const search = getSearchTerm();
if (search) params.set("search", search);
const res = await fetch(`/api/v1/profiles?${params}`);
if (res.status === 401) {
location.href = "/login";
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
const creators = await res.json();
if (creators.length === 0) {
const search = getSearchTerm();
emptyTitle.textContent = search
? "No matching creators"
: "No creators found";
emptyText.textContent = search
? `No creator matches "${search}". Try another name or clear the search.`
: "Create another local user to see creators here.";
emptyEl.style.display = "block";
return;
}
grid.innerHTML = creators.map(renderCard).join("");
grid.querySelectorAll(".follow-btn").forEach((btn) => {
btn.addEventListener("click", () => toggleFollow(btn));
});
} catch (e) {
errorEl.style.display = "block";
errorMsg.textContent = e.message;
}
}
// ── Filter buttons ───────────────────────────────────────────────
document.querySelectorAll(".filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document
.querySelectorAll(".filter-btn")
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
loadCreators(btn.dataset.sort);
grid.querySelectorAll('.follow-btn').forEach(btn => {
btn.addEventListener('click', () => toggleFollow(btn));
});
} catch (e) {
errorEl.style.display = 'block';
errorMsg.textContent = e.message;
}
}
// ── Filter buttons ───────────────────────────────────────────────
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadCreators(btn.dataset.sort);
});
});
window.applyCreatorSearch = (value) => {
currentSearch = value.trim();
loadCreators(activeSort);
};
window.applyCreatorSearch = (value) => {
currentSearch = value.trim();
loadCreators(activeSort);
};
loadCreators();
</script>
</body>
loadCreators();
</script>
</body>
</html>

View File

@ -17,16 +17,15 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div class="page-body">
<div style="flex:1; display: flex; flex-direction: column;">
<div id="topbar-container"></div>
<main class="create-main" id="main-content" tabindex="-1">
<main class="create-main">
<div class="create-container">
<div class="create-header">
<h1 id="create-title">Create AI Prompt</h1>
@ -37,7 +36,7 @@
<!-- Title -->
<div class="form-group">
<label for="title">Prompt Title *</label>
<input type="text" id="title" name="title" placeholder="e.g., Write an inspiring startup story about innovation" autocomplete="off" required>
<input type="text" id="title" name="title" placeholder="e.g., Write an inspiring startup story about innovation" required>
</div>
<!-- Description -->
@ -62,8 +61,8 @@
<!-- Prompt Content -->
<div class="form-group">
<label for="promptContent">Prompt Content *</label>
<textarea id="promptContent" name="promptContent" rows="6" placeholder="Write your prompt instructions here..." aria-describedby="promptContentHint" required></textarea>
<small class="form-hint" id="promptContentHint">Use clear, step-by-step instructions for the AI.</small>
<textarea id="promptContent" name="promptContent" rows="6" placeholder="Write your prompt instructions here..." required></textarea>
<small class="form-hint">Use clear, step-by-step instructions for the AI.</small>
</div>
<!-- Example Output (Text) -->
@ -75,28 +74,24 @@
<!-- Example Image (optional) -->
<div class="form-group">
<label for="exampleImage">Example Image (optional)</label>
<input type="file" id="exampleImage" name="exampleImage" accept="image/png, image/jpeg, image/jpg" aria-describedby="exampleImageHint">
<small class="form-hint" id="exampleImageHint">Upload a PNG or JPG. Preview will appear below.</small>
<div id="imagePreview" aria-live="polite">
<img id="previewImg" src="#" alt="Selected example image preview">
<input type="file" id="exampleImage" name="exampleImage" accept="image/png, image/jpeg, image/jpg">
<small class="form-hint">Upload a PNG or JPG preview will appear below.</small>
<div id="imagePreview" style="margin-top: 10px; display: none;">
<img id="previewImg" src="#" alt="Preview" style="max-width: 100%; max-height: 200px; border-radius: 12px;">
</div>
</div>
<!-- Pricing (with toggle) -->
<div class="form-group pricing-group">
<span class="form-label" id="access-label">Access</span>
<div class="pricing-toggle" role="group" aria-labelledby="access-label">
<button type="button" id="freeBtn" class="price-option active" aria-pressed="true">Free</button>
<button type="button" id="tierBtn" class="price-option" aria-pressed="false">Tier</button>
<label>Pricing</label>
<div class="pricing-toggle">
<button type="button" id="freeBtn" class="price-option active">Free</button>
<button type="button" id="paidBtn" class="price-option">Paid</button>
</div>
<div id="tierField">
<label for="subscriptionTier" class="sr-only">Subscription tier</label>
<select id="subscriptionTier" name="subscriptionTier">
<option value="">No tiers created yet</option>
</select>
<a class="tier-manage-link" href="subscription-tiers.html">Manage tiers</a>
<div id="priceField" style="display: none;">
<input type="number" id="price" name="price" step="0.01" min="0" placeholder="Price in USD (e.g., 19.99)">
</div>
<small class="form-hint">Free prompts are public. Tier prompts require a monthly creator subscription.</small>
<small class="form-hint">You can set a price later or keep it free.</small>
</div>
<!-- Submit Button -->
@ -104,7 +99,7 @@
<button type="submit" class="submit-btn" id="submitPromptBtn">Publish Prompt</button>
<button type="button" class="cancel-btn">Cancel</button>
</div>
<p id="create-status" role="status" aria-live="polite"></p>
<p id="create-status" style="text-align:center;color:#64748b;margin:0;"></p>
</form>
</div>
</main>
@ -114,28 +109,23 @@
<script>
// Toggle between free and paid
const freeBtn = document.getElementById('freeBtn');
const tierBtn = document.getElementById('tierBtn');
const tierField = document.getElementById('tierField');
const tierSelect = document.getElementById('subscriptionTier');
const paidBtn = document.getElementById('paidBtn');
const priceField = document.getElementById('priceField');
const priceInput = document.getElementById('price');
const editPromptId = new URLSearchParams(location.search).get('id');
const submitPromptBtn = document.getElementById('submitPromptBtn');
let ownSubscriptionTiers = [];
freeBtn.addEventListener('click', () => {
freeBtn.classList.add('active');
tierBtn.classList.remove('active');
freeBtn.setAttribute('aria-pressed', 'true');
tierBtn.setAttribute('aria-pressed', 'false');
tierField.style.display = 'none';
tierSelect.removeAttribute('required');
paidBtn.classList.remove('active');
priceField.style.display = 'none';
priceInput.removeAttribute('required');
});
tierBtn.addEventListener('click', () => {
tierBtn.classList.add('active');
paidBtn.addEventListener('click', () => {
paidBtn.classList.add('active');
freeBtn.classList.remove('active');
tierBtn.setAttribute('aria-pressed', 'true');
freeBtn.setAttribute('aria-pressed', 'false');
tierField.style.display = 'grid';
tierSelect.setAttribute('required', 'required');
priceField.style.display = 'block';
priceInput.setAttribute('required', 'required');
});
// Image preview for example image
@ -181,31 +171,6 @@
}
}
async function loadSubscriptionTiers() {
try {
const response = await fetch('/api/v1/subscriptions/tiers', {
credentials: 'same-origin'
});
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) return;
ownSubscriptionTiers = await response.json();
if (!ownSubscriptionTiers.length) {
tierSelect.innerHTML = '<option value="">Create a tier first</option>';
return;
}
tierSelect.innerHTML = ownSubscriptionTiers
.map((tier) => `<option value="${tier.level}">${tier.name} - $${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</option>`)
.join('');
} catch {
tierSelect.innerHTML = '<option value="">Tiers could not be loaded</option>';
}
}
async function loadPromptForEdit() {
if (!editPromptId) return;
@ -237,9 +202,9 @@
imagePreview.style.display = 'block';
}
if (prompt.tierLevel != null) {
tierBtn.click();
tierSelect.value = String(prompt.tierLevel);
if (prompt.price != null && Number(prompt.price) > 0) {
paidBtn.click();
priceInput.value = Number(prompt.price);
} else {
freeBtn.click();
}
@ -259,7 +224,8 @@
submitBtn.disabled = true;
try {
const isTier = tierBtn.classList.contains('active');
const isPaid = paidBtn.classList.contains('active');
const price = isPaid ? Number(priceInput.value || 0) : null;
const payload = {
title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(),
@ -267,8 +233,8 @@
content: document.getElementById('promptContent').value.trim(),
exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
exampleImageUrl: exampleImageUrl || null,
price: null,
subscriptionTier: isTier ? Number(tierSelect.value) : null,
price,
subscriptionTier: null,
slug: null
};
@ -324,21 +290,17 @@
// Remove active class from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active');
link.removeAttribute('aria-current');
});
// Optionally set active on "Create New" if it exists, otherwise keep none
const createLink = document.querySelector('#sidebar-container a[href="create.html"]');
if (createLink) {
createLink.classList.add('active');
createLink.setAttribute('aria-current', 'page');
}
if (createLink) createLink.classList.add('active');
});
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
Promise.all([loadCategories(), loadSubscriptionTiers()]).then(loadPromptForEdit);
loadCategories().then(loadPromptForEdit);
</script>
</body>
</html>

View File

@ -18,58 +18,6 @@ body {
color: var(--text);
}
.skip-link {
position: fixed;
top: 12px;
left: 12px;
z-index: 1000;
transform: translateY(-160%);
padding: 10px 14px;
border-radius: 8px;
background: #111827;
color: #ffffff;
font-weight: 700;
text-decoration: none;
transition: transform 0.2s ease;
}
.skip-link + .skip-link {
top: 58px;
}
.skip-link:focus {
transform: translateY(0);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 3px;
outline-style: solid !important;
outline-width: 3px !important;
}
button:disabled,
[aria-disabled="true"] {
cursor: not-allowed;
}
/* Form errors */
.form-error {
color: red;
@ -80,7 +28,7 @@ button:disabled,
.form-error ul {
list-style: none;
padding-left: 0;
list-style: "*";
list-style: '*';
}
.form-error li {
@ -90,75 +38,4 @@ button:disabled,
.form-error li .error {
color: red;
font-style: italic;
}
/* ── Layout ──────────────────────────────────────────────────────────── */
.layout {
display: flex;
min-height: 100vh;
width: 100%;
background: var(--bg);
}
#sidebar-container {
flex-shrink: 0;
display: flex;
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
overflow-y: auto;
}
@media (max-width: 700px) {
.layout {
padding-bottom: 74px;
}
#sidebar-container {
position: fixed;
top: auto;
right: 0;
bottom: 0;
left: 0;
z-index: 100;
width: 100%;
height: auto;
overflow: visible;
border-top: 1px solid #e2e8f0;
}
}
/* Main content area - flex child that fills remaining space */
.page-body {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
margin: 0;
padding: 0;
}
/* ── Reusable empty / error state components ─────────────────────────── */
.state-empty,
.state-error {
display: none;
text-align: center;
padding: 60px 20px;
}
.state-empty {
color: #64748b;
}
.state-error {
color: #ef4444;
}
.state-icon {
font-size: 3rem;
display: block;
margin-bottom: 16px;
}
.state-title {
margin-bottom: 8px;
}
}

View File

@ -1,5 +1,13 @@
/* Chats page - Two column layout: chat list + active chat window */
/* Full width layout */
.layout > div[style*="flex:1"] {
margin: 0 !important;
max-width: 100% !important;
padding: 0 !important;
width: 100% !important;
}
.chats-main {
flex: 1;
padding: 20px 32px;
@ -12,7 +20,7 @@
gap: 24px;
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;
height: calc(100vh - 120px); /* Adjust based on topbar height */
min-height: 500px;
@ -42,81 +50,9 @@
.new-chat-btn {
background: none;
border: none;
border-radius: 999px;
font-size: 1.2rem;
color: #3b82f6;
cursor: pointer;
padding: 8px;
}
.new-chat-btn:hover,
.new-chat-btn[aria-expanded="true"] {
background: #eef2ff;
}
.new-chat-panel {
border-bottom: 1px solid #eef2f7;
padding: 14px 16px;
}
.new-chat-panel input {
width: 100%;
border: 1px solid #dbe2ea;
border-radius: 12px;
padding: 10px 12px;
font: inherit;
}
.creator-search-results {
display: grid;
gap: 8px;
margin-top: 10px;
max-height: 260px;
overflow-y: auto;
}
.creator-result {
align-items: center;
background: #f8fafc;
border: 1px solid transparent;
border-radius: 12px;
color: inherit;
cursor: pointer;
display: flex;
gap: 10px;
padding: 10px;
text-align: left;
width: 100%;
}
.creator-result:hover,
.creator-result:focus-visible {
background: #eef2ff;
border-color: #c7d2fe;
}
.creator-result img {
border-radius: 50%;
height: 36px;
object-fit: cover;
width: 36px;
}
.creator-result span {
display: grid;
}
.creator-result small,
.creator-result-empty,
.chat-empty {
color: #64748b;
font-size: 0.82rem;
}
.creator-result-empty,
.chat-empty {
padding: 18px;
text-align: center;
}
.chat-list-items {
@ -125,22 +61,15 @@
}
.chat-item {
background: transparent;
border: 0;
border-bottom: 1px solid #f0f2f5;
color: inherit;
display: flex;
align-items: center;
gap: 12px;
font: inherit;
text-align: left;
padding: 16px 20px;
cursor: pointer;
transition: background 0.2s;
width: 100%;
border-bottom: 1px solid #f0f2f5;
}
.chat-item:hover,
.chat-item:focus-visible {
.chat-item:hover {
background: #f8fafc;
}
.chat-item.active {
@ -267,7 +196,6 @@
padding: 16px 24px;
border-top: 1px solid #eef2f7;
background: #fff;
margin: 0;
}
.chat-input-area input {
flex: 1;
@ -280,12 +208,6 @@
.chat-input-area input:focus {
border-color: #3b82f6;
}
.chat-input-area input:disabled {
background: #f8fafc;
cursor: not-allowed;
}
.send-btn {
background: var(--gradient);
border: none;
@ -300,11 +222,6 @@
opacity: 0.85;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.chats-main {
@ -327,4 +244,4 @@
.chat-active {
height: 500px;
}
}
}

View File

@ -1,5 +1,13 @@
/* Creators page - Discover creators, filter buttons, creator cards */
/* Full width layout */
.layout > div[style*="flex:1"] {
margin: 0 !important;
max-width: 100% !important;
padding: 0 !important;
width: 100% !important;
}
.creators-main {
background: transparent !important;
padding: 20px 32px !important;
@ -63,24 +71,15 @@
.creator-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);
padding: 20px;
display: flex;
gap: 16px;
transition:
transform 0.2s,
box-shadow 0.2s;
transition: transform 0.2s, box-shadow 0.2s;
}
.creator-card:hover,
.creator-card:focus-within {
.creator-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
}
.creator-avatar-link {
border-radius: 50%;
display: inline-flex;
flex-shrink: 0;
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
}
.creator-avatar {
@ -99,15 +98,6 @@
font-weight: 700;
margin-bottom: 4px;
}
.creator-name a {
color: inherit;
text-decoration: none;
}
.creator-name a:hover {
text-decoration: underline;
}
.creator-handle {
color: #64748b;
font-size: 0.85rem;
@ -133,14 +123,7 @@
.creator-stats i {
margin-right: 4px;
}
.creator-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.follow-btn,
.creator-chat-btn {
.follow-btn {
background: var(--gradient);
color: white;
border: none;
@ -149,25 +132,11 @@
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
min-height: 34px;
text-decoration: none;
transition: opacity 0.2s;
}
.follow-btn:hover,
.creator-chat-btn:hover {
.follow-btn:hover {
opacity: 0.85;
}
.creator-chat-btn {
background: #eef2ff;
color: #2563eb;
}
.follow-btn.following {
background: transparent;
border: 2px solid #94a3b8;
@ -208,17 +177,4 @@
.follow-btn {
width: 100%;
}
.creator-actions,
.creator-chat-btn {
width: 100%;
}
}
/* Star rating in creator cards */
.creator-stars {
color: #f59e0b;
}
.creator-stars-value {
color: #64748b;
font-size: 0.8rem;
}
}

View File

@ -1,5 +1,13 @@
/* Create page - Form for publishing new AI prompts */
/* Full width layout */
.layout > div[style*="flex:1"] {
margin: 0 !important;
max-width: 100% !important;
padding: 0 !important;
width: 100% !important;
}
.create-main {
flex: 1;
display: flex;
@ -14,12 +22,12 @@
width: 100%;
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);
padding: 32px;
transition: box-shadow 0.2s;
}
.create-container:hover {
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
}
/* Header */
@ -49,8 +57,7 @@
flex-direction: column;
gap: 8px;
}
.form-group label,
.form-label {
.form-group label {
font-weight: 600;
font-size: 0.95rem;
}
@ -70,7 +77,7 @@
.form-group select:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
box-shadow: 0 0 0 3px rgba(124,58,237,0.1);
}
.form-hint {
font-size: 0.75rem;
@ -97,49 +104,17 @@
background: var(--gradient);
color: white;
}
#tierField {
display: none;
gap: 8px;
#priceField {
margin-top: 8px;
}
.tier-manage-link {
color: #3b82f6;
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
}
.tier-manage-link:hover {
text-decoration: underline;
}
/* Image preview */
#imagePreview {
margin-top: 10px;
display: none;
}
#imagePreview img {
max-width: 100%;
max-height: 200px;
border-radius: 12px;
}
/* Status message */
#create-status {
text-align: center;
color: #64748b;
margin: 0;
}
/* Buttons */
.form-actions {
display: flex;
gap: 16px;
margin-top: 8px;
}
.submit-btn,
.cancel-btn {
.submit-btn, .cancel-btn {
flex: 1;
border: none;
padding: 12px;
@ -157,8 +132,7 @@
background: #f1f5f9;
color: #475569;
}
.submit-btn:hover,
.cancel-btn:hover {
.submit-btn:hover, .cancel-btn:hover {
opacity: 0.85;
}
@ -178,4 +152,4 @@
.form-actions {
flex-direction: column;
}
}
}

View File

@ -1,5 +1,13 @@
/* Feed page - Multi-column grid, square images, like/comment/save actions */
/* Full width layout */
.layout > div[style*="flex:1"] {
margin: 0 !important;
max-width: 100% !important;
padding: 0 !important;
width: 100% !important;
}
.feed-main {
background: transparent !important;
padding: 20px 32px !important;
@ -72,20 +80,11 @@
display: flex;
flex-direction: column;
}
.post-card:hover,
.post-card:focus-within {
.post-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
}
.post-card-link {
color: inherit;
display: flex;
flex: 1;
flex-direction: column;
text-decoration: none;
}
/* Post Header */
.post-header {
display: flex;
@ -126,7 +125,7 @@
.post-title {
font-size: 1.1rem;
font-weight: 700;
margin: 10px 0 6px 0;
margin: 0 0 6px 0;
}
.post-description {
color: #334155;

View File

@ -1,5 +1,13 @@
/* Marketplace Page - Prompt cards, filter buttons, full width layout */
/* Full width layout */
.layout > div[style*="flex:1"] {
margin: 0 !important;
max-width: 100% !important;
padding: 0 !important;
width: 100% !important;
}
.marketplace-main {
background: transparent !important;
padding: 20px 32px !important;
@ -115,7 +123,6 @@
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.prompt-title {
@ -146,7 +153,6 @@
gap: 8px;
font-size: 0.85rem;
color: #f59e0b;
text-decoration: none;
}
.prompt-rating span:first-child i {
color: #f59e0b;
@ -159,7 +165,7 @@
font-size: 1.3rem;
font-weight: 700;
color: #3b82f6;
margin: auto 0 4px;
margin: 8px 0 4px;
}
.prompt-actions {
@ -222,68 +228,26 @@
}
}
.market-card-header {
/* Payment method buttons */
.pay-method-btn {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.market-card-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: #6366f1;
color: #fff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
font-size: 0.95rem;
flex-shrink: 0;
}
.market-card-time {
margin-left: auto;
font-size: 0.75rem;
color: #94a3b8;
}
.market-card-rating {
margin-bottom: 12px;
}
.market-rating-none {
color: #94a3b8;
font-size: 0.8rem;
text-decoration: none;
}
.market-rating-clickable {
cursor: pointer;
}
.market-rating-stars {
color: #f59e0b;
}
.buy-btn-locked {
background: #ef4444 !important;
}
.buy-btn-unlocked {
background: #10b981 !important;
}
.market-price-badge {
background: #fef3c7;
color: #92400e;
border-radius: 20px;
padding: 4px 14px;
font-size: 0.8rem;
font-weight: 600;
color: #1e293b;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
text-align: left;
}
.market-heart-icon {
color: #ef4444;
}
.market-bookmark-icon {
color: #f59e0b;
}
.market-save-span {
margin-left: 12px;
}
.details-btn[disabled] {
opacity: 0.45;
cursor: not-allowed;
.pay-method-btn:hover {
border-color: #6366f1;
background: #f5f3ff;
}

View File

@ -1,5 +1,13 @@
/* Post Detail page - Full prompt view, rating, example output, unlock button */
/* Full width layout */
.layout > div[style*="flex:1"] {
margin: 0 !important;
max-width: 100% !important;
padding: 0 !important;
width: 100% !important;
}
.post-detail-main {
flex: 1;
display: flex;
@ -13,12 +21,12 @@
width: 100%;
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);
padding: 32px;
transition: box-shadow 0.2s;
}
.post-detail-container:hover {
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
}
/* Header */
@ -311,190 +319,3 @@
padding: 16px;
}
}
/* ── Loading / error states ──────────────────────────────────────────── */
#detail-loading {
text-align: center;
padding: 60px 20px;
color: #64748b;
}
/* Smaller state icons for this page */
#detail-loading .state-icon,
#detail-error .state-icon {
font-size: 2.5rem;
margin-bottom: 12px;
}
#detail-error-msg {
color: #64748b;
margin-top: 8px;
}
.detail-back-btn {
margin-top: 20px;
padding: 10px 24px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
}
/* ── Detail body ─────────────────────────────────────────────────────── */
#detail-body {
display: none;
}
.detail-creator-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
#creator-avatar {
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;
}
#creator-name {
font-weight: 600;
font-size: 0.95rem;
}
#prompt-date {
display: block;
font-size: 0.8rem;
color: #94a3b8;
}
.detail-actions-right {
margin-left: auto;
}
#edit-prompt-btn {
display: none;
margin-left: 10px;
padding: 6px 14px;
border: none;
border-radius: 10px;
background: #f1f5f9;
color: #334155;
font-weight: 700;
cursor: pointer;
}
#prompt-body {
white-space: pre-wrap;
font-family: monospace;
background: #f8fafc;
border-radius: 10px;
padding: 16px;
font-size: 0.9rem;
line-height: 1.7;
}
#example-section {
display: none;
}
#example-output-text {
white-space: pre-wrap;
}
#example-image {
display: none;
}
/* ── Locked section ──────────────────────────────────────────────────── */
#locked-section {
display: none;
text-align: center;
padding: 40px 20px;
background: #f8fafc;
border-radius: 12px;
margin-bottom: 28px;
}
.locked-icon {
font-size: 2.5rem;
color: #94a3b8;
display: block;
margin-bottom: 12px;
}
.locked-title {
margin-bottom: 8px;
}
.locked-desc {
color: #64748b;
margin-bottom: 20px;
}
#locked-subscribe-btn {
padding: 12px 28px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
.detail-heart-icon {
color: #ef4444;
}
.detail-bookmark-span {
margin-left: 12px;
}
.detail-bookmark-icon {
color: #f59e0b;
}
.detail-loading-text {
color: #94a3b8;
}
.detail-error-text {
color: #ef4444;
}
.rating-stars-display {
font-size: 1.1rem;
color: #f59e0b;
}
.rating-value {
margin-left: 8px;
font-weight: 600;
}
.rating-count {
font-size: 0.85rem;
color: #94a3b8;
margin-left: 4px;
}
.rating-none {
font-size: 0.9rem;
color: #94a3b8;
}
.tier-badge-tier {
background: #f1f5f9;
color: #475569;
border-radius: 20px;
padding: 4px 14px;
font-size: 0.8rem;
font-weight: 600;
}
.tier-badge-free {
background: #dcfce7;
color: #166534;
border-radius: 20px;
padding: 4px 14px;
font-size: 0.8rem;
font-weight: 600;
}

View File

@ -1,217 +1,14 @@
/* Profile Page - Full width layout, darker share button, responsive grid */
/* ── Profile header ──────────────────────────────────────────────────── */
.profile-header {
display: flex;
align-items: center;
gap: 32px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 24px;
/* Force main content container to full width, remove centering and max-width */
.layout > div[style*="flex:1"] {
margin: 0 !important;
max-width: 100% !important;
padding: 0 !important;
width: 100% !important;
}
/* ── Profile avatar ──────────────────────────────────────────────────── */
.profile-avatar {
width: 110px;
height: 110px;
object-fit: cover;
}
/* ── Profile info column ─────────────────────────────────────────────── */
.profile-info {
flex: 1;
}
#profileDisplayName {
font-size: 2rem;
font-weight: 700;
margin-bottom: 4px;
}
#profileSlug {
color: #64748b;
margin-bottom: 8px;
}
.profile-badge-icon {
color: #3b82f6;
}
#profileBio {
margin-bottom: 8px;
}
#profileSpecialities {
color: #64748b;
}
#profileStats {
display: flex;
gap: 18px;
color: #64748b;
margin-top: 12px;
font-size: 0.95rem;
}
#profileStats strong {
color: #111827;
}
/* ── Profile actions column ──────────────────────────────────────────── */
#profileActions {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 220px;
}
#profileActions .login-button {
box-sizing: border-box;
width: 100%;
}
.profile-tier-list {
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 16px;
display: grid;
gap: 10px;
margin-top: 6px;
padding: 14px;
}
.profile-tier-list h3 {
font-size: 0.95rem;
margin: 0 0 4px;
}
.profile-tier-option {
align-items: center;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
color: #334155;
cursor: pointer;
display: flex;
gap: 12px;
justify-content: space-between;
padding: 10px 12px;
text-align: left;
}
.profile-tier-option.active {
background: #eef2ff;
border-color: #818cf8;
color: #2563eb;
}
.profile-tier-option strong,
.profile-tier-option small {
display: block;
}
.profile-tier-option small {
color: #64748b;
font-size: 0.75rem;
margin-top: 2px;
}
.profile-tier-option b {
white-space: nowrap;
}
/* ── Profile tabs ────────────────────────────────────────────────────── */
.profile-tabs {
display: flex;
gap: 24px;
border-bottom: 2px solid #e5e7eb;
margin: 32px 0 18px 0;
flex-wrap: wrap;
}
/* ── Prompts grid ────────────────────────────────────────────────────── */
#profile-prompts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
}
.profile-grid-loading {
grid-column: 1 / -1;
color: #64748b;
text-align: center;
padding: 28px;
}
.profile-grid-empty {
grid-column: 1 / -1;
color: #64748b;
text-align: center;
padding: 28px;
}
.profile-grid-error {
grid-column: 1 / -1;
color: #ef4444;
text-align: center;
padding: 28px;
}
.profile-prompt-card {
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
padding: 18px;
display: flex;
gap: 16px;
align-items: flex-start;
}
.profile-prompt-card:focus-within,
.profile-prompt-card:hover {
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
}
.profile-prompt-link {
color: inherit;
display: flex;
flex: 1;
gap: 16px;
min-width: 0;
text-decoration: none;
}
.profile-prompt-img {
width: 72px;
height: 72px;
border-radius: 12px;
object-fit: cover;
}
.profile-prompt-body {
flex: 1;
min-width: 0;
}
.profile-prompt-title {
font-weight: 700;
}
.profile-prompt-desc {
color: #64748b;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.profile-prompt-meta {
display: flex;
gap: 16px;
color: #64748b;
align-items: center;
flex-wrap: wrap;
}
.profile-prompt-edit-btn {
border: none;
background: #f1f5f9;
color: #334155;
border-radius: 10px;
padding: 6px 10px;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
}
/* ── Inner spacing for the profile card ─────────────────────────────── */
/* Inner spacing for the profile card */
.profile-main {
background: transparent !important;
border-radius: 0 !important;
@ -219,7 +16,7 @@
padding: 20px 32px !important;
margin: 0 auto !important;
width: 100%;
max-width: 1600px; /* Limits content on very large screens, but still wide */
max-width: 1600px; /* Limits content on very large screens, but still wide */
}
/* Make prompts grid use more columns on large screens */
@ -231,32 +28,16 @@
}
/* Share button: darker background and text */
#shareProfileButton {
background: #cbd5e1 !important; /* darker gray */
.profile-header button:last-child {
background: #cbd5e1 !important; /* darker gray */
color: #1e293b !important;
box-shadow: none !important;
border: none !important;
}
#manageTiersButton {
background: #f3e8ff !important;
color: #7c3aed !important;
box-shadow: none !important;
border: 1px solid #d8b4fe !important;
}
/* Buttons keep rounded corners */
.login-button {
align-items: center;
border-radius: 14px !important;
display: flex;
gap: 8px;
justify-content: center;
text-decoration: none;
}
.login-button i {
font-size: 1rem;
}
.profile-tab {
@ -283,7 +64,7 @@
/* Prompt cards: rounded corners */
.profile-main section > div {
border-radius: 18px !important;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
}
/* Prompt images: rounded corners */
@ -294,9 +75,6 @@
/* Avatar remains round */
.profile-avatar {
border-radius: 50% !important;
width: 110px;
height: 110px;
object-fit: cover;
}
/* All outer containers stay square */

View File

@ -1,5 +1,12 @@
/* Settings page - tabs, form styling */
.layout > div[style*="flex:1"] {
margin: 0 !important;
max-width: 100% !important;
padding: 0 !important;
width: 100% !important;
}
.settings-main {
flex: 1;
display: flex;
@ -14,7 +21,7 @@
width: 100%;
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);
padding: 32px;
}
@ -94,7 +101,7 @@
.form-group textarea:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
box-shadow: 0 0 0 3px rgba(124,58,237,0.1);
}
.checkbox-label {
display: flex;
@ -174,11 +181,4 @@
flex-direction: column;
align-items: flex-start;
}
}
/* Save status message */
#profileSaveStatus {
margin-top: 10px;
color: #64748b;
text-align: center;
}
}

View File

@ -154,18 +154,7 @@
}
.sidebar .nav-text,
.sidebar-logout .nav-text {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sidebar-logout .nav-text,
.logout-arrow {
display: none;
}
@ -174,7 +163,6 @@
.sidebar-logout {
justify-content: center;
padding: 12px;
position: relative;
}
.sidebar a.active {
@ -184,45 +172,8 @@
}
@media (max-width: 700px) {
.sidebar-shell {
height: auto;
padding: 7px 6px;
border-right: none;
border-top: 1px solid #eef2f7;
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.08);
}
.sidebar-logo,
.sidebar-bottom {
display: none;
}
.sidebar ul {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 1px;
}
.sidebar li {
margin: 0;
}
.sidebar li.mobile-hidden {
display: none;
}
.sidebar a,
.sidebar-logout {
min-height: 46px;
padding: 7px 2px;
}
.sidebar i {
font-size: 1.05rem;
}
.sidebar a.active {
border-right: none;
border-top: 3px solid #3b82f6;
padding: 10px;
}
}

View File

@ -1,271 +0,0 @@
/* Subscription tiers page - manage monthly creator access levels */
.tiers-main {
flex: 1;
padding: 20px 32px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.tiers-header {
text-align: center;
margin-bottom: 28px;
}
.tiers-header h1 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 8px;
}
.tiers-header p {
color: #64748b;
}
.tiers-layout {
display: grid;
grid-template-columns: minmax(280px, 380px) 1fr;
gap: 24px;
align-items: start;
}
.tiers-tabs {
border-bottom: 2px solid #e5e7eb;
display: flex;
gap: 24px;
margin-bottom: 24px;
}
.tiers-tab {
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: #64748b;
cursor: pointer;
font: inherit;
font-weight: 800;
padding: 10px 0;
}
.tiers-tab.active {
border-bottom-color: #3b82f6;
color: #2563eb;
}
.subscriptions-panel {
display: none;
}
.tier-panel,
.tier-card {
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
}
.tier-panel {
padding: 24px;
}
.tier-panel h2,
.tier-list-header h2 {
font-size: 1.25rem;
margin-bottom: 16px;
}
.tier-form {
display: grid;
gap: 16px;
}
.tier-form label {
display: grid;
gap: 8px;
color: #334155;
font-weight: 700;
}
.tier-form input,
.tier-form textarea {
border: 1px solid #dbe2ea;
border-radius: 12px;
font: inherit;
padding: 12px 14px;
}
.tier-form textarea {
min-height: 88px;
resize: vertical;
}
.tier-form input:focus,
.tier-form textarea:focus {
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.12);
outline: none;
}
.tier-form-actions {
display: flex;
gap: 12px;
}
.tier-primary-btn,
.tier-secondary-btn,
.tier-card button {
border: none;
border-radius: 14px;
cursor: pointer;
font: inherit;
font-weight: 800;
padding: 12px 16px;
}
.tier-primary-btn {
background: var(--gradient);
color: #ffffff;
flex: 1;
}
.tier-secondary-btn {
background: #f1f5f9;
color: #334155;
}
#tier-status {
color: #64748b;
min-height: 20px;
}
.tier-list-header {
margin-bottom: 16px;
}
.tier-list-header p {
color: #64748b;
}
.tiers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.subscriptions-grid {
display: grid;
gap: 14px;
}
.subscription-card {
align-items: center;
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
display: flex;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
}
.subscription-card h3 {
margin-bottom: 4px;
}
.subscription-card p {
color: #64748b;
}
.subscription-price {
color: #3b82f6;
font-weight: 900;
white-space: nowrap;
}
.tier-card {
padding: 20px;
}
.tier-card-top {
align-items: start;
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.tier-card h3 {
font-size: 1.2rem;
margin-bottom: 4px;
}
.tier-level {
color: #64748b;
font-size: 0.9rem;
font-weight: 700;
}
.tier-price {
color: #3b82f6;
font-size: 1.4rem;
font-weight: 900;
white-space: nowrap;
}
.tier-desc {
color: #475569;
line-height: 1.45;
min-height: 44px;
}
.tier-card-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.tier-card button {
background: #f1f5f9;
color: #334155;
padding: 9px 12px;
}
.tier-delete-btn {
color: #dc2626 !important;
}
.tiers-empty,
.tiers-error {
background: #ffffff;
border-radius: 18px;
color: #64748b;
padding: 32px;
text-align: center;
}
.tiers-error {
color: #ef4444;
}
@media (max-width: 900px) {
.tiers-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {
.tiers-main {
padding: 16px;
}
.tiers-tabs {
gap: 16px;
}
.subscription-card {
align-items: flex-start;
flex-direction: column;
}
}

View File

@ -1,35 +1,31 @@
/*
Topbar styles for OnlyPrompt
- sticky on all app pages
- search bar fills the available width
- logout, messages and profile avatar stay on the right
- clean, modern, full-width
- search bar centered (expands on full screen), profile avatar always on the right
- ONLY search bar and avatar have rounded corners
*/
.topbar-shell {
align-items: center;
width: 100%;
background: #ffffff;
border-bottom: 1px solid #eef2f7;
box-sizing: border-box;
display: flex;
gap: 18px;
justify-content: space-between;
padding: 16px 32px;
position: sticky;
top: 0;
width: 100%;
z-index: 90;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.topbar-search {
flex: 1; /* Takes all available space */
max-width: none; /* No upper limit, expands freely */
display: flex;
align-items: center;
gap: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 14px;
display: flex;
flex: 1;
gap: 12px;
max-width: none;
padding: 10px 20px;
border-radius: 14px; /* Rounded like login inputs */
}
.topbar-search i {
@ -38,17 +34,12 @@
}
.topbar-search input {
background: transparent;
border: none;
color: #334155;
font-size: 0.95rem;
outline: none;
width: 100%;
}
.topbar-search:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
border: none;
outline: none;
background: transparent;
font-size: 0.95rem;
color: #334155;
}
.topbar-search input::placeholder {
@ -56,28 +47,24 @@
font-weight: 400;
}
/* Icons and avatar container */
.topbar-actions {
align-items: center;
display: flex;
align-items: center;
gap: 16px;
}
.topbar-logout-form {
display: flex;
margin: 0;
}
.topbar-icon-btn {
align-items: center;
background: transparent;
border: none;
border-radius: 50%;
font-size: 1.4rem;
color: #475569;
cursor: pointer;
display: flex;
font-size: 1.4rem;
justify-content: center;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s, color 0.2s;
}
@ -87,47 +74,42 @@
}
.topbar-avatar-btn {
align-items: center;
background: transparent;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.topbar-avatar {
border: 1px solid #e2e8f0;
border-radius: 50%;
width: 48px;
height: 48px;
object-fit: cover;
width: 48px;
border: 1px solid #e2e8f0;
border-radius: 50%; /* Avatar round */
}
/* Responsive adjustments */
@media (max-width: 768px) {
.topbar-shell {
gap: 12px;
padding: 12px 20px;
}
.topbar-search {
padding: 8px 16px;
}
.topbar-search i {
font-size: 1.1rem;
}
.topbar-avatar {
height: 40px;
width: 40px;
height: 40px;
}
.topbar-icon-btn {
.topbar-icon-btn {
font-size: 1.2rem;
padding: 6px;
}
.topbar-actions {
gap: 8px;
}
@ -135,14 +117,9 @@
@media (max-width: 480px) {
.topbar-shell {
padding: 10px 12px;
padding: 10px 16px;
}
.topbar-search {
padding: 6px 10px;
padding: 6px 12px;
}
.topbar-actions {
gap: 4px;
}
}
}

View File

@ -20,14 +20,16 @@
/>
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<div id="sidebar-container"></div>
<div class="page-body">
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="feed-main" id="main-content" tabindex="-1">
<main class="feed-main">
<!-- Optional: Feed Header -->
<div class="feed-header">
<h1>Feed</h1>
@ -35,44 +37,62 @@
</div>
<!-- Filter Buttons -->
<div class="filter-buttons" role="group" aria-label="Sort feed">
<div class="filter-buttons">
<button
type="button"
class="filter-btn active"
data-sort="date"
data-ascending="false"
aria-pressed="true"
>
Recent
</button>
<button
type="button"
class="filter-btn"
data-sort="rating"
data-ascending="false"
aria-pressed="false"
>
Top Rated
</button>
<button type="button" class="filter-btn" data-sort="date" data-ascending="true" aria-pressed="false">
<button class="filter-btn" data-sort="date" data-ascending="true">
Oldest
</button>
</div>
<!-- Posts Grid -->
<div class="posts-grid" id="posts-grid" aria-live="polite"></div>
<div class="posts-grid" id="posts-grid"></div>
<!-- Empty State -->
<div id="feed-empty" class="state-empty" role="status" aria-live="polite">
<i class="bi bi-inbox state-icon" aria-hidden="true"></i>
<h3 class="state-title">No posts yet</h3>
<div
id="feed-empty"
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #64748b;
"
>
<i
class="bi bi-inbox"
style="font-size: 3rem; display: block; margin-bottom: 16px"
></i>
<h3 style="margin-bottom: 8px">No posts yet</h3>
<p>Follow some creators to see their prompts here.</p>
</div>
<!-- Error State -->
<div id="feed-error" class="state-error" role="alert" aria-live="assertive">
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
<h3 class="state-title">Could not load feed</h3>
<div
id="feed-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 feed</h3>
<p id="feed-error-msg"></p>
</div>
</main>
@ -87,17 +107,11 @@
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((link) => {
link.classList.remove("active");
link.removeAttribute("aria-current");
});
.forEach((link) => link.classList.remove("active"));
const firstLink = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[0];
if (firstLink) {
firstLink.classList.add("active");
firstLink.setAttribute("aria-current", "page");
}
if (firstLink) firstLink.classList.add("active");
});
fetch("/topbar.html")
@ -128,9 +142,9 @@
function renderStars(rating) {
if (rating == null) return "";
const stars = Math.round(rating);
return `<span class="post-rating" title="${rating.toFixed(1)} / 5" aria-label="${rating.toFixed(1)} out of 5 stars">
${'<i class="bi bi-star-fill" aria-hidden="true"></i>'.repeat(stars)}${'<i class="bi bi-star" aria-hidden="true"></i>'.repeat(5 - stars)}
<span aria-hidden="true">${rating.toFixed(1)}</span>
return `<span class="post-rating" title="${rating.toFixed(1)} / 5">
${'<i class="bi bi-star-fill"></i>'.repeat(stars)}${'<i class="bi bi-star"></i>'.repeat(5 - stars)}
<span>${rating.toFixed(1)}</span>
</span>`;
}
@ -147,37 +161,35 @@
const liked = prompt.isLiked;
const saved = prompt.isSaved;
return `
<article class="post-card${locked ? " post-locked" : ""}">
<a class="post-card-link" href="${profileUrl(prompt.creatorId)}" aria-label="Open profile for ${prompt.creatorName}">
<div class="post-header">
<img class="post-avatar" src="${prompt.creatorAvatarUrl || "../images/content/cat.png"}" alt="${prompt.creatorName}">
<div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='${profileUrl(prompt.creatorId)}'">
<div class="post-header">
<img class="post-avatar" src="${prompt.creatorAvatarUrl || '../images/content/cat.png'}" alt="${prompt.creatorName}">
<div class="post-author">
<span class="post-name">${prompt.creatorName}</span>
</div>
<span class="post-date"><time datetime="${prompt.timeStamp}">${timeAgo(prompt.timeStamp)}</time></span>
</div>
<div class="post-content">
${prompt.exampleImageUrl ? `<img class="post-image${locked ? " post-image-locked" : ""}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? " post-image-locked" : ""}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
<h3 class="post-title">${prompt.title}</h3>
<p class="post-description">${prompt.description || ""}</p>
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill" aria-hidden="true"></i> ${prompt.tierName ?? "Subscription"} tier required</p>` : ""}
${renderStars(prompt.averageRating)}
</div>
</a>
<div class="post-actions">
<button type="button" class="action-btn like-btn ${liked ? "active" : ""}" aria-pressed="${liked}" aria-label="${liked ? "Unlike" : "Like"} ${prompt.title}. ${prompt.likeCount || 0} likes" onclick="toggleLike(event, '${prompt.id}', ${liked})"><i class="bi ${liked ? "bi-heart-fill" : "bi-heart"}" aria-hidden="true"></i> <span>Like (${prompt.likeCount || 0})</span></button>
<button type="button" class="action-btn comment-btn" aria-label="Review ${prompt.title}" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat" aria-hidden="true"></i> <span>Review</span></button>
<button type="button" class="action-btn share-btn" aria-label="Share ${prompt.title}" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share" aria-hidden="true"></i> <span>Share</span></button>
<button type="button" class="action-btn save-btn ${saved ? "active" : ""}" aria-pressed="${saved}" aria-label="${saved ? "Remove saved" : "Save"} ${prompt.title}. ${prompt.saveCount || 0} saves" onclick="toggleSave(event, '${prompt.id}', ${saved})"><i class="bi ${saved ? "bi-bookmark-fill" : "bi-bookmark"}" aria-hidden="true"></i> <span>Save (${prompt.saveCount || 0})</span></button>
<span class="post-date">${timeAgo(prompt.timeStamp)}</span>
</div>
</article>`;
<div class="post-content">
${prompt.exampleImageUrl ? `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
<h3 class="post-title" style="margin-top:10px">${prompt.title}</h3>
<p class="post-description">${prompt.description || ''}</p>
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? 'Paid'} tier required</p>` : ''}
${renderStars(prompt.averageRating)}
</div>
<div class="post-actions">
<button class="action-btn like-btn ${liked ? 'active' : ''}" onclick="toggleLike(event, '${prompt.id}', ${liked})"><i class="bi ${liked ? 'bi-heart-fill' : 'bi-heart'}"></i> <span>Like (${prompt.likeCount || 0})</span></button>
<button class="action-btn comment-btn" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat"></i> <span>Review</span></button>
<button class="action-btn share-btn" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share"></i> <span>Share</span></button>
<button class="action-btn save-btn ${saved ? 'active' : ''}" onclick="toggleSave(event, '${prompt.id}', ${saved})"><i class="bi ${saved ? 'bi-bookmark-fill' : 'bi-bookmark'}"></i> <span>Save (${prompt.saveCount || 0})</span></button>
</div>
</div>`;
}
window.toggleLike = async function (event, id, isLiked) {
window.toggleLike = async function(event, id, isLiked) {
event.stopPropagation();
const response = await fetch(`/api/v1/prompts/${id}/likes`, {
method: isLiked ? "DELETE" : "PUT",
credentials: "same-origin",
credentials: "same-origin"
});
if (response.status === 401) {
@ -188,28 +200,26 @@
if (!response.ok) return;
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending ===
"true",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
window.toggleFeedState = function (event, type, id) {
window.toggleFeedState = function(event, type, id) {
event.stopPropagation();
const key = `prompt-${type}-${id}`;
const next = localStorage.getItem(key) !== "true";
localStorage.setItem(key, next);
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending ===
"true",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
window.toggleSave = async function (event, id, isSaved) {
window.toggleSave = async function(event, id, isSaved) {
event.stopPropagation();
const response = await fetch(`/api/v1/prompts/${id}/saves`, {
method: isSaved ? "DELETE" : "PUT",
credentials: "same-origin",
credentials: "same-origin"
});
if (response.status === 401) {
@ -220,16 +230,13 @@
if (!response.ok) return;
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending ===
"true",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
window.sharePrompt = function (event, id) {
window.sharePrompt = function(event, id) {
event.stopPropagation();
navigator.clipboard.writeText(
`${location.origin}/post-detail?id=${id}`,
);
navigator.clipboard.writeText(`${location.origin}/post-detail?id=${id}`);
};
async function loadFeed(sortBy = "date", ascending = false) {
@ -264,12 +271,8 @@
btn.addEventListener("click", () => {
document
.querySelectorAll(".filter-btn")
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
loadFeed(btn.dataset.sort, btn.dataset.ascending === "true");
});
});

View File

@ -13,12 +13,8 @@ async function redirectIfAlreadySignedIn() {
function togglePassword() {
const passwordInput = document.getElementById('password');
const togglePasswordButton = document.getElementById('togglePassword');
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
passwordInput.type = newInputType;
const isVisible = newInputType === 'text';
togglePasswordButton.textContent = isVisible ? 'Hide' : 'Show';
togglePasswordButton.setAttribute('aria-pressed', String(isVisible));
}
async function submitLoginForm(){

View File

@ -1,115 +1,99 @@
import "./linq.js";
import { Template } from "./template.js";
import './linq.js'
import { Template } from './template.js';
export function formToObject(form) {
const data = new FormData(form);
const object = {};
data.forEach((value, key) => {
setNestedValue(object, key, value);
});
return object;
const data = new FormData(form);
const object = {};
data.forEach((value, key) => {
setNestedValue(object, key, value);
});
return object;
}
function setNestedValue(obj, path, value) {
path
.split(".")
.asEnumerable()
.isLast()
.forEach((key, isLast) => {
if (isLast) {
obj[key] = value;
} else {
if (!obj[key]) {
obj[key] = {};
}
path.split('.').asEnumerable()
.isLast()
.forEach((key, isLast) => {
if (isLast) {
obj[key] = value;
}
else {
if (!obj[key]) {
obj[key] = {};
}
obj = obj[key];
}
});
obj = obj[key];
}
});
}
export async function sendFormAsync(form, url, method) {
url = url || form.action;
method = method || form.method || "post";
const data = formToObject(form);
const response = await sendJsonAsync(url, data, method);
if (response.ok && response.redirected) {
window.location.href = response.url;
return null;
}
url = url || form.action;
method = method || form.method || 'post';
const data = formToObject(form);
const response = await sendJsonAsync(url, data, method);
if (response.ok && response.redirected) {
window.location.href = response.url;
return null;
}
const responseText = await response.text();
if (
response.ok == false &&
handleValidationError(response, responseText, form)
) {
return null;
}
const responseText = await response.text();
if (response.ok == false && handleValidationError(response, responseText, form)) {
return null;
}
if (response.ok == false) {
handleGenericFormError(response, responseText, form);
return null;
} else {
return responseText.length == 0 ? null : JSON.parse(responseText);
}
if (response.ok == false) {
handleGenericFormError(response, responseText, form);
return null;
} else {
return responseText.length == 0 ? null : JSON.parse(responseText);
}
}
export async function sendJsonAsync(url, data, method = "post") {
const response = await fetch(url, {
method: method.toUpperCase(),
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
export async function sendJsonAsync(url, data, method = 'post') {
const response = await fetch(url, {
method: method.toUpperCase(),
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
return response;
return response;
}
export async function postAndRenderAsync(url, data, template, targetElement) {
const response = await sendJsonAsync(url, data);
if (response.ok) {
const responseText = await response.text();
targetElement.innerHTML = template.render(
responseText.length == 0 ? undefined : JSON.parse(responseText),
);
}
const response = await sendJsonAsync(url, data);
if (response.ok) {
const responseText = await response.text();
targetElement.innerHTML = template.render(responseText.length == 0 ? undefined : JSON.parse(responseText));
}
}
export async function postFormAndRenderAsync(
url,
form,
template,
targetElement,
) {
const object = formToObject(form);
const data = await postFormAsync(url, object, template, targetElement);
if (data) {
targetElement.innerHTML = template.render(data);
}
export async function postFormAndRenderAsync(url, form, template, targetElement) {
const object = formToObject(form);
const data = await postFormAsync(url, object, template, targetElement);
if (data) {
targetElement.innerHTML = template.render(data);
}
}
const genericFormErrorTemplate = new Template(`
<div class="form-error" role="alert" aria-live="assertive">
{{ $this }}
<div class="form-error">
An error occurred while submitting the form. Please try again later.
{{ $this }}
</div>
`);
function handleGenericFormError(response, responseText, form) {
if (!response.ok) {
// Remove all existing form-level errors before adding a new one
form.querySelectorAll(":scope > .form-error").forEach((el) => el.remove());
let message = responseText;
try {
message = JSON.parse(responseText);
} catch (_) {}
const html = genericFormErrorTemplate.render(message);
form.insertAdjacentHTML("beforeend", html);
}
if (!response.ok) {
const html = genericFormErrorTemplate.render(responseText);
form.insertAdjacentHTML('beforeend', html);
}
}
const validationErrorTemplate = new Template(`
<div class="form-error" role="alert" aria-live="assertive">
<div class="form-error">
<ul>
@for(error of $this) {
<li class="error">{{error}}</li>
@ -119,7 +103,7 @@ const validationErrorTemplate = new Template(`
`);
const unknownInputErrorTemplate = new Template(`
<div class="form-error" role="alert" aria-live="assertive">
<div class="form-error">
<p>An error occurred with the following fields:</p>
@for(field, errors of Object.entries($this)) {
<ul>
@ -132,49 +116,45 @@ const unknownInputErrorTemplate = new Template(`
`);
function toCamelCase(str) {
str = str.replace(/([-_][a-z])/gi, (match) => {
return match.toUpperCase().replace("-", "").replace("_", "");
});
str = str.replace(/([-_][a-z])/gi, (match) => {
return match.toUpperCase()
.replace('-', '')
.replace('_', '');
});
str = str[0].toLowerCase() + str.substring(1);
return str;
str = str[0].toLowerCase() + str.substring(1);
return str;
}
function handleValidationError(response, responseText, form) {
if (response.status !== 400) return false;
const responseObject = JSON.parse(responseText);
const unknownInputErrors = {};
if (
responseObject.type ===
"https://tools.ietf.org/html/rfc9110#section-15.5.1" &&
responseObject.errors
) {
for (const [field, messages] of Object.entries(responseObject.errors)) {
const input = form.querySelector(`[name="${toCamelCase(field)}"]`);
if (input) {
const parent = input.parentElement;
const errorHtml = validationErrorTemplate.render(messages);
let errorContainer = parent.querySelector(".form-error"); // Check if an error container already exists
if (errorContainer) {
errorContainer.outerHTML = errorHtml; // Replace existing error container
} else {
parent.insertAdjacentHTML("beforeend", errorHtml);
if (response.status !== 400) return false;
const responseObject = JSON.parse(responseText);
const unknownInputErrors = {};
if (responseObject.type === 'https://tools.ietf.org/html/rfc9110#section-15.5.1' && responseObject.errors) {
for (const [field, messages] of Object.entries(responseObject.errors)) {
const input = form.querySelector(`[name="${toCamelCase(field)}"]`);
if (input) {
const parent = input.parentElement;
const errorHtml = validationErrorTemplate.render(messages);
let errorContainer = parent.querySelector('.form-error'); // Check if an error container already exists
if (errorContainer) {
errorContainer.outerHTML = errorHtml; // Replace existing error container
} else {
parent.insertAdjacentHTML('beforeend', errorHtml);
}
} else {
unknownInputErrors[field] = messages;
}
}
} else {
unknownInputErrors[field] = messages;
}
if (Object.keys(unknownInputErrors).length > 0) {
const html = unknownInputErrorTemplate.render(unknownInputErrors);
form.insertAdjacentHTML('beforeend', html);
}
return true;
}
if (Object.keys(unknownInputErrors).length > 0) {
form
.querySelectorAll(":scope > .form-error")
.forEach((el) => el.remove());
const html = unknownInputErrorTemplate.render(unknownInputErrors);
form.insertAdjacentHTML("beforeend", html);
}
return true;
}
return false;
}
return false;
}

View File

@ -5,20 +5,8 @@ async function signupAsync(params) {
await sendFormAsync(form);
}
function togglePassword() {
const passwordInput = document.getElementById('password');
const togglePasswordButton = document.getElementById('togglePassword');
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
passwordInput.type = newInputType;
const isVisible = newInputType === 'text';
togglePasswordButton.textContent = isVisible ? 'Hide' : 'Show';
togglePasswordButton.setAttribute('aria-pressed', String(isVisible));
}
document.getElementById('togglePassword')?.addEventListener('click', togglePassword);
const signupForm = document.getElementById('signupForm');
signupForm.addEventListener('submit', async (event) => {
event.preventDefault(); // Prevent the default form submission
await signupAsync();
});
});

View File

@ -20,9 +20,8 @@
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<!-- Main container for the login page (CSS layout) -->
<main class="login-page" id="main-content" tabindex="-1">
<main class="login-page">
<!-- White login card -->
<section class="login-card">
<!-- Logo container -->
@ -56,10 +55,9 @@
id="password"
name="password"
placeholder="Enter your password"
autocomplete="current-password"
required
>
<button type="button" id="togglePassword" class="toggle-password" aria-controls="password" aria-pressed="false">
<button type="button" id="togglePassword" class="toggle-password">
Show <!-- Click to show/hide password -->
</button>
</div>
@ -77,4 +75,4 @@
<script type="module" src="js/login.js"></script>
</body>
</html>
</html>

View File

@ -1,4 +1,4 @@
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort and tier access -->
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort, crypto payment modal -->
<!doctype html>
<html lang="en">
@ -17,16 +17,59 @@
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) {
.filter-sort-row {
flex-direction: column;
align-items: stretch;
}
.sort-dropdown {
align-self: flex-start;
}
}
</style>
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<div id="sidebar-container"></div>
<div class="page-body">
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="marketplace-main" id="main-content" tabindex="-1">
<main class="marketplace-main">
<!-- Header -->
<div class="marketplace-header">
<h1>Marketplace</h1>
@ -35,8 +78,8 @@
<!-- Filter + Sort Row -->
<div class="filter-sort-row">
<div class="filter-buttons" id="category-filters" role="group" aria-label="Filter prompts by category">
<button type="button" class="filter-btn active" data-category="" aria-pressed="true">All</button>
<div class="filter-buttons" id="category-filters">
<button class="filter-btn active" data-category="">All</button>
</div>
<select
class="sort-dropdown"
@ -48,31 +91,267 @@
<option value="rating|false">Best Rating</option>
<option value="rating|true">Lowest Rating</option>
<option value="free|true">Free</option>
<option value="price|true">Lowest Tier Price</option>
<option value="price|false">Highest Tier Price</option>
<option value="price|true">Lowest Price</option>
<option value="price|false">Highest Price</option>
</select>
</div>
<!-- Prompts Grid -->
<div class="prompts-grid" id="prompts-grid" aria-live="polite"></div>
<div class="prompts-grid" id="prompts-grid"></div>
<!-- Empty State -->
<div id="market-empty" class="state-empty" role="status" aria-live="polite">
<i class="bi bi-bag-x state-icon" aria-hidden="true"></i>
<h3 class="state-title">No prompts found</h3>
<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>
<!-- Error State -->
<div id="market-error" class="state-error" role="alert" aria-live="assertive">
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
<h3 class="state-title">Could not load prompts</h3>
<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>
</main>
</div>
</div>
<!-- ── 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")
@ -81,17 +360,11 @@
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((l) => {
l.classList.remove("active");
l.removeAttribute("aria-current");
});
.forEach((l) => l.classList.remove("active"));
const link = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[1];
if (link) {
link.classList.add("active");
link.setAttribute("aria-current", "page");
}
if (link) link.classList.add("active");
});
fetch("/topbar.html")
.then((r) => r.text())
@ -114,40 +387,31 @@
return `${Math.floor(h / 24)}d ago`;
}
function renderStars(
rating,
reviewCount = 0,
promptId = null,
locked = false,
) {
const href =
promptId && !locked
? `/post-detail?id=${encodeURIComponent(promptId)}#rating-section`
: "";
function renderStars(rating, reviewCount = 0, promptId = null, locked = false) {
const target = promptId && !locked
? ` onclick="location.href='/post-detail?id=${promptId}#rating-section'" title="View reviews" style="cursor:pointer;"`
: "";
if (rating == null)
return href
? `<a href="${href}" title="View reviews" class="market-rating-none market-rating-clickable">No reviews yet</a>`
: `<span class="market-rating-none">No reviews yet</span>`;
return `<span${target} style="color:#94a3b8;font-size:0.8rem;${promptId && !locked ? 'cursor:pointer;' : ''}">No reviews yet</span>`;
const stars = Math.round(rating);
const label = reviewCount === 1 ? "review" : "reviews";
const content = `<span class="market-rating-stars" aria-hidden="true">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> <span aria-label="${rating.toFixed(1)} out of 5 stars">${rating.toFixed(1)}</span> (${reviewCount} ${label})`;
return href
? `<a class="prompt-rating market-rating-clickable" href="${href}" title="View reviews">${content}</a>`
: `<span class="prompt-rating">${content}</span>`;
return `<span class="prompt-rating"${target}><span style="color:#f59e0b">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> ${rating.toFixed(1)} (${reviewCount} ${label})</span>`;
}
function promptPrice(prompt) {
if (prompt.tierLevel) {
const price = prompt.tierMonthlyPrice == null
? ""
: ` - $${Number(prompt.tierMonthlyPrice).toFixed(2)}/mo`;
return `${prompt.tierName || `Tier ${prompt.tierLevel}`}${price}`;
if (prompt.price != null && Number(prompt.price) > 0) {
return `$${Number(prompt.price).toFixed(2)}`;
}
if (prompt.tierLevel) return `$${(prompt.tierLevel * 4.99).toFixed(2)}/mo`;
if (prompt.canAccess === false) return "Paid";
return "Free";
}
function getNumericPrice(prompt) {
if (prompt.tierMonthlyPrice != null) return Number(prompt.tierMonthlyPrice);
if (prompt.price != null && Number(prompt.price) > 0) {
return Number(prompt.price);
}
if (prompt.tierLevel) return prompt.tierLevel * 4.99;
return 0;
}
@ -160,9 +424,7 @@
const direction = ascending === "true" ? 1 : -1;
return prompts
.slice()
.sort(
(a, b) => (getNumericPrice(a) - getNumericPrice(b)) * direction,
);
.sort((a, b) => (getNumericPrice(a) - getNumericPrice(b)) * direction);
}
return prompts;
@ -179,34 +441,32 @@
let cardIndex = 0;
function renderCard(p) {
const locked = p.canAccess === false;
const img =
p.exampleImageUrl ||
p._img ||
MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
const paid = p.price != null && Number(p.price) > 0;
const locked = p.canAccess === false || paid || p.tierLevel != null;
const img = p.exampleImageUrl || p._img || MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
return `
<div class="prompt-card">
<img src="${img}" alt="${p.title}" class="prompt-img">
<div class="prompt-info">
<div class="market-card-header">
<div class="market-card-avatar">${p.creatorName.charAt(0).toUpperCase()}</div>
<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 class="market-card-time"><time datetime="${p.timeStamp}">${timeAgo(p.timeStamp)}</time></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 class="prompt-description">${p.description || "No description yet."}</p>
<div class="market-card-rating">${renderStars(p.averageRating, p.reviewCount || 0, p.id, locked)}</div>
<p class="prompt-description">${p.description || 'No description yet.'}</p>
<div style="margin-bottom:12px;">${renderStars(p.averageRating, p.reviewCount || 0, p.id, locked)}</div>
<div class="prompt-price">${promptPrice(p)}</div>
<div class="prompt-actions">
${
locked
? `<button type="button" class="buy-btn buy-btn-locked" aria-label="Subscribe to unlock ${p.title}" onclick='subscribeToPromptTier(${JSON.stringify(p)})'><i class="bi bi-lock-fill" aria-hidden="true"></i> Subscribe</button>`
: `<button type="button" class="buy-btn buy-btn-unlocked" aria-label="Access ${p.title}" onclick="location.href='/post-detail?id=${p.id}'">Access <i class="bi bi-unlock-fill" aria-hidden="true"></i></button>`
? `<button class="buy-btn" style="background:#ef4444;" onclick='openPayment(${JSON.stringify(p)})'><i class="bi bi-lock-fill"></i> Pay</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>`
}
${
locked
? `<button type="button" class="details-btn" disabled aria-label="Details for ${p.title} are locked"><i class="bi bi-lock-fill" aria-hidden="true"></i> Details</button>`
: `<button type="button" class="details-btn" aria-label="View details for ${p.title}" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>`
? `<button class="details-btn" disabled style="opacity:.45;cursor:not-allowed;"><i class="bi bi-lock-fill"></i> Details</button>`
: `<button class="details-btn" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>`
}
</div>
</div>
@ -217,8 +477,7 @@
const grid = document.getElementById("prompts-grid");
const emptyEl = document.getElementById("market-empty");
const errorEl = document.getElementById("market-error");
const search =
document.getElementById("topbarSearchInput")?.value.trim() || "";
const search = document.getElementById("topbarSearchInput")?.value.trim() || "";
const [sortBy, ascending] = document
.getElementById("sort-select")
.value.split("|");
@ -229,10 +488,8 @@
cardIndex = 0;
try {
const apiSortBy =
sortBy === "price" || sortBy === "free" ? "date" : sortBy;
const apiAscending =
sortBy === "price" || sortBy === "free" ? "false" : ascending;
const apiSortBy = sortBy === "price" || sortBy === "free" ? "date" : sortBy;
const apiAscending = sortBy === "price" || sortBy === "free" ? "false" : ascending;
let url = `/api/v1/prompts?sortBy=${apiSortBy}&ascending=${apiAscending}&limit=50`;
if (activeCategory) url += `&category=${activeCategory}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
@ -244,11 +501,7 @@
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
let prompts = applyMarketplaceSort(
await res.json(),
sortBy,
ascending,
);
let prompts = applyMarketplaceSort(await res.json(), sortBy, ascending);
if (prompts.length === 0) {
emptyEl.style.display = "block";
@ -270,20 +523,14 @@
const container = document.getElementById("category-filters");
cats.forEach((c) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "filter-btn";
btn.dataset.category = c.slug;
btn.setAttribute("aria-pressed", "false");
btn.textContent = c.name;
btn.addEventListener("click", () => {
document
.querySelectorAll("#category-filters .filter-btn")
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
activeCategory = c.slug;
loadPrompts();
});
@ -298,12 +545,8 @@
.addEventListener("click", function () {
document
.querySelectorAll("#category-filters .filter-btn")
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
.forEach((b) => b.classList.remove("active"));
this.classList.add("active");
this.setAttribute("aria-pressed", "true");
activeCategory = "";
loadPrompts();
});
@ -332,26 +575,89 @@
topbarObserver.observe(document.body, { childList: true, subtree: true });
wireMarketplaceTopbarSearch();
window.subscribeToPromptTier = async function (prompt) {
if (!prompt.tierLevel) return;
// Make openPayment global
window.openPayment = openPayment;
const response = await fetch(
`/api/v1/subscriptions/${encodeURIComponent(prompt.creatorId)}/${prompt.tierLevel}`,
{
method: "PUT",
credentials: "same-origin",
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
if (response.ok) {
loadPrompts();
}
// ── 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.price != null && Number(prompt.price) > 0
? Number(prompt.price)
: 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();

View File

@ -19,44 +19,121 @@
/>
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<div id="sidebar-container"></div>
<div class="page-body">
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="post-detail-main" id="main-content" tabindex="-1">
<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" aria-hidden="true"></i>
<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>
<!-- Error -->
<div id="detail-error" class="state-error" role="alert" aria-live="assertive">
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
<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"></p>
<button type="button" onclick="history.back()" class="detail-back-btn">
<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>
<!-- Content (hidden until loaded) -->
<div id="detail-body">
<div id="detail-body" style="display: none">
<!-- Header -->
<div class="post-header">
<div class="detail-creator-row">
<div id="creator-avatar"></div>
<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"></span>
<span id="prompt-date"></span>
<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 class="detail-actions-right">
<div style="margin-left: auto">
<span id="tier-badge"></span>
<button type="button" id="edit-prompt-btn">Edit</button>
<button
id="edit-prompt-btn"
style="
display: none;
margin-left: 10px;
padding: 6px 14px;
border: none;
border-radius: 10px;
background: #f1f5f9;
color: #334155;
font-weight: 700;
cursor: pointer;
"
>
Edit
</button>
</div>
</div>
<h1 class="post-title" id="prompt-title"></h1>
@ -80,38 +157,73 @@
<!-- 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
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>
<!-- Example Output -->
<div class="example-section" id="example-section">
<div class="example-section" id="example-section" style="display: none">
<h2>EXAMPLE OUTPUT</h2>
<div class="example-content">
<div
id="example-output-text"
class="example-output-text"
></div>
<div id="example-image" class="example-image">
<img
id="example-image-img"
src=""
alt="Example output image"
/>
<div id="example-output-text" class="example-output-text" style="white-space: pre-wrap"></div>
<div id="example-image" class="example-image" style="display: none">
<img id="example-image-img" src="" alt="Example output image">
</div>
</div>
</div>
<!-- Locked section (shown instead of prompt if no access) -->
<div id="locked-section">
<i class="bi bi-lock-fill locked-icon" aria-hidden="true"></i>
<h3 class="locked-title">
<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 class="locked-desc">
<p style="color: #64748b; margin-bottom: 20px">
Subscribe to <strong id="locked-creator"></strong> to access
this prompt.
</p>
<button type="button" id="locked-subscribe-btn">
<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>
@ -126,34 +238,19 @@
<h2>REVIEWS</h2>
<div class="review-form" id="review-form">
<h3>Your review</h3>
<div
class="review-star-input"
id="review-star-input"
role="group"
aria-label="Select rating"
>
<button type="button" aria-pressed="false" aria-label="1 star" data-rating="1"></button>
<button type="button" aria-pressed="false" aria-label="2 stars" data-rating="2"></button>
<button type="button" aria-pressed="false" aria-label="3 stars" data-rating="3"></button>
<button type="button" aria-pressed="false" aria-label="4 stars" data-rating="4"></button>
<button type="button" aria-pressed="false" aria-label="5 stars" data-rating="5"></button>
<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..."
aria-label="Review comment"
aria-describedby="review-comment-hint"
></textarea>
<p id="review-comment-hint" class="sr-only">Optional comment, maximum 200 characters.</p>
<button type="button" id="submit-review-btn">
Submit Review
</button>
<p id="review-message" role="status" aria-live="polite"></p>
<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" aria-live="polite">
<p class="detail-loading-text">Loading reviews...</p>
<div class="reviews-list" id="reviews-list">
<p style="color:#94a3b8;">Loading reviews...</p>
</div>
</div>
</div>
@ -169,10 +266,7 @@
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((l) => {
l.classList.remove("active");
l.removeAttribute("aria-current");
});
.forEach((l) => l.classList.remove("active"));
});
fetch("/topbar.html")
.then((r) => r.text())
@ -237,8 +331,8 @@
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" aria-hidden="true"></i> ${p.likeCount || 0} likes
<span class="detail-bookmark-span"><i class="bi ${p.isSaved ? "bi-bookmark-fill" : "bi-bookmark"} detail-bookmark-icon" aria-hidden="true"></i> ${p.saveCount || 0} saves</span>`;
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes
<span style="margin-left:12px;"><i class="bi ${p.isSaved ? 'bi-bookmark-fill' : 'bi-bookmark'}" style="color:#f59e0b;"></i> ${p.saveCount || 0} saves</span>`;
renderExamples(p);
renderOwnerActions(p);
setupReviewSection(p);
@ -246,29 +340,28 @@
// Tier badge
const badge = document.getElementById("tier-badge");
if (p.tierName) {
const price = p.tierMonthlyPrice == null
? ""
: ` - $${Number(p.tierMonthlyPrice).toFixed(2)}/mo`;
badge.innerHTML = `<span class="tier-badge-tier"><i class="bi bi-lock-fill" aria-hidden="true"></i> ${p.tierName}${price}</span>`;
if (p.price != null && Number(p.price) > 0) {
badge.innerHTML = `<span style="background:#fef3c7;color:#92400e;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">$${Number(p.price).toFixed(2)}</span>`;
} else if (p.tierName) {
badge.innerHTML = `<span style="background:#f1f5f9;color:#475569;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;"><i class="bi bi-lock-fill"></i> ${p.tierName}</span>`;
} else {
badge.innerHTML = `<span class="tier-badge-free">Free</span>`;
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 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>`;
`<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 (${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" aria-hidden="true"></i> ${p.likeCount || 0} likes
<span class="detail-bookmark-span"><i class="bi ${p.isSaved ? "bi-bookmark-fill" : "bi-bookmark"} detail-bookmark-icon" aria-hidden="true"></i> ${p.saveCount || 0} saves</span>
<span class="detail-bookmark-span"><i class="bi bi-star-fill detail-bookmark-icon" aria-hidden="true"></i> ${p.averageRating.toFixed(1)} (${p.reviewCount || 0})</span>`;
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes
<span style="margin-left:12px;"><i class="bi ${p.isSaved ? 'bi-bookmark-fill' : 'bi-bookmark'}" style="color:#f59e0b;"></i> ${p.saveCount || 0} saves</span>
<span style="margin-left:12px;"><i class="bi bi-star-fill" style="color:#f59e0b;"></i> ${p.averageRating.toFixed(1)} (${p.reviewCount || 0})</span>`;
} else {
document.getElementById("rating-display").innerHTML =
'<span class="rating-none">No ratings yet</span>';
'<span style="color:#94a3b8;font-size:0.9rem;">No ratings yet</span>';
}
// Content visibility
@ -284,7 +377,6 @@
document.getElementById("locked-tier-name").textContent = p.tierName
? `— ${p.tierName}`
: "";
setupLockedSubscription(p);
}
document.getElementById("detail-loading").style.display = "none";
@ -292,38 +384,6 @@
scrollToHashSection();
}
function setupLockedSubscription(prompt) {
const button = document.getElementById("locked-subscribe-btn");
if (!button) return;
button.disabled = prompt.tierLevel == null;
button.onclick = async () => {
if (prompt.tierLevel == null) return;
button.disabled = true;
button.textContent = "Subscribing...";
const response = await fetch(
`/api/v1/subscriptions/${encodeURIComponent(prompt.creatorId)}/${prompt.tierLevel}`,
{
method: "PUT",
credentials: "same-origin",
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) {
button.disabled = false;
button.textContent = await response.text();
return;
}
location.reload();
};
}
function scrollToHashSection() {
if (!location.hash) return;
@ -337,14 +397,10 @@
function setReviewRating(rating) {
selectedReviewRating = rating;
document
.querySelectorAll("#review-star-input button")
.forEach((button) => {
const value = Number(button.dataset.rating);
button.textContent = value <= rating ? "★" : "☆";
button.setAttribute("aria-pressed", String(value === rating));
button.setAttribute("aria-label", `${value} ${value === 1 ? "star" : "stars"}${value === rating ? ", selected" : ""}`);
});
document.querySelectorAll("#review-star-input button").forEach((button) => {
const value = Number(button.dataset.rating);
button.textContent = value <= rating ? "★" : "☆";
});
}
function escapeHtml(value) {
@ -395,12 +451,9 @@
// 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));
});
document.querySelectorAll("#review-star-input button").forEach((button) => {
button.onclick = () => setReviewRating(Number(button.dataset.rating));
});
submitBtn.onclick = async () => {
if (!selectedReviewRating) {
@ -417,8 +470,7 @@
credentials: "same-origin",
body: JSON.stringify({
rating: selectedReviewRating,
comment:
document.getElementById("review-comment").value.trim() || null,
comment: document.getElementById("review-comment").value.trim() || null,
}),
});
@ -453,16 +505,13 @@
const reviews = await response.json();
if (reviews.length === 0) {
list.innerHTML =
'<p class="detail-loading-text">No reviews yet.</p>';
list.innerHTML = '<p style="color:#94a3b8;">No reviews yet.</p>';
return;
}
list.innerHTML = reviews
.map((review) => {
const stars =
"★".repeat(review.rating) + "☆".repeat(5 - review.rating);
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>
@ -470,10 +519,9 @@
</div>
<p class="review-card-comment">${escapeHtml(review.comment || "No comment.")}</p>
</article>`;
})
.join("");
}).join("");
} catch (error) {
list.innerHTML = `<p class="detail-error-text">${error.message}</p>`;
list.innerHTML = `<p style="color:#ef4444;">${error.message}</p>`;
}
}

View File

@ -1,627 +1,398 @@
<!-- OnlyPrompt - Profile page:
- User profile display with avatar, bio, stats, and prompt cards (personal prompts) -->
<!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 - Profile</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/profile.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>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Profile</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/profile.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" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<div id="topbar-container"></div>
<main class="login-card profile-main" id="main-content" tabindex="-1">
<section class="profile-header">
<img
id="profileAvatar"
src="../images/content/cat.png"
alt="Profile avatar"
class="profile-avatar"
/>
<div class="profile-info">
<h1 id="profileDisplayName">Loading...</h1>
<div id="profileSlug">
@profile
<i class="bi bi-patch-check-fill profile-badge-icon" aria-hidden="true"></i>
</div>
<div id="profileBio">Loading profile...</div>
<div id="profileSpecialities"></div>
<div id="profileStats">
<span><strong id="profileRating">0.0</strong> rating</span>
<span
><strong id="profileSubscribers">0</strong> subscribers</span
>
</div>
<main class="login-card profile-main" style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:24px;">
<section class="profile-header" style="display:flex;align-items:center;gap:32px;border-bottom:1px solid #e5e7eb;padding-bottom:24px;">
<img id="profileAvatar" src="../images/content/cat.png" class="profile-avatar" style="width:110px;height:110px;border-radius:50%;object-fit:cover;">
<div class="profile-info" style="flex:1;">
<h1 id="profileDisplayName" style="font-size:2rem;font-weight:700;margin-bottom:4px;">Loading...</h1>
<div id="profileSlug" style="color:#64748b;margin-bottom:8px;">
@profile <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>
</div>
<div id="profileActions">
<button
type="button"
id="primaryProfileButton"
class="login-button"
onclick="location.href = 'settings.html'"
>
<i class="bi bi-gear" aria-hidden="true"></i>
Edit Profile
</button>
<button type="button" id="shareProfileButton" class="login-button">
<i class="bi bi-share" aria-hidden="true"></i>
Share Profile
</button>
<a
id="manageTiersButton"
class="login-button"
href="subscription-tiers.html"
>
<i class="bi bi-gem" aria-hidden="true"></i>
Manage Tiers
</a>
<div id="creatorTierList"></div>
<div id="profileBio" style="margin-bottom:8px;">
Loading profile...
</div>
</section>
<nav class="profile-tabs" role="tablist" aria-label="Profile prompt lists">
<button
type="button"
class="profile-tab active"
data-tab="mine"
id="myPromptsTab"
role="tab"
aria-selected="true"
aria-controls="profile-prompts-grid"
>
My Prompts
</button>
<button
type="button"
class="profile-tab"
data-tab="favorites"
id="favoritesTab"
role="tab"
aria-selected="false"
aria-controls="profile-prompts-grid"
>
Favorites
</button>
<button
type="button"
class="profile-tab"
data-tab="saved"
id="savedTab"
role="tab"
aria-selected="false"
aria-controls="profile-prompts-grid"
>
Saved
</button>
</nav>
<div id="profileSpecialities" style="color:#64748b;"></div>
<div id="profileStats" style="display:flex;gap:18px;color:#64748b;margin-top:12px;font-size:0.95rem;">
<span><strong id="profileRating" style="color:#111827;">0.0</strong> rating</span>
<span><strong id="profileSubscribers" style="color:#111827;">0</strong> subscribers</span>
</div>
</div>
<section id="profile-prompts-grid" role="tabpanel" aria-live="polite" aria-labelledby="myPromptsTab">
<div class="profile-grid-loading">Loading prompts...</div>
</section>
</main>
</div>
<div id="profileActions" style="display:flex;flex-direction:column;gap:10px;">
<button id="primaryProfileButton" class="login-button" onclick="location.href='settings.html'">Edit Profile</button>
<button id="shareProfileButton" class="login-button" style="background:#f3f4f6;color:#111;box-shadow:none;">Share Profile</button>
</div>
</section>
<nav class="profile-tabs" style="display:flex;gap:24px;border-bottom:2px solid #e5e7eb;margin:32px 0 18px 0;flex-wrap:wrap;">
<button type="button" class="profile-tab active" data-tab="mine" id="myPromptsTab">My Prompts</button>
<button type="button" class="profile-tab" data-tab="favorites" id="favoritesTab">Favorites</button>
<button type="button" class="profile-tab" data-tab="saved" id="savedTab">Saved</button>
</nav>
<section id="profile-prompts-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px;">
<div style="grid-column:1/-1;color:#64748b;text-align:center;padding:28px;">Loading prompts...</div>
</section>
</main>
</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");
link.removeAttribute("aria-current");
});
// Then set 'active' only on the My Profile link
const profileLink = document.querySelector(
'#sidebar-container a[href="profile.html"]',
);
if (profileLink) {
profileLink.classList.add("active");
profileLink.setAttribute("aria-current", "page");
}
<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');
});
// Then set 'active' only on the My Profile link
const profileLink = document.querySelector('#sidebar-container a[href="profile.html"]');
if (profileLink) profileLink.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);
const profileAvatar = document.getElementById("profileAvatar");
const profileDisplayName = document.getElementById("profileDisplayName");
const profileSlug = document.getElementById("profileSlug");
const profileBio = document.getElementById("profileBio");
const profileSpecialities = document.getElementById(
"profileSpecialities",
);
const profileRating = document.getElementById("profileRating");
const profileSubscribers = document.getElementById("profileSubscribers");
const profilePromptsGrid = document.getElementById(
"profile-prompts-grid",
);
const myPromptsTab = document.getElementById("myPromptsTab");
const favoritesTab = document.getElementById("favoritesTab");
const savedTab = document.getElementById("savedTab");
const profileActions = document.getElementById("profileActions");
const primaryProfileButton = document.getElementById(
"primaryProfileButton",
);
const shareProfileButton = document.getElementById("shareProfileButton");
const manageTiersButton = document.getElementById("manageTiersButton");
const creatorTierList = document.getElementById("creatorTierList");
const profileTabs = document.querySelector(".profile-tabs");
const params = new URLSearchParams(location.search);
const profileId = params.get("id");
let ownPrompts = [];
let allPrompts = [];
let profilePrompts = [];
let activeProfileTab = "mine";
let currentUserId = null;
let isOwnProfile = !profileId;
let profileLoaded = false;
let currentIsFollowing = false;
let currentSubscriptionTier = null;
let creatorSubscriptionTiers = [];
const profileAvatar = document.getElementById('profileAvatar');
const profileDisplayName = document.getElementById('profileDisplayName');
const profileSlug = document.getElementById('profileSlug');
const profileBio = document.getElementById('profileBio');
const profileSpecialities = document.getElementById('profileSpecialities');
const profileRating = document.getElementById('profileRating');
const profileSubscribers = document.getElementById('profileSubscribers');
const profilePromptsGrid = document.getElementById('profile-prompts-grid');
const myPromptsTab = document.getElementById('myPromptsTab');
const favoritesTab = document.getElementById('favoritesTab');
const savedTab = document.getElementById('savedTab');
const profileActions = document.getElementById('profileActions');
const primaryProfileButton = document.getElementById('primaryProfileButton');
const shareProfileButton = document.getElementById('shareProfileButton');
const profileTabs = document.querySelector('.profile-tabs');
const params = new URLSearchParams(location.search);
const profileId = params.get('id');
let ownPrompts = [];
let allPrompts = [];
let profilePrompts = [];
let activeProfileTab = 'mine';
let currentUserId = null;
let isOwnProfile = !profileId;
let profileLoaded = false;
let currentIsFollowing = false;
async function fetchJson(url) {
const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) {
location.href = "/login";
return null;
}
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
async function fetchJson(url) {
const response = await fetch(url, { credentials: 'same-origin' });
if (response.status === 401) {
location.href = '/login';
return null;
}
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
}
function renderProfile(profile, fallbackName = "Profile") {
profileDisplayName.textContent = profile.displayName || fallbackName;
profileSlug.innerHTML = `@${profile.user?.userName || profile.slug || "profile"} <i class="bi bi-patch-check-fill profile-badge-icon" aria-hidden="true"></i>`;
profileBio.textContent = profile.bio || "No bio yet.";
profileSpecialities.textContent =
profile.specialities || "No specialities added yet.";
profileRating.textContent = Number(profile.averageRating || 0).toFixed(
1,
);
profileSubscribers.textContent = profile.subscribers || 0;
function renderProfile(profile, fallbackName = 'Profile') {
profileDisplayName.textContent = profile.displayName || fallbackName;
profileSlug.innerHTML = `@${profile.user?.userName || profile.slug || 'profile'} <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>`;
profileBio.textContent = profile.bio || 'No bio yet.';
profileSpecialities.textContent = profile.specialities || 'No specialities added yet.';
profileRating.textContent = Number(profile.averageRating || 0).toFixed(1);
profileSubscribers.textContent = profile.subscribers || 0;
if (profile.avatarUrl) {
profileAvatar.src = profile.avatarUrl;
}
profileLoaded = true;
if (profile.avatarUrl) {
profileAvatar.src = profile.avatarUrl;
}
profileLoaded = true;
}
function renderProfileFromPrompt(prompt) {
if (!prompt || profileLoaded) return;
function renderProfileFromPrompt(prompt) {
if (!prompt || profileLoaded) return;
profileDisplayName.textContent =
prompt.creatorName || "Creator Profile";
profileSlug.innerHTML = `@${prompt.creatorName || "creator"} <i class="bi bi-patch-check-fill profile-badge-icon" aria-hidden="true"></i>`;
profileBio.textContent = "No bio yet.";
profileSpecialities.textContent = "";
profileRating.textContent = Number(prompt.averageRating || 0).toFixed(
1,
);
profileSubscribers.textContent = 0;
profileDisplayName.textContent = prompt.creatorName || 'Creator Profile';
profileSlug.innerHTML = `@${prompt.creatorName || 'creator'} <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>`;
profileBio.textContent = 'No bio yet.';
profileSpecialities.textContent = '';
profileRating.textContent = Number(prompt.averageRating || 0).toFixed(1);
profileSubscribers.textContent = 0;
if (prompt.creatorAvatarUrl) {
profileAvatar.src = prompt.creatorAvatarUrl;
if (prompt.creatorAvatarUrl) {
profileAvatar.src = prompt.creatorAvatarUrl;
}
}
async function loadCreatorCardFallback() {
if (isOwnProfile || profileLoaded || !profileId) return;
try {
const creators = await fetchJson('/api/v1/profiles?limit=100');
const creator = creators.find((item) => item.userId?.toLowerCase() === profileId.toLowerCase());
if (!creator) return;
renderProfile({
displayName: creator.displayName,
slug: creator.slug,
bio: creator.bio,
avatarUrl: creator.avatarUrl,
specialities: null,
averageRating: creator.averageRating,
subscribers: creator.subscribers
}, 'Creator Profile');
} catch {
// Prompt data below still provides a minimal fallback if creator cards fail.
}
}
async function loadProfile() {
try {
const currentProfile = await window.loadCurrentProfile();
currentUserId = currentProfile.user?.id;
isOwnProfile = !profileId || profileId.toLowerCase() === currentUserId?.toLowerCase();
if (isOwnProfile) {
renderProfile(currentProfile, 'My Profile');
return;
}
const profile = await fetchJson(`/api/v1/profiles/${encodeURIComponent(profileId)}`);
renderProfile(profile, 'Creator Profile');
} catch (error) {
if (isOwnProfile) {
profileDisplayName.textContent = 'Profile unavailable';
profileBio.textContent = error.message;
} else {
profileDisplayName.textContent = 'Loading creator...';
profileBio.textContent = '';
}
}
}
async function loadCreatorCardFallback() {
if (isOwnProfile || profileLoaded || !profileId) return;
try {
const creators = await fetchJson("/api/v1/profiles?limit=100");
const creator = creators.find(
(item) => item.userId?.toLowerCase() === profileId.toLowerCase(),
);
if (!creator) return;
renderProfile(
{
displayName: creator.displayName,
slug: creator.slug,
bio: creator.bio,
avatarUrl: creator.avatarUrl,
specialities: null,
averageRating: creator.averageRating,
subscribers: creator.subscribers,
},
"Creator Profile",
);
} catch {
// Prompt data below still provides a minimal fallback if creator cards fail.
}
function isPromptMarked(type, id) {
if (type === 'liked') {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isLiked === true;
}
async function loadProfile() {
try {
const currentProfile = await window.loadCurrentProfile();
currentUserId = currentProfile.user?.id;
isOwnProfile =
!profileId ||
profileId.toLowerCase() === currentUserId?.toLowerCase();
if (isOwnProfile) {
renderProfile(currentProfile, "My Profile");
return;
}
const profile = await fetchJson(
`/api/v1/profiles/${encodeURIComponent(profileId)}`,
);
renderProfile(profile, "Creator Profile");
} catch (error) {
if (isOwnProfile) {
profileDisplayName.textContent = "Profile unavailable";
profileBio.textContent = error.message;
} else {
profileDisplayName.textContent = "Loading creator...";
profileBio.textContent = "";
}
}
if (type === 'saved') {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isSaved === true;
}
function isPromptMarked(type, id) {
if (type === "liked") {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isLiked === true;
}
return false;
}
if (type === "saved") {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isSaved === true;
}
return false;
}
function renderProfilePrompt(prompt, options = {}) {
const image = prompt.exampleImageUrl || "../images/content/post1.png";
const showEdit = options.showEdit === true;
const rating =
prompt.averageRating == null
? "No ratings"
: prompt.averageRating.toFixed(1);
return `
<article class="profile-prompt-card">
<a class="profile-prompt-link" href="/post-detail?id=${encodeURIComponent(prompt.id)}" aria-label="Open prompt ${prompt.title}">
<img src="${image}" alt="${prompt.title}" class="profile-prompt-img">
<div class="profile-prompt-body">
<div class="profile-prompt-title">${prompt.title}</div>
<div class="profile-prompt-desc">${prompt.description || "No description yet."}</div>
<div class="profile-prompt-meta">
<span><i class="bi bi-star" aria-hidden="true"></i> ${rating}</span>
${prompt.creatorName ? `<span>@${prompt.creatorName}</span>` : ""}
</div>
function renderProfilePrompt(prompt, options = {}) {
const image = prompt.exampleImageUrl || '../images/content/post1.png';
const showEdit = options.showEdit === true;
const rating = prompt.averageRating == null ? 'No ratings' : prompt.averageRating.toFixed(1);
return `
<div onclick="location.href='/post-detail?id=${prompt.id}'" style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:18px;display:flex;gap:16px;cursor:pointer;">
<img src="${image}" alt="${prompt.title}" style="width:72px;height:72px;border-radius:12px;object-fit:cover;">
<div style="flex:1;min-width:0;">
<div style="font-weight:700;">${prompt.title}</div>
<div style="color:#64748b;margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${prompt.description || 'No description yet.'}</div>
<div style="display:flex;gap:16px;color:#64748b;align-items:center;flex-wrap:wrap;">
<span><i class="bi bi-star"></i> ${rating}</span>
${prompt.creatorName ? `<span>@${prompt.creatorName}</span>` : ''}
${showEdit ? `<button onclick="event.stopPropagation(); location.href='/create?id=${prompt.id}'" style="border:none;background:#f1f5f9;color:#334155;border-radius:10px;padding:6px 10px;font-weight:700;cursor:pointer;">Edit</button>` : ''}
</div>
</a>
${showEdit ? `<button type="button" onclick="location.href='/create?id=${encodeURIComponent(prompt.id)}'" class="profile-prompt-edit-btn" aria-label="Edit ${prompt.title}">Edit</button>` : ""}
</article>`;
</div>
</div>`;
}
function renderPromptList(prompts, emptyText, options = {}) {
if (!prompts.length) {
profilePromptsGrid.innerHTML = `<div style="grid-column:1/-1;color:#64748b;text-align:center;padding:28px;">${emptyText}</div>`;
return;
}
function renderPromptList(prompts, emptyText, options = {}) {
if (!prompts.length) {
profilePromptsGrid.innerHTML = `<div class="profile-grid-empty">${emptyText}</div>`;
return;
}
profilePromptsGrid.innerHTML = prompts.map((prompt) => renderProfilePrompt(prompt, options)).join('');
}
profilePromptsGrid.innerHTML = prompts
.map((prompt) => renderProfilePrompt(prompt, options))
.join("");
function updateTabs() {
document.querySelectorAll('.profile-tab').forEach((tab) => {
tab.classList.toggle('active', tab.dataset.tab === activeProfileTab);
});
const liked = allPrompts.filter((prompt) => isPromptMarked('liked', prompt.id));
const saved = allPrompts.filter((prompt) => isPromptMarked('saved', prompt.id));
myPromptsTab.textContent = `My Prompts (${ownPrompts.length})`;
favoritesTab.textContent = `Favorites (${liked.length})`;
savedTab.textContent = `Saved (${saved.length})`;
if (activeProfileTab === 'favorites') {
renderPromptList(liked, 'No liked prompts yet.');
} else if (activeProfileTab === 'saved') {
renderPromptList(saved, 'No saved prompts yet.');
} else {
renderPromptList(ownPrompts, 'No prompts yet.', { showEdit: true });
}
}
function updateTabs() {
document.querySelectorAll(".profile-tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.tab === activeProfileTab);
tab.setAttribute("aria-selected", String(tab.dataset.tab === activeProfileTab));
});
profilePromptsGrid.setAttribute("aria-labelledby", activeProfileTab === "favorites" ? "favoritesTab" : activeProfileTab === "saved" ? "savedTab" : "myPromptsTab");
const liked = allPrompts.filter((prompt) =>
isPromptMarked("liked", prompt.id),
);
const saved = allPrompts.filter((prompt) =>
isPromptMarked("saved", prompt.id),
);
myPromptsTab.textContent = `My Prompts (${ownPrompts.length})`;
favoritesTab.textContent = `Favorites (${liked.length})`;
savedTab.textContent = `Saved (${saved.length})`;
if (activeProfileTab === "favorites") {
renderPromptList(liked, "No liked prompts yet.");
} else if (activeProfileTab === "saved") {
renderPromptList(saved, "No saved prompts yet.");
} else {
renderPromptList(ownPrompts, "No prompts yet.", { showEdit: true });
}
}
function updateProfileMode() {
if (isOwnProfile) {
profileActions.style.display = "flex";
primaryProfileButton.innerHTML = '<i class="bi bi-gear" aria-hidden="true"></i> Edit Profile';
primaryProfileButton.disabled = false;
primaryProfileButton.onclick = () =>
(location.href = "settings.html");
manageTiersButton.style.display = "flex";
profileTabs.style.display = "flex";
return;
}
profileActions.style.display = "flex";
manageTiersButton.style.display = "none";
primaryProfileButton.textContent = currentIsFollowing
? currentSubscriptionTier
? `Subscribed: ${currentSubscriptionTier.name}`
: "Following"
: "Follow";
function updateProfileMode() {
if (isOwnProfile) {
profileActions.style.display = 'flex';
primaryProfileButton.textContent = 'Edit Profile';
primaryProfileButton.disabled = false;
primaryProfileButton.onclick = toggleProfileFollow;
favoritesTab.style.display = "none";
savedTab.style.display = "none";
myPromptsTab.textContent = `Prompts (${profilePrompts.length})`;
renderPromptList(profilePrompts, "No prompts yet.");
renderCreatorTiers();
primaryProfileButton.onclick = () => location.href = 'settings.html';
profileTabs.style.display = 'flex';
return;
}
function renderCreatorTiers() {
if (isOwnProfile) {
creatorTierList.innerHTML = "";
return;
}
profileActions.style.display = 'flex';
primaryProfileButton.textContent = currentIsFollowing ? 'Following' : 'Follow';
primaryProfileButton.disabled = false;
primaryProfileButton.onclick = toggleProfileFollow;
favoritesTab.style.display = 'none';
savedTab.style.display = 'none';
myPromptsTab.textContent = `Prompts (${profilePrompts.length})`;
renderPromptList(profilePrompts, 'No prompts yet.');
}
if (!creatorSubscriptionTiers.length) {
creatorTierList.innerHTML = "";
return;
}
async function loadFollowState() {
if (isOwnProfile || !profileId) return;
creatorTierList.innerHTML = `
<div class="profile-tier-list">
<h3>Subscription Tiers</h3>
${creatorSubscriptionTiers
.map(
(tier) => `
<button type="button" class="profile-tier-option ${currentSubscriptionTier?.level === tier.level ? "active" : ""}" data-tier-level="${tier.level}" aria-pressed="${currentSubscriptionTier?.level === tier.level}">
<span>
<strong>${tier.name}</strong>
<small>Level ${tier.level}</small>
</span>
<b>$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</b>
</button>
`,
)
.join("")}
</div>`;
creatorTierList
.querySelectorAll("[data-tier-level]")
.forEach((button) => {
button.addEventListener("click", () =>
subscribeToTier(Number(button.dataset.tierLevel)),
);
});
}
async function loadFollowState() {
if (isOwnProfile || !profileId) return;
try {
const response = await fetch(
`/api/v1/subscriptions/${encodeURIComponent(profileId)}`,
{
credentials: "same-origin",
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
const subscription = response.ok ? await response.json() : null;
currentIsFollowing = subscription !== null;
currentSubscriptionTier = subscription?.currentTier || null;
updateProfileMode();
} catch {
currentIsFollowing = false;
currentSubscriptionTier = null;
}
}
async function loadCreatorTiers() {
if (isOwnProfile || !profileId) return;
try {
const response = await fetch(
`/api/v1/subscriptions/tiers/${encodeURIComponent(profileId)}`,
{
credentials: "same-origin",
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) return;
creatorSubscriptionTiers = await response.json();
renderCreatorTiers();
} catch {
creatorSubscriptionTiers = [];
}
}
async function toggleProfileFollow() {
if (!profileId) return;
primaryProfileButton.disabled = true;
const response = await fetch(
`/api/v1/subscriptions/${encodeURIComponent(profileId)}`,
{
method: currentIsFollowing ? "DELETE" : "PUT",
credentials: "same-origin",
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
if (response.ok) {
const currentSubscribers = Number(
profileSubscribers.textContent || 0,
);
currentIsFollowing = !currentIsFollowing;
if (!currentIsFollowing) currentSubscriptionTier = null;
profileSubscribers.textContent = Math.max(
0,
currentSubscribers + (currentIsFollowing ? 1 : -1),
);
updateProfileMode();
} else {
primaryProfileButton.disabled = false;
}
}
async function subscribeToTier(level) {
if (!profileId) return;
creatorTierList
.querySelectorAll("button")
.forEach((button) => (button.disabled = true));
const response = await fetch(
`/api/v1/subscriptions/${encodeURIComponent(profileId)}/${level}`,
{
method: "PUT",
credentials: "same-origin",
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
creatorTierList
.querySelectorAll("button")
.forEach((button) => (button.disabled = false));
if (!response.ok) return;
const wasFollowing = currentIsFollowing;
currentIsFollowing = true;
currentSubscriptionTier =
creatorSubscriptionTiers.find((tier) => tier.level === level) || null;
if (!wasFollowing) {
profileSubscribers.textContent =
Number(profileSubscribers.textContent || 0) + 1;
}
updateProfileMode();
}
shareProfileButton.addEventListener("click", async () => {
const url = isOwnProfile
? `${location.origin}/profile.html`
: `${location.origin}/profile.html?id=${encodeURIComponent(profileId)}`;
try {
await navigator.clipboard.writeText(url);
shareProfileButton.innerHTML = '<i class="bi bi-check2" aria-hidden="true"></i> Copied';
setTimeout(
() => (shareProfileButton.innerHTML = '<i class="bi bi-share" aria-hidden="true"></i> Share Profile'),
1200,
);
} catch {
location.href = url;
}
});
async function loadOwnPrompts() {
try {
const response = await fetch("/api/v1/prompts/mine");
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) throw new Error("Prompts could not be loaded.");
ownPrompts = await response.json();
updateTabs();
} catch (error) {
profilePromptsGrid.innerHTML = `<div class="profile-grid-error">${error.message}</div>`;
}
}
async function loadAllPromptReferences() {
try {
const response = await fetch("/api/v1/prompts?limit=100");
if (!response.ok) return;
allPrompts = await response.json();
if (isOwnProfile) {
updateTabs();
} else {
profilePrompts = allPrompts.filter(
(prompt) =>
prompt.creatorId?.toLowerCase() === profileId.toLowerCase(),
);
renderProfileFromPrompt(profilePrompts[0]);
updateProfileMode();
}
} catch {
// Favorites and saved stay empty if prompts cannot be loaded.
}
}
document.querySelectorAll(".profile-tab").forEach((tab) => {
tab.addEventListener("click", () => {
if (!isOwnProfile) {
updateProfileMode();
return;
}
activeProfileTab = tab.dataset.tab;
updateTabs();
try {
const response = await fetch(`/api/v1/subscriptions/${encodeURIComponent(profileId)}`, {
credentials: 'same-origin'
});
if (response.status === 401) {
location.href = '/login';
return;
}
const subscription = response.ok ? await response.json() : null;
currentIsFollowing = subscription !== null;
updateProfileMode();
} catch {
currentIsFollowing = false;
}
}
async function toggleProfileFollow() {
if (!profileId) return;
primaryProfileButton.disabled = true;
const response = await fetch(`/api/v1/subscriptions/${encodeURIComponent(profileId)}`, {
method: currentIsFollowing ? 'DELETE' : 'PUT',
credentials: 'same-origin'
});
(async function initProfilePage() {
await loadProfile();
await loadCreatorCardFallback();
await loadFollowState();
await loadCreatorTiers();
if (response.status === 401) {
location.href = '/login';
return;
}
if (response.ok) {
const currentSubscribers = Number(profileSubscribers.textContent || 0);
currentIsFollowing = !currentIsFollowing;
profileSubscribers.textContent = Math.max(0, currentSubscribers + (currentIsFollowing ? 1 : -1));
updateProfileMode();
if (isOwnProfile) {
loadOwnPrompts();
} else {
primaryProfileButton.disabled = false;
}
}
shareProfileButton.addEventListener('click', async () => {
const url = isOwnProfile
? `${location.origin}/profile.html`
: `${location.origin}/profile.html?id=${encodeURIComponent(profileId)}`;
try {
await navigator.clipboard.writeText(url);
shareProfileButton.textContent = 'Copied';
setTimeout(() => shareProfileButton.textContent = 'Share Profile', 1200);
} catch {
location.href = url;
}
});
async function loadOwnPrompts() {
try {
const response = await fetch('/api/v1/prompts/mine');
if (response.status === 401) {
location.href = '/login';
return;
}
loadAllPromptReferences();
})();
</script>
</body>
if (!response.ok) throw new Error('Prompts could not be loaded.');
ownPrompts = await response.json();
updateTabs();
} catch (error) {
profilePromptsGrid.innerHTML = `<div style="grid-column:1/-1;color:#ef4444;text-align:center;padding:28px;">${error.message}</div>`;
}
}
async function loadAllPromptReferences() {
try {
const response = await fetch('/api/v1/prompts?limit=100');
if (!response.ok) return;
allPrompts = await response.json();
if (isOwnProfile) {
updateTabs();
} else {
profilePrompts = allPrompts.filter((prompt) => prompt.creatorId?.toLowerCase() === profileId.toLowerCase());
renderProfileFromPrompt(profilePrompts[0]);
updateProfileMode();
}
} catch {
// Favorites and saved stay empty if prompts cannot be loaded.
}
}
document.querySelectorAll('.profile-tab').forEach((tab) => {
tab.addEventListener('click', () => {
if (!isOwnProfile) {
updateProfileMode();
return;
}
activeProfileTab = tab.dataset.tab;
updateTabs();
});
});
(async function initProfilePage() {
await loadProfile();
await loadCreatorCardFallback();
await loadFollowState();
updateProfileMode();
if (isOwnProfile) {
loadOwnPrompts();
}
loadAllPromptReferences();
})();
</script>
</body>
</html>

View File

@ -17,16 +17,15 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div class="page-body">
<div style="flex:1; display: flex; flex-direction: column;">
<div id="topbar-container"></div>
<main class="settings-main" id="main-content" tabindex="-1">
<main class="settings-main">
<div class="settings-container">
<div class="settings-header">
<h1>Settings</h1>
@ -34,21 +33,21 @@
</div>
<!-- Tabs -->
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
<button type="button" class="tab-btn active" data-tab="profile" id="profileTabButton" role="tab" aria-selected="true" aria-controls="profileTab">Profile</button>
<button type="button" class="tab-btn" data-tab="security" id="securityTabButton" role="tab" aria-selected="false" aria-controls="securityTab">Security</button>
<button type="button" class="tab-btn" data-tab="notifications" id="notificationsTabButton" role="tab" aria-selected="false" aria-controls="notificationsTab">Notifications</button>
<div class="settings-tabs">
<button class="tab-btn active" data-tab="profile">Profile</button>
<button class="tab-btn" data-tab="security">Security</button>
<button class="tab-btn" data-tab="notifications">Notifications</button>
</div>
<!-- Tab Content: Profile -->
<div id="profileTab" class="tab-content active" role="tabpanel" aria-labelledby="profileTabButton">
<div id="profileTab" class="tab-content active">
<form class="settings-form" id="profileSettingsForm">
<div class="form-group">
<label for="avatar">Profile Picture</label>
<div class="avatar-upload">
<img src="../images/content/cat.png" alt="Profile picture preview" class="settings-avatar" id="avatarPreview">
<img src="../images/content/cat.png" alt="Avatar" class="settings-avatar" id="avatarPreview">
<input type="file" id="avatarUpload" accept="image/png, image/jpeg">
<button type="button" class="upload-btn" aria-controls="avatarUpload">Upload new</button>
<button type="button" class="upload-btn">Upload new</button>
</div>
</div>
<div class="form-group">
@ -69,13 +68,13 @@
</div>
<div class="form-actions">
<button type="submit" class="save-btn">Save Changes</button>
<p id="profileSaveStatus" role="status" aria-live="polite"></p>
<p id="profileSaveStatus" style="margin-top:10px;color:#64748b;text-align:center;"></p>
</div>
</form>
</div>
<!-- Tab Content: Security -->
<div id="securityTab" class="tab-content" role="tabpanel" aria-labelledby="securityTabButton" hidden>
<div id="securityTab" class="tab-content">
<form class="settings-form">
<div class="form-group">
<label for="currentPw">Current Password</label>
@ -101,7 +100,7 @@
</div>
<!-- Tab Content: Notifications (erweitert) -->
<div id="notificationsTab" class="tab-content" role="tabpanel" aria-labelledby="notificationsTabButton" hidden>
<div id="notificationsTab" class="tab-content">
<form class="settings-form">
<div class="form-group">
<label class="checkbox-label">
@ -150,19 +149,10 @@
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tabId = btn.getAttribute('data-tab');
tabBtns.forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
});
tabBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
tabContents.forEach(content => {
content.classList.remove('active');
content.hidden = true;
});
const selectedTab = document.getElementById(`${tabId}Tab`);
selectedTab.classList.add('active');
selectedTab.hidden = false;
tabContents.forEach(content => content.classList.remove('active'));
document.getElementById(`${tabId}Tab`).classList.add('active');
});
});
@ -257,13 +247,9 @@
document.getElementById('sidebar-container').innerHTML = data;
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active');
link.removeAttribute('aria-current');
});
const settingsLink = document.querySelector('#sidebar-container a[href="settings.html"]');
if (settingsLink) {
settingsLink.classList.add('active');
settingsLink.setAttribute('aria-current', 'page');
}
if (settingsLink) settingsLink.classList.add('active');
});
fetch('/topbar.html')
.then(r => r.text())

View File

@ -6,81 +6,74 @@
<div class="sidebar-shell">
<!-- Logo -->
<div class="sidebar-logo" aria-label="OnlyPrompt">
<img src="../images/logo_full.png" alt="" class="sidebar-logo-full">
<img src="../images/logo_icon.png" alt="" class="sidebar-logo-icon">
<div class="sidebar-logo">
<img src="../images/logo_full.png" alt="OnlyPrompt Logo" class="sidebar-logo-full">
<img src="../images/logo_icon.png" alt="OnlyPrompt Icon" class="sidebar-logo-icon">
</div>
<!-- Navigation -->
<nav class="sidebar" id="main-navigation" aria-label="Main navigation" tabindex="-1">
<nav class="sidebar">
<ul>
<li class="mobile-nav-item">
<a href="dashboard.html" class="active" aria-current="page">
<i class="bi bi-house icon-blue" aria-hidden="true"></i>
<li>
<a href="dashboard.html" class="active">
<i class="bi bi-house icon-blue"></i>
<span class="nav-text">Dashboard</span>
</a>
</li>
<li class="mobile-nav-item">
<li>
<a href="marketplace.html">
<i class="bi bi-shop icon-purple" aria-hidden="true"></i>
<i class="bi bi-shop icon-purple"></i>
<span class="nav-text">Marketplace</span>
</a>
</li>
<li class="mobile-nav-item">
<li>
<a href="community.html">
<i class="bi bi-people icon-pink" aria-hidden="true"></i>
<i class="bi bi-people icon-pink"></i>
<span class="nav-text">Community</span>
</a>
</li>
<li class="mobile-nav-item">
<li>
<a href="chats.html">
<i class="bi bi-chat-dots icon-blue" aria-hidden="true"></i>
<i class="bi bi-chat-dots icon-blue"></i>
<span class="nav-text">Chats</span>
</a>
</li>
<li class="mobile-nav-item">
<li>
<a href="settings.html">
<i class="bi bi-gear icon-purple" aria-hidden="true"></i>
<i class="bi bi-gear icon-purple"></i>
<span class="nav-text">Settings</span>
</a>
</li>
<li class="mobile-nav-item">
<li>
<a href="profile.html">
<i class="bi bi-person icon-pink" aria-hidden="true"></i>
<i class="bi bi-person icon-pink"></i>
<span class="nav-text">My Profile</span>
</a>
</li>
<li class="mobile-nav-item">
<li>
<a href="create.html">
<i class="bi bi-plus-circle-fill icon-blue" aria-hidden="true"></i>
<i class="bi bi-plus-circle-fill icon-blue"></i>
<span class="nav-text">Create New</span>
</a>
</li>
<li class="mobile-nav-item">
<a href="subscription-tiers.html">
<i class="bi bi-gem icon-purple" aria-hidden="true"></i>
<span class="nav-text">Subscriptions</span>
</a>
</li>
</ul>
</nav>
<!-- Logout bottom -->
<div class="sidebar-bottom">
<form action="/api/v1/auth/logout" method="post">
<button type="submit" class="sidebar-logout" aria-label="Logout">
<button type="submit" class="sidebar-logout">
<div class="logout-left">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
<i class="bi bi-box-arrow-right"></i>
<span class="nav-text">Logout</span>
</div>
<i class="bi bi-chevron-right logout-arrow" aria-hidden="true"></i>
<i class="bi bi-chevron-right logout-arrow"></i>
</button>
</form>
</div>

View File

@ -11,7 +11,7 @@
<!-- For responsive design: adapts width for different devices -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Title shown in browser tab -->
<title>OnlyPrompt - Sign Up</title>
<title>OnlyPrompt - Login</title>
<!-- CSS files for variables, base styles, and login page -->
<link rel="stylesheet" href="../css/variables.css">
@ -20,9 +20,8 @@
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<!-- Main container for the login page (CSS layout) -->
<main class="login-page" id="main-content" tabindex="-1">
<main class="login-page">
<!-- White login card -->
<section class="login-card">
<!-- Logo container -->
@ -38,25 +37,25 @@
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="yourname@email.com" autocomplete="email" required>
<input type="email" id="email" name="email" placeholder="yourname@email.com" required>
</div>
<div class="form-group">
<label for="displayName">Display Name (how it will appear to others)</label>
<input type="text" id="displayName" name="displayName" placeholder="Enter your display name" autocomplete="name" required>
<input type="text" id="displayName" name="displayName" placeholder="Enter your display name" required>
</div>
<div class="form-group">
<label for="userName">Username</label>
<input type="text" id="userName" name="userName" placeholder="Choose a username" autocomplete="username" required>
<input type="text" id="userName" name="userName" placeholder="Choose a username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<!-- Password field with button to show/hide password -->
<div class="password-wrapper">
<input type="password" id="password" name="password" placeholder="Enter your password" autocomplete="new-password" required>
<button type="button" id="togglePassword" class="toggle-password" aria-controls="password" aria-pressed="false">
<input type="password" id="password" name="password" placeholder="Enter your password" required>
<button type="button" id="togglePassword" class="toggle-password">
Show <!-- Click to show/hide password -->
</button>
</div>
@ -74,7 +73,7 @@
<p class="signup-text">
Have an account?
<a href="/login">Log In</a>
<a href="#">Log In</a>
</p>
</section>
</main>

View File

@ -1,389 +0,0 @@
<!-- OnlyPrompt - Subscription tiers page: create and manage monthly creator tiers -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Subscription Tiers</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/topbar.css" />
<link rel="stylesheet" href="../css/subscription-tiers.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>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<main class="tiers-main" id="main-content" tabindex="-1">
<header class="tiers-header">
<h1>Subscription Tiers</h1>
<p>Create monthly access levels for your paid prompts.</p>
</header>
<nav class="tiers-tabs" role="tablist" aria-label="Subscription tier sections">
<button type="button" class="tiers-tab active" data-tab="manage" id="manageTiersTab" role="tab" aria-selected="true" aria-controls="manage-tab-panel">
My Tiers
</button>
<button type="button" class="tiers-tab" data-tab="subscriptions" id="subscriptionsTiersTab" role="tab" aria-selected="false" aria-controls="subscriptions-tab-panel">
My Subscriptions
</button>
</nav>
<section class="tiers-layout" id="manage-tab-panel" role="tabpanel" aria-labelledby="manageTiersTab">
<article class="tier-panel">
<h2 id="tier-form-title">Create Tier</h2>
<form id="tier-form" class="tier-form">
<label>
Tier Name
<input
id="tier-name"
type="text"
placeholder="Supporter"
required
/>
</label>
<label>
Level
<input id="tier-level" type="number" min="1" step="1" value="1" required />
</label>
<label>
Monthly Price
<input
id="tier-price"
type="number"
min="0"
step="0.01"
placeholder="4.99"
required
/>
</label>
<label>
Description
<textarea
id="tier-description"
placeholder="Access to basic premium prompts."
></textarea>
</label>
<div class="tier-form-actions">
<button type="submit" class="tier-primary-btn" id="tier-submit-btn">
Save Tier
</button>
<button type="button" class="tier-secondary-btn" id="tier-reset-btn">
Clear
</button>
</div>
<p id="tier-status" role="status" aria-live="polite"></p>
</form>
</article>
<section>
<div class="tier-list-header">
<h2>Your Tiers</h2>
<p>Higher levels include access to prompts from lower levels.</p>
</div>
<div class="tiers-grid" id="tiers-grid" aria-live="polite">
<div class="tiers-empty">Loading tiers...</div>
</div>
</section>
</section>
<section class="subscriptions-panel" id="subscriptions-tab-panel" role="tabpanel" aria-labelledby="subscriptionsTiersTab" hidden>
<div class="tier-list-header">
<h2>Your Subscriptions</h2>
<p>Creators you follow or support with a monthly tier.</p>
</div>
<div class="subscriptions-grid" id="subscriptions-grid" aria-live="polite">
<div class="tiers-empty">Loading subscriptions...</div>
</div>
</section>
</main>
</div>
</div>
<script>
fetch("/sidebar.html")
.then((r) => r.text())
.then((data) => {
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((link) => {
link.classList.remove("active");
link.removeAttribute("aria-current");
});
const tiersLink = document.querySelector(
'#sidebar-container a[href="subscription-tiers.html"]',
);
if (tiersLink) {
tiersLink.classList.add("active");
tiersLink.setAttribute("aria-current", "page");
}
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
const tierForm = document.getElementById("tier-form");
const tierFormTitle = document.getElementById("tier-form-title");
const tierName = document.getElementById("tier-name");
const tierLevel = document.getElementById("tier-level");
const tierPrice = document.getElementById("tier-price");
const tierDescription = document.getElementById("tier-description");
const tierStatus = document.getElementById("tier-status");
const tiersGrid = document.getElementById("tiers-grid");
const subscriptionsGrid = document.getElementById("subscriptions-grid");
const manageTabPanel = document.getElementById("manage-tab-panel");
const subscriptionsTabPanel = document.getElementById(
"subscriptions-tab-panel",
);
const resetBtn = document.getElementById("tier-reset-btn");
let editingTierId = null;
let tiers = [];
let subscriptions = [];
function setActiveTab(tabName) {
document.querySelectorAll(".tiers-tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.tab === tabName);
tab.setAttribute("aria-selected", String(tab.dataset.tab === tabName));
});
manageTabPanel.style.display = tabName === "manage" ? "grid" : "none";
manageTabPanel.hidden = tabName !== "manage";
subscriptionsTabPanel.style.display =
tabName === "subscriptions" ? "block" : "none";
subscriptionsTabPanel.hidden = tabName !== "subscriptions";
if (tabName === "subscriptions") loadSubscriptions();
}
function resetForm() {
editingTierId = null;
tierFormTitle.textContent = "Create Tier";
tierForm.reset();
tierLevel.value = tiers.length ? Math.max(...tiers.map((t) => t.level)) + 1 : 1;
tierStatus.textContent = "";
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function getFriendlyTierError(response) {
const fallback = `Server error ${response.status}`;
const text = await response.text();
if (!text) return fallback;
try {
const error = JSON.parse(text);
const messages = error.errors
? Object.values(error.errors).flat()
: [error.title || fallback];
return messages
.map((message) =>
message === "Tier with this level already exists."
? "A tier with this level already exists. Please choose another level."
: message,
)
.join(" ");
} catch {
return text;
}
}
async function loadTiers() {
try {
const response = await fetch("/api/v1/subscriptions/tiers", {
credentials: "same-origin",
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) throw new Error(`Server error ${response.status}`);
tiers = await response.json();
renderTiers();
if (!editingTierId) resetForm();
} catch (error) {
tiersGrid.innerHTML = `<div class="tiers-error">${escapeHtml(error.message)}</div>`;
}
}
async function loadSubscriptions() {
try {
const response = await fetch("/api/v1/subscriptions", {
credentials: "same-origin",
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) throw new Error(`Server error ${response.status}`);
subscriptions = await response.json();
renderSubscriptions();
} catch (error) {
subscriptionsGrid.innerHTML = `<div class="tiers-error">${escapeHtml(error.message)}</div>`;
}
}
function renderSubscriptions() {
if (!subscriptions.length) {
subscriptionsGrid.innerHTML =
'<div class="tiers-empty">No subscriptions yet.</div>';
return;
}
subscriptionsGrid.innerHTML = subscriptions
.map((subscription) => {
const tier = subscription.currentTier;
return `
<article class="subscription-card">
<div>
<h3>${escapeHtml(subscription.subscribedToName)}</h3>
<p>${tier ? `${escapeHtml(tier.name)} - Level ${tier.level}` : "Following without tier"}</p>
</div>
<div class="subscription-price">
${tier ? `$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo` : "Free"}
</div>
</article>`;
})
.join("");
}
function renderTiers() {
if (!tiers.length) {
tiersGrid.innerHTML = '<div class="tiers-empty">No tiers yet.</div>';
return;
}
tiersGrid.innerHTML = tiers
.map(
(tier) => `
<article class="tier-card">
<div class="tier-card-top">
<div>
<h3>${escapeHtml(tier.name)}</h3>
<div class="tier-level">Level ${tier.level}</div>
</div>
<div class="tier-price">$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</div>
</div>
<p class="tier-desc">${escapeHtml(tier.description || "No description yet.")}</p>
<div class="tier-card-actions">
<button type="button" data-edit="${tier.id}" aria-label="Edit ${escapeHtml(tier.name)} tier">Edit</button>
<button type="button" data-delete="${tier.id}" class="tier-delete-btn" aria-label="Delete ${escapeHtml(tier.name)} tier">Delete</button>
</div>
</article>`,
)
.join("");
tiersGrid.querySelectorAll("[data-edit]").forEach((button) => {
button.addEventListener("click", () => editTier(button.dataset.edit));
});
tiersGrid.querySelectorAll("[data-delete]").forEach((button) => {
button.addEventListener("click", () => deleteTier(button.dataset.delete));
});
}
function editTier(id) {
const tier = tiers.find((item) => item.id === id);
if (!tier) return;
editingTierId = id;
tierFormTitle.textContent = "Edit Tier";
tierName.value = tier.name || "";
tierLevel.value = tier.level || 1;
tierPrice.value = tier.monthlyPrice || 0;
tierDescription.value = tier.description || "";
tierStatus.textContent = "";
}
async function deleteTier(id) {
const tier = tiers.find((item) => item.id === id);
if (!tier || !confirm(`Delete ${tier.name}?`)) return;
const response = await fetch(`/api/v1/subscriptions/tiers/${id}`, {
method: "DELETE",
credentials: "same-origin",
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) {
tierStatus.textContent = await getFriendlyTierError(response);
return;
}
resetForm();
loadTiers();
}
tierForm.addEventListener("submit", async (event) => {
event.preventDefault();
tierStatus.textContent = "Saving...";
const payload = {
name: tierName.value.trim(),
level: Number(tierLevel.value),
monthlyPrice: Number(tierPrice.value),
description: tierDescription.value.trim() || null,
};
const response = await fetch(
editingTierId
? `/api/v1/subscriptions/tiers/${editingTierId}`
: "/api/v1/subscriptions/tiers",
{
method: editingTierId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(payload),
},
);
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) {
tierStatus.textContent = await getFriendlyTierError(response);
return;
}
tierStatus.textContent = "Tier saved.";
editingTierId = null;
await loadTiers();
});
resetBtn.addEventListener("click", resetForm);
document.querySelectorAll(".tiers-tab").forEach((tab) => {
tab.addEventListener("click", () => setActiveTab(tab.dataset.tab));
});
setActiveTab("manage");
loadTiers();
</script>
</body>
</html>

View File

@ -1,29 +1,27 @@
<!--
Reusable topbar for OnlyPrompt
- Search in the middle
- small chat & logout icons
- small chat & notification icons
- profile avatar on the right
-->
<header class="topbar-shell">
<div class="topbar-search">
<i class="bi bi-search" aria-hidden="true"></i>
<input id="topbarSearchInput" type="search" placeholder="Search" aria-label="Search prompts and creators">
<i class="bi bi-search"></i>
<input id="topbarSearchInput" type="search" placeholder="Search">
</div>
<div class="topbar-actions">
<form action="/api/v1/auth/logout" method="post" class="topbar-logout-form">
<button class="topbar-icon-btn" type="submit" aria-label="Logout" title="Logout">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</button>
</form>
<button class="topbar-icon-btn" type="button" aria-label="Messages" title="Messages" onclick="location.href='/chats.html'">
<i class="bi bi-chat-dots" aria-hidden="true"></i>
<button class="topbar-icon-btn" aria-label="Notifications">
<i class="bi bi-bell"></i>
</button>
<button class="topbar-icon-btn" aria-label="Messages">
<i class="bi bi-chat-dots"></i>
</button>
<!-- Profile avatar on the right (must be changed with backend) -->
<button class="topbar-avatar-btn" type="button" aria-label="Profile" title="Profile" onclick="location.href='/profile.html'">
<img id="topbarAvatar" src="../images/content/cat.png" alt="" class="topbar-avatar">
<button class="topbar-avatar-btn" aria-label="Profile">
<img id="topbarAvatar" src="../images/content/cat.png" alt="Profile Picture" class="topbar-avatar">
</button>
</div>
</header>

View File

@ -1,24 +1,20 @@
# OnlyPrompt - AI Prompt Marketplace
## Description
OnlyPrompt is a web application where users can create, publish, discover and interact with AI prompts. Users can edit their profiles, follow creators, like and save prompts, write reviews and browse free or subscription-based prompt cards in a marketplace.
OnlyPrompt is a web application where users can create, publish, discover and interact with AI prompts. Users can edit their profiles, follow creators, like and save prompts, write reviews and browse free or paid prompt cards in a marketplace.
This project is built with HTML, CSS and JavaScript.
## Special Features
- 📝 Create, edit and publish AI prompts
- 🔍 Browse prompts in a marketplace with category, search and tier price filters
- 🔍 Browse prompts in a marketplace with category, search and price filters
- 📄 View prompt detail pages with examples, ratings and access states
- 💎 Create creator subscription tiers and assign prompts to tier levels
- 🔐 Subscribe to creator tiers to unlock matching and lower-level prompts
- ⭐ Write reviews with star ratings and comments
- ❤️ Like and save prompts
- 👥 Follow and discover other creators
- 💬 Start chats with creators from the community page and send local messages
- 👤 Edit user profiles with display name, username, bio and profile picture
- 🌐 View own and public creator profiles
- 📱 Responsive layout for desktop and mobile, including a bottom icon navigation on smartphones
- ♿ Accessibility improvements such as keyboard focus states, skip links, labels, ARIA states and live status messages
- 📱 Responsive layout for desktop and mobile
- 🔄 Server communication through a REST API
- 💾 Shared data persistence with backend and database
@ -37,13 +33,6 @@ DB_NAME=onlyprompt
DB_PASSWORD=onlyprompt
```
## Local Usage Notes
- The community page includes a chat button on creator cards. It opens the chat page and starts a conversation with the selected creator.
- The chats page supports selecting conversations, searching creators through the new-chat button and sending messages.
- Chat messages are stored in the browser's `localStorage` for the local frontend demo. They are not persisted in PostgreSQL yet.
- Keyboard users can use `Tab`, `Shift + Tab`, `Enter` and `Space` to navigate links, buttons, filters, tabs and forms.
- On macOS, full keyboard navigation may need to be enabled in System Settings or the browser settings so that `Tab` also focuses links.
## Technologies, Libraries, Frameworks
- HTML5 for page structure
- CSS3 with Flexbox/Grid for layout and responsive design
@ -64,13 +53,12 @@ AI tools were used as support during development, mainly for debugging, comparin
The project uses authentication with a JWT cookie so that protected pages and API endpoints require a logged-in user. User input is validated on the backend for important operations such as registration, profile updates, prompt creation and reviews.
Known limitations:
- Subscription access is simulated for the semester project and is not connected to a real payment provider.
- Chat messages are currently stored locally in the browser and are not connected to a backend chat API.
- Payment and premium access are simulated and are not connected to a real payment provider.
- User-generated content is displayed in the frontend, so XSS prevention is important. The project avoids intentionally executing user input as code, but further output sanitization would be needed for production.
- Authentication is implemented for local project use and would need additional hardening for production.
## Reflection
A main challenge was connecting static frontend pages with dynamic backend data while keeping the application usable and consistent. During development, the project evolved from demo pages into a connected application with real profiles, prompts, reviews, likes, saves, creator interactions and subscription tiers. We learned how important clear API structures, consistent data models, responsive navigation and regular browser testing are when multiple pages depend on the same shared data.
A main challenge was connecting static frontend pages with dynamic backend data while keeping the application usable and consistent. During development, the project evolved from demo pages into a connected application with real profiles, prompts, reviews, likes, saves and creator interactions. We learned how important clear API structures, consistent data models and regular browser testing are when multiple pages depend on the same shared data.
## Group members and their roles
| Name | Role |