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. 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 ### Feed
@ -187,6 +187,8 @@ Request:
Response: created prompt. The frontend redirects to `/post-detail?id={id}`. 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 ### Own Prompts
```http ```http
@ -202,7 +204,7 @@ PUT /api/v1/prompts/{id}
Content-Type: application/json 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 ### Prompt Detail
@ -216,13 +218,13 @@ Response includes:
- prompt content if accessible - prompt content if accessible
- category - category
- creator information - creator information
- price or free state - tier or free state
- example output - example output
- example image - example image
- average rating and review count - average rating and review count
- like/save state and counts - 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 ### Likes and Saves
@ -283,10 +285,49 @@ Default categories are created automatically when the backend starts.
```http ```http
GET /api/v1/subscriptions/{creatorId} GET /api/v1/subscriptions/{creatorId}
PUT /api/v1/subscriptions/{creatorId} PUT /api/v1/subscriptions/{creatorId}
PUT /api/v1/subscriptions/{creatorId}/{level}
DELETE /api/v1/subscriptions/{creatorId} 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 ## Error Handling

View File

@ -1,7 +1,7 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt 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 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, 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 ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
public record ApiLikeState(int LikeCount, bool IsLiked); public record ApiLikeState(int LikeCount, bool IsLiked);
public record ApiSaveState(int SaveCount, bool IsSaved); public record ApiSaveState(int SaveCount, bool IsSaved);

View File

@ -1,5 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt 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 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.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level, x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name, x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating), x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count, 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(); )).ToArrayAsync();
return prompts; return prompts;

View File

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

View File

@ -100,6 +100,35 @@ namespace OnlyPrompt.Backend.Controllers
return subscription; 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}")] [HttpDelete("{userId}")]
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId) 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.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level) .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.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.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId) .MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating)) .MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
@ -66,6 +67,7 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.IsSaved, x => false) .MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level) .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.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.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count) .MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
.MapCtorParamFrom(x => x.CanAccess, x => true); .MapCtorParamFrom(x => x.CanAccess, x => true);

View File

