Compare commits

...

8 Commits

Author SHA1 Message Date
7e2dc33dc2 Merge pull request 'dev Add local creator chats and keyboard accessibility' (#3) from dev into main
Reviewed-on: #3
2026-06-14 12:13:30 +02:00
Thuvaraka Yogarajah
a3bfcb5347 Update README for chat and accessibility 2026-06-14 12:08:32 +02:00
Thuvaraka Yogarajah
10592b76c7 Add basic creator chat flow 2026-06-14 12:07:01 +02:00
Thuvaraka Yogarajah
af7271e2f8 Improve frontend accessibility 2026-06-14 11:58:04 +02:00
de1d3d2d63 Add subscription tiers and responsive navigation 2026-06-13 18:14:56 +02:00
GeNii96
289f7eebbe t 2026-06-03 08:56:56 +02:00
GeNii96
6df5485707 fix loginpage error message /display onyl once 2026-06-02 11:18:19 +02:00
GeNii96
7a347b093e inline css entfernen 2026-06-02 10:36:02 +02:00
36 changed files with 3485 additions and 1676 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, price, like/save counts, average rating, review count and access state.
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.
### Feed
@ -187,6 +187,8 @@ 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
@ -202,7 +204,7 @@ PUT /api/v1/prompts/{id}
Content-Type: application/json
```
Request body uses the same editable fields as prompt creation. Used by the edit prompt flow.
Request body uses the same editable fields as prompt creation, including `subscriptionTier`. Used by the edit prompt flow.
### Prompt Detail
@ -216,13 +218,13 @@ Response includes:
- prompt content if accessible
- category
- creator information
- price or free state
- tier or free state
- example output
- example image
- average rating and review count
- like/save state and counts
Paid prompts return no detail content for users without access.
Tier prompts return no detail content for users without a matching creator subscription.
### Likes and Saves
@ -283,10 +285,49 @@ 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 and public profiles to read follow state, follow creators or unfollow creators.
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."
}
```
## 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, 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 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 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, 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);
}

View File

@ -71,9 +71,10 @@ 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,9 +81,10 @@ 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.Price == null || x.Price <= 0) && (x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)))
x.CreatorId == userId || x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level <= s.SubscriptionTier.Level)
)).ToArrayAsync();
return prompts;
@ -112,6 +113,7 @@ 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
@ -132,9 +134,6 @@ 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
{
@ -169,7 +168,20 @@ namespace OnlyPrompt.Backend.Controllers
prompt.Category = category;
prompt.ExampleOutput = request.ExampleOutput;
prompt.ExampleImageUrl = request.ExampleImageUrl;
prompt.Price = request.Price;
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;
}
await _db.SaveChangesAsync();
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = prompt.Prompt, CanAccess = true };
@ -315,7 +327,7 @@ namespace OnlyPrompt.Backend.Controllers
Prompt = request.Content,
ExampleOutput = request.ExampleOutput,
ExampleImageUrl = request.ExampleImageUrl,
Price = request.Price,
Price = null,
CreatorId = userId.Value,
SubscriptionTier = subscriptionTier,
Category = category,

View File

@ -100,6 +100,35 @@ 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,6 +44,7 @@ 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))
@ -66,6 +67,7 @@ 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,130 +1,381 @@
<!-- 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>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<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>
<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">
<!-- 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>
<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>
</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>
<!-- 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>
<div class="chat-time">10:17 AM</div>
</div>
<!-- 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 class="chat-messages" id="chatMessages" aria-live="polite" aria-label="Conversation">
</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>
<!-- 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>
</div>
<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>
</div>
</main>
</main>
</div>
</div>
</div>
<script>
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
// Remove 'active' from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active');
<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");
}
});
// 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');
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>
</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;
}
function selectConversation(id) {
activeConversationId = id;
renderChatList();
renderMessages();
}
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(),
});
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();
});
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
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>
</html>

View File