@ -1,130 +1,381 @@
<!-- OnlyPrompt - Chats page: <!-- OnlyPrompt - Chats page:
- Direct messaging interface with conversation list and active chat window --> - Direct messaging interface with conversation list and active chat window -->
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Chats</title> <title>OnlyPrompt - Chats</title>
<link rel="stylesheet" href="../css/variables.css"> <link rel="stylesheet" href="../css/variables.css" />
<link rel="stylesheet" href="../css/base.css"> <link rel="stylesheet" href="../css/base.css" />
<link rel="stylesheet" href="../css/sidebar.css"> <link rel="stylesheet" href="../css/sidebar.css" />
<link rel="stylesheet" href="../css/login.css"> <link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.css"> <link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/chats.css"> <link rel="stylesheet" href="../css/chats.css" />
<script src="../js/profile-shared.js"></script> <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"> <link
</head> rel="stylesheet"
<body> href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);"> />
</head>
<div id="sidebar-container"></div> <body>
<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>
<div id="topbar-container"></div>
<main class="chats-main"> <main class="chats-main" id="main-content" tabindex="-1">
<!-- Chat Container: Left column (list) + Right column (active chat) --> <!-- Chat Container: Left column (list) + Right column (active chat) -->
<div class="chat-container"> <div class="chat-container">
<!-- Left Column: Chat Overview -->
<!-- Left Column: Chat Overview --> <div class="chat-list">
<div class="chat-list"> <div class="chat-list-header">
<div class="chat-list-header"> <h2>Messages</h2>
<h2>Messages</h2> <button type="button" class="new-chat-btn" id="newChatBtn" aria-label="New chat" aria-expanded="false" aria-controls="new-chat-panel">
<button class="new-chat-btn"><i class="bi bi-pencil-square"></i></button> <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>
<div class="chat-list-items">
<!-- Chat Entry 1 (active) --> <!-- Right Column: Active Chat -->
<div class="chat-item active"> <div class="chat-active">
<img src="../images/content/creator2.png" alt="Alex Chen" class="chat-avatar"> <div class="chat-header">
<div class="chat-item-info"> <img
<div class="chat-name">Alex Chen</div> src="../images/content/cat.png"
<div class="chat-last-msg">Hey Sarah! Really loved your last video on minimalism...</div> 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>
<div class="chat-time">10:17 AM</div>
</div> </div>
<!-- Chat Entry 2 --> <div class="chat-messages" id="chatMessages" aria-live="polite" aria-label="Conversation">
<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> </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>
</div> </div>
</main>
<!-- Right Column: Active Chat (with Alex Chen) --> </div>
<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>
</div> </div>
</div>
<script> <script>
fetch('/sidebar.html') fetch("/sidebar.html")
.then(r => r.text()) .then((r) => r.text())
.then(data => { .then((data) => {
document.getElementById('sidebar-container').innerHTML = data; document.getElementById("sidebar-container").innerHTML = data;
// Remove 'active' from all sidebar links // Remove 'active' from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => { document
link.classList.remove('active'); .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]; fetch("/topbar.html")
if (chatsLink) chatsLink.classList.add('active'); .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') creatorSearch.addEventListener("input", renderCreatorResults);
.then(r => r.text()) chatForm.addEventListener("submit", (event) => {
.then(data => document.getElementById('topbar-container').innerHTML = data); event.preventDefault();
</script> addMessage(messageInput.value);
</body> messageInput.value = "";
});
loadCreators().then(() => {
if (!openConversationFromUrl()) {
activeConversationId = conversations[0]?.id || null;
renderChatList();
renderMessages();
}
});
</script>
</body>
</html> </html>

View File

@ -1,204 +1,247 @@
<!-- OnlyPrompt - Community page: <!-- OnlyPrompt - Community page:
- Discover creators, follow/unfollow, dynamic via API --> - Discover creators, follow/unfollow, dynamic via API -->
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Discover Creators</title> <title>OnlyPrompt - Discover Creators</title>
<link rel="stylesheet" href="../css/variables.css"> <link rel="stylesheet" href="../css/variables.css" />
<link rel="stylesheet" href="../css/base.css"> <link rel="stylesheet" href="../css/base.css" />
<link rel="stylesheet" href="../css/sidebar.css"> <link rel="stylesheet" href="../css/sidebar.css" />
<link rel="stylesheet" href="../css/login.css"> <link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.css"> <link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/community.css"> <link rel="stylesheet" href="../css/community.css" />
<script src="../js/profile-shared.js"></script> <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"> <link
</head> rel="stylesheet"
<body> href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);"> />
</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="creators-main" id="main-content" tabindex="-1">
<div class="creators-header">
<div id="topbar-container"></div> <h1>Discover Creators</h1>
<p>Follow your favorite prompt artists and get inspired.</p>
<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>` : ''}
</div> </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 ──────────────────────────────────────────── <div class="filter-buttons" role="group" aria-label="Sort creators">
async function toggleFollow(btn) { <button type="button" class="filter-btn active" data-sort="popular" aria-pressed="true">
const userId = btn.dataset.userid; Popular
const isFollowing = btn.dataset.following === 'true'; </button>
btn.disabled = true; <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}`, { <div class="creators-grid" id="creators-grid" aria-live="polite"></div>
method: isFollowing ? 'DELETE' : 'PUT',
credentials: 'same-origin'
});
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) { <div id="creators-error" class="state-error" role="alert" aria-live="assertive">
const nowFollowing = !isFollowing; <i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
btn.dataset.following = nowFollowing; <h3 class="state-title">Could not load creators</h3>
btn.textContent = nowFollowing ? 'Following' : 'Follow'; <p id="creators-error-msg"></p>
btn.classList.toggle('following', nowFollowing); </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 ──────────────────────────────────────────────── // ── Follow / Unfollow ────────────────────────────────────────────
const grid = document.getElementById('creators-grid'); async function toggleFollow(btn) {
const emptyEl = document.getElementById('creators-empty'); const userId = btn.dataset.userid;
const emptyTitle = document.getElementById('creators-empty-title'); const isFollowing = btn.dataset.following === "true";
const emptyText = document.getElementById('creators-empty-text'); btn.disabled = true;
const errorEl = document.getElementById('creators-error');
const errorMsg = document.getElementById('creators-error-msg');
let activeSort = 'popular'; const res = await fetch(`/api/v1/subscriptions/${userId}`, {
let currentSearch = new URLSearchParams(location.search).get('search') || ''; method: isFollowing ? "DELETE" : "PUT",
credentials: "same-origin",
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) {
if (res.status === 401) { location.href = '/login'; return; } location.href = "/login";
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; 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.disabled = false;
btn.addEventListener('click', () => toggleFollow(btn));
});
} catch (e) {
errorEl.style.display = 'block';
errorMsg.textContent = e.message;
} }
}
// ── Filter buttons ─────────────────────────────────────────────── // ── Load Creators ────────────────────────────────────────────────
document.querySelectorAll('.filter-btn').forEach(btn => { const grid = document.getElementById("creators-grid");
btn.addEventListener('click', () => { const emptyEl = document.getElementById("creators-empty");
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); const emptyTitle = document.getElementById("creators-empty-title");
btn.classList.add('active'); const emptyText = document.getElementById("creators-empty-text");
loadCreators(btn.dataset.sort); 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) => { window.applyCreatorSearch = (value) => {
currentSearch = value.trim(); currentSearch = value.trim();
loadCreators(activeSort); loadCreators(activeSort);
}; };
loadCreators(); loadCreators();
</script> </script>
</body> </body>
</html> </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"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head> </head>
<body> <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 id="sidebar-container"></div>
<div style="flex:1; display: flex; flex-direction: column;"> <div class="page-body">
<div id="topbar-container"></div> <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-container">
<div class="create-header"> <div class="create-header">
<h1 id="create-title">Create AI Prompt</h1> <h1 id="create-title">Create AI Prompt</h1>
@ -36,7 +37,7 @@
<!-- Title --> <!-- Title -->
<div class="form-group"> <div class="form-group">
<label for="title">Prompt Title *</label> <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> </div>
<!-- Description --> <!-- Description -->
@ -61,8 +62,8 @@
<!-- Prompt Content --> <!-- Prompt Content -->
<div class="form-group"> <div class="form-group">
<label for="promptContent">Prompt Content *</label> <label for="promptContent">Prompt Content *</label>
<textarea id="promptContent" name="promptContent" rows="6" placeholder="Write your prompt instructions here..." required></textarea> <textarea id="promptContent" name="promptContent" rows="6" placeholder="Write your prompt instructions here..." aria-describedby="promptContentHint" required></textarea>
<small class="form-hint">Use clear, step-by-step instructions for the AI.</small> <small class="form-hint" id="promptContentHint">Use clear, step-by-step instructions for the AI.</small>
</div> </div>
<!-- Example Output (Text) --> <!-- Example Output (Text) -->
@ -74,24 +75,28 @@
<!-- Example Image (optional) --> <!-- Example Image (optional) -->
<div class="form-group"> <div class="form-group">
<label for="exampleImage">Example Image (optional)</label> <label for="exampleImage">Example Image (optional)</label>
<input type="file" id="exampleImage" name="exampleImage" accept="image/png, image/jpeg, image/jpg"> <input type="file" id="exampleImage" name="exampleImage" accept="image/png, image/jpeg, image/jpg" aria-describedby="exampleImageHint">
<small class="form-hint">Upload a PNG or JPG preview will appear below.</small> <small class="form-hint" id="exampleImageHint">Upload a PNG or JPG. Preview will appear below.</small>
<div id="imagePreview" style="margin-top: 10px; display: none;"> <div id="imagePreview" aria-live="polite">
<img id="previewImg" src="#" alt="Preview" style="max-width: 100%; max-height: 200px; border-radius: 12px;"> <img id="previewImg" src="#" alt="Selected example image preview">
</div> </div>
</div> </div>
<!-- Pricing (with toggle) --> <!-- Pricing (with toggle) -->
<div class="form-group pricing-group"> <div class="form-group pricing-group">
<label>Pricing</label> <span class="form-label" id="access-label">Access</span>
<div class="pricing-toggle"> <div class="pricing-toggle" role="group" aria-labelledby="access-label">
<button type="button" id="freeBtn" class="price-option active">Free</button> <button type="button" id="freeBtn" class="price-option active" aria-pressed="true">Free</button>
<button type="button" id="paidBtn" class="price-option">Paid</button> <button type="button" id="tierBtn" class="price-option" aria-pressed="false">Tier</button>
</div> </div>
<div id="priceField" style="display: none;"> <div id="tierField">
<input type="number" id="price" name="price" step="0.01" min="0" placeholder="Price in USD (e.g., 19.99)"> <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> </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> </div>
<!-- Submit Button --> <!-- Submit Button -->
@ -99,7 +104,7 @@
<button type="submit" class="submit-btn" id="submitPromptBtn">Publish Prompt</button> <button type="submit" class="submit-btn" id="submitPromptBtn">Publish Prompt</button>
<button type="button" class="cancel-btn">Cancel</button> <button type="button" class="cancel-btn">Cancel</button>
</div> </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> </form>
</div> </div>
</main> </main>
@ -109,23 +114,28 @@
<script> <script>
// Toggle between free and paid // Toggle between free and paid
const freeBtn = document.getElementById('freeBtn'); const freeBtn = document.getElementById('freeBtn');
const paidBtn = document.getElementById('paidBtn'); const tierBtn = document.getElementById('tierBtn');
const priceField = document.getElementById('priceField'); const tierField = document.getElementById('tierField');
const priceInput = document.getElementById('price'); const tierSelect = document.getElementById('subscriptionTier');
const editPromptId = new URLSearchParams(location.search).get('id'); const editPromptId = new URLSearchParams(location.search).get('id');
const submitPromptBtn = document.getElementById('submitPromptBtn'); const submitPromptBtn = document.getElementById('submitPromptBtn');
let ownSubscriptionTiers = [];
freeBtn.addEventListener('click', () => { freeBtn.addEventListener('click', () => {
freeBtn.classList.add('active'); freeBtn.classList.add('active');
paidBtn.classList.remove('active'); tierBtn.classList.remove('active');
priceField.style.display = 'none'; freeBtn.setAttribute('aria-pressed', 'true');
priceInput.removeAttribute('required'); tierBtn.setAttribute('aria-pressed', 'false');
tierField.style.display = 'none';
tierSelect.removeAttribute('required');
}); });
paidBtn.addEventListener('click', () => { tierBtn.addEventListener('click', () => {
paidBtn.classList.add('active'); tierBtn.classList.add('active');
freeBtn.classList.remove('active'); freeBtn.classList.remove('active');
priceField.style.display = 'block'; tierBtn.setAttribute('aria-pressed', 'true');
priceInput.setAttribute('required', 'required'); freeBtn.setAttribute('aria-pressed', 'false');
tierField.style.display = 'grid';
tierSelect.setAttribute('required', 'required');
}); });
// Image preview for example image // 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() { async function loadPromptForEdit() {
if (!editPromptId) return; if (!editPromptId) return;
@ -202,9 +237,9 @@
imagePreview.style.display = 'block'; imagePreview.style.display = 'block';
} }
if (prompt.price != null && Number(prompt.price) > 0) { if (prompt.tierLevel != null) {
paidBtn.click(); tierBtn.click();
priceInput.value = Number(prompt.price); tierSelect.value = String(prompt.tierLevel);
} else { } else {
freeBtn.click(); freeBtn.click();
} }
@ -224,8 +259,7 @@
submitBtn.disabled = true; submitBtn.disabled = true;
try { try {
const isPaid = paidBtn.classList.contains('active'); const isTier = tierBtn.classList.contains('active');
const price = isPaid ? Number(priceInput.value || 0) : null;
const payload = { const payload = {
title: document.getElementById('title').value.trim(), title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(), description: document.getElementById('description').value.trim(),
@ -233,8 +267,8 @@
content: document.getElementById('promptContent').value.trim(), content: document.getElementById('promptContent').value.trim(),
exampleOutput: document.getElementById('exampleOutput').value.trim() || null, exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
exampleImageUrl: exampleImageUrl || null, exampleImageUrl: exampleImageUrl || null,
price, price: null,
subscriptionTier: null, subscriptionTier: isTier ? Number(tierSelect.value) : null,
slug: null slug: null
}; };
@ -290,17 +324,21 @@
// Remove active class from all sidebar links // Remove active class from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => { document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active'); link.classList.remove('active');
link.removeAttribute('aria-current');
}); });
// Optionally set active on "Create New" if it exists, otherwise keep none // Optionally set active on "Create New" if it exists, otherwise keep none
const createLink = document.querySelector('#sidebar-container a[href="create.html"]'); 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') fetch('/topbar.html')
.then(r => r.text()) .then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data); .then(data => document.getElementById('topbar-container').innerHTML = data);
loadCategories().then(loadPromptForEdit); Promise.all([loadCategories(), loadSubscriptionTiers()]).then(loadPromptForEdit);
</script> </script>
</body> </body>
</html> </html>

View File

@ -18,6 +18,58 @@ body {
color: var(--text); 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 errors */
.form-error { .form-error {
color: red; color: red;
@ -28,7 +80,7 @@ body {
.form-error ul { .form-error ul {
list-style: none; list-style: none;
padding-left: 0; padding-left: 0;
list-style: '*'; list-style: "*";
} }
.form-error li { .form-error li {
@ -38,4 +90,75 @@ body {
.form-error li .error { .form-error li .error {
color: red; color: red;
font-style: italic; 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 */ /* 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 { .chats-main {
flex: 1; flex: 1;
padding: 20px 32px; padding: 20px 32px;
@ -20,7 +12,7 @@
gap: 24px; gap: 24px;
background: #fff; background: #fff;
border-radius: 18px; 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; overflow: hidden;
height: calc(100vh - 120px); /* Adjust based on topbar height */ height: calc(100vh - 120px); /* Adjust based on topbar height */
min-height: 500px; min-height: 500px;
@ -50,9 +42,81 @@
.new-chat-btn { .new-chat-btn {
background: none; background: none;
border: none; border: none;
border-radius: 999px;
font-size: 1.2rem; font-size: 1.2rem;
color: #3b82f6; color: #3b82f6;
cursor: pointer; 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 { .chat-list-items {
@ -61,15 +125,22 @@
} }
.chat-item { .chat-item {
background: transparent;
border: 0;
border-bottom: 1px solid #f0f2f5;
color: inherit;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
font: inherit;
text-align: left;
padding: 16px 20px; padding: 16px 20px;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
border-bottom: 1px solid #f0f2f5; width: 100%;
} }
.chat-item:hover { .chat-item:hover,
.chat-item:focus-visible {
background: #f8fafc; background: #f8fafc;
} }
.chat-item.active { .chat-item.active {
@ -196,6 +267,7 @@
padding: 16px 24px; padding: 16px 24px;
border-top: 1px solid #eef2f7; border-top: 1px solid #eef2f7;
background: #fff; background: #fff;
margin: 0;
} }
.chat-input-area input { .chat-input-area input {
flex: 1; flex: 1;
@ -208,6 +280,12 @@
.chat-input-area input:focus { .chat-input-area input:focus {
border-color: #3b82f6; border-color: #3b82f6;
} }
.chat-input-area input:disabled {
background: #f8fafc;
cursor: not-allowed;
}
.send-btn { .send-btn {
background: var(--gradient); background: var(--gradient);
border: none; border: none;
@ -222,6 +300,11 @@
opacity: 0.85; opacity: 0.85;
} }
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.chats-main { .chats-main {
@ -244,4 +327,4 @@
.chat-active { .chat-active {
height: 500px; height: 500px;
} }
} }

View File

@ -1,13 +1,5 @@
/* Creators page - Discover creators, filter buttons, creator cards */ /* 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 { .creators-main {
background: transparent !important; background: transparent !important;
padding: 20px 32px !important; padding: 20px 32px !important;
@ -71,15 +63,24 @@
.creator-card { .creator-card {
background: #fff; background: #fff;
border-radius: 18px; 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; padding: 20px;
display: flex; display: flex;
gap: 16px; 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); 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 { .creator-avatar {
@ -98,6 +99,15 @@
font-weight: 700; font-weight: 700;
margin-bottom: 4px; margin-bottom: 4px;
} }
.creator-name a {
color: inherit;
text-decoration: none;
}
.creator-name a:hover {
text-decoration: underline;
}
.creator-handle { .creator-handle {
color: #64748b; color: #64748b;
font-size: 0.85rem; font-size: 0.85rem;
@ -123,7 +133,14 @@
.creator-stats i { .creator-stats i {
margin-right: 4px; margin-right: 4px;
} }
.follow-btn { .creator-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.follow-btn,
.creator-chat-btn {
background: var(--gradient); background: var(--gradient);
color: white; color: white;
border: none; border: none;
@ -132,11 +149,25 @@
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
min-height: 34px;
text-decoration: none;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
.follow-btn:hover {
.follow-btn:hover,
.creator-chat-btn:hover {
opacity: 0.85; opacity: 0.85;
} }
.creator-chat-btn {
background: #eef2ff;
color: #2563eb;
}
.follow-btn.following { .follow-btn.following {
background: transparent; background: transparent;
border: 2px solid #94a3b8; border: 2px solid #94a3b8;
@ -177,4 +208,17 @@
.follow-btn { .follow-btn {
width: 100%; 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 */ /* 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 { .create-main {
flex: 1; flex: 1;
display: flex; display: flex;
@ -22,12 +14,12 @@
width: 100%; width: 100%;
background: #fff; background: #fff;
border-radius: 18px; 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; padding: 32px;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
} }
.create-container:hover { .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 */ /* Header */
@ -57,7 +49,8 @@
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.form-group label { .form-group label,
.form-label {
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
} }
@ -77,7 +70,7 @@
.form-group select:focus { .form-group select:focus {
outline: none; outline: none;
border-color: #7c3aed; 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 { .form-hint {
font-size: 0.75rem; font-size: 0.75rem;
@ -104,17 +97,49 @@
background: var(--gradient); background: var(--gradient);
color: white; color: white;
} }
#priceField { #tierField {
display: none;
gap: 8px;
margin-top: 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 */ /* Buttons */
.form-actions { .form-actions {
display: flex; display: flex;
gap: 16px; gap: 16px;
margin-top: 8px; margin-top: 8px;
} }
.submit-btn, .cancel-btn { .submit-btn,
.cancel-btn {
flex: 1; flex: 1;
border: none; border: none;
padding: 12px; padding: 12px;
@ -132,7 +157,8 @@
background: #f1f5f9; background: #f1f5f9;
color: #475569; color: #475569;
} }
.submit-btn:hover, .cancel-btn:hover { .submit-btn:hover,
.cancel-btn:hover {
opacity: 0.85; opacity: 0.85;
} }
@ -152,4 +178,4 @@
.form-actions { .form-actions {
flex-direction: column; flex-direction: column;
} }
} }

View File

@ -1,13 +1,5 @@
/* Feed page - Multi-column grid, square images, like/comment/save actions */ /* 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 { .feed-main {
background: transparent !important; background: transparent !important;
padding: 20px 32px !important; padding: 20px 32px !important;
@ -80,11 +72,20 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.post-card:hover { .post-card:hover,
.post-card:focus-within {
transform: translateY(-2px); 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);
} }
.post-card-link {
color: inherit;
display: flex;
flex: 1;
flex-direction: column;
text-decoration: none;
}
/* Post Header */ /* Post Header */
.post-header { .post-header {
display: flex; display: flex;
@ -125,7 +126,7 @@
.post-title { .post-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
margin: 0 0 6px 0; margin: 10px 0 6px 0;
} }
.post-description { .post-description {
color: #334155; color: #334155;

View File

@ -1,13 +1,5 @@
/* Marketplace Page - Prompt cards, filter buttons, full width layout */ /* 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 { .marketplace-main {
background: transparent !important; background: transparent !important;
padding: 20px 32px !important; padding: 20px 32px !important;
@ -123,6 +115,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
flex: 1;
} }
.prompt-title { .prompt-title {
@ -153,6 +146,7 @@
gap: 8px; gap: 8px;
font-size: 0.85rem; font-size: 0.85rem;
color: #f59e0b; color: #f59e0b;
text-decoration: none;
} }
.prompt-rating span:first-child i { .prompt-rating span:first-child i {
color: #f59e0b; color: #f59e0b;
@ -165,7 +159,7 @@
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 700; font-weight: 700;
color: #3b82f6; color: #3b82f6;
margin: 8px 0 4px; margin: auto 0 4px;
} }
.prompt-actions { .prompt-actions {
@ -228,26 +222,68 @@
} }
} }
/* Payment method buttons */ .market-card-header {
.pay-method-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
width: 100%; margin-bottom: 8px;
padding: 14px 16px; }
background: #f8fafc; .market-card-avatar {
border: 1px solid #e2e8f0; width: 34px;
border-radius: 12px; 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-size: 0.95rem;
font-weight: 600; flex-shrink: 0;
color: #1e293b; }
.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; cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
text-align: left;
} }
.pay-method-btn:hover { .market-rating-stars {
border-color: #6366f1; color: #f59e0b;
background: #f5f3ff; }
.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 */ /* 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 { .post-detail-main {
flex: 1; flex: 1;
display: flex; display: flex;
@ -21,12 +13,12 @@
width: 100%; width: 100%;
background: #fff; background: #fff;
border-radius: 18px; 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; padding: 32px;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
} }
.post-detail-container:hover { .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 */ /* Header */
@ -319,3 +311,190 @@
padding: 16px; 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 */ /* Profile Page - Full width layout, darker share button, responsive grid */
/* Force main content container to full width, remove centering and max-width */ /* ── Profile header ──────────────────────────────────────────────────── */
.layout > div[style*="flex:1"] { .profile-header {
margin: 0 !important; display: flex;
max-width: 100% !important; align-items: center;
padding: 0 !important; gap: 32px;
width: 100% !important; 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 { .profile-main {
background: transparent !important; background: transparent !important;
border-radius: 0 !important; border-radius: 0 !important;
@ -16,7 +219,7 @@
padding: 20px 32px !important; padding: 20px 32px !important;
margin: 0 auto !important; margin: 0 auto !important;
width: 100%; 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 */ /* Make prompts grid use more columns on large screens */
@ -28,16 +231,32 @@
} }
/* Share button: darker background and text */ /* Share button: darker background and text */
.profile-header button:last-child { #shareProfileButton {
background: #cbd5e1 !important; /* darker gray */ background: #cbd5e1 !important; /* darker gray */
color: #1e293b !important; color: #1e293b !important;
box-shadow: none !important; box-shadow: none !important;
border: 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 */ /* Buttons keep rounded corners */
.login-button { .login-button {
align-items: center;
border-radius: 14px !important; border-radius: 14px !important;
display: flex;
gap: 8px;
justify-content: center;
text-decoration: none;
}
.login-button i {
font-size: 1rem;
} }
.profile-tab { .profile-tab {
@ -64,7 +283,7 @@
/* Prompt cards: rounded corners */ /* Prompt cards: rounded corners */
.profile-main section > div { .profile-main section > div {
border-radius: 18px !important; 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 */ /* Prompt images: rounded corners */
@ -75,6 +294,9 @@
/* Avatar remains round */ /* Avatar remains round */
.profile-avatar { .profile-avatar {
border-radius: 50% !important; border-radius: 50% !important;
width: 110px;
height: 110px;
object-fit: cover;
} }
/* All outer containers stay square */ /* All outer containers stay square */

View File

@ -1,12 +1,5 @@
/* Settings page - tabs, form styling */ /* 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 { .settings-main {
flex: 1; flex: 1;
display: flex; display: flex;
@ -21,7 +14,7 @@
width: 100%; width: 100%;
background: #fff; background: #fff;
border-radius: 18px; 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; padding: 32px;
} }
@ -101,7 +94,7 @@
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: #7c3aed; 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 { .checkbox-label {
display: flex; display: flex;
@ -181,4 +174,11 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; 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 .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 { .logout-arrow {
display: none; display: none;
} }
@ -163,6 +174,7 @@
.sidebar-logout { .sidebar-logout {
justify-content: center; justify-content: center;
padding: 12px; padding: 12px;
position: relative;
} }
.sidebar a.active { .sidebar a.active {
@ -172,8 +184,45 @@
} }
@media (max-width: 700px) { @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 a,
.sidebar-logout { .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 Topbar styles for OnlyPrompt
- clean, modern, full-width - sticky on all app pages
- search bar centered (expands on full screen), profile avatar always on the right - search bar fills the available width
- ONLY search bar and avatar have rounded corners - logout, messages and profile avatar stay on the right
*/ */
.topbar-shell { .topbar-shell {
width: 100%; align-items: center;
background: #ffffff; background: #ffffff;
border-bottom: 1px solid #eef2f7; border-bottom: 1px solid #eef2f7;
padding: 16px 32px; box-sizing: border-box;
display: flex; display: flex;
align-items: center;
justify-content: space-between;
gap: 18px; gap: 18px;
justify-content: space-between;
padding: 16px 32px;
position: sticky;
top: 0;
width: 100%;
z-index: 90;
} }
.topbar-search { .topbar-search {
flex: 1; /* Takes all available space */
max-width: none; /* No upper limit, expands freely */
display: flex;
align-items: center; align-items: center;
gap: 12px;
background: #f8fafc; background: #f8fafc;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 14px;
display: flex;
flex: 1;
gap: 12px;
max-width: none;
padding: 10px 20px; padding: 10px 20px;
border-radius: 14px; /* Rounded like login inputs */
} }
.topbar-search i { .topbar-search i {
@ -34,12 +38,17 @@
} }
.topbar-search input { .topbar-search input {
width: 100%;
border: none;
outline: none;
background: transparent; background: transparent;
font-size: 0.95rem; border: none;
color: #334155; 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 { .topbar-search input::placeholder {
@ -47,24 +56,28 @@
font-weight: 400; font-weight: 400;
} }
/* Icons and avatar container */
.topbar-actions { .topbar-actions {
display: flex;
align-items: center; align-items: center;
display: flex;
gap: 16px; gap: 16px;
} }
.topbar-logout-form {
display: flex;
margin: 0;
}
.topbar-icon-btn { .topbar-icon-btn {
align-items: center;
background: transparent; background: transparent;
border: none; border: none;
font-size: 1.4rem; border-radius: 50%;
color: #475569; color: #475569;
cursor: pointer; cursor: pointer;
padding: 8px;
display: flex; display: flex;
align-items: center; font-size: 1.4rem;
justify-content: center; justify-content: center;
border-radius: 50%; padding: 8px;
transition: background 0.2s, color 0.2s; transition: background 0.2s, color 0.2s;
} }
@ -74,42 +87,47 @@
} }
.topbar-avatar-btn { .topbar-avatar-btn {
border: none; align-items: center;
background: transparent; background: transparent;
padding: 0; border: none;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
padding: 0;
} }
.topbar-avatar { .topbar-avatar {
width: 48px; border: 1px solid #e2e8f0;
border-radius: 50%;
height: 48px; height: 48px;
object-fit: cover; object-fit: cover;
border: 1px solid #e2e8f0; width: 48px;
border-radius: 50%; /* Avatar round */
} }
/* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.topbar-shell { .topbar-shell {
gap: 12px;
padding: 12px 20px; padding: 12px 20px;
} }
.topbar-search { .topbar-search {
padding: 8px 16px; padding: 8px 16px;
} }
.topbar-search i { .topbar-search i {
font-size: 1.1rem; font-size: 1.1rem;
} }
.topbar-avatar { .topbar-avatar {
width: 40px;
height: 40px; height: 40px;
width: 40px;
} }
.topbar-icon-btn {
.topbar-icon-btn {
font-size: 1.2rem; font-size: 1.2rem;
padding: 6px; padding: 6px;
} }
.topbar-actions { .topbar-actions {
gap: 8px; gap: 8px;
} }
@ -117,9 +135,14 @@
@media (max-width: 480px) { @media (max-width: 480px) {
.topbar-shell { .topbar-shell {
padding: 10px 16px; padding: 10px 12px;
} }
.topbar-search { .topbar-search {
padding: 6px 12px; padding: 6px 10px;
} }
}
.topbar-actions {
gap: 4px;
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

@ -19,121 +19,44 @@
/> />
</head> </head>
<body> <body>
<div <a class="skip-link" href="#main-content">Skip to main content</a>
class="layout" <div class="layout">
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<div id="sidebar-container"></div> <div id="sidebar-container"></div>
<div style="flex: 1; display: flex; flex-direction: column"> <div class="page-body">
<div id="topbar-container"></div> <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"> <div class="post-detail-container" id="detail-content">
<!-- Loading --> <!-- Loading -->
<div <div id="detail-loading">
id="detail-loading" <i class="bi bi-hourglass-split state-icon" aria-hidden="true"></i>
style="text-align: center; padding: 60px 20px; color: #64748b"
>
<i
class="bi bi-hourglass-split"
style="font-size: 2.5rem; display: block; margin-bottom: 12px"
></i>
<p>Loading prompt...</p> <p>Loading prompt...</p>
</div> </div>
<!-- Error --> <!-- Error -->
<div <div id="detail-error" class="state-error" role="alert" aria-live="assertive">
id="detail-error" <i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
style="
display: none;
text-align: center;
padding: 60px 20px;
color: #ef4444;
"
>
<i
class="bi bi-exclamation-circle"
style="font-size: 2.5rem; display: block; margin-bottom: 12px"
></i>
<h3 id="detail-error-title">Prompt not found</h3> <h3 id="detail-error-title">Prompt not found</h3>
<p <p id="detail-error-msg"></p>
id="detail-error-msg" <button type="button" onclick="history.back()" class="detail-back-btn">
style="color: #64748b; margin-top: 8px"
></p>
<button
onclick="history.back()"
style="
margin-top: 20px;
padding: 10px 24px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
"
>
Go Back Go Back
</button> </button>
</div> </div>
<!-- Content (hidden until loaded) --> <!-- Content (hidden until loaded) -->
<div id="detail-body" style="display: none"> <div id="detail-body">
<!-- Header --> <!-- Header -->
<div class="post-header"> <div class="post-header">
<div <div class="detail-creator-row">
style=" <div id="creator-avatar"></div>
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> <div>
<span <span id="creator-name"></span>
id="creator-name" <span id="prompt-date"></span>
style="font-weight: 600; font-size: 0.95rem"
></span>
<span
id="prompt-date"
style="display: block; font-size: 0.8rem; color: #94a3b8"
></span>
</div> </div>
<div style="margin-left: auto"> <div class="detail-actions-right">
<span id="tier-badge"></span> <span id="tier-badge"></span>
<button <button type="button" id="edit-prompt-btn">Edit</button>
id="edit-prompt-btn"
style="
display: none;
margin-left: 10px;
padding: 6px 14px;
border: none;
border-radius: 10px;
background: #f1f5f9;
color: #334155;
font-weight: 700;
cursor: pointer;
"
>
Edit
</button>
</div> </div>
</div> </div>
<h1 class="post-title" id="prompt-title"></h1> <h1 class="post-title" id="prompt-title"></h1>
@ -157,73 +80,38 @@
<!-- Prompt Content (only if accessible) --> <!-- Prompt Content (only if accessible) -->
<div class="prompt-section" id="prompt-content-section"> <div class="prompt-section" id="prompt-content-section">
<h2>PROMPT</h2> <h2>PROMPT</h2>
<div <div class="prompt-content" id="prompt-body"></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> </div>
<!-- Example Output --> <!-- Example Output -->
<div class="example-section" id="example-section" style="display: none"> <div class="example-section" id="example-section">
<h2>EXAMPLE OUTPUT</h2> <h2>EXAMPLE OUTPUT</h2>
<div class="example-content"> <div class="example-content">
<div id="example-output-text" class="example-output-text" style="white-space: pre-wrap"></div> <div
<div id="example-image" class="example-image" style="display: none"> id="example-output-text"
<img id="example-image-img" src="" alt="Example output image"> 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> </div>
</div> </div>
<!-- Locked section (shown instead of prompt if no access) --> <!-- Locked section (shown instead of prompt if no access) -->
<div <div id="locked-section">
id="locked-section" <i class="bi bi-lock-fill locked-icon" aria-hidden="true"></i>
style=" <h3 class="locked-title">
display: none;
text-align: center;
padding: 40px 20px;
background: #f8fafc;
border-radius: 12px;
margin-bottom: 28px;
"
>
<i
class="bi bi-lock-fill"
style="
font-size: 2.5rem;
color: #94a3b8;
display: block;
margin-bottom: 12px;
"
></i>
<h3 style="margin-bottom: 8px">
This prompt requires a subscription This prompt requires a subscription
</h3> </h3>
<p style="color: #64748b; margin-bottom: 20px"> <p class="locked-desc">
Subscribe to <strong id="locked-creator"></strong> to access Subscribe to <strong id="locked-creator"></strong> to access
this prompt. this prompt.
</p> </p>
<button <button type="button" id="locked-subscribe-btn">
id="locked-subscribe-btn"
style="
padding: 12px 28px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
"
>
Subscribe <span id="locked-tier-name"></span> Subscribe <span id="locked-tier-name"></span>
</button> </button>
</div> </div>
@ -238,19 +126,34 @@
<h2>REVIEWS</h2> <h2>REVIEWS</h2>
<div class="review-form" id="review-form"> <div class="review-form" id="review-form">
<h3>Your review</h3> <h3>Your review</h3>
<div class="review-star-input" id="review-star-input" aria-label="Select rating"> <div
<button type="button" data-rating="1"></button> class="review-star-input"
<button type="button" data-rating="2"></button> id="review-star-input"
<button type="button" data-rating="3"></button> role="group"
<button type="button" data-rating="4"></button> aria-label="Select rating"
<button type="button" data-rating="5"></button> >
<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> </div>
<textarea id="review-comment" maxlength="200" rows="3" placeholder="Write a short comment..."></textarea> <textarea
<button type="button" id="submit-review-btn">Submit Review</button> id="review-comment"
<p id="review-message"></p> 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>
<div class="reviews-list" id="reviews-list"> <div class="reviews-list" id="reviews-list" aria-live="polite">
<p style="color:#94a3b8;">Loading reviews...</p> <p class="detail-loading-text">Loading reviews...</p>
</div> </div>
</div> </div>
</div> </div>
@ -266,7 +169,10 @@
document.getElementById("sidebar-container").innerHTML = data; document.getElementById("sidebar-container").innerHTML = data;
document document
.querySelectorAll("#sidebar-container .sidebar a") .querySelectorAll("#sidebar-container .sidebar a")
.forEach((l) => l.classList.remove("active")); .forEach((l) => {
l.classList.remove("active");
l.removeAttribute("aria-current");
});
}); });
fetch("/topbar.html") fetch("/topbar.html")
.then((r) => r.text()) .then((r) => r.text())
@ -331,8 +237,8 @@
p.description; p.description;
document.getElementById("prompt-body").textContent = p.content; document.getElementById("prompt-body").textContent = p.content;
document.getElementById("prompt-rating-stat").innerHTML = document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes `<i class="bi ${p.isLiked ? "bi-heart-fill" : "bi-heart"} detail-heart-icon" aria-hidden="true"></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 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); renderExamples(p);
renderOwnerActions(p); renderOwnerActions(p);
setupReviewSection(p); setupReviewSection(p);
@ -340,28 +246,29 @@
// Tier badge // Tier badge
const badge = document.getElementById("tier-badge"); const badge = document.getElementById("tier-badge");
if (p.price != null && Number(p.price) > 0) { if (p.tierName) {
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>`; const price = p.tierMonthlyPrice == null
} 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>`; : ` - $${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 { } 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 // Rating
if (p.averageRating != null) { if (p.averageRating != null) {
const stars = Math.round(p.averageRating); const stars = Math.round(p.averageRating);
document.getElementById("rating-display").innerHTML = document.getElementById("rating-display").innerHTML =
`<span style="color:#f59e0b;font-size:1.1rem;">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> `<span class="rating-stars-display">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span>
<span style="margin-left:8px;font-weight:600;">${p.averageRating.toFixed(1)}</span> <span class="rating-value">${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-count">/ 5.0 (${p.reviewCount || 0} ${(p.reviewCount || 0) === 1 ? "review" : "reviews"})</span>`;
document.getElementById("prompt-rating-stat").innerHTML = document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes `<i class="bi ${p.isLiked ? "bi-heart-fill" : "bi-heart"} detail-heart-icon" aria-hidden="true"></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 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 style="margin-left:12px;"><i class="bi bi-star-fill" style="color:#f59e0b;"></i> ${p.averageRating.toFixed(1)} (${p.reviewCount || 0})</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 { } else {
document.getElementById("rating-display").innerHTML = 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 // Content visibility
@ -377,6 +284,7 @@
document.getElementById("locked-tier-name").textContent = p.tierName document.getElementById("locked-tier-name").textContent = p.tierName
? `— ${p.tierName}` ? `— ${p.tierName}`
: ""; : "";
setupLockedSubscription(p);
} }
document.getElementById("detail-loading").style.display = "none"; document.getElementById("detail-loading").style.display = "none";
@ -384,6 +292,38 @@
scrollToHashSection(); 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() { function scrollToHashSection() {
if (!location.hash) return; if (!location.hash) return;
@ -397,10 +337,14 @@
function setReviewRating(rating) { function setReviewRating(rating) {
selectedReviewRating = rating; selectedReviewRating = rating;
document.querySelectorAll("#review-star-input button").forEach((button) => { document
const value = Number(button.dataset.rating); .querySelectorAll("#review-star-input button")
button.textContent = value <= rating ? "★" : "☆"; .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) { function escapeHtml(value) {
@ -451,9 +395,12 @@
// Keep the review form visible; the API will reject unauthenticated users. // Keep the review form visible; the API will reject unauthenticated users.
} }
document.querySelectorAll("#review-star-input button").forEach((button) => { document
button.onclick = () => setReviewRating(Number(button.dataset.rating)); .querySelectorAll("#review-star-input button")
}); .forEach((button) => {
button.onclick = () =>
setReviewRating(Number(button.dataset.rating));
});
submitBtn.onclick = async () => { submitBtn.onclick = async () => {
if (!selectedReviewRating) { if (!selectedReviewRating) {
@ -470,7 +417,8 @@
credentials: "same-origin", credentials: "same-origin",
body: JSON.stringify({ body: JSON.stringify({
rating: selectedReviewRating, 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(); const reviews = await response.json();
if (reviews.length === 0) { 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; return;
} }
list.innerHTML = reviews.map((review) => { list.innerHTML = reviews
const stars = "★".repeat(review.rating) + "☆".repeat(5 - review.rating); .map((review) => {
return ` const stars =
"★".repeat(review.rating) + "☆".repeat(5 - review.rating);
return `
<article class="review-card"> <article class="review-card">
<div class="review-card-header"> <div class="review-card-header">
<span class="review-card-user">@${escapeHtml(review.creatorName)}</span> <span class="review-card-user">@${escapeHtml(review.creatorName)}</span>
@ -519,9 +470,10 @@
</div> </div>
<p class="review-card-comment">${escapeHtml(review.comment || "No comment.")}</p> <p class="review-card-comment">${escapeHtml(review.comment || "No comment.")}</p>
</article>`; </article>`;
}).join(""); })
.join("");
} catch (error) { } 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: <!-- OnlyPrompt - Profile page:
- User profile display with avatar, bio, stats, and prompt cards (personal prompts) --> - User profile display with avatar, bio, stats, and prompt cards (personal prompts) -->
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Profile</title> <title>OnlyPrompt - Profile</title>
<link rel="stylesheet" href="../css/variables.css"> <link rel="stylesheet" href="../css/variables.css" />
<link rel="stylesheet" href="../css/base.css"> <link rel="stylesheet" href="../css/base.css" />
<link rel="stylesheet" href="../css/sidebar.css"> <link rel="stylesheet" href="../css/sidebar.css" />
<link rel="stylesheet" href="../css/login.css"> <link rel="stylesheet" href="../css/login.css" />
<link rel="stylesheet" href="../css/topbar.css"> <link rel="stylesheet" href="../css/topbar.css" />
<link rel="stylesheet" href="../css/profile.css"> <link rel="stylesheet" href="../css/profile.css" />
<script src="../js/profile-shared.js"></script> <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"> <link
</head> rel="stylesheet"
<body> href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);"> />
</head>
<div id="sidebar-container"></div> <body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;"> <div class="page-body">
<div id="topbar-container"></div>
<div id="topbar-container"></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;"> <main class="login-card profile-main" id="main-content" tabindex="-1">
<section class="profile-header">
<section class="profile-header" style="display:flex;align-items:center;gap:32px;border-bottom:1px solid #e5e7eb;padding-bottom:24px;"> <img
id="profileAvatar"
<img id="profileAvatar" src="../images/content/cat.png" class="profile-avatar" style="width:110px;height:110px;border-radius:50%;object-fit:cover;"> src="../images/content/cat.png"
alt="Profile avatar"
<div class="profile-info" style="flex:1;"> class="profile-avatar"
<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 class="profile-info">
<h1 id="profileDisplayName">Loading...</h1>
<div id="profileSlug">
@profile
<i class="bi bi-patch-check-fill profile-badge-icon" aria-hidden="true"></i>
</div>
<div id="profileBio">Loading profile...</div>
<div id="profileSpecialities"></div>
<div id="profileStats">
<span><strong id="profileRating">0.0</strong> rating</span>
<span
><strong id="profileSubscribers">0</strong> subscribers</span
>
</div>
</div> </div>
<div id="profileBio" style="margin-bottom:8px;"> <div id="profileActions">
Loading profile... <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> </div>
</section>
<div id="profileSpecialities" style="color:#64748b;"></div> <nav class="profile-tabs" role="tablist" aria-label="Profile prompt lists">
<div id="profileStats" style="display:flex;gap:18px;color:#64748b;margin-top:12px;font-size:0.95rem;"> <button
<span><strong id="profileRating" style="color:#111827;">0.0</strong> rating</span> type="button"
<span><strong id="profileSubscribers" style="color:#111827;">0</strong> subscribers</span> class="profile-tab active"
</div> data-tab="mine"
</div> 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;"> <section id="profile-prompts-grid" role="tabpanel" aria-live="polite" aria-labelledby="myPromptsTab">
<button id="primaryProfileButton" class="login-button" onclick="location.href='settings.html'">Edit Profile</button> <div class="profile-grid-loading">Loading prompts...</div>
<button id="shareProfileButton" class="login-button" style="background:#f3f4f6;color:#111;box-shadow:none;">Share Profile</button> </section>
</div> </main>
</div>
</section>
<nav class="profile-tabs" style="display:flex;gap:24px;border-bottom:2px solid #e5e7eb;margin:32px 0 18px 0;flex-wrap:wrap;">
<button type="button" class="profile-tab active" data-tab="mine" id="myPromptsTab">My Prompts</button>
<button type="button" class="profile-tab" data-tab="favorites" id="favoritesTab">Favorites</button>
<button type="button" class="profile-tab" data-tab="saved" id="savedTab">Saved</button>
</nav>
<section id="profile-prompts-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px;">
<div style="grid-column:1/-1;color:#64748b;text-align:center;padding:28px;">Loading prompts...</div>
</section>
</main>
</div> </div>
</div>
<script> <script>
fetch('/sidebar.html') fetch("/sidebar.html")
.then(r => r.text()) .then((r) => r.text())
.then(data => { .then((data) => {
document.getElementById('sidebar-container').innerHTML = data; document.getElementById("sidebar-container").innerHTML = data;
// Remove 'active' from all sidebar links // Remove 'active' from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => { document
link.classList.remove('active'); .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') fetch("/topbar.html")
.then(r => r.text()) .then((r) => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data); .then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
const profileAvatar = document.getElementById('profileAvatar'); const profileAvatar = document.getElementById("profileAvatar");
const profileDisplayName = document.getElementById('profileDisplayName'); const profileDisplayName = document.getElementById("profileDisplayName");
const profileSlug = document.getElementById('profileSlug'); const profileSlug = document.getElementById("profileSlug");
const profileBio = document.getElementById('profileBio'); const profileBio = document.getElementById("profileBio");
const profileSpecialities = document.getElementById('profileSpecialities'); const profileSpecialities = document.getElementById(
const profileRating = document.getElementById('profileRating'); "profileSpecialities",
const profileSubscribers = document.getElementById('profileSubscribers'); );
const profilePromptsGrid = document.getElementById('profile-prompts-grid'); const profileRating = document.getElementById("profileRating");
const myPromptsTab = document.getElementById('myPromptsTab'); const profileSubscribers = document.getElementById("profileSubscribers");
const favoritesTab = document.getElementById('favoritesTab'); const profilePromptsGrid = document.getElementById(
const savedTab = document.getElementById('savedTab'); "profile-prompts-grid",
const profileActions = document.getElementById('profileActions'); );
const primaryProfileButton = document.getElementById('primaryProfileButton'); const myPromptsTab = document.getElementById("myPromptsTab");
const shareProfileButton = document.getElementById('shareProfileButton'); const favoritesTab = document.getElementById("favoritesTab");
const profileTabs = document.querySelector('.profile-tabs'); const savedTab = document.getElementById("savedTab");
const params = new URLSearchParams(location.search); const profileActions = document.getElementById("profileActions");
const profileId = params.get('id'); const primaryProfileButton = document.getElementById(
let ownPrompts = []; "primaryProfileButton",
let allPrompts = []; );
let profilePrompts = []; const shareProfileButton = document.getElementById("shareProfileButton");
let activeProfileTab = 'mine'; const manageTiersButton = document.getElementById("manageTiersButton");
let currentUserId = null; const creatorTierList = document.getElementById("creatorTierList");
let isOwnProfile = !profileId; const profileTabs = document.querySelector(".profile-tabs");
let profileLoaded = false; const params = new URLSearchParams(location.search);
let currentIsFollowing = false; 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) { async function fetchJson(url) {
const response = await fetch(url, { credentials: 'same-origin' }); const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) { if (response.status === 401) {
location.href = '/login'; location.href = "/login";
return null; 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;
} }
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
}
const profile = await fetchJson(`/api/v1/profiles/${encodeURIComponent(profileId)}`); function renderProfile(profile, fallbackName = "Profile") {
renderProfile(profile, 'Creator Profile'); profileDisplayName.textContent = profile.displayName || fallbackName;
} catch (error) { profileSlug.innerHTML = `@${profile.user?.userName || profile.slug || "profile"} <i class="bi bi-patch-check-fill profile-badge-icon" aria-hidden="true"></i>`;
if (isOwnProfile) { profileBio.textContent = profile.bio || "No bio yet.";
profileDisplayName.textContent = 'Profile unavailable'; profileSpecialities.textContent =
profileBio.textContent = error.message; profile.specialities || "No specialities added yet.";
} else { profileRating.textContent = Number(profile.averageRating || 0).toFixed(
profileDisplayName.textContent = 'Loading creator...'; 1,
profileBio.textContent = ''; );
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) { async function loadCreatorCardFallback() {
if (type === 'liked') { if (isOwnProfile || profileLoaded || !profileId) return;
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isLiked === true; 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') { async function loadProfile() {
const prompt = allPrompts.find((item) => item.id === id); try {
return prompt?.isSaved === true; 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 = {}) { if (type === "saved") {
const image = prompt.exampleImageUrl || '../images/content/post1.png'; const prompt = allPrompts.find((item) => item.id === id);
const showEdit = options.showEdit === true; return prompt?.isSaved === 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;"> return false;
<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> function renderProfilePrompt(prompt, options = {}) {
<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> const image = prompt.exampleImageUrl || "../images/content/post1.png";
<div style="display:flex;gap:16px;color:#64748b;align-items:center;flex-wrap:wrap;"> const showEdit = options.showEdit === true;
<span><i class="bi bi-star"></i> ${rating}</span> const rating =
${prompt.creatorName ? `<span>@${prompt.creatorName}</span>` : ''} prompt.averageRating == null
${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>` : ''} ? "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> </a>
</div>`; ${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>`;
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;
} }
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() { profilePromptsGrid.innerHTML = prompts
document.querySelectorAll('.profile-tab').forEach((tab) => { .map((prompt) => renderProfilePrompt(prompt, options))
tab.classList.toggle('active', tab.dataset.tab === activeProfileTab); .join("");
});
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;
} }
profileActions.style.display = 'flex'; function updateTabs() {
primaryProfileButton.textContent = currentIsFollowing ? 'Following' : 'Follow'; document.querySelectorAll(".profile-tab").forEach((tab) => {
primaryProfileButton.disabled = false; tab.classList.toggle("active", tab.dataset.tab === activeProfileTab);
primaryProfileButton.onclick = toggleProfileFollow; tab.setAttribute("aria-selected", String(tab.dataset.tab === activeProfileTab));
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'
}); });
if (response.status === 401) { profilePromptsGrid.setAttribute("aria-labelledby", activeProfileTab === "favorites" ? "favoritesTab" : activeProfileTab === "saved" ? "savedTab" : "myPromptsTab");
location.href = '/login';
return;
}
const subscription = response.ok ? await response.json() : null; const liked = allPrompts.filter((prompt) =>
currentIsFollowing = subscription !== null; isPromptMarked("liked", prompt.id),
updateProfileMode(); );
} catch { const saved = allPrompts.filter((prompt) =>
currentIsFollowing = false; isPromptMarked("saved", prompt.id),
} );
}
async function toggleProfileFollow() { myPromptsTab.textContent = `My Prompts (${ownPrompts.length})`;
if (!profileId) return; favoritesTab.textContent = `Favorites (${liked.length})`;
savedTab.textContent = `Saved (${saved.length})`;
primaryProfileButton.disabled = true; if (activeProfileTab === "favorites") {
const response = await fetch(`/api/v1/subscriptions/${encodeURIComponent(profileId)}`, { renderPromptList(liked, "No liked prompts yet.");
method: currentIsFollowing ? 'DELETE' : 'PUT', } else if (activeProfileTab === "saved") {
credentials: 'same-origin' renderPromptList(saved, "No saved prompts yet.");
});
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();
} else { } else {
profilePrompts = allPrompts.filter((prompt) => prompt.creatorId?.toLowerCase() === profileId.toLowerCase()); renderPromptList(ownPrompts, "No prompts yet.", { showEdit: true });
renderProfileFromPrompt(profilePrompts[0]);
updateProfileMode();
} }
} catch {
// Favorites and saved stay empty if prompts cannot be loaded.
} }
}
document.querySelectorAll('.profile-tab').forEach((tab) => { function updateProfileMode() {
tab.addEventListener('click', () => { if (isOwnProfile) {
if (!isOwnProfile) { profileActions.style.display = "flex";
updateProfileMode(); 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; return;
} }
activeProfileTab = tab.dataset.tab;
updateTabs();
});
});
(async function initProfilePage() { profileActions.style.display = "flex";
await loadProfile(); manageTiersButton.style.display = "none";
await loadCreatorCardFallback(); primaryProfileButton.textContent = currentIsFollowing
await loadFollowState(); ? currentSubscriptionTier
updateProfileMode(); ? `Subscribed: ${currentSubscriptionTier.name}`
if (isOwnProfile) { : "Following"
loadOwnPrompts(); : "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();
})(); function renderCreatorTiers() {
</script> if (isOwnProfile) {
</body> 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> </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"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head> </head>
<body> <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 id="sidebar-container"></div>
<div style="flex:1; display: flex; flex-direction: column;"> <div class="page-body">
<div id="topbar-container"></div> <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-container">
<div class="settings-header"> <div class="settings-header">
<h1>Settings</h1> <h1>Settings</h1>
@ -33,21 +34,21 @@
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="settings-tabs"> <div class="settings-tabs" role="tablist" aria-label="Settings sections">
<button class="tab-btn active" data-tab="profile">Profile</button> <button type="button" class="tab-btn active" data-tab="profile" id="profileTabButton" role="tab" aria-selected="true" aria-controls="profileTab">Profile</button>
<button class="tab-btn" data-tab="security">Security</button> <button type="button" class="tab-btn" data-tab="security" id="securityTabButton" role="tab" aria-selected="false" aria-controls="securityTab">Security</button>
<button class="tab-btn" data-tab="notifications">Notifications</button> <button type="button" class="tab-btn" data-tab="notifications" id="notificationsTabButton" role="tab" aria-selected="false" aria-controls="notificationsTab">Notifications</button>
</div> </div>
<!-- Tab Content: Profile --> <!-- 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"> <form class="settings-form" id="profileSettingsForm">
<div class="form-group"> <div class="form-group">
<label for="avatar">Profile Picture</label> <label for="avatar">Profile Picture</label>
<div class="avatar-upload"> <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"> <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> </div>
<div class="form-group"> <div class="form-group">
@ -68,13 +69,13 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="save-btn">Save Changes</button> <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> </div>
</form> </form>
</div> </div>
<!-- Tab Content: Security --> <!-- 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"> <form class="settings-form">
<div class="form-group"> <div class="form-group">
<label for="currentPw">Current Password</label> <label for="currentPw">Current Password</label>
@ -100,7 +101,7 @@
</div> </div>
<!-- Tab Content: Notifications (erweitert) --> <!-- 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"> <form class="settings-form">
<div class="form-group"> <div class="form-group">
<label class="checkbox-label"> <label class="checkbox-label">
@ -149,10 +150,19 @@
tabBtns.forEach(btn => { tabBtns.forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const tabId = btn.getAttribute('data-tab'); 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'); btn.classList.add('active');
tabContents.forEach(content => content.classList.remove('active')); btn.setAttribute('aria-selected', 'true');
document.getElementById(`${tabId}Tab`).classList.add('active'); 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.getElementById('sidebar-container').innerHTML = data;
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => { document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active'); link.classList.remove('active');
link.removeAttribute('aria-current');
}); });
const settingsLink = document.querySelector('#sidebar-container a[href="settings.html"]'); 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') fetch('/topbar.html')
.then(r => r.text()) .then(r => r.text())

View File

@ -6,74 +6,81 @@
<div class="sidebar-shell"> <div class="sidebar-shell">
<!-- Logo --> <!-- Logo -->
<div class="sidebar-logo"> <div class="sidebar-logo" aria-label="OnlyPrompt">
<img src="../images/logo_full.png" alt="OnlyPrompt Logo" class="sidebar-logo-full"> <img src="../images/logo_full.png" alt="" class="sidebar-logo-full">
<img src="../images/logo_icon.png" alt="OnlyPrompt Icon" class="sidebar-logo-icon"> <img src="../images/logo_icon.png" alt="" class="sidebar-logo-icon">
</div> </div>
<!-- Navigation --> <!-- Navigation -->
<nav class="sidebar"> <nav class="sidebar" id="main-navigation" aria-label="Main navigation" tabindex="-1">
<ul> <ul>
<li> <li class="mobile-nav-item">
<a href="dashboard.html" class="active"> <a href="dashboard.html" class="active" aria-current="page">
<i class="bi bi-house icon-blue"></i> <i class="bi bi-house icon-blue" aria-hidden="true"></i>
<span class="nav-text">Dashboard</span> <span class="nav-text">Dashboard</span>
</a> </a>
</li> </li>
<li> <li class="mobile-nav-item">
<a href="marketplace.html"> <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> <span class="nav-text">Marketplace</span>
</a> </a>
</li> </li>
<li> <li class="mobile-nav-item">
<a href="community.html"> <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> <span class="nav-text">Community</span>
</a> </a>
</li> </li>
<li> <li class="mobile-nav-item">
<a href="chats.html"> <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> <span class="nav-text">Chats</span>
</a> </a>
</li> </li>
<li> <li class="mobile-nav-item">
<a href="settings.html"> <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> <span class="nav-text">Settings</span>
</a> </a>
</li> </li>
<li> <li class="mobile-nav-item">
<a href="profile.html"> <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> <span class="nav-text">My Profile</span>
</a> </a>
</li> </li>
<li> <li class="mobile-nav-item">
<a href="create.html"> <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> <span class="nav-text">Create New</span>
</a> </a>
</li> </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> </ul>
</nav> </nav>
<!-- Logout bottom --> <!-- Logout bottom -->
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<form action="/api/v1/auth/logout" method="post"> <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"> <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> <span class="nav-text">Logout</span>
</div> </div>
<i class="bi bi-chevron-right logout-arrow"></i> <i class="bi bi-chevron-right logout-arrow" aria-hidden="true"></i>
</button> </button>
</form> </form>
</div> </div>

View File

@ -11,7 +11,7 @@
<!-- For responsive design: adapts width for different devices --> <!-- For responsive design: adapts width for different devices -->
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Title shown in browser tab --> <!-- Title shown in browser tab -->
<title>OnlyPrompt - Login</title> <title>OnlyPrompt - Sign Up</title>
<!-- CSS files for variables, base styles, and login page --> <!-- CSS files for variables, base styles, and login page -->
<link rel="stylesheet" href="../css/variables.css"> <link rel="stylesheet" href="../css/variables.css">
@ -20,8 +20,9 @@
</head> </head>
<body> <body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<!-- Main container for the login page (CSS layout) --> <!-- Main container for the login page (CSS layout) -->
<main class="login-page"> <main class="login-page" id="main-content" tabindex="-1">
<!-- White login card --> <!-- White login card -->
<section class="login-card"> <section class="login-card">
<!-- Logo container --> <!-- Logo container -->
@ -37,25 +38,25 @@
<div class="form-group"> <div class="form-group">
<label for="email">Email Address</label> <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>
<div class="form-group"> <div class="form-group">
<label for="displayName">Display Name (how it will appear to others)</label> <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>
<div class="form-group"> <div class="form-group">
<label for="userName">Username</label> <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>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
<!-- Password field with button to show/hide password --> <!-- Password field with button to show/hide password -->
<div class="password-wrapper"> <div class="password-wrapper">
<input type="password" id="password" name="password" placeholder="Enter your password" required> <input type="password" id="password" name="password" placeholder="Enter your password" autocomplete="new-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 --> Show <!-- Click to show/hide password -->
</button> </button>
</div> </div>
@ -73,7 +74,7 @@
<p class="signup-text"> <p class="signup-text">
Have an account? Have an account?
<a href="#">Log In</a> <a href="/login">Log In</a>
</p> </p>
</section> </section>
</main> </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 Reusable topbar for OnlyPrompt
- Search in the middle - Search in the middle
- small chat & notification icons - small chat & logout icons
- profile avatar on the right - profile avatar on the right
--> -->
<header class="topbar-shell"> <header class="topbar-shell">
<div class="topbar-search"> <div class="topbar-search">
<i class="bi bi-search"></i> <i class="bi bi-search" aria-hidden="true"></i>
<input id="topbarSearchInput" type="search" placeholder="Search"> <input id="topbarSearchInput" type="search" placeholder="Search" aria-label="Search prompts and creators">
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<button class="topbar-icon-btn" aria-label="Notifications"> <form action="/api/v1/auth/logout" method="post" class="topbar-logout-form">
<i class="bi bi-bell"></i> <button class="topbar-icon-btn" type="submit" aria-label="Logout" title="Logout">
</button> <i class="bi bi-box-arrow-right" aria-hidden="true"></i>
<button class="topbar-icon-btn" aria-label="Messages"> </button>
<i class="bi bi-chat-dots"></i> </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> </button>
<!-- Profile avatar on the right (must be changed with backend) --> <!-- Profile avatar on the right (must be changed with backend) -->
<button class="topbar-avatar-btn" aria-label="Profile"> <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="Profile Picture" class="topbar-avatar"> <img id="topbarAvatar" src="../images/content/cat.png" alt="" class="topbar-avatar">
</button> </button>
</div> </div>
</header> </header>

View File

@ -1,20 +1,24 @@
# OnlyPrompt - AI Prompt Marketplace # OnlyPrompt - AI Prompt Marketplace
## Description ## 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. This project is built with HTML, CSS and JavaScript.
## Special Features ## Special Features
- 📝 Create, edit and publish AI prompts - 📝 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 - 📄 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 - ⭐ Write reviews with star ratings and comments
- ❤️ Like and save prompts - ❤️ Like and save prompts
- 👥 Follow and discover other creators - 👥 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 - 👤 Edit user profiles with display name, username, bio and profile picture
- 🌐 View own and public creator profiles - 🌐 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 - 🔄 Server communication through a REST API
- 💾 Shared data persistence with backend and database - 💾 Shared data persistence with backend and database
@ -33,6 +37,13 @@ DB_NAME=onlyprompt
DB_PASSWORD=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 ## Technologies, Libraries, Frameworks
- HTML5 for page structure - HTML5 for page structure
- CSS3 with Flexbox/Grid for layout and responsive design - 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. 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: 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. - 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. - Authentication is implemented for local project use and would need additional hardening for production.
## Reflection ## 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 ## Group members and their roles
| Name | Role | | Name | Role |