@ -1,204 +1,247 @@
<!-- 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>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<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>
<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="creators-main">
<div class="creators-header">
<h1>Discover Creators</h1>
<p>Follow your favorite prompt artists and get inspired.</p>
</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'));
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 style="color:#f59e0b">${'★'.repeat(stars)}${'☆'.repeat(5 - stars)}</span> <span style="color:#64748b;font-size:0.8rem">${rating.toFixed(1)}</span>`;
}
function renderCard(c) {
return `
<div class="creator-card">
<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"
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>
<div class="creator-stats">
<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>` : ''}
<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>
<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;
<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>
const res = await fetch(`/api/v1/subscriptions/${userId}`, {
method: isFollowing ? 'DELETE' : 'PUT',
credentials: 'same-origin'
});
<div class="creators-grid" id="creators-grid" aria-live="polite"></div>
if (res.status === 401) { location.href = '/login'; return; }
<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>
if (res.ok) {
const nowFollowing = !isFollowing;
btn.dataset.following = nowFollowing;
btn.textContent = nowFollowing ? 'Following' : 'Follow';
btn.classList.toggle('following', nowFollowing);
<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>
<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),
);
// ── 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>`;
}
btn.disabled = false;
}
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 `
<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>
<div class="creator-info">
<h3 class="creator-name"><a href="${profileHref}">${c.displayName}</a></h3>
<div class="creator-handle">@${c.slug}</div>
<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>
</div>
</div>
</div>`;
}
// ── 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');
// ── Follow / Unfollow ────────────────────────────────────────────
async function toggleFollow(btn) {
const userId = btn.dataset.userid;
const isFollowing = btn.dataset.following === "true";
btn.disabled = true;
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 res = await fetch(`/api/v1/subscriptions/${userId}`, {
method: isFollowing ? "DELETE" : "PUT",
credentials: "same-origin",
});
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';
if (res.status === 401) {
location.href = "/login";
return;
}
grid.innerHTML = creators.map(renderCard).join('');
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.querySelectorAll('.follow-btn').forEach(btn => {
btn.addEventListener('click', () => toggleFollow(btn));
});
} catch (e) {
errorEl.style.display = 'block';
errorMsg.textContent = e.message;
btn.disabled = false;
}
}
// ── 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);
// ── 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);
});
});
});
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,15 +17,16 @@
<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);">
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div style="flex:1; display: flex; flex-direction: column;">
<div class="page-body">
<div id="topbar-container"></div>
<main class="create-main">
<main class="create-main" id="main-content" tabindex="-1">
<div class="create-container">
<div class="create-header">
<h1 id="create-title">Create AI Prompt</h1>
@ -36,7 +37,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" required>
<input type="text" id="title" name="title" placeholder="e.g., Write an inspiring startup story about innovation" autocomplete="off" required>
</div>
<!-- Description -->
@ -61,8 +62,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..." required></textarea>
<small class="form-hint">Use clear, step-by-step instructions for the AI.</small>
<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>
</div>
<!-- Example Output (Text) -->
@ -74,24 +75,28 @@
<!-- 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">
<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;">
<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">
</div>
</div>
<!-- Pricing (with toggle) -->
<div class="form-group pricing-group">
<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>
<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>
</div>
<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 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>
<small class="form-hint">You can set a price later or keep it free.</small>
<small class="form-hint">Free prompts are public. Tier prompts require a monthly creator subscription.</small>
</div>
<!-- Submit Button -->
@ -99,7 +104,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" style="text-align:center;color:#64748b;margin:0;"></p>
<p id="create-status" role="status" aria-live="polite"></p>
</form>
</div>
</main>
@ -109,23 +114,28 @@
<script>
// Toggle between free and paid
const freeBtn = document.getElementById('freeBtn');
const paidBtn = document.getElementById('paidBtn');
const priceField = document.getElementById('priceField');
const priceInput = document.getElementById('price');
const tierBtn = document.getElementById('tierBtn');
const tierField = document.getElementById('tierField');
const tierSelect = document.getElementById('subscriptionTier');
const editPromptId = new URLSearchParams(location.search).get('id');
const submitPromptBtn = document.getElementById('submitPromptBtn');
let ownSubscriptionTiers = [];
freeBtn.addEventListener('click', () => {
freeBtn.classList.add('active');
paidBtn.classList.remove('active');
priceField.style.display = 'none';
priceInput.removeAttribute('required');
tierBtn.classList.remove('active');
freeBtn.setAttribute('aria-pressed', 'true');
tierBtn.setAttribute('aria-pressed', 'false');
tierField.style.display = 'none';
tierSelect.removeAttribute('required');
});
paidBtn.addEventListener('click', () => {
paidBtn.classList.add('active');
tierBtn.addEventListener('click', () => {
tierBtn.classList.add('active');
freeBtn.classList.remove('active');
priceField.style.display = 'block';
priceInput.setAttribute('required', 'required');
tierBtn.setAttribute('aria-pressed', 'true');
freeBtn.setAttribute('aria-pressed', 'false');
tierField.style.display = 'grid';
tierSelect.setAttribute('required', 'required');
});
// Image preview for example image
@ -171,6 +181,31 @@
}
}
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;
@ -202,9 +237,9 @@
imagePreview.style.display = 'block';
}
if (prompt.price != null && Number(prompt.price) > 0) {
paidBtn.click();
priceInput.value = Number(prompt.price);
if (prompt.tierLevel != null) {
tierBtn.click();
tierSelect.value = String(prompt.tierLevel);
} else {
freeBtn.click();
}
@ -224,8 +259,7 @@
submitBtn.disabled = true;
try {
const isPaid = paidBtn.classList.contains('active');
const price = isPaid ? Number(priceInput.value || 0) : null;
const isTier = tierBtn.classList.contains('active');
const payload = {
title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(),
@ -233,8 +267,8 @@
content: document.getElementById('promptContent').value.trim(),
exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
exampleImageUrl: exampleImageUrl || null,
price,
subscriptionTier: null,
price: null,
subscriptionTier: isTier ? Number(tierSelect.value) : null,
slug: null
};
@ -290,17 +324,21 @@
// 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');
if (createLink) {
createLink.classList.add('active');
createLink.setAttribute('aria-current', 'page');
}
});
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
loadCategories().then(loadPromptForEdit);
Promise.all([loadCategories(), loadSubscriptionTiers()]).then(loadPromptForEdit);
</script>
</body>
</html>

View File

@ -18,6 +18,58 @@ 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;
@ -28,7 +80,7 @@ body {
.form-error ul {
list-style: none;
padding-left: 0;
list-style: '*';
list-style: "*";
}
.form-error li {
@ -39,3 +91,74 @@ body {
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,13 +1,5 @@
/* 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;
@ -20,7 +12,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;
@ -50,9 +42,81 @@
.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 {
@ -61,15 +125,22 @@
}
.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;
border-bottom: 1px solid #f0f2f5;
width: 100%;
}
.chat-item:hover {
.chat-item:hover,
.chat-item:focus-visible {
background: #f8fafc;
}
.chat-item.active {
@ -196,6 +267,7 @@
padding: 16px 24px;
border-top: 1px solid #eef2f7;
background: #fff;
margin: 0;
}
.chat-input-area input {
flex: 1;
@ -208,6 +280,12 @@
.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;
@ -222,6 +300,11 @@
opacity: 0.85;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.chats-main {

View File

@ -1,13 +1,5 @@
/* 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;
@ -71,15 +63,24 @@
.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:hover,
.creator-card:focus-within {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
}
.creator-avatar-link {
border-radius: 50%;
display: inline-flex;
flex-shrink: 0;
}
.creator-avatar {
@ -98,6 +99,15 @@
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;
@ -123,7 +133,14 @@
.creator-stats i {
margin-right: 4px;
}
.follow-btn {
.creator-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.follow-btn,
.creator-chat-btn {
background: var(--gradient);
color: white;
border: none;
@ -132,11 +149,25 @@
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 {
.follow-btn:hover,
.creator-chat-btn:hover {
opacity: 0.85;
}
.creator-chat-btn {
background: #eef2ff;
color: #2563eb;
}
.follow-btn.following {
background: transparent;
border: 2px solid #94a3b8;
@ -177,4 +208,17 @@
.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,13 +1,5 @@
/* 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;
@ -22,12 +14,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 */
@ -57,7 +49,8 @@
flex-direction: column;
gap: 8px;
}
.form-group label {
.form-group label,
.form-label {
font-weight: 600;
font-size: 0.95rem;
}
@ -77,7 +70,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;
@ -104,17 +97,49 @@
background: var(--gradient);
color: white;
}
#priceField {
#tierField {
display: none;
gap: 8px;
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;
@ -132,7 +157,8 @@
background: #f1f5f9;
color: #475569;
}
.submit-btn:hover, .cancel-btn:hover {
.submit-btn:hover,
.cancel-btn:hover {
opacity: 0.85;
}

View File

@ -1,13 +1,5 @@
/* 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;
@ -80,11 +72,20 @@
display: flex;
flex-direction: column;
}
.post-card:hover {
.post-card:hover,
.post-card:focus-within {
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;
@ -125,7 +126,7 @@
.post-title {
font-size: 1.1rem;
font-weight: 700;
margin: 0 0 6px 0;
margin: 10px 0 6px 0;
}
.post-description {
color: #334155;

View File

@ -1,13 +1,5 @@
/* 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;
@ -123,6 +115,7 @@
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.prompt-title {
@ -153,6 +146,7 @@
gap: 8px;
font-size: 0.85rem;
color: #f59e0b;
text-decoration: none;
}
.prompt-rating span:first-child i {
color: #f59e0b;
@ -165,7 +159,7 @@
font-size: 1.3rem;
font-weight: 700;
color: #3b82f6;
margin: 8px 0 4px;
margin: auto 0 4px;
}
.prompt-actions {
@ -228,26 +222,68 @@
}
}
/* Payment method buttons */
.pay-method-btn {
.market-card-header {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
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;
font-size: 0.95rem;
font-weight: 600;
color: #1e293b;
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;
transition:
border-color 0.2s,
background 0.2s;
text-align: left;
}
.pay-method-btn:hover {
border-color: #6366f1;
background: #f5f3ff;
.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;
}
.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;
}

View File

@ -1,13 +1,5 @@
/* 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;
@ -21,12 +13,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 */
@ -319,3 +311,190 @@
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,14 +1,217 @@
/* Profile Page - Full width layout, darker share button, responsive grid */
/* 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 header ──────────────────────────────────────────────────── */
.profile-header {
display: flex;
align-items: center;
gap: 32px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 24px;
}
/* Inner spacing for the profile card */
/* ── 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 ─────────────────────────────── */
.profile-main {
background: transparent !important;
border-radius: 0 !important;
@ -16,7 +219,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 */
@ -28,16 +231,32 @@
}
/* Share button: darker background and text */
.profile-header button:last-child {
background: #cbd5e1 !important; /* darker gray */
#shareProfileButton {
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 {
@ -64,7 +283,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 */
@ -75,6 +294,9 @@
/* Avatar remains round */
.profile-avatar {
border-radius: 50% !important;
width: 110px;
height: 110px;
object-fit: cover;
}
/* All outer containers stay square */

View File

@ -1,12 +1,5 @@
/* 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;
@ -21,7 +14,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;
}
@ -101,7 +94,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;
@ -182,3 +175,10 @@
align-items: flex-start;
}
}
/* Save status message */
#profileSaveStatus {
margin-top: 10px;
color: #64748b;
text-align: center;
}

View File

@ -154,7 +154,18 @@
}
.sidebar .nav-text,
.sidebar-logout .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;
}
.logout-arrow {
display: none;
}
@ -163,6 +174,7 @@
.sidebar-logout {
justify-content: center;
padding: 12px;
position: relative;
}
.sidebar a.active {
@ -172,8 +184,45 @@
}
@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 {
padding: 10px;
min-height: 46px;
padding: 7px 2px;
}
.sidebar i {
font-size: 1.05rem;
}
.sidebar a.active {
border-right: none;
border-top: 3px solid #3b82f6;
}
}

View File

@ -0,0 +1,271 @@
/* 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,31 +1,35 @@
/*
Topbar styles for OnlyPrompt
- 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
- sticky on all app pages
- search bar fills the available width
- logout, messages and profile avatar stay on the right
*/
.topbar-shell {
width: 100%;
align-items: center;
background: #ffffff;
border-bottom: 1px solid #eef2f7;
padding: 16px 32px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
justify-content: space-between;
padding: 16px 32px;
position: sticky;
top: 0;
width: 100%;
z-index: 90;
}
.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 {
@ -34,12 +38,17 @@
}
.topbar-search input {
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: 0.95rem;
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);
}
.topbar-search input::placeholder {
@ -47,24 +56,28 @@
font-weight: 400;
}
/* Icons and avatar container */
.topbar-actions {
display: flex;
align-items: center;
display: flex;
gap: 16px;
}
.topbar-logout-form {
display: flex;
margin: 0;
}
.topbar-icon-btn {
align-items: center;
background: transparent;
border: none;
font-size: 1.4rem;
border-radius: 50%;
color: #475569;
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
font-size: 1.4rem;
justify-content: center;
border-radius: 50%;
padding: 8px;
transition: background 0.2s, color 0.2s;
}
@ -74,42 +87,47 @@
}
.topbar-avatar-btn {
border: none;
align-items: center;
background: transparent;
padding: 0;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.topbar-avatar {
width: 48px;
border: 1px solid #e2e8f0;
border-radius: 50%;
height: 48px;
object-fit: cover;
border: 1px solid #e2e8f0;
border-radius: 50%; /* Avatar round */
width: 48px;
}
/* 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 {
width: 40px;
height: 40px;
width: 40px;
}
.topbar-icon-btn {
.topbar-icon-btn {
font-size: 1.2rem;
padding: 6px;
}
.topbar-actions {
gap: 8px;
}
@ -117,9 +135,14 @@
@media (max-width: 480px) {
.topbar-shell {
padding: 10px 16px;
padding: 10px 12px;
}
.topbar-search {
padding: 6px 12px;
padding: 6px 10px;
}
.topbar-actions {
gap: 4px;
}
}

View File

@ -20,16 +20,14 @@
/>
</head>
<body>
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div style="flex: 1; display: flex; flex-direction: column">
<div class="page-body">
<div id="topbar-container"></div>
<main class="feed-main">
<main class="feed-main" id="main-content" tabindex="-1">
<!-- Optional: Feed Header -->
<div class="feed-header">
<h1>Feed</h1>
@ -37,62 +35,44 @@
</div>
<!-- Filter Buttons -->
<div class="filter-buttons">
<div class="filter-buttons" role="group" aria-label="Sort feed">
<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 class="filter-btn" data-sort="date" data-ascending="true">
<button type="button" class="filter-btn" data-sort="date" data-ascending="true" aria-pressed="false">
Oldest
</button>
</div>
<!-- Posts Grid -->
<div class="posts-grid" id="posts-grid"></div>
<div class="posts-grid" id="posts-grid" aria-live="polite"></div>
<!-- Empty State -->
<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>
<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>
<p>Follow some creators to see their prompts here.</p>
</div>
<!-- Error State -->
<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>
<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>
<p id="feed-error-msg"></p>
</div>
</main>
@ -107,11 +87,17 @@
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((link) => link.classList.remove("active"));
.forEach((link) => {
link.classList.remove("active");
link.removeAttribute("aria-current");
});
const firstLink = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[0];
if (firstLink) firstLink.classList.add("active");
if (firstLink) {
firstLink.classList.add("active");
firstLink.setAttribute("aria-current", "page");
}
});
fetch("/topbar.html")
@ -142,9 +128,9 @@
function renderStars(rating) {
if (rating == null) return "";
const stars = Math.round(rating);
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>
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>
</span>`;
}
@ -161,35 +147,37 @@
const liked = prompt.isLiked;
const saved = prompt.isSaved;
return `
<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}">
<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-author">
<span class="post-name">${prompt.creatorName}</span>
</div>
<span class="post-date">${timeAgo(prompt.timeStamp)}</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" 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>` : ''}
<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>
</div>
</a>
<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>
<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>
</div>
</div>`;
</article>`;
}
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) {
@ -200,26 +188,28 @@
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) {
@ -230,13 +220,16 @@
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) {
@ -271,8 +264,12 @@
btn.addEventListener("click", () => {
document
.querySelectorAll(".filter-btn")
.forEach((b) => b.classList.remove("active"));
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
loadFeed(btn.dataset.sort, btn.dataset.ascending === "true");
});
});

View File

@ -13,8 +13,12 @@ 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,99 +1,115 @@
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">
An error occurred while submitting the form. Please try again later.
{{ $this }}
<div class="form-error" role="alert" aria-live="assertive">
{{ $this }}
</div>
`);
function handleGenericFormError(response, responseText, form) {
if (!response.ok) {
const html = genericFormErrorTemplate.render(responseText);
form.insertAdjacentHTML('beforeend', html);
}
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);
}
}
const validationErrorTemplate = new Template(`
<div class="form-error">
<div class="form-error" role="alert" aria-live="assertive">
<ul>
@for(error of $this) {
<li class="error">{{error}}</li>
@ -103,7 +119,7 @@ const validationErrorTemplate = new Template(`
`);
const unknownInputErrorTemplate = new Template(`
<div class="form-error">
<div class="form-error" role="alert" aria-live="assertive">
<p>An error occurred with the following fields:</p>
@for(field, errors of Object.entries($this)) {
<ul>
@ -116,45 +132,49 @@ 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);
}
} else {
unknownInputErrors[field] = messages;
}
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 (Object.keys(unknownInputErrors).length > 0) {
const html = unknownInputErrorTemplate.render(unknownInputErrors);
form.insertAdjacentHTML('beforeend', html);
}
return true;
} else {
unknownInputErrors[field] = messages;
}
}
return false;
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;
}

View File

@ -5,6 +5,18 @@ 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

View File

@ -20,8 +20,9 @@
</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">
<main class="login-page" id="main-content" tabindex="-1">
<!-- White login card -->
<section class="login-card">
<!-- Logo container -->
@ -55,9 +56,10 @@
id="password"
name="password"
placeholder="Enter your password"
autocomplete="current-password"
required
>
<button type="button" id="togglePassword" class="toggle-password">
<button type="button" id="togglePassword" class="toggle-password" aria-controls="password" aria-pressed="false">
Show <!-- Click to show/hide password -->
</button>
</div>

View File

@ -1,4 +1,4 @@
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort, crypto payment modal -->
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort and tier access -->
<!doctype html>
<html lang="en">
@ -17,59 +17,16 @@
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>
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div style="flex: 1; display: flex; flex-direction: column">
<div class="page-body">
<div id="topbar-container"></div>
<main class="marketplace-main">
<main class="marketplace-main" id="main-content" tabindex="-1">
<!-- Header -->
<div class="marketplace-header">
<h1>Marketplace</h1>
@ -78,8 +35,8 @@
<!-- Filter + Sort Row -->
<div class="filter-sort-row">
<div class="filter-buttons" id="category-filters">
<button class="filter-btn active" data-category="">All</button>
<div 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>
<select
class="sort-dropdown"
@ -91,267 +48,31 @@
<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 Price</option>
<option value="price|false">Highest Price</option>
<option value="price|true">Lowest Tier Price</option>
<option value="price|false">Highest Tier Price</option>
</select>
</div>
<!-- Prompts Grid -->
<div class="prompts-grid" id="prompts-grid"></div>
<div class="prompts-grid" id="prompts-grid" aria-live="polite"></div>
<!-- Empty State -->
<div
id="market-empty"
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #64748b;
"
>
<i
class="bi bi-bag-x"
style="font-size: 3rem; display: block; margin-bottom: 16px"
></i>
<h3 style="margin-bottom: 8px">No prompts found</h3>
<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>
<p>Try a different category or search term.</p>
</div>
<!-- Error State -->
<div
id="market-error"
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #ef4444;
"
>
<i
class="bi bi-exclamation-circle"
style="font-size: 3rem; display: block; margin-bottom: 16px"
></i>
<h3 style="margin-bottom: 8px">Could not load prompts</h3>
<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>
<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")
@ -360,11 +81,17 @@
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((l) => l.classList.remove("active"));
.forEach((l) => {
l.classList.remove("active");
l.removeAttribute("aria-current");
});
const link = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[1];
if (link) link.classList.add("active");
if (link) {
link.classList.add("active");
link.setAttribute("aria-current", "page");
}
});
fetch("/topbar.html")
.then((r) => r.text())
@ -387,31 +114,40 @@
return `${Math.floor(h / 24)}d ago`;
}
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;"`
: "";
function renderStars(
rating,
reviewCount = 0,
promptId = null,
locked = false,
) {
const href =
promptId && !locked
? `/post-detail?id=${encodeURIComponent(promptId)}#rating-section`
: "";
if (rating == null)
return `<span${target} style="color:#94a3b8;font-size:0.8rem;${promptId && !locked ? 'cursor:pointer;' : ''}">No reviews yet</span>`;
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>`;
const stars = Math.round(rating);
const label = reviewCount === 1 ? "review" : "reviews";
return `<span class="prompt-rating"${target}><span style="color:#f59e0b">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> ${rating.toFixed(1)} (${reviewCount} ${label})</span>`;
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>`;
}
function promptPrice(prompt) {
if (prompt.price != null && Number(prompt.price) > 0) {
return `$${Number(prompt.price).toFixed(2)}`;
if (prompt.tierLevel) {
const price = prompt.tierMonthlyPrice == null
? ""
: ` - $${Number(prompt.tierMonthlyPrice).toFixed(2)}/mo`;
return `${prompt.tierName || `Tier ${prompt.tierLevel}`}${price}`;
}
if (prompt.tierLevel) return `$${(prompt.tierLevel * 4.99).toFixed(2)}/mo`;
if (prompt.canAccess === false) return "Paid";
return "Free";
}
function getNumericPrice(prompt) {
if (prompt.price != null && Number(prompt.price) > 0) {
return Number(prompt.price);
}
if (prompt.tierLevel) return prompt.tierLevel * 4.99;
if (prompt.tierMonthlyPrice != null) return Number(prompt.tierMonthlyPrice);
return 0;
}
@ -424,7 +160,9 @@
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;
@ -441,32 +179,34 @@
let cardIndex = 0;
function renderCard(p) {
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];
const locked = p.canAccess === false;
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 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>
<div class="market-card-header">
<div class="market-card-avatar">${p.creatorName.charAt(0).toUpperCase()}</div>
<span class="prompt-author">@${p.creatorName}</span>
<span style="margin-left:auto;font-size:0.75rem;color:#94a3b8;">${timeAgo(p.timeStamp)}</span>
<span class="market-card-time"><time datetime="${p.timeStamp}">${timeAgo(p.timeStamp)}</time></span>
</div>
<h3 class="prompt-title">${p.title}</h3>
<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>
<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>
<div class="prompt-price">${promptPrice(p)}</div>
<div class="prompt-actions">
${
locked
? `<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>`
? `<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>`
}
${
locked
? `<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>`
? `<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>`
}
</div>
</div>
@ -477,7 +217,8 @@
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("|");
@ -488,8 +229,10 @@
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)}`;
@ -501,7 +244,11 @@
}
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";
@ -523,14 +270,20 @@
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"));
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
activeCategory = c.slug;
loadPrompts();
});
@ -545,8 +298,12 @@
.addEventListener("click", function () {
document
.querySelectorAll("#category-filters .filter-btn")
.forEach((b) => b.classList.remove("active"));
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
this.classList.add("active");
this.setAttribute("aria-pressed", "true");
activeCategory = "";
loadPrompts();
});
@ -575,89 +332,26 @@
topbarObserver.observe(document.body, { childList: true, subtree: true });
wireMarketplaceTopbarSearch();
// Make openPayment global
window.openPayment = openPayment;
window.subscribeToPromptTier = async function (prompt) {
if (!prompt.tierLevel) return;
// ── Payment Modal ──────────────────────────────────────────────────
const CRYPTO_ADDRESSES = {
btc: "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf1N",
eth: "0x742d35Cc6634C0532925a3b8D4C9B8E4D8F2b1a",
sol: "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV1",
usdt: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
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();
}
};
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,121 +19,44 @@
/>
</head>
<body>
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div style="flex: 1; display: flex; flex-direction: column">
<div class="page-body">
<div id="topbar-container"></div>
<main class="post-detail-main">
<main class="post-detail-main" id="main-content" tabindex="-1">
<div class="post-detail-container" id="detail-content">
<!-- Loading -->
<div
id="detail-loading"
style="text-align: center; padding: 60px 20px; color: #64748b"
>
<i
class="bi bi-hourglass-split"
style="font-size: 2.5rem; display: block; margin-bottom: 12px"
></i>
<div id="detail-loading">
<i class="bi bi-hourglass-split state-icon" aria-hidden="true"></i>
<p>Loading prompt...</p>
</div>
<!-- Error -->
<div
id="detail-error"
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #ef4444;
"
>
<i
class="bi bi-exclamation-circle"
style="font-size: 2.5rem; display: block; margin-bottom: 12px"
></i>
<div id="detail-error" class="state-error" role="alert" aria-live="assertive">
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
<h3 id="detail-error-title">Prompt not found</h3>
<p
id="detail-error-msg"
style="color: #64748b; margin-top: 8px"
></p>
<button
onclick="history.back()"
style="
margin-top: 20px;
padding: 10px 24px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
"
>
<p id="detail-error-msg"></p>
<button type="button" onclick="history.back()" class="detail-back-btn">
Go Back
</button>
</div>
<!-- Content (hidden until loaded) -->
<div id="detail-body" style="display: none">
<div id="detail-body">
<!-- Header -->
<div class="post-header">
<div
style="
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
"
>
<div
id="creator-avatar"
style="
width: 42px;
height: 42px;
border-radius: 50%;
background: #6366f1;
color: #fff;
font-weight: 700;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
"
></div>
<div class="detail-creator-row">
<div id="creator-avatar"></div>
<div>
<span
id="creator-name"
style="font-weight: 600; font-size: 0.95rem"
></span>
<span
id="prompt-date"
style="display: block; font-size: 0.8rem; color: #94a3b8"
></span>
<span id="creator-name"></span>
<span id="prompt-date"></span>
</div>
<div style="margin-left: auto">
<div class="detail-actions-right">
<span id="tier-badge"></span>
<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>
<button type="button" id="edit-prompt-btn">Edit</button>
</div>
</div>
<h1 class="post-title" id="prompt-title"></h1>
@ -157,73 +80,38 @@
<!-- Prompt Content (only if accessible) -->
<div class="prompt-section" id="prompt-content-section">
<h2>PROMPT</h2>
<div
class="prompt-content"
id="prompt-body"
style="
white-space: pre-wrap;
font-family: monospace;
background: #f8fafc;
border-radius: 10px;
padding: 16px;
font-size: 0.9rem;
line-height: 1.7;
"
></div>
<div class="prompt-content" id="prompt-body"></div>
</div>
<!-- Example Output -->
<div class="example-section" id="example-section" style="display: none">
<div class="example-section" id="example-section">
<h2>EXAMPLE OUTPUT</h2>
<div class="example-content">
<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
id="example-output-text"
class="example-output-text"
></div>
<div id="example-image" class="example-image">
<img
id="example-image-img"
src=""
alt="Example output image"
/>
</div>
</div>
</div>
<!-- Locked section (shown instead of prompt if no access) -->
<div
id="locked-section"
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">
<div id="locked-section">
<i class="bi bi-lock-fill locked-icon" aria-hidden="true"></i>
<h3 class="locked-title">
This prompt requires a subscription
</h3>
<p style="color: #64748b; margin-bottom: 20px">
<p class="locked-desc">
Subscribe to <strong id="locked-creator"></strong> to access
this prompt.
</p>
<button
id="locked-subscribe-btn"
style="
padding: 12px 28px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
"
>
<button type="button" id="locked-subscribe-btn">
Subscribe <span id="locked-tier-name"></span>
</button>
</div>
@ -238,19 +126,34 @@
<h2>REVIEWS</h2>
<div class="review-form" id="review-form">
<h3>Your review</h3>
<div class="review-star-input" id="review-star-input" aria-label="Select rating">
<button type="button" data-rating="1"></button>
<button type="button" data-rating="2"></button>
<button type="button" data-rating="3"></button>
<button type="button" data-rating="4"></button>
<button type="button" data-rating="5"></button>
<div
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>
<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>
<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>
</div>
<div class="reviews-list" id="reviews-list">
<p style="color:#94a3b8;">Loading reviews...</p>
<div class="reviews-list" id="reviews-list" aria-live="polite">
<p class="detail-loading-text">Loading reviews...</p>
</div>
</div>
</div>
@ -266,7 +169,10 @@
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((l) => l.classList.remove("active"));
.forEach((l) => {
l.classList.remove("active");
l.removeAttribute("aria-current");
});
});
fetch("/topbar.html")
.then((r) => r.text())
@ -331,8 +237,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'}" 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>`;
`<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>`;
renderExamples(p);
renderOwnerActions(p);
setupReviewSection(p);
@ -340,28 +246,29 @@
// Tier badge
const badge = document.getElementById("tier-badge");
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>`;
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>`;
} else {
badge.innerHTML = `<span style="background:#dcfce7;color:#166534;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">Free</span>`;
badge.innerHTML = `<span class="tier-badge-free">Free</span>`;
}
// Rating
if (p.averageRating != null) {
const stars = Math.round(p.averageRating);
document.getElementById("rating-display").innerHTML =
`<span 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>`;
`<span class="rating-stars-display">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span>
<span class="rating-value">${p.averageRating.toFixed(1)}</span>
<span class="rating-count">/ 5.0 (${p.reviewCount || 0} ${(p.reviewCount || 0) === 1 ? "review" : "reviews"})</span>`;
document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" 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>`;
`<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>`;
} else {
document.getElementById("rating-display").innerHTML =
'<span style="color:#94a3b8;font-size:0.9rem;">No ratings yet</span>';
'<span class="rating-none">No ratings yet</span>';
}
// Content visibility
@ -377,6 +284,7 @@
document.getElementById("locked-tier-name").textContent = p.tierName
? `— ${p.tierName}`
: "";
setupLockedSubscription(p);
}
document.getElementById("detail-loading").style.display = "none";
@ -384,6 +292,38 @@
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;
@ -397,10 +337,14 @@
function setReviewRating(rating) {
selectedReviewRating = rating;
document.querySelectorAll("#review-star-input button").forEach((button) => {
const value = Number(button.dataset.rating);
button.textContent = value <= 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" : ""}`);
});
}
function escapeHtml(value) {
@ -451,9 +395,12 @@
// 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) {
@ -470,7 +417,8 @@
credentials: "same-origin",
body: JSON.stringify({
rating: selectedReviewRating,
comment: document.getElementById("review-comment").value.trim() || null,
comment:
document.getElementById("review-comment").value.trim() || null,
}),
});
@ -505,13 +453,16 @@
const reviews = await response.json();
if (reviews.length === 0) {
list.innerHTML = '<p style="color:#94a3b8;">No reviews yet.</p>';
list.innerHTML =
'<p class="detail-loading-text">No reviews yet.</p>';
return;
}
list.innerHTML = reviews.map((review) => {
const stars = "★".repeat(review.rating) + "☆".repeat(5 - review.rating);
return `
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>
@ -519,9 +470,10 @@
</div>
<p class="review-card-comment">${escapeHtml(review.comment || "No comment.")}</p>
</article>`;
}).join("");
})
.join("");
} catch (error) {
list.innerHTML = `<p style="color:#ef4444;">${error.message}</p>`;
list.innerHTML = `<p class="detail-error-text">${error.message}</p>`;
}
}

View File

@ -1,398 +1,627 @@
<!-- 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>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<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>
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<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 id="topbar-container"></div>
<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>
<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;">
<div id="profileBio">Loading profile...</div>
<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 id="profileSpecialities"></div>
<div id="profileStats">
<span><strong id="profileRating">0.0</strong> rating</span>
<span
><strong id="profileSubscribers">0</strong> subscribers</span
>
</div>
</div>
<div id="profileBio" style="margin-bottom:8px;">
Loading profile...
<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>
</section>
<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>
<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="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>
<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>
</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');
<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");
}
});
// 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 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;
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 = [];
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" 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;
}
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" 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;
}
}
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;
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();
}
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 = '';
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;
if (profile.avatarUrl) {
profileAvatar.src = profile.avatarUrl;
}
profileLoaded = true;
}
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;
if (prompt.creatorAvatarUrl) {
profileAvatar.src = prompt.creatorAvatarUrl;
}
}
}
function isPromptMarked(type, id) {
if (type === 'liked') {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isLiked === true;
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.
}
}
if (type === 'saved') {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isSaved === 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 = "";
}
}
}
return false;
}
function isPromptMarked(type, id) {
if (type === "liked") {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isLiked === true;
}
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>` : ''}
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>
</div>
</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;
</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>`;
}
profilePromptsGrid.innerHTML = prompts.map((prompt) => renderProfilePrompt(prompt, options)).join('');
}
function renderPromptList(prompts, emptyText, options = {}) {
if (!prompts.length) {
profilePromptsGrid.innerHTML = `<div class="profile-grid-empty">${emptyText}</div>`;
return;
}
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 updateProfileMode() {
if (isOwnProfile) {
profileActions.style.display = 'flex';
primaryProfileButton.textContent = 'Edit Profile';
primaryProfileButton.disabled = false;
primaryProfileButton.onclick = () => location.href = 'settings.html';
profileTabs.style.display = 'flex';
return;
profilePromptsGrid.innerHTML = prompts
.map((prompt) => renderProfilePrompt(prompt, options))
.join("");
}
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.');
}
async function loadFollowState() {
if (isOwnProfile || !profileId) return;
try {
const response = await fetch(`/api/v1/subscriptions/${encodeURIComponent(profileId)}`, {
credentials: 'same-origin'
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));
});
if (response.status === 401) {
location.href = '/login';
return;
}
profilePromptsGrid.setAttribute("aria-labelledby", activeProfileTab === "favorites" ? "favoritesTab" : activeProfileTab === "saved" ? "savedTab" : "myPromptsTab");
const subscription = response.ok ? await response.json() : null;
currentIsFollowing = subscription !== null;
updateProfileMode();
} catch {
currentIsFollowing = false;
}
}
const liked = allPrompts.filter((prompt) =>
isPromptMarked("liked", prompt.id),
);
const saved = allPrompts.filter((prompt) =>
isPromptMarked("saved", prompt.id),
);
async function toggleProfileFollow() {
if (!profileId) return;
myPromptsTab.textContent = `My Prompts (${ownPrompts.length})`;
favoritesTab.textContent = `Favorites (${liked.length})`;
savedTab.textContent = `Saved (${saved.length})`;
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;
profileSubscribers.textContent = Math.max(0, currentSubscribers + (currentIsFollowing ? 1 : -1));
updateProfileMode();
} 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;
}
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();
if (activeProfileTab === "favorites") {
renderPromptList(liked, "No liked prompts yet.");
} else if (activeProfileTab === "saved") {
renderPromptList(saved, "No saved prompts yet.");
} else {
profilePrompts = allPrompts.filter((prompt) => prompt.creatorId?.toLowerCase() === profileId.toLowerCase());
renderProfileFromPrompt(profilePrompts[0]);
updateProfileMode();
renderPromptList(ownPrompts, "No prompts yet.", { showEdit: true });
}
} catch {
// Favorites and saved stay empty if prompts cannot be loaded.
}
}
document.querySelectorAll('.profile-tab').forEach((tab) => {
tab.addEventListener('click', () => {
if (!isOwnProfile) {
updateProfileMode();
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;
}
activeProfileTab = tab.dataset.tab;
updateTabs();
});
});
(async function initProfilePage() {
await loadProfile();
await loadCreatorCardFallback();
await loadFollowState();
updateProfileMode();
if (isOwnProfile) {
loadOwnPrompts();
profileActions.style.display = "flex";
manageTiersButton.style.display = "none";
primaryProfileButton.textContent = currentIsFollowing
? currentSubscriptionTier
? `Subscribed: ${currentSubscriptionTier.name}`
: "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.");
renderCreatorTiers();
}
loadAllPromptReferences();
})();
</script>
</body>
function renderCreatorTiers() {
if (isOwnProfile) {
creatorTierList.innerHTML = "";
return;
}
if (!creatorSubscriptionTiers.length) {
creatorTierList.innerHTML = "";
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();
});
});
(async function initProfilePage() {
await loadProfile();
await loadCreatorCardFallback();
await loadFollowState();
await loadCreatorTiers();
updateProfileMode();
if (isOwnProfile) {
loadOwnPrompts();
}
loadAllPromptReferences();
})();
</script>
</body>
</html>

View File

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

View File

@ -6,74 +6,81 @@
<div class="sidebar-shell">
<!-- Logo -->
<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 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>
<!-- Navigation -->
<nav class="sidebar">
<nav class="sidebar" id="main-navigation" aria-label="Main navigation" tabindex="-1">
<ul>
<li>
<a href="dashboard.html" class="active">
<i class="bi bi-house icon-blue"></i>
<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>
<span class="nav-text">Dashboard</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="marketplace.html">
<i class="bi bi-shop icon-purple"></i>
<i class="bi bi-shop icon-purple" aria-hidden="true"></i>
<span class="nav-text">Marketplace</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="community.html">
<i class="bi bi-people icon-pink"></i>
<i class="bi bi-people icon-pink" aria-hidden="true"></i>
<span class="nav-text">Community</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="chats.html">
<i class="bi bi-chat-dots icon-blue"></i>
<i class="bi bi-chat-dots icon-blue" aria-hidden="true"></i>
<span class="nav-text">Chats</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="settings.html">
<i class="bi bi-gear icon-purple"></i>
<i class="bi bi-gear icon-purple" aria-hidden="true"></i>
<span class="nav-text">Settings</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="profile.html">
<i class="bi bi-person icon-pink"></i>
<i class="bi bi-person icon-pink" aria-hidden="true"></i>
<span class="nav-text">My Profile</span>
</a>
</li>
<li>
<li class="mobile-nav-item">
<a href="create.html">
<i class="bi bi-plus-circle-fill icon-blue"></i>
<i class="bi bi-plus-circle-fill icon-blue" aria-hidden="true"></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">
<button type="submit" class="sidebar-logout" aria-label="Logout">
<div class="logout-left">
<i class="bi bi-box-arrow-right"></i>
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
<span class="nav-text">Logout</span>
</div>
<i class="bi bi-chevron-right logout-arrow"></i>
<i class="bi bi-chevron-right logout-arrow" aria-hidden="true"></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 - Login</title>
<title>OnlyPrompt - Sign Up</title>
<!-- CSS files for variables, base styles, and login page -->
<link rel="stylesheet" href="../css/variables.css">
@ -20,8 +20,9 @@
</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">
<main class="login-page" id="main-content" tabindex="-1">
<!-- White login card -->
<section class="login-card">
<!-- Logo container -->
@ -37,25 +38,25 @@
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="yourname@email.com" required>
<input type="email" id="email" name="email" placeholder="yourname@email.com" autocomplete="email" 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" required>
<input type="text" id="displayName" name="displayName" placeholder="Enter your display name" autocomplete="name" required>
</div>
<div class="form-group">
<label for="userName">Username</label>
<input type="text" id="userName" name="userName" placeholder="Choose a username" required>
<input type="text" id="userName" name="userName" placeholder="Choose a username" autocomplete="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" required>
<button type="button" id="togglePassword" class="toggle-password">
<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">
Show <!-- Click to show/hide password -->
</button>
</div>
@ -73,7 +74,7 @@
<p class="signup-text">
Have an account?
<a href="#">Log In</a>
<a href="/login">Log In</a>
</p>
</section>
</main>

View File

@ -0,0 +1,389 @@
<!-- 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,27 +1,29 @@
<!--
Reusable topbar for OnlyPrompt
- Search in the middle
- small chat & notification icons
- small chat & logout icons
- profile avatar on the right
-->
<header class="topbar-shell">
<div class="topbar-search">
<i class="bi bi-search"></i>
<input id="topbarSearchInput" type="search" placeholder="Search">
<i class="bi bi-search" aria-hidden="true"></i>
<input id="topbarSearchInput" type="search" placeholder="Search" aria-label="Search prompts and creators">
</div>
<div class="topbar-actions">
<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>
<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>
<!-- Profile avatar on the right (must be changed with backend) -->
<button class="topbar-avatar-btn" aria-label="Profile">
<img id="topbarAvatar" src="../images/content/cat.png" alt="Profile Picture" class="topbar-avatar">
<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>
</div>
</header>

View File

@ -1,20 +1,24 @@
# 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 paid 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 subscription-based 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 price filters
- 🔍 Browse prompts in a marketplace with category, search and tier 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
- 📱 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
- 🔄 Server communication through a REST API
- 💾 Shared data persistence with backend and database
@ -33,6 +37,13 @@ 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
@ -53,12 +64,13 @@ 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:
- Payment and premium access are simulated and are not connected to a real payment provider.
- 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.
- 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 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.
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.
## Group members and their roles
| Name | Role |