Compare commits
8 Commits
debe06fb86
...
7e2dc33dc2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e2dc33dc2 | |||
|
|
a3bfcb5347 | ||
|
|
10592b76c7 | ||
|
|
af7271e2f8 | ||
| de1d3d2d63 | |||
|
|
289f7eebbe | ||
|
|
6df5485707 | ||
|
|
7a347b093e |
51
API.md
51
API.md
@ -152,7 +152,7 @@ GET /api/v1/prompts?sortBy=date&ascending=false&limit=50&search=cat
|
||||
|
||||
Used by Marketplace. Supports sorting, search and category filtering. The marketplace excludes prompts created by the logged-in user.
|
||||
|
||||
Response items include prompt title, description, creator id, creator name, creator avatar, example image, price, like/save counts, average rating, review count and access state.
|
||||
Response items include prompt title, description, creator id, creator name, creator avatar, example image, tier data, like/save counts, average rating, review count and access state.
|
||||
|
||||
### Feed
|
||||
|
||||
@ -187,6 +187,8 @@ Request:
|
||||
|
||||
Response: created prompt. The frontend redirects to `/post-detail?id={id}`.
|
||||
|
||||
`subscriptionTier` is the creator's tier level. `null` means the prompt is public/free. `price` is kept as `null` because prompt access is handled through monthly creator tiers.
|
||||
|
||||
### Own Prompts
|
||||
|
||||
```http
|
||||
@ -202,7 +204,7 @@ PUT /api/v1/prompts/{id}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Request body uses the same editable fields as prompt creation. Used by the edit prompt flow.
|
||||
Request body uses the same editable fields as prompt creation, including `subscriptionTier`. Used by the edit prompt flow.
|
||||
|
||||
### Prompt Detail
|
||||
|
||||
@ -216,13 +218,13 @@ Response includes:
|
||||
- prompt content if accessible
|
||||
- category
|
||||
- creator information
|
||||
- price or free state
|
||||
- tier or free state
|
||||
- example output
|
||||
- example image
|
||||
- average rating and review count
|
||||
- like/save state and counts
|
||||
|
||||
Paid prompts return no detail content for users without access.
|
||||
Tier prompts return no detail content for users without a matching creator subscription.
|
||||
|
||||
### Likes and Saves
|
||||
|
||||
@ -283,10 +285,49 @@ Default categories are created automatically when the backend starts.
|
||||
```http
|
||||
GET /api/v1/subscriptions/{creatorId}
|
||||
PUT /api/v1/subscriptions/{creatorId}
|
||||
PUT /api/v1/subscriptions/{creatorId}/{level}
|
||||
DELETE /api/v1/subscriptions/{creatorId}
|
||||
```
|
||||
|
||||
Used by Community and public profiles to read follow state, follow creators or unfollow creators.
|
||||
Used by Community, public profiles and locked prompt details to read follow state, follow creators, subscribe to a monthly tier or unfollow creators.
|
||||
|
||||
- `PUT /api/v1/subscriptions/{creatorId}` follows a creator without a paid tier.
|
||||
- `PUT /api/v1/subscriptions/{creatorId}/{level}` subscribes to one of the creator's tiers. A higher tier gives access to prompts from the same level and lower levels.
|
||||
|
||||
### Subscription Tiers
|
||||
|
||||
```http
|
||||
GET /api/v1/subscriptions/tiers
|
||||
GET /api/v1/subscriptions/tiers/{creatorId}
|
||||
POST /api/v1/subscriptions/tiers
|
||||
PUT /api/v1/subscriptions/tiers/{tierId}
|
||||
DELETE /api/v1/subscriptions/tiers/{tierId}
|
||||
```
|
||||
|
||||
Used by the Subscription Tiers page, Create Prompt and public creator profiles.
|
||||
|
||||
Create request:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Supporter",
|
||||
"monthlyPrice": 4.99,
|
||||
"level": 1,
|
||||
"description": "Access to basic premium prompts."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Supporter",
|
||||
"level": 1,
|
||||
"monthlyPrice": 4.99,
|
||||
"description": "Access to basic premium prompts."
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, int ReviewCount, bool CanAccess);
|
||||
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, double? AverageRating, int ReviewCount, bool CanAccess);
|
||||
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, decimal? TierMonthlyPrice, double? AverageRating, int ReviewCount, bool CanAccess);
|
||||
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, decimal? TierMonthlyPrice, double? AverageRating, int ReviewCount, bool CanAccess);
|
||||
public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
|
||||
public record ApiLikeState(int LikeCount, bool IsLiked);
|
||||
public record ApiSaveState(int SaveCount, bool IsSaved);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public record ApiCreatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? Slug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
|
||||
public record ApiUpdatePromptRequest(string Title, string Description, string Content, string Category, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
|
||||
public record ApiUpdatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
|
||||
}
|
||||
|
||||
@ -71,9 +71,10 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
x.Saves.Any(s => s.UserId == userId),
|
||||
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
|
||||
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
|
||||
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
|
||||
x.Reviews.Average(r => (double?)r.Rating),
|
||||
x.Reviews.Count,
|
||||
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
|
||||
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level <= s.SubscriptionTier.Level)
|
||||
)).ToArrayAsync();
|
||||
|
||||
return prompts;
|
||||
|
||||
@ -81,9 +81,10 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
x.Saves.Any(s => s.UserId == userId),
|
||||
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
|
||||
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
|
||||
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
|
||||
x.Reviews.Average(r => (double?)r.Rating),
|
||||
x.Reviews.Count,
|
||||
x.CreatorId == userId || ((x.Price == null || x.Price <= 0) && (x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)))
|
||||
x.CreatorId == userId || x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level <= s.SubscriptionTier.Level)
|
||||
)).ToArrayAsync();
|
||||
|
||||
return prompts;
|
||||
@ -112,6 +113,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
x.Saves.Any(s => s.UserId == userId),
|
||||
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
|
||||
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
|
||||
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
|
||||
x.Reviews.Average(r => (double?)r.Rating),
|
||||
x.Reviews.Count,
|
||||
true
|
||||
@ -132,9 +134,6 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
if (prompt is null)
|
||||
return TypedResults.NotFound("Prompt not found");
|
||||
|
||||
if (prompt.CreatorId != userId && prompt.Price.HasValue && prompt.Price.Value > 0)
|
||||
return TypedResults.NotFound("Prompt not found or requires payment");
|
||||
|
||||
var canAccess = await GetAccessiblePrompts(userId.Value).AnyAsync(p => p.Id == prompt.Id);
|
||||
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with
|
||||
{
|
||||
@ -169,7 +168,20 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
prompt.Category = category;
|
||||
prompt.ExampleOutput = request.ExampleOutput;
|
||||
prompt.ExampleImageUrl = request.ExampleImageUrl;
|
||||
prompt.Price = request.Price;
|
||||
prompt.Price = null;
|
||||
prompt.SubscriptionTier = null;
|
||||
if (request.SubscriptionTier.HasValue)
|
||||
{
|
||||
var subscriptionTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(
|
||||
t => t.Level == request.SubscriptionTier.Value
|
||||
&& t.UserId == userId
|
||||
);
|
||||
|
||||
if (subscriptionTier is null)
|
||||
return TypedResults.NotFound("Subscription tier not found");
|
||||
|
||||
prompt.SubscriptionTier = subscriptionTier;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = prompt.Prompt, CanAccess = true };
|
||||
@ -315,7 +327,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
Prompt = request.Content,
|
||||
ExampleOutput = request.ExampleOutput,
|
||||
ExampleImageUrl = request.ExampleImageUrl,
|
||||
Price = request.Price,
|
||||
Price = null,
|
||||
CreatorId = userId.Value,
|
||||
SubscriptionTier = subscriptionTier,
|
||||
Category = category,
|
||||
|
||||
@ -100,6 +100,35 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
return subscription;
|
||||
}
|
||||
|
||||
[HttpGet("tiers")]
|
||||
public async Task<ApiSubscriptionTier[]> GetOwnSubscriptionTiersAsync()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
return await _db.SubscriptionTiers
|
||||
.Where(t => t.UserId == userId)
|
||||
.OrderBy(t => t.Level)
|
||||
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
|
||||
.ToArrayAsync();
|
||||
}
|
||||
|
||||
[HttpGet("tiers/{userId}")]
|
||||
public async Task<Results<Ok<ApiSubscriptionTier[]>, NotFound<string>>> GetCreatorSubscriptionTiersAsync([FromRoute(Name = "userId")] Identifier creatorId)
|
||||
{
|
||||
var creatorExists = await _db.Users.AnyAsync(
|
||||
user => creatorId.Id.HasValue ? user.Id == creatorId.Id.Value : user.Profile.Slug == creatorId.Slug
|
||||
);
|
||||
if (creatorExists == false)
|
||||
return TypedResults.NotFound($"No user found with identifier {creatorId}");
|
||||
|
||||
var tiers = await _db.SubscriptionTiers
|
||||
.Where(t => creatorId.Id.HasValue ? t.UserId == creatorId.Id.Value : t.User.Profile.Slug == creatorId.Slug)
|
||||
.OrderBy(t => t.Level)
|
||||
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
|
||||
.ToArrayAsync();
|
||||
|
||||
return TypedResults.Ok(tiers);
|
||||
}
|
||||
|
||||
[HttpDelete("{userId}")]
|
||||
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
|
||||
{
|
||||
|
||||
@ -44,6 +44,7 @@ namespace OnlyPrompt.Backend.Utils
|
||||
.MapCtorParamFrom(x => x.IsSaved, x => false)
|
||||
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
|
||||
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
|
||||
.MapCtorParamFrom(x => x.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
|
||||
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
|
||||
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
|
||||
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
|
||||
@ -66,6 +67,7 @@ namespace OnlyPrompt.Backend.Utils
|
||||
.MapCtorParamFrom(x => x.IsSaved, x => false)
|
||||
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
|
||||
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
|
||||
.MapCtorParamFrom(x => x.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
|
||||
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
|
||||
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
|
||||
.MapCtorParamFrom(x => x.CanAccess, x => true);
|
||||
|
||||
@ -1,130 +1,381 @@
|
||||
<!-- OnlyPrompt - Chats page:
|
||||
- Direct messaging interface with conversation list and active chat window -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OnlyPrompt - Chats</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/chats.css">
|
||||
<link rel="stylesheet" href="../css/variables.css" />
|
||||
<link rel="stylesheet" href="../css/base.css" />
|
||||
<link rel="stylesheet" href="../css/sidebar.css" />
|
||||
<link rel="stylesheet" href="../css/login.css" />
|
||||
<link rel="stylesheet" href="../css/topbar.css" />
|
||||
<link rel="stylesheet" href="../css/chats.css" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex:1; display: flex; flex-direction: column;">
|
||||
|
||||
<div class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="chats-main">
|
||||
<main class="chats-main" id="main-content" tabindex="-1">
|
||||
<!-- Chat Container: Left column (list) + Right column (active chat) -->
|
||||
<div class="chat-container">
|
||||
|
||||
<!-- Left Column: Chat Overview -->
|
||||
<div class="chat-list">
|
||||
<div class="chat-list-header">
|
||||
<h2>Messages</h2>
|
||||
<button class="new-chat-btn"><i class="bi bi-pencil-square"></i></button>
|
||||
<button type="button" class="new-chat-btn" id="newChatBtn" aria-label="New chat" aria-expanded="false" aria-controls="new-chat-panel">
|
||||
<i class="bi bi-pencil-square" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-list-items">
|
||||
<!-- Chat Entry 1 (active) -->
|
||||
<div class="chat-item active">
|
||||
<img src="../images/content/creator2.png" alt="Alex Chen" class="chat-avatar">
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">Alex Chen</div>
|
||||
<div class="chat-last-msg">Hey Sarah! Really loved your last video on minimalism...</div>
|
||||
</div>
|
||||
<div class="chat-time">10:17 AM</div>
|
||||
</div>
|
||||
<!-- Chat Entry 2 -->
|
||||
<div class="chat-item">
|
||||
<img src="../images/content/creator3.png" alt="Mia Wong" class="chat-avatar">
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">Mia Wong</div>
|
||||
<div class="chat-last-msg">Thanks for the prompt tips! They worked perfectly.</div>
|
||||
</div>
|
||||
<div class="chat-time">Yesterday</div>
|
||||
</div>
|
||||
<!-- Chat Entry 3 -->
|
||||
<div class="chat-item">
|
||||
<img src="../images/content/creator4.png" alt="Tom Rivera" class="chat-avatar">
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">Tom Rivera</div>
|
||||
<div class="chat-last-msg">Let's schedule a call for the collab?</div>
|
||||
</div>
|
||||
<div class="chat-time">Yesterday</div>
|
||||
<div class="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>
|
||||
|
||||
<!-- Right Column: Active Chat (with Alex Chen) -->
|
||||
<!-- Right Column: Active Chat -->
|
||||
<div class="chat-active">
|
||||
<div class="chat-header">
|
||||
<img src="../images/content/creator2.png" alt="Alex Chen" class="chat-avatar-large">
|
||||
<img
|
||||
src="../images/content/cat.png"
|
||||
alt=""
|
||||
class="chat-avatar-large"
|
||||
id="activeChatAvatar"
|
||||
/>
|
||||
<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 class="chat-header-name" id="activeChatName">Select a chat</div>
|
||||
<div class="chat-header-status">
|
||||
<span class="online-dot" aria-hidden="true"></span>
|
||||
<span id="activeChatStatus">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-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 class="chat-messages" id="chatMessages" aria-live="polite" aria-label="Conversation">
|
||||
</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>
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<script>
|
||||
fetch('/sidebar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
document.getElementById('sidebar-container').innerHTML = data;
|
||||
fetch("/sidebar.html")
|
||||
.then((r) => r.text())
|
||||
.then((data) => {
|
||||
document.getElementById("sidebar-container").innerHTML = data;
|
||||
// Remove 'active' from all sidebar links
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
document
|
||||
.querySelectorAll("#sidebar-container .sidebar a")
|
||||
.forEach((link) => {
|
||||
link.classList.remove("active");
|
||||
link.removeAttribute("aria-current");
|
||||
});
|
||||
// Set 'active' on the Chats link (4th link, index 3)
|
||||
const chatsLink = document.querySelectorAll('#sidebar-container .sidebar li a')[3];
|
||||
if (chatsLink) chatsLink.classList.add('active');
|
||||
const chatsLink = document.querySelectorAll(
|
||||
"#sidebar-container .sidebar li a",
|
||||
)[3];
|
||||
if (chatsLink) {
|
||||
chatsLink.classList.add("active");
|
||||
chatsLink.setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
|
||||
fetch('/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
fetch("/topbar.html")
|
||||
.then((r) => r.text())
|
||||
.then(
|
||||
(data) =>
|
||||
(document.getElementById("topbar-container").innerHTML = data),
|
||||
);
|
||||
|
||||
const STORAGE_KEY = "onlyprompt-chat-conversations";
|
||||
const chatList = document.getElementById("chatList");
|
||||
const chatMessages = document.getElementById("chatMessages");
|
||||
const chatForm = document.getElementById("chatForm");
|
||||
const messageInput = document.getElementById("messageInput");
|
||||
const activeChatAvatar = document.getElementById("activeChatAvatar");
|
||||
const activeChatName = document.getElementById("activeChatName");
|
||||
const activeChatStatus = document.getElementById("activeChatStatus");
|
||||
const newChatBtn = document.getElementById("newChatBtn");
|
||||
const newChatPanel = document.getElementById("new-chat-panel");
|
||||
const creatorSearch = document.getElementById("creatorSearch");
|
||||
const creatorSearchResults = document.getElementById("creatorSearchResults");
|
||||
|
||||
let conversations = loadConversations();
|
||||
let creators = [];
|
||||
let activeConversationId = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
creatorSearch.addEventListener("input", renderCreatorResults);
|
||||
chatForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
addMessage(messageInput.value);
|
||||
messageInput.value = "";
|
||||
});
|
||||
|
||||
loadCreators().then(() => {
|
||||
if (!openConversationFromUrl()) {
|
||||
activeConversationId = conversations[0]?.id || null;
|
||||
renderChatList();
|
||||
renderMessages();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,107 +1,134 @@
|
||||
<!-- OnlyPrompt - Community page:
|
||||
- Discover creators, follow/unfollow, dynamic via API -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OnlyPrompt - Discover Creators</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/community.css">
|
||||
<link rel="stylesheet" href="../css/variables.css" />
|
||||
<link rel="stylesheet" href="../css/base.css" />
|
||||
<link rel="stylesheet" href="../css/sidebar.css" />
|
||||
<link rel="stylesheet" href="../css/login.css" />
|
||||
<link rel="stylesheet" href="../css/topbar.css" />
|
||||
<link rel="stylesheet" href="../css/community.css" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex:1; margin:40px auto; max-width:950px;">
|
||||
|
||||
<div class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="creators-main">
|
||||
|
||||
<main class="creators-main" id="main-content" tabindex="-1">
|
||||
<div class="creators-header">
|
||||
<h1>Discover Creators</h1>
|
||||
<p>Follow your favorite prompt artists and get inspired.</p>
|
||||
</div>
|
||||
|
||||
<div 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 class="filter-buttons" role="group" aria-label="Sort creators">
|
||||
<button type="button" class="filter-btn active" data-sort="popular" aria-pressed="true">
|
||||
Popular
|
||||
</button>
|
||||
<button type="button" class="filter-btn" data-sort="prompts" aria-pressed="false">Rising</button>
|
||||
<button type="button" class="filter-btn" data-sort="new" aria-pressed="false">New</button>
|
||||
<button type="button" class="filter-btn" data-sort="rating" aria-pressed="false">Top Rated</button>
|
||||
</div>
|
||||
|
||||
<div class="creators-grid" id="creators-grid"></div>
|
||||
<div class="creators-grid" id="creators-grid" aria-live="polite"></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 id="creators-empty" class="state-empty" role="status" aria-live="polite">
|
||||
<i class="bi bi-people state-icon" aria-hidden="true"></i>
|
||||
<h3 id="creators-empty-title" class="state-title">
|
||||
No creators found
|
||||
</h3>
|
||||
<p id="creators-empty-text">
|
||||
Check back later for new creators to follow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<div id="creators-error" class="state-error" role="alert" aria-live="assertive">
|
||||
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">Could not load creators</h3>
|
||||
<p id="creators-error-msg"></p>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// ── Sidebar & Topbar ─────────────────────────────────────────────
|
||||
fetch('/sidebar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
document.getElementById('sidebar-container').innerHTML = data;
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(l => l.classList.remove('active'));
|
||||
const thirdLink = document.querySelectorAll('#sidebar-container .sidebar li a')[2];
|
||||
if (thirdLink) thirdLink.classList.add('active');
|
||||
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");
|
||||
});
|
||||
fetch('/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
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 '';
|
||||
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>`;
|
||||
return `<span class="creator-stars">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> <span class="creator-stars-value">${rating.toFixed(1)}</span>`;
|
||||
}
|
||||
|
||||
function renderCard(c) {
|
||||
const profileHref = `/profile?id=${encodeURIComponent(c.userId)}`;
|
||||
const chatHref = `/chats.html?userId=${encodeURIComponent(c.userId)}&name=${encodeURIComponent(c.displayName)}&avatar=${encodeURIComponent(c.avatarUrl || "../images/content/cat.png")}`;
|
||||
return `
|
||||
<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}"
|
||||
style="cursor:pointer"
|
||||
onclick="location.href='/profile?id=${c.userId}'">
|
||||
src="${c.avatarUrl || "../images/content/cat.png"}"
|
||||
alt="${c.displayName}">
|
||||
</a>
|
||||
<div class="creator-info">
|
||||
<h3 class="creator-name"
|
||||
style="cursor:pointer"
|
||||
onclick="location.href='/profile?id=${c.userId}'">${c.displayName}</h3>
|
||||
<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>
|
||||
<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>` : ''}
|
||||
<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>
|
||||
<button class="follow-btn ${c.isFollowing ? 'following' : ''}"
|
||||
<div class="creator-actions">
|
||||
<button type="button" class="follow-btn ${c.isFollowing ? "following" : ""}"
|
||||
data-userid="${c.userId}"
|
||||
data-following="${c.isFollowing}">
|
||||
${c.isFollowing ? 'Following' : 'Follow'}
|
||||
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>`;
|
||||
}
|
||||
@ -109,36 +136,41 @@
|
||||
// ── Follow / Unfollow ────────────────────────────────────────────
|
||||
async function toggleFollow(btn) {
|
||||
const userId = btn.dataset.userid;
|
||||
const isFollowing = btn.dataset.following === 'true';
|
||||
const isFollowing = btn.dataset.following === "true";
|
||||
btn.disabled = true;
|
||||
|
||||
const res = await fetch(`/api/v1/subscriptions/${userId}`, {
|
||||
method: isFollowing ? 'DELETE' : 'PUT',
|
||||
credentials: 'same-origin'
|
||||
method: isFollowing ? "DELETE" : "PUT",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (res.status === 401) { location.href = '/login'; return; }
|
||||
if (res.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
const nowFollowing = !isFollowing;
|
||||
btn.dataset.following = nowFollowing;
|
||||
btn.textContent = nowFollowing ? 'Following' : 'Follow';
|
||||
btn.classList.toggle('following', nowFollowing);
|
||||
btn.textContent = nowFollowing ? "Following" : "Follow";
|
||||
btn.setAttribute("aria-pressed", String(nowFollowing));
|
||||
btn.classList.toggle("following", nowFollowing);
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ── Load Creators ────────────────────────────────────────────────
|
||||
const grid = document.getElementById('creators-grid');
|
||||
const emptyEl = document.getElementById('creators-empty');
|
||||
const emptyTitle = document.getElementById('creators-empty-title');
|
||||
const emptyText = document.getElementById('creators-empty-text');
|
||||
const errorEl = document.getElementById('creators-error');
|
||||
const errorMsg = document.getElementById('creators-error-msg');
|
||||
const grid = document.getElementById("creators-grid");
|
||||
const emptyEl = document.getElementById("creators-empty");
|
||||
const emptyTitle = document.getElementById("creators-empty-title");
|
||||
const emptyText = document.getElementById("creators-empty-text");
|
||||
const errorEl = document.getElementById("creators-error");
|
||||
const errorMsg = document.getElementById("creators-error-msg");
|
||||
|
||||
let activeSort = 'popular';
|
||||
let currentSearch = new URLSearchParams(location.search).get('search') || '';
|
||||
let activeSort = "popular";
|
||||
let currentSearch =
|
||||
new URLSearchParams(location.search).get("search") || "";
|
||||
|
||||
function getSearchTerm() {
|
||||
return currentSearch.trim();
|
||||
@ -146,49 +178,60 @@
|
||||
|
||||
async function loadCreators(sort = activeSort) {
|
||||
activeSort = sort;
|
||||
grid.innerHTML = '';
|
||||
emptyEl.style.display = 'none';
|
||||
errorEl.style.display = 'none';
|
||||
grid.innerHTML = "";
|
||||
emptyEl.style.display = "none";
|
||||
errorEl.style.display = "none";
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
sort,
|
||||
limit: '50'
|
||||
limit: "50",
|
||||
});
|
||||
const search = getSearchTerm();
|
||||
if (search) params.set('search', search);
|
||||
if (search) params.set("search", search);
|
||||
|
||||
const res = await fetch(`/api/v1/profiles?${params}`);
|
||||
if (res.status === 401) { location.href = '/login'; return; }
|
||||
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';
|
||||
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';
|
||||
: "Create another local user to see creators here.";
|
||||
emptyEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = creators.map(renderCard).join('');
|
||||
grid.innerHTML = creators.map(renderCard).join("");
|
||||
|
||||
grid.querySelectorAll('.follow-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => toggleFollow(btn));
|
||||
grid.querySelectorAll(".follow-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => toggleFollow(btn));
|
||||
});
|
||||
} catch (e) {
|
||||
errorEl.style.display = 'block';
|
||||
errorEl.style.display = "block";
|
||||
errorMsg.textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter buttons ───────────────────────────────────────────────
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,15 +17,16 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex:1; display: flex; flex-direction: column;">
|
||||
<div class="page-body">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="create-main">
|
||||
<main class="create-main" id="main-content" tabindex="-1">
|
||||
<div class="create-container">
|
||||
<div class="create-header">
|
||||
<h1 id="create-title">Create AI Prompt</h1>
|
||||
@ -36,7 +37,7 @@
|
||||
<!-- Title -->
|
||||
<div class="form-group">
|
||||
<label for="title">Prompt Title *</label>
|
||||
<input type="text" id="title" name="title" placeholder="e.g., Write an inspiring startup story about innovation" required>
|
||||
<input type="text" id="title" name="title" placeholder="e.g., Write an inspiring startup story about innovation" autocomplete="off" required>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@ -61,8 +62,8 @@
|
||||
<!-- Prompt Content -->
|
||||
<div class="form-group">
|
||||
<label for="promptContent">Prompt Content *</label>
|
||||
<textarea id="promptContent" name="promptContent" rows="6" placeholder="Write your prompt instructions here..." required></textarea>
|
||||
<small class="form-hint">Use clear, step-by-step instructions for the AI.</small>
|
||||
<textarea id="promptContent" name="promptContent" rows="6" placeholder="Write your prompt instructions here..." aria-describedby="promptContentHint" required></textarea>
|
||||
<small class="form-hint" id="promptContentHint">Use clear, step-by-step instructions for the AI.</small>
|
||||
</div>
|
||||
|
||||
<!-- Example Output (Text) -->
|
||||
@ -74,24 +75,28 @@
|
||||
<!-- Example Image (optional) -->
|
||||
<div class="form-group">
|
||||
<label for="exampleImage">Example Image (optional)</label>
|
||||
<input type="file" id="exampleImage" name="exampleImage" accept="image/png, image/jpeg, image/jpg">
|
||||
<small class="form-hint">Upload a PNG or JPG – preview will appear below.</small>
|
||||
<div id="imagePreview" style="margin-top: 10px; display: none;">
|
||||
<img id="previewImg" src="#" alt="Preview" style="max-width: 100%; max-height: 200px; border-radius: 12px;">
|
||||
<input type="file" id="exampleImage" name="exampleImage" accept="image/png, image/jpeg, image/jpg" aria-describedby="exampleImageHint">
|
||||
<small class="form-hint" id="exampleImageHint">Upload a PNG or JPG. Preview will appear below.</small>
|
||||
<div id="imagePreview" aria-live="polite">
|
||||
<img id="previewImg" src="#" alt="Selected example image preview">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing (with toggle) -->
|
||||
<div class="form-group pricing-group">
|
||||
<label>Pricing</label>
|
||||
<div class="pricing-toggle">
|
||||
<button type="button" id="freeBtn" class="price-option active">Free</button>
|
||||
<button type="button" id="paidBtn" class="price-option">Paid</button>
|
||||
<span class="form-label" id="access-label">Access</span>
|
||||
<div class="pricing-toggle" role="group" aria-labelledby="access-label">
|
||||
<button type="button" id="freeBtn" class="price-option active" aria-pressed="true">Free</button>
|
||||
<button type="button" id="tierBtn" class="price-option" aria-pressed="false">Tier</button>
|
||||
</div>
|
||||
<div id="priceField" style="display: none;">
|
||||
<input type="number" id="price" name="price" step="0.01" min="0" placeholder="Price in USD (e.g., 19.99)">
|
||||
<div id="tierField">
|
||||
<label for="subscriptionTier" class="sr-only">Subscription tier</label>
|
||||
<select id="subscriptionTier" name="subscriptionTier">
|
||||
<option value="">No tiers created yet</option>
|
||||
</select>
|
||||
<a class="tier-manage-link" href="subscription-tiers.html">Manage tiers</a>
|
||||
</div>
|
||||
<small class="form-hint">You can set a price later or keep it free.</small>
|
||||
<small class="form-hint">Free prompts are public. Tier prompts require a monthly creator subscription.</small>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
@ -99,7 +104,7 @@
|
||||
<button type="submit" class="submit-btn" id="submitPromptBtn">Publish Prompt</button>
|
||||
<button type="button" class="cancel-btn">Cancel</button>
|
||||
</div>
|
||||
<p id="create-status" style="text-align:center;color:#64748b;margin:0;"></p>
|
||||
<p id="create-status" role="status" aria-live="polite"></p>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@ -109,23 +114,28 @@
|
||||
<script>
|
||||
// Toggle between free and paid
|
||||
const freeBtn = document.getElementById('freeBtn');
|
||||
const paidBtn = document.getElementById('paidBtn');
|
||||
const priceField = document.getElementById('priceField');
|
||||
const priceInput = document.getElementById('price');
|
||||
const tierBtn = document.getElementById('tierBtn');
|
||||
const tierField = document.getElementById('tierField');
|
||||
const tierSelect = document.getElementById('subscriptionTier');
|
||||
const editPromptId = new URLSearchParams(location.search).get('id');
|
||||
const submitPromptBtn = document.getElementById('submitPromptBtn');
|
||||
let ownSubscriptionTiers = [];
|
||||
|
||||
freeBtn.addEventListener('click', () => {
|
||||
freeBtn.classList.add('active');
|
||||
paidBtn.classList.remove('active');
|
||||
priceField.style.display = 'none';
|
||||
priceInput.removeAttribute('required');
|
||||
tierBtn.classList.remove('active');
|
||||
freeBtn.setAttribute('aria-pressed', 'true');
|
||||
tierBtn.setAttribute('aria-pressed', 'false');
|
||||
tierField.style.display = 'none';
|
||||
tierSelect.removeAttribute('required');
|
||||
});
|
||||
paidBtn.addEventListener('click', () => {
|
||||
paidBtn.classList.add('active');
|
||||
tierBtn.addEventListener('click', () => {
|
||||
tierBtn.classList.add('active');
|
||||
freeBtn.classList.remove('active');
|
||||
priceField.style.display = 'block';
|
||||
priceInput.setAttribute('required', 'required');
|
||||
tierBtn.setAttribute('aria-pressed', 'true');
|
||||
freeBtn.setAttribute('aria-pressed', 'false');
|
||||
tierField.style.display = 'grid';
|
||||
tierSelect.setAttribute('required', 'required');
|
||||
});
|
||||
|
||||
// Image preview for example image
|
||||
@ -171,6 +181,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubscriptionTiers() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/subscriptions/tiers', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (response.status === 401) {
|
||||
location.href = '/login';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) return;
|
||||
|
||||
ownSubscriptionTiers = await response.json();
|
||||
if (!ownSubscriptionTiers.length) {
|
||||
tierSelect.innerHTML = '<option value="">Create a tier first</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
tierSelect.innerHTML = ownSubscriptionTiers
|
||||
.map((tier) => `<option value="${tier.level}">${tier.name} - $${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</option>`)
|
||||
.join('');
|
||||
} catch {
|
||||
tierSelect.innerHTML = '<option value="">Tiers could not be loaded</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPromptForEdit() {
|
||||
if (!editPromptId) return;
|
||||
|
||||
@ -202,9 +237,9 @@
|
||||
imagePreview.style.display = 'block';
|
||||
}
|
||||
|
||||
if (prompt.price != null && Number(prompt.price) > 0) {
|
||||
paidBtn.click();
|
||||
priceInput.value = Number(prompt.price);
|
||||
if (prompt.tierLevel != null) {
|
||||
tierBtn.click();
|
||||
tierSelect.value = String(prompt.tierLevel);
|
||||
} else {
|
||||
freeBtn.click();
|
||||
}
|
||||
@ -224,8 +259,7 @@
|
||||
submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const isPaid = paidBtn.classList.contains('active');
|
||||
const price = isPaid ? Number(priceInput.value || 0) : null;
|
||||
const isTier = tierBtn.classList.contains('active');
|
||||
const payload = {
|
||||
title: document.getElementById('title').value.trim(),
|
||||
description: document.getElementById('description').value.trim(),
|
||||
@ -233,8 +267,8 @@
|
||||
content: document.getElementById('promptContent').value.trim(),
|
||||
exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
|
||||
exampleImageUrl: exampleImageUrl || null,
|
||||
price,
|
||||
subscriptionTier: null,
|
||||
price: null,
|
||||
subscriptionTier: isTier ? Number(tierSelect.value) : null,
|
||||
slug: null
|
||||
};
|
||||
|
||||
@ -290,17 +324,21 @@
|
||||
// Remove active class from all sidebar links
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
link.removeAttribute('aria-current');
|
||||
});
|
||||
// Optionally set active on "Create New" if it exists, otherwise keep none
|
||||
const createLink = document.querySelector('#sidebar-container a[href="create.html"]');
|
||||
if (createLink) createLink.classList.add('active');
|
||||
if (createLink) {
|
||||
createLink.classList.add('active');
|
||||
createLink.setAttribute('aria-current', 'page');
|
||||
}
|
||||
});
|
||||
|
||||
fetch('/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
|
||||
loadCategories().then(loadPromptForEdit);
|
||||
Promise.all([loadCategories(), loadSubscriptionTiers()]).then(loadPromptForEdit);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -18,6 +18,58 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1000;
|
||||
transform: translateY(-160%);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.skip-link + .skip-link {
|
||||
top: 58px;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid #2563eb;
|
||||
outline-offset: 3px;
|
||||
outline-style: solid !important;
|
||||
outline-width: 3px !important;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Form errors */
|
||||
.form-error {
|
||||
color: red;
|
||||
@ -28,7 +80,7 @@ body {
|
||||
.form-error ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
list-style: '*';
|
||||
list-style: "*";
|
||||
}
|
||||
|
||||
.form-error li {
|
||||
@ -39,3 +91,74 @@ body {
|
||||
color: red;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
#sidebar-container {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.layout {
|
||||
padding-bottom: 74px;
|
||||
}
|
||||
|
||||
#sidebar-container {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Main content area - flex child that fills remaining space */
|
||||
.page-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Reusable empty / error state components ─────────────────────────── */
|
||||
.state-empty,
|
||||
.state-error {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
.state-empty {
|
||||
color: #64748b;
|
||||
}
|
||||
.state-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
font-size: 3rem;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
/* Chats page - Two column layout: chat list + active chat window */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.chats-main {
|
||||
flex: 1;
|
||||
padding: 20px 32px;
|
||||
@ -50,9 +42,81 @@
|
||||
.new-chat-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 1.2rem;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.new-chat-btn:hover,
|
||||
.new-chat-btn[aria-expanded="true"] {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.new-chat-panel {
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.new-chat-panel input {
|
||||
width: 100%;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.creator-search-results {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.creator-result {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.creator-result:hover,
|
||||
.creator-result:focus-visible {
|
||||
background: #eef2ff;
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
.creator-result img {
|
||||
border-radius: 50%;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.creator-result span {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.creator-result small,
|
||||
.creator-result-empty,
|
||||
.chat-empty {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.creator-result-empty,
|
||||
.chat-empty {
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-list-items {
|
||||
@ -61,15 +125,22 @@
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-item:hover {
|
||||
.chat-item:hover,
|
||||
.chat-item:focus-visible {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.chat-item.active {
|
||||
@ -196,6 +267,7 @@
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
@ -208,6 +280,12 @@
|
||||
.chat-input-area input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.chat-input-area input:disabled {
|
||||
background: #f8fafc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: var(--gradient);
|
||||
border: none;
|
||||
@ -222,6 +300,11 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.chats-main {
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
/* Creators page - Discover creators, filter buttons, creator cards */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.creators-main {
|
||||
background: transparent !important;
|
||||
padding: 20px 32px !important;
|
||||
@ -75,13 +67,22 @@
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
.creator-card:hover {
|
||||
.creator-card:hover,
|
||||
.creator-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.creator-avatar-link {
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.creator-avatar {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
@ -98,6 +99,15 @@
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.creator-name a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.creator-name a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.creator-handle {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
@ -123,7 +133,14 @@
|
||||
.creator-stats i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.follow-btn {
|
||||
.creator-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.follow-btn,
|
||||
.creator-chat-btn {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
@ -132,11 +149,25 @@
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.follow-btn:hover {
|
||||
|
||||
.follow-btn:hover,
|
||||
.creator-chat-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.creator-chat-btn {
|
||||
background: #eef2ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.follow-btn.following {
|
||||
background: transparent;
|
||||
border: 2px solid #94a3b8;
|
||||
@ -177,4 +208,17 @@
|
||||
.follow-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.creator-actions,
|
||||
.creator-chat-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Star rating in creator cards */
|
||||
.creator-stars {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.creator-stars-value {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@ -1,13 +1,5 @@
|
||||
/* Create page - Form for publishing new AI prompts */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.create-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -57,7 +49,8 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-group label {
|
||||
.form-group label,
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@ -104,17 +97,49 @@
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
#priceField {
|
||||
#tierField {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tier-manage-link {
|
||||
color: #3b82f6;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tier-manage-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Image preview */
|
||||
#imagePreview {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
}
|
||||
#imagePreview img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Status message */
|
||||
#create-status {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.submit-btn, .cancel-btn {
|
||||
.submit-btn,
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
@ -132,7 +157,8 @@
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.submit-btn:hover, .cancel-btn:hover {
|
||||
.submit-btn:hover,
|
||||
.cancel-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
/* Feed page - Multi-column grid, square images, like/comment/save actions */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.feed-main {
|
||||
background: transparent !important;
|
||||
padding: 20px 32px !important;
|
||||
@ -80,11 +72,20 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.post-card:hover {
|
||||
.post-card:hover,
|
||||
.post-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.post-card-link {
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Post Header */
|
||||
.post-header {
|
||||
display: flex;
|
||||
@ -125,7 +126,7 @@
|
||||
.post-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px 0;
|
||||
margin: 10px 0 6px 0;
|
||||
}
|
||||
.post-description {
|
||||
color: #334155;
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
/* Marketplace Page - Prompt cards, filter buttons, full width layout */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.marketplace-main {
|
||||
background: transparent !important;
|
||||
padding: 20px 32px !important;
|
||||
@ -123,6 +115,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.prompt-title {
|
||||
@ -153,6 +146,7 @@
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: #f59e0b;
|
||||
text-decoration: none;
|
||||
}
|
||||
.prompt-rating span:first-child i {
|
||||
color: #f59e0b;
|
||||
@ -165,7 +159,7 @@
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
margin: 8px 0 4px;
|
||||
margin: auto 0 4px;
|
||||
}
|
||||
|
||||
.prompt-actions {
|
||||
@ -228,26 +222,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Payment method buttons */
|
||||
.pay-method-btn {
|
||||
.market-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.market-card-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.market-card-time {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.market-card-rating {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.market-rating-none {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.market-rating-clickable {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
text-align: left;
|
||||
}
|
||||
.pay-method-btn:hover {
|
||||
border-color: #6366f1;
|
||||
background: #f5f3ff;
|
||||
.market-rating-stars {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.buy-btn-locked {
|
||||
background: #ef4444 !important;
|
||||
}
|
||||
.buy-btn-unlocked {
|
||||
background: #10b981 !important;
|
||||
}
|
||||
.market-price-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 20px;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.market-heart-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
.market-bookmark-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.market-save-span {
|
||||
margin-left: 12px;
|
||||
}
|
||||
.details-btn[disabled] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
/* Post Detail page - Full prompt view, rating, example output, unlock button */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.post-detail-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -319,3 +311,190 @@
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Loading / error states ──────────────────────────────────────────── */
|
||||
#detail-loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Smaller state icons for this page */
|
||||
#detail-loading .state-icon,
|
||||
#detail-error .state-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#detail-error-msg {
|
||||
color: #64748b;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detail-back-btn {
|
||||
margin-top: 20px;
|
||||
padding: 10px 24px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Detail body ─────────────────────────────────────────────────────── */
|
||||
#detail-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-creator-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
#creator-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#creator-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
#prompt-date {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.detail-actions-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#edit-prompt-btn {
|
||||
display: none;
|
||||
margin-left: 10px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#prompt-body {
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
#example-section {
|
||||
display: none;
|
||||
}
|
||||
#example-output-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#example-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Locked section ──────────────────────────────────────────────────── */
|
||||
#locked-section {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.locked-icon {
|
||||
font-size: 2.5rem;
|
||||
color: #94a3b8;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.locked-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.locked-desc {
|
||||
color: #64748b;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#locked-subscribe-btn {
|
||||
padding: 12px 28px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.detail-heart-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
.detail-bookmark-span {
|
||||
margin-left: 12px;
|
||||
}
|
||||
.detail-bookmark-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.detail-loading-text {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.detail-error-text {
|
||||
color: #ef4444;
|
||||
}
|
||||
.rating-stars-display {
|
||||
font-size: 1.1rem;
|
||||
color: #f59e0b;
|
||||
}
|
||||
.rating-value {
|
||||
margin-left: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rating-count {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.rating-none {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.tier-badge-tier {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border-radius: 20px;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tier-badge-free {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-radius: 20px;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@ -1,14 +1,217 @@
|
||||
/* Profile Page - Full width layout, darker share button, responsive grid */
|
||||
|
||||
/* Force main content container to full width, remove centering and max-width */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
/* ── Profile header ──────────────────────────────────────────────────── */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Inner spacing for the profile card */
|
||||
/* ── Profile avatar ──────────────────────────────────────────────────── */
|
||||
.profile-avatar {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ── Profile info column ─────────────────────────────────────────────── */
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#profileDisplayName {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#profileSlug {
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-badge-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
#profileBio {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#profileSpecialities {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
#profileStats {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
color: #64748b;
|
||||
margin-top: 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
#profileStats strong {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* ── Profile actions column ──────────────────────────────────────────── */
|
||||
#profileActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
#profileActions .login-button {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-tier-list {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.profile-tier-list h3 {
|
||||
font-size: 0.95rem;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.profile-tier-option {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-tier-option.active {
|
||||
background: #eef2ff;
|
||||
border-color: #818cf8;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.profile-tier-option strong,
|
||||
.profile-tier-option small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-tier-option small {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.profile-tier-option b {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Profile tabs ────────────────────────────────────────────────────── */
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
margin: 32px 0 18px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Prompts grid ────────────────────────────────────────────────────── */
|
||||
#profile-prompts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.profile-grid-loading {
|
||||
grid-column: 1 / -1;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
}
|
||||
.profile-grid-empty {
|
||||
grid-column: 1 / -1;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
}
|
||||
.profile-grid-error {
|
||||
grid-column: 1 / -1;
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
}
|
||||
.profile-prompt-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.profile-prompt-card:focus-within,
|
||||
.profile-prompt-card:hover {
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
.profile-prompt-link {
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.profile-prompt-img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.profile-prompt-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.profile-prompt-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.profile-prompt-desc {
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.profile-prompt-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
color: #64748b;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.profile-prompt-edit-btn {
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Inner spacing for the profile card ─────────────────────────────── */
|
||||
.profile-main {
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
@ -28,16 +231,32 @@
|
||||
}
|
||||
|
||||
/* Share button: darker background and text */
|
||||
.profile-header button:last-child {
|
||||
#shareProfileButton {
|
||||
background: #cbd5e1 !important; /* darker gray */
|
||||
color: #1e293b !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
#manageTiersButton {
|
||||
background: #f3e8ff !important;
|
||||
color: #7c3aed !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #d8b4fe !important;
|
||||
}
|
||||
|
||||
/* Buttons keep rounded corners */
|
||||
.login-button {
|
||||
align-items: center;
|
||||
border-radius: 14px !important;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-button i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
@ -75,6 +294,9 @@
|
||||
/* Avatar remains round */
|
||||
.profile-avatar {
|
||||
border-radius: 50% !important;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* All outer containers stay square */
|
||||
|
||||
@ -1,12 +1,5 @@
|
||||
/* Settings page - tabs, form styling */
|
||||
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -182,3 +175,10 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Save status message */
|
||||
#profileSaveStatus {
|
||||
margin-top: 10px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@ -154,7 +154,18 @@
|
||||
}
|
||||
|
||||
.sidebar .nav-text,
|
||||
.sidebar-logout .nav-text,
|
||||
.sidebar-logout .nav-text {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.logout-arrow {
|
||||
display: none;
|
||||
}
|
||||
@ -163,6 +174,7 @@
|
||||
.sidebar-logout {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar a.active {
|
||||
@ -172,8 +184,45 @@
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.sidebar-shell {
|
||||
height: auto;
|
||||
padding: 7px 6px;
|
||||
border-right: none;
|
||||
border-top: 1px solid #eef2f7;
|
||||
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sidebar li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar li.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar a,
|
||||
.sidebar-logout {
|
||||
padding: 10px;
|
||||
min-height: 46px;
|
||||
padding: 7px 2px;
|
||||
}
|
||||
|
||||
.sidebar i {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.sidebar a.active {
|
||||
border-right: none;
|
||||
border-top: 3px solid #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
271
OnlyPrompt.Frontend/css/subscription-tiers.css
Normal file
271
OnlyPrompt.Frontend/css/subscription-tiers.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,31 +1,35 @@
|
||||
/*
|
||||
Topbar styles for OnlyPrompt
|
||||
- clean, modern, full-width
|
||||
- search bar centered (expands on full screen), profile avatar always on the right
|
||||
- ONLY search bar and avatar have rounded corners
|
||||
- sticky on all app pages
|
||||
- search bar fills the available width
|
||||
- logout, messages and profile avatar stay on the right
|
||||
*/
|
||||
|
||||
.topbar-shell {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
padding: 16px 32px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
justify-content: space-between;
|
||||
padding: 16px 32px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
flex: 1; /* Takes all available space */
|
||||
max-width: none; /* No upper limit, expands freely */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
max-width: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 14px; /* Rounded like login inputs */
|
||||
}
|
||||
|
||||
.topbar-search i {
|
||||
@ -34,12 +38,17 @@
|
||||
}
|
||||
|
||||
.topbar-search input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
border: none;
|
||||
color: #334155;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar-search:focus-within {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.topbar-search input::placeholder {
|
||||
@ -47,24 +56,28 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Icons and avatar container */
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topbar-logout-form {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topbar-icon-btn {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
border-radius: 50%;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.4rem;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
padding: 8px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
@ -74,42 +87,47 @@
|
||||
}
|
||||
|
||||
.topbar-avatar-btn {
|
||||
border: none;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.topbar-avatar {
|
||||
width: 48px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 50%;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 50%; /* Avatar round */
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.topbar-shell {
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.topbar-search i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.topbar-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.topbar-icon-btn {
|
||||
font-size: 1.2rem;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
@ -117,9 +135,14 @@
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.topbar-shell {
|
||||
padding: 10px 16px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
padding: 6px 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
@ -20,16 +20,14 @@
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="layout"
|
||||
style="display: flex; min-height: 100vh; background: var(--bg)"
|
||||
>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column">
|
||||
<div class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="feed-main">
|
||||
<main class="feed-main" id="main-content" tabindex="-1">
|
||||
<!-- Optional: Feed Header -->
|
||||
<div class="feed-header">
|
||||
<h1>Feed</h1>
|
||||
@ -37,62 +35,44 @@
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="filter-buttons">
|
||||
<div class="filter-buttons" role="group" aria-label="Sort feed">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn active"
|
||||
data-sort="date"
|
||||
data-ascending="false"
|
||||
aria-pressed="true"
|
||||
>
|
||||
Recent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
data-sort="rating"
|
||||
data-ascending="false"
|
||||
aria-pressed="false"
|
||||
>
|
||||
Top Rated
|
||||
</button>
|
||||
<button class="filter-btn" data-sort="date" data-ascending="true">
|
||||
<button type="button" class="filter-btn" data-sort="date" data-ascending="true" aria-pressed="false">
|
||||
Oldest
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Posts Grid -->
|
||||
<div class="posts-grid" id="posts-grid"></div>
|
||||
<div class="posts-grid" id="posts-grid" aria-live="polite"></div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
id="feed-empty"
|
||||
style="
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #64748b;
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="bi bi-inbox"
|
||||
style="font-size: 3rem; display: block; margin-bottom: 16px"
|
||||
></i>
|
||||
<h3 style="margin-bottom: 8px">No posts yet</h3>
|
||||
<div id="feed-empty" class="state-empty" role="status" aria-live="polite">
|
||||
<i class="bi bi-inbox state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">No posts yet</h3>
|
||||
<p>Follow some creators to see their prompts here.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
id="feed-error"
|
||||
style="
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #ef4444;
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="bi bi-exclamation-circle"
|
||||
style="font-size: 3rem; display: block; margin-bottom: 16px"
|
||||
></i>
|
||||
<h3 style="margin-bottom: 8px">Could not load feed</h3>
|
||||
<div id="feed-error" class="state-error" role="alert" aria-live="assertive">
|
||||
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">Could not load feed</h3>
|
||||
<p id="feed-error-msg"></p>
|
||||
</div>
|
||||
</main>
|
||||
@ -107,11 +87,17 @@
|
||||
document.getElementById("sidebar-container").innerHTML = data;
|
||||
document
|
||||
.querySelectorAll("#sidebar-container .sidebar a")
|
||||
.forEach((link) => link.classList.remove("active"));
|
||||
.forEach((link) => {
|
||||
link.classList.remove("active");
|
||||
link.removeAttribute("aria-current");
|
||||
});
|
||||
const firstLink = document.querySelectorAll(
|
||||
"#sidebar-container .sidebar li a",
|
||||
)[0];
|
||||
if (firstLink) firstLink.classList.add("active");
|
||||
if (firstLink) {
|
||||
firstLink.classList.add("active");
|
||||
firstLink.setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
|
||||
fetch("/topbar.html")
|
||||
@ -142,9 +128,9 @@
|
||||
function renderStars(rating) {
|
||||
if (rating == null) return "";
|
||||
const stars = Math.round(rating);
|
||||
return `<span class="post-rating" title="${rating.toFixed(1)} / 5">
|
||||
${'<i class="bi bi-star-fill"></i>'.repeat(stars)}${'<i class="bi bi-star"></i>'.repeat(5 - stars)}
|
||||
<span>${rating.toFixed(1)}</span>
|
||||
return `<span class="post-rating" title="${rating.toFixed(1)} / 5" aria-label="${rating.toFixed(1)} out of 5 stars">
|
||||
${'<i class="bi bi-star-fill" aria-hidden="true"></i>'.repeat(stars)}${'<i class="bi bi-star" aria-hidden="true"></i>'.repeat(5 - stars)}
|
||||
<span aria-hidden="true">${rating.toFixed(1)}</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
@ -161,35 +147,37 @@
|
||||
const liked = prompt.isLiked;
|
||||
const saved = prompt.isSaved;
|
||||
return `
|
||||
<div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='${profileUrl(prompt.creatorId)}'">
|
||||
<article class="post-card${locked ? " post-locked" : ""}">
|
||||
<a class="post-card-link" href="${profileUrl(prompt.creatorId)}" aria-label="Open profile for ${prompt.creatorName}">
|
||||
<div class="post-header">
|
||||
<img class="post-avatar" src="${prompt.creatorAvatarUrl || '../images/content/cat.png'}" alt="${prompt.creatorName}">
|
||||
<img class="post-avatar" src="${prompt.creatorAvatarUrl || "../images/content/cat.png"}" alt="${prompt.creatorName}">
|
||||
<div class="post-author">
|
||||
<span class="post-name">${prompt.creatorName}</span>
|
||||
</div>
|
||||
<span class="post-date">${timeAgo(prompt.timeStamp)}</span>
|
||||
<span class="post-date"><time datetime="${prompt.timeStamp}">${timeAgo(prompt.timeStamp)}</time></span>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
${prompt.exampleImageUrl ? `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
|
||||
<h3 class="post-title" style="margin-top:10px">${prompt.title}</h3>
|
||||
<p class="post-description">${prompt.description || ''}</p>
|
||||
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? 'Paid'} tier required</p>` : ''}
|
||||
${prompt.exampleImageUrl ? `<img class="post-image${locked ? " post-image-locked" : ""}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? " post-image-locked" : ""}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
|
||||
<h3 class="post-title">${prompt.title}</h3>
|
||||
<p class="post-description">${prompt.description || ""}</p>
|
||||
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill" aria-hidden="true"></i> ${prompt.tierName ?? "Subscription"} tier required</p>` : ""}
|
||||
${renderStars(prompt.averageRating)}
|
||||
</div>
|
||||
</a>
|
||||
<div class="post-actions">
|
||||
<button class="action-btn like-btn ${liked ? 'active' : ''}" onclick="toggleLike(event, '${prompt.id}', ${liked})"><i class="bi ${liked ? 'bi-heart-fill' : 'bi-heart'}"></i> <span>Like (${prompt.likeCount || 0})</span></button>
|
||||
<button class="action-btn comment-btn" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat"></i> <span>Review</span></button>
|
||||
<button class="action-btn share-btn" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share"></i> <span>Share</span></button>
|
||||
<button class="action-btn save-btn ${saved ? 'active' : ''}" onclick="toggleSave(event, '${prompt.id}', ${saved})"><i class="bi ${saved ? 'bi-bookmark-fill' : 'bi-bookmark'}"></i> <span>Save (${prompt.saveCount || 0})</span></button>
|
||||
<button type="button" class="action-btn like-btn ${liked ? "active" : ""}" aria-pressed="${liked}" aria-label="${liked ? "Unlike" : "Like"} ${prompt.title}. ${prompt.likeCount || 0} likes" onclick="toggleLike(event, '${prompt.id}', ${liked})"><i class="bi ${liked ? "bi-heart-fill" : "bi-heart"}" aria-hidden="true"></i> <span>Like (${prompt.likeCount || 0})</span></button>
|
||||
<button type="button" class="action-btn comment-btn" aria-label="Review ${prompt.title}" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat" aria-hidden="true"></i> <span>Review</span></button>
|
||||
<button type="button" class="action-btn share-btn" aria-label="Share ${prompt.title}" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share" aria-hidden="true"></i> <span>Share</span></button>
|
||||
<button type="button" class="action-btn save-btn ${saved ? "active" : ""}" aria-pressed="${saved}" aria-label="${saved ? "Remove saved" : "Save"} ${prompt.title}. ${prompt.saveCount || 0} saves" onclick="toggleSave(event, '${prompt.id}', ${saved})"><i class="bi ${saved ? "bi-bookmark-fill" : "bi-bookmark"}" aria-hidden="true"></i> <span>Save (${prompt.saveCount || 0})</span></button>
|
||||
</div>
|
||||
</div>`;
|
||||
</article>`;
|
||||
}
|
||||
|
||||
window.toggleLike = async function (event, id, isLiked) {
|
||||
event.stopPropagation();
|
||||
const response = await fetch(`/api/v1/prompts/${id}/likes`, {
|
||||
method: isLiked ? "DELETE" : "PUT",
|
||||
credentials: "same-origin"
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
@ -200,7 +188,8 @@
|
||||
if (!response.ok) return;
|
||||
loadFeed(
|
||||
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
|
||||
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
|
||||
document.querySelector(".filter-btn.active")?.dataset.ascending ===
|
||||
"true",
|
||||
);
|
||||
};
|
||||
|
||||
@ -211,7 +200,8 @@
|
||||
localStorage.setItem(key, next);
|
||||
loadFeed(
|
||||
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
|
||||
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
|
||||
document.querySelector(".filter-btn.active")?.dataset.ascending ===
|
||||
"true",
|
||||
);
|
||||
};
|
||||
|
||||
@ -219,7 +209,7 @@
|
||||
event.stopPropagation();
|
||||
const response = await fetch(`/api/v1/prompts/${id}/saves`, {
|
||||
method: isSaved ? "DELETE" : "PUT",
|
||||
credentials: "same-origin"
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
@ -230,13 +220,16 @@
|
||||
if (!response.ok) return;
|
||||
loadFeed(
|
||||
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
|
||||
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
|
||||
document.querySelector(".filter-btn.active")?.dataset.ascending ===
|
||||
"true",
|
||||
);
|
||||
};
|
||||
|
||||
window.sharePrompt = function (event, id) {
|
||||
event.stopPropagation();
|
||||
navigator.clipboard.writeText(`${location.origin}/post-detail?id=${id}`);
|
||||
navigator.clipboard.writeText(
|
||||
`${location.origin}/post-detail?id=${id}`,
|
||||
);
|
||||
};
|
||||
|
||||
async function loadFeed(sortBy = "date", ascending = false) {
|
||||
@ -271,8 +264,12 @@
|
||||
btn.addEventListener("click", () => {
|
||||
document
|
||||
.querySelectorAll(".filter-btn")
|
||||
.forEach((b) => b.classList.remove("active"));
|
||||
.forEach((b) => {
|
||||
b.classList.remove("active");
|
||||
b.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
btn.classList.add("active");
|
||||
btn.setAttribute("aria-pressed", "true");
|
||||
loadFeed(btn.dataset.sort, btn.dataset.ascending === "true");
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,8 +13,12 @@ async function redirectIfAlreadySignedIn() {
|
||||
|
||||
function togglePassword() {
|
||||
const passwordInput = document.getElementById('password');
|
||||
const togglePasswordButton = document.getElementById('togglePassword');
|
||||
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
|
||||
passwordInput.type = newInputType;
|
||||
const isVisible = newInputType === 'text';
|
||||
togglePasswordButton.textContent = isVisible ? 'Hide' : 'Show';
|
||||
togglePasswordButton.setAttribute('aria-pressed', String(isVisible));
|
||||
}
|
||||
|
||||
async function submitLoginForm(){
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import './linq.js'
|
||||
import { Template } from './template.js';
|
||||
import "./linq.js";
|
||||
import { Template } from "./template.js";
|
||||
|
||||
export function formToObject(form) {
|
||||
const data = new FormData(form);
|
||||
@ -11,13 +11,14 @@ export function formToObject(form) {
|
||||
}
|
||||
|
||||
function setNestedValue(obj, path, value) {
|
||||
path.split('.').asEnumerable()
|
||||
path
|
||||
.split(".")
|
||||
.asEnumerable()
|
||||
.isLast()
|
||||
.forEach((key, isLast) => {
|
||||
if (isLast) {
|
||||
obj[key] = value;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
if (!obj[key]) {
|
||||
obj[key] = {};
|
||||
}
|
||||
@ -29,7 +30,7 @@ function setNestedValue(obj, path, value) {
|
||||
|
||||
export async function sendFormAsync(form, url, method) {
|
||||
url = url || form.action;
|
||||
method = method || form.method || 'post';
|
||||
method = method || form.method || "post";
|
||||
const data = formToObject(form);
|
||||
const response = await sendJsonAsync(url, data, method);
|
||||
if (response.ok && response.redirected) {
|
||||
@ -38,7 +39,10 @@ export async function sendFormAsync(form, url, method) {
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (response.ok == false && handleValidationError(response, responseText, form)) {
|
||||
if (
|
||||
response.ok == false &&
|
||||
handleValidationError(response, responseText, form)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -50,13 +54,13 @@ export async function sendFormAsync(form, url, method) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendJsonAsync(url, data, method = 'post') {
|
||||
export async function sendJsonAsync(url, data, method = "post") {
|
||||
const response = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
return response;
|
||||
@ -66,11 +70,18 @@ export async function postAndRenderAsync(url, data, template, targetElement) {
|
||||
const response = await sendJsonAsync(url, data);
|
||||
if (response.ok) {
|
||||
const responseText = await response.text();
|
||||
targetElement.innerHTML = template.render(responseText.length == 0 ? undefined : JSON.parse(responseText));
|
||||
targetElement.innerHTML = template.render(
|
||||
responseText.length == 0 ? undefined : JSON.parse(responseText),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postFormAndRenderAsync(url, form, template, targetElement) {
|
||||
export async function postFormAndRenderAsync(
|
||||
url,
|
||||
form,
|
||||
template,
|
||||
targetElement,
|
||||
) {
|
||||
const object = formToObject(form);
|
||||
const data = await postFormAsync(url, object, template, targetElement);
|
||||
if (data) {
|
||||
@ -79,21 +90,26 @@ export async function postFormAndRenderAsync(url, form, template, targetElement)
|
||||
}
|
||||
|
||||
const genericFormErrorTemplate = new Template(`
|
||||
<div class="form-error">
|
||||
An error occurred while submitting the form. Please try again later.
|
||||
<div class="form-error" role="alert" aria-live="assertive">
|
||||
{{ $this }}
|
||||
</div>
|
||||
`);
|
||||
|
||||
function handleGenericFormError(response, responseText, form) {
|
||||
if (!response.ok) {
|
||||
const html = genericFormErrorTemplate.render(responseText);
|
||||
form.insertAdjacentHTML('beforeend', html);
|
||||
// Remove all existing form-level errors before adding a new one
|
||||
form.querySelectorAll(":scope > .form-error").forEach((el) => el.remove());
|
||||
let message = responseText;
|
||||
try {
|
||||
message = JSON.parse(responseText);
|
||||
} catch (_) {}
|
||||
const html = genericFormErrorTemplate.render(message);
|
||||
form.insertAdjacentHTML("beforeend", html);
|
||||
}
|
||||
}
|
||||
|
||||
const validationErrorTemplate = new Template(`
|
||||
<div class="form-error">
|
||||
<div class="form-error" role="alert" aria-live="assertive">
|
||||
<ul>
|
||||
@for(error of $this) {
|
||||
<li class="error">{{error}}</li>
|
||||
@ -103,7 +119,7 @@ const validationErrorTemplate = new Template(`
|
||||
`);
|
||||
|
||||
const unknownInputErrorTemplate = new Template(`
|
||||
<div class="form-error">
|
||||
<div class="form-error" role="alert" aria-live="assertive">
|
||||
<p>An error occurred with the following fields:</p>
|
||||
@for(field, errors of Object.entries($this)) {
|
||||
<ul>
|
||||
@ -117,31 +133,32 @@ const unknownInputErrorTemplate = new Template(`
|
||||
|
||||
function toCamelCase(str) {
|
||||
str = str.replace(/([-_][a-z])/gi, (match) => {
|
||||
return match.toUpperCase()
|
||||
.replace('-', '')
|
||||
.replace('_', '');
|
||||
return match.toUpperCase().replace("-", "").replace("_", "");
|
||||
});
|
||||
|
||||
str = str[0].toLowerCase() + str.substring(1);
|
||||
return str;
|
||||
}
|
||||
|
||||
|
||||
function handleValidationError(response, responseText, form) {
|
||||
if (response.status !== 400) return false;
|
||||
const responseObject = JSON.parse(responseText);
|
||||
const unknownInputErrors = {};
|
||||
if (responseObject.type === 'https://tools.ietf.org/html/rfc9110#section-15.5.1' && responseObject.errors) {
|
||||
if (
|
||||
responseObject.type ===
|
||||
"https://tools.ietf.org/html/rfc9110#section-15.5.1" &&
|
||||
responseObject.errors
|
||||
) {
|
||||
for (const [field, messages] of Object.entries(responseObject.errors)) {
|
||||
const input = form.querySelector(`[name="${toCamelCase(field)}"]`);
|
||||
if (input) {
|
||||
const parent = input.parentElement;
|
||||
const errorHtml = validationErrorTemplate.render(messages);
|
||||
let errorContainer = parent.querySelector('.form-error'); // Check if an error container already exists
|
||||
let errorContainer = parent.querySelector(".form-error"); // Check if an error container already exists
|
||||
if (errorContainer) {
|
||||
errorContainer.outerHTML = errorHtml; // Replace existing error container
|
||||
} else {
|
||||
parent.insertAdjacentHTML('beforeend', errorHtml);
|
||||
parent.insertAdjacentHTML("beforeend", errorHtml);
|
||||
}
|
||||
} else {
|
||||
unknownInputErrors[field] = messages;
|
||||
@ -149,8 +166,11 @@ function handleValidationError(response, responseText, form) {
|
||||
}
|
||||
|
||||
if (Object.keys(unknownInputErrors).length > 0) {
|
||||
form
|
||||
.querySelectorAll(":scope > .form-error")
|
||||
.forEach((el) => el.remove());
|
||||
const html = unknownInputErrorTemplate.render(unknownInputErrors);
|
||||
form.insertAdjacentHTML('beforeend', html);
|
||||
form.insertAdjacentHTML("beforeend", html);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@ -5,6 +5,18 @@ async function signupAsync(params) {
|
||||
await sendFormAsync(form);
|
||||
}
|
||||
|
||||
function togglePassword() {
|
||||
const passwordInput = document.getElementById('password');
|
||||
const togglePasswordButton = document.getElementById('togglePassword');
|
||||
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
|
||||
passwordInput.type = newInputType;
|
||||
const isVisible = newInputType === 'text';
|
||||
togglePasswordButton.textContent = isVisible ? 'Hide' : 'Show';
|
||||
togglePasswordButton.setAttribute('aria-pressed', String(isVisible));
|
||||
}
|
||||
|
||||
document.getElementById('togglePassword')?.addEventListener('click', togglePassword);
|
||||
|
||||
const signupForm = document.getElementById('signupForm');
|
||||
signupForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault(); // Prevent the default form submission
|
||||
|
||||
@ -20,8 +20,9 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<!-- Main container for the login page (CSS layout) -->
|
||||
<main class="login-page">
|
||||
<main class="login-page" id="main-content" tabindex="-1">
|
||||
<!-- White login card -->
|
||||
<section class="login-card">
|
||||
<!-- Logo container -->
|
||||
@ -55,9 +56,10 @@
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
>
|
||||
<button type="button" id="togglePassword" class="toggle-password">
|
||||
<button type="button" id="togglePassword" class="toggle-password" aria-controls="password" aria-pressed="false">
|
||||
Show <!-- Click to show/hide password -->
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort, crypto payment modal -->
|
||||
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort and tier access -->
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
@ -17,59 +17,16 @@
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
<style>
|
||||
/* Additional inline style for sort dropdown – can be moved to marketplace.css */
|
||||
.filter-sort-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.filter-buttons {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.sort-dropdown {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 30px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.sort-dropdown:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.filter-sort-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.sort-dropdown {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="layout"
|
||||
style="display: flex; min-height: 100vh; background: var(--bg)"
|
||||
>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column">
|
||||
<div class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="marketplace-main">
|
||||
<main class="marketplace-main" id="main-content" tabindex="-1">
|
||||
<!-- Header -->
|
||||
<div class="marketplace-header">
|
||||
<h1>Marketplace</h1>
|
||||
@ -78,8 +35,8 @@
|
||||
|
||||
<!-- Filter + Sort Row -->
|
||||
<div class="filter-sort-row">
|
||||
<div class="filter-buttons" id="category-filters">
|
||||
<button class="filter-btn active" data-category="">All</button>
|
||||
<div class="filter-buttons" id="category-filters" role="group" aria-label="Filter prompts by category">
|
||||
<button type="button" class="filter-btn active" data-category="" aria-pressed="true">All</button>
|
||||
</div>
|
||||
<select
|
||||
class="sort-dropdown"
|
||||
@ -91,267 +48,31 @@
|
||||
<option value="rating|false">Best Rating</option>
|
||||
<option value="rating|true">Lowest Rating</option>
|
||||
<option value="free|true">Free</option>
|
||||
<option value="price|true">Lowest Price</option>
|
||||
<option value="price|false">Highest Price</option>
|
||||
<option value="price|true">Lowest Tier Price</option>
|
||||
<option value="price|false">Highest Tier Price</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Prompts Grid -->
|
||||
<div class="prompts-grid" id="prompts-grid"></div>
|
||||
<div class="prompts-grid" id="prompts-grid" aria-live="polite"></div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
id="market-empty"
|
||||
style="
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #64748b;
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="bi bi-bag-x"
|
||||
style="font-size: 3rem; display: block; margin-bottom: 16px"
|
||||
></i>
|
||||
<h3 style="margin-bottom: 8px">No prompts found</h3>
|
||||
<div id="market-empty" class="state-empty" role="status" aria-live="polite">
|
||||
<i class="bi bi-bag-x state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">No prompts found</h3>
|
||||
<p>Try a different category or search term.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
id="market-error"
|
||||
style="
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #ef4444;
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="bi bi-exclamation-circle"
|
||||
style="font-size: 3rem; display: block; margin-bottom: 16px"
|
||||
></i>
|
||||
<h3 style="margin-bottom: 8px">Could not load prompts</h3>
|
||||
<div id="market-error" class="state-error" role="alert" aria-live="assertive">
|
||||
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">Could not load prompts</h3>
|
||||
<p id="market-error-msg"></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Payment Modal ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
id="payment-overlay"
|
||||
style="
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
max-width: 460px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
"
|
||||
>
|
||||
<button
|
||||
onclick="closePayment()"
|
||||
style="
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
"
|
||||
>
|
||||
✕
|
||||
</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;
|
||||
"
|
||||
>
|
||||
← 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 1–2 minutes.
|
||||
</p>
|
||||
<button
|
||||
onclick="closePayment()"
|
||||
style="
|
||||
padding: 12px 28px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// ── Sidebar & Topbar ──────────────────────────────────────────────
|
||||
fetch("/sidebar.html")
|
||||
@ -360,11 +81,17 @@
|
||||
document.getElementById("sidebar-container").innerHTML = data;
|
||||
document
|
||||
.querySelectorAll("#sidebar-container .sidebar a")
|
||||
.forEach((l) => l.classList.remove("active"));
|
||||
.forEach((l) => {
|
||||
l.classList.remove("active");
|
||||
l.removeAttribute("aria-current");
|
||||
});
|
||||
const link = document.querySelectorAll(
|
||||
"#sidebar-container .sidebar li a",
|
||||
)[1];
|
||||
if (link) link.classList.add("active");
|
||||
if (link) {
|
||||
link.classList.add("active");
|
||||
link.setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
fetch("/topbar.html")
|
||||
.then((r) => r.text())
|
||||
@ -387,31 +114,40 @@
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function renderStars(rating, reviewCount = 0, promptId = null, locked = false) {
|
||||
const target = promptId && !locked
|
||||
? ` onclick="location.href='/post-detail?id=${promptId}#rating-section'" title="View reviews" style="cursor:pointer;"`
|
||||
function renderStars(
|
||||
rating,
|
||||
reviewCount = 0,
|
||||
promptId = null,
|
||||
locked = false,
|
||||
) {
|
||||
const href =
|
||||
promptId && !locked
|
||||
? `/post-detail?id=${encodeURIComponent(promptId)}#rating-section`
|
||||
: "";
|
||||
if (rating == null)
|
||||
return `<span${target} style="color:#94a3b8;font-size:0.8rem;${promptId && !locked ? 'cursor:pointer;' : ''}">No reviews yet</span>`;
|
||||
return href
|
||||
? `<a href="${href}" title="View reviews" class="market-rating-none market-rating-clickable">No reviews yet</a>`
|
||||
: `<span class="market-rating-none">No reviews yet</span>`;
|
||||
const stars = Math.round(rating);
|
||||
const label = reviewCount === 1 ? "review" : "reviews";
|
||||
return `<span class="prompt-rating"${target}><span style="color:#f59e0b">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> ${rating.toFixed(1)} (${reviewCount} ${label})</span>`;
|
||||
const content = `<span class="market-rating-stars" aria-hidden="true">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> <span aria-label="${rating.toFixed(1)} out of 5 stars">${rating.toFixed(1)}</span> (${reviewCount} ${label})`;
|
||||
return href
|
||||
? `<a class="prompt-rating market-rating-clickable" href="${href}" title="View reviews">${content}</a>`
|
||||
: `<span class="prompt-rating">${content}</span>`;
|
||||
}
|
||||
|
||||
function promptPrice(prompt) {
|
||||
if (prompt.price != null && Number(prompt.price) > 0) {
|
||||
return `$${Number(prompt.price).toFixed(2)}`;
|
||||
if (prompt.tierLevel) {
|
||||
const price = prompt.tierMonthlyPrice == null
|
||||
? ""
|
||||
: ` - $${Number(prompt.tierMonthlyPrice).toFixed(2)}/mo`;
|
||||
return `${prompt.tierName || `Tier ${prompt.tierLevel}`}${price}`;
|
||||
}
|
||||
if (prompt.tierLevel) return `$${(prompt.tierLevel * 4.99).toFixed(2)}/mo`;
|
||||
if (prompt.canAccess === false) return "Paid";
|
||||
return "Free";
|
||||
}
|
||||
|
||||
function getNumericPrice(prompt) {
|
||||
if (prompt.price != null && Number(prompt.price) > 0) {
|
||||
return Number(prompt.price);
|
||||
}
|
||||
if (prompt.tierLevel) return prompt.tierLevel * 4.99;
|
||||
if (prompt.tierMonthlyPrice != null) return Number(prompt.tierMonthlyPrice);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@ -424,7 +160,9 @@
|
||||
const direction = ascending === "true" ? 1 : -1;
|
||||
return prompts
|
||||
.slice()
|
||||
.sort((a, b) => (getNumericPrice(a) - getNumericPrice(b)) * direction);
|
||||
.sort(
|
||||
(a, b) => (getNumericPrice(a) - getNumericPrice(b)) * direction,
|
||||
);
|
||||
}
|
||||
|
||||
return prompts;
|
||||
@ -441,32 +179,34 @@
|
||||
let cardIndex = 0;
|
||||
|
||||
function renderCard(p) {
|
||||
const paid = p.price != null && Number(p.price) > 0;
|
||||
const locked = p.canAccess === false || paid || p.tierLevel != null;
|
||||
const img = p.exampleImageUrl || p._img || MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
|
||||
const locked = p.canAccess === false;
|
||||
const img =
|
||||
p.exampleImageUrl ||
|
||||
p._img ||
|
||||
MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
|
||||
return `
|
||||
<div class="prompt-card">
|
||||
<img src="${img}" alt="${p.title}" class="prompt-img">
|
||||
<div class="prompt-info">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<div style="width:34px;height:34px;border-radius:50%;background:#6366f1;color:#fff;font-weight:700;display:flex;align-items:center;justify-content:center;font-size:0.95rem;flex-shrink:0;">${p.creatorName.charAt(0).toUpperCase()}</div>
|
||||
<div class="market-card-header">
|
||||
<div class="market-card-avatar">${p.creatorName.charAt(0).toUpperCase()}</div>
|
||||
<span class="prompt-author">@${p.creatorName}</span>
|
||||
<span style="margin-left:auto;font-size:0.75rem;color:#94a3b8;">${timeAgo(p.timeStamp)}</span>
|
||||
<span class="market-card-time"><time datetime="${p.timeStamp}">${timeAgo(p.timeStamp)}</time></span>
|
||||
</div>
|
||||
<h3 class="prompt-title">${p.title}</h3>
|
||||
<p class="prompt-description">${p.description || 'No description yet.'}</p>
|
||||
<div style="margin-bottom:12px;">${renderStars(p.averageRating, p.reviewCount || 0, p.id, locked)}</div>
|
||||
<p class="prompt-description">${p.description || "No description yet."}</p>
|
||||
<div class="market-card-rating">${renderStars(p.averageRating, p.reviewCount || 0, p.id, locked)}</div>
|
||||
<div class="prompt-price">${promptPrice(p)}</div>
|
||||
<div class="prompt-actions">
|
||||
${
|
||||
locked
|
||||
? `<button class="buy-btn" style="background:#ef4444;" onclick='openPayment(${JSON.stringify(p)})'><i class="bi bi-lock-fill"></i> Pay</button>`
|
||||
: `<button class="buy-btn" style="background:#10b981;" onclick="location.href='/post-detail?id=${p.id}'">Access <i class="bi bi-unlock-fill"></i></button>`
|
||||
? `<button type="button" class="buy-btn buy-btn-locked" aria-label="Subscribe to unlock ${p.title}" onclick='subscribeToPromptTier(${JSON.stringify(p)})'><i class="bi bi-lock-fill" aria-hidden="true"></i> Subscribe</button>`
|
||||
: `<button type="button" class="buy-btn buy-btn-unlocked" aria-label="Access ${p.title}" onclick="location.href='/post-detail?id=${p.id}'">Access <i class="bi bi-unlock-fill" aria-hidden="true"></i></button>`
|
||||
}
|
||||
${
|
||||
locked
|
||||
? `<button class="details-btn" disabled style="opacity:.45;cursor:not-allowed;"><i class="bi bi-lock-fill"></i> Details</button>`
|
||||
: `<button class="details-btn" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>`
|
||||
? `<button type="button" class="details-btn" disabled aria-label="Details for ${p.title} are locked"><i class="bi bi-lock-fill" aria-hidden="true"></i> Details</button>`
|
||||
: `<button type="button" class="details-btn" aria-label="View details for ${p.title}" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@ -477,7 +217,8 @@
|
||||
const grid = document.getElementById("prompts-grid");
|
||||
const emptyEl = document.getElementById("market-empty");
|
||||
const errorEl = document.getElementById("market-error");
|
||||
const search = document.getElementById("topbarSearchInput")?.value.trim() || "";
|
||||
const search =
|
||||
document.getElementById("topbarSearchInput")?.value.trim() || "";
|
||||
const [sortBy, ascending] = document
|
||||
.getElementById("sort-select")
|
||||
.value.split("|");
|
||||
@ -488,8 +229,10 @@
|
||||
cardIndex = 0;
|
||||
|
||||
try {
|
||||
const apiSortBy = sortBy === "price" || sortBy === "free" ? "date" : sortBy;
|
||||
const apiAscending = sortBy === "price" || sortBy === "free" ? "false" : ascending;
|
||||
const apiSortBy =
|
||||
sortBy === "price" || sortBy === "free" ? "date" : sortBy;
|
||||
const apiAscending =
|
||||
sortBy === "price" || sortBy === "free" ? "false" : ascending;
|
||||
let url = `/api/v1/prompts?sortBy=${apiSortBy}&ascending=${apiAscending}&limit=50`;
|
||||
if (activeCategory) url += `&category=${activeCategory}`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
@ -501,7 +244,11 @@
|
||||
}
|
||||
if (!res.ok) throw new Error(`Server error ${res.status}`);
|
||||
|
||||
let prompts = applyMarketplaceSort(await res.json(), sortBy, ascending);
|
||||
let prompts = applyMarketplaceSort(
|
||||
await res.json(),
|
||||
sortBy,
|
||||
ascending,
|
||||
);
|
||||
|
||||
if (prompts.length === 0) {
|
||||
emptyEl.style.display = "block";
|
||||
@ -523,14 +270,20 @@
|
||||
const container = document.getElementById("category-filters");
|
||||
cats.forEach((c) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "filter-btn";
|
||||
btn.dataset.category = c.slug;
|
||||
btn.setAttribute("aria-pressed", "false");
|
||||
btn.textContent = c.name;
|
||||
btn.addEventListener("click", () => {
|
||||
document
|
||||
.querySelectorAll("#category-filters .filter-btn")
|
||||
.forEach((b) => b.classList.remove("active"));
|
||||
.forEach((b) => {
|
||||
b.classList.remove("active");
|
||||
b.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
btn.classList.add("active");
|
||||
btn.setAttribute("aria-pressed", "true");
|
||||
activeCategory = c.slug;
|
||||
loadPrompts();
|
||||
});
|
||||
@ -545,8 +298,12 @@
|
||||
.addEventListener("click", function () {
|
||||
document
|
||||
.querySelectorAll("#category-filters .filter-btn")
|
||||
.forEach((b) => b.classList.remove("active"));
|
||||
.forEach((b) => {
|
||||
b.classList.remove("active");
|
||||
b.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
this.classList.add("active");
|
||||
this.setAttribute("aria-pressed", "true");
|
||||
activeCategory = "";
|
||||
loadPrompts();
|
||||
});
|
||||
@ -575,89 +332,26 @@
|
||||
topbarObserver.observe(document.body, { childList: true, subtree: true });
|
||||
wireMarketplaceTopbarSearch();
|
||||
|
||||
// Make openPayment global
|
||||
window.openPayment = openPayment;
|
||||
window.subscribeToPromptTier = async function (prompt) {
|
||||
if (!prompt.tierLevel) return;
|
||||
|
||||
// ── Payment Modal ──────────────────────────────────────────────────
|
||||
const CRYPTO_ADDRESSES = {
|
||||
btc: "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf1N",
|
||||
eth: "0x742d35Cc6634C0532925a3b8D4C9B8E4D8F2b1a",
|
||||
sol: "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV1",
|
||||
usdt: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
|
||||
};
|
||||
const CRYPTO_NAMES = {
|
||||
btc: "Bitcoin (BTC)",
|
||||
eth: "Ethereum (ETH)",
|
||||
sol: "Solana (SOL)",
|
||||
usdt: "USDT TRC-20",
|
||||
};
|
||||
let currentPrompt = null;
|
||||
let currentCrypto = null;
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(prompt.creatorId)}/${prompt.tierLevel}`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
|
||||
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";
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
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`,
|
||||
if (response.ok) {
|
||||
loadPrompts();
|
||||
}
|
||||
};
|
||||
document.getElementById("pay-crypto-title").textContent =
|
||||
CRYPTO_NAMES[crypto];
|
||||
document.getElementById("pay-amount").textContent = amounts[crypto];
|
||||
document.getElementById("pay-address").textContent =
|
||||
CRYPTO_ADDRESSES[crypto];
|
||||
document.getElementById("pay-step-1").style.display = "none";
|
||||
document.getElementById("pay-step-2").style.display = "block";
|
||||
};
|
||||
|
||||
window.backToStep1 = function () {
|
||||
document.getElementById("pay-step-1").style.display = "block";
|
||||
document.getElementById("pay-step-2").style.display = "none";
|
||||
};
|
||||
|
||||
window.copyAddress = function () {
|
||||
navigator.clipboard.writeText(CRYPTO_ADDRESSES[currentCrypto]);
|
||||
};
|
||||
|
||||
window.confirmPayment = function () {
|
||||
document.getElementById("pay-step-2").style.display = "none";
|
||||
document.getElementById("pay-step-3").style.display = "block";
|
||||
};
|
||||
|
||||
window.closePayment = function () {
|
||||
document.getElementById("payment-overlay").style.display = "none";
|
||||
};
|
||||
|
||||
document
|
||||
.getElementById("payment-overlay")
|
||||
.addEventListener("click", function (e) {
|
||||
if (e.target === this) closePayment();
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────
|
||||
await loadCategories();
|
||||
|
||||
@ -19,121 +19,44 @@
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="layout"
|
||||
style="display: flex; min-height: 100vh; background: var(--bg)"
|
||||
>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column">
|
||||
<div class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="post-detail-main">
|
||||
<main class="post-detail-main" id="main-content" tabindex="-1">
|
||||
<div class="post-detail-container" id="detail-content">
|
||||
<!-- Loading -->
|
||||
<div
|
||||
id="detail-loading"
|
||||
style="text-align: center; padding: 60px 20px; color: #64748b"
|
||||
>
|
||||
<i
|
||||
class="bi bi-hourglass-split"
|
||||
style="font-size: 2.5rem; display: block; margin-bottom: 12px"
|
||||
></i>
|
||||
<div id="detail-loading">
|
||||
<i class="bi bi-hourglass-split state-icon" aria-hidden="true"></i>
|
||||
<p>Loading prompt...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div
|
||||
id="detail-error"
|
||||
style="
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #ef4444;
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="bi bi-exclamation-circle"
|
||||
style="font-size: 2.5rem; display: block; margin-bottom: 12px"
|
||||
></i>
|
||||
<div id="detail-error" class="state-error" role="alert" aria-live="assertive">
|
||||
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
|
||||
<h3 id="detail-error-title">Prompt not found</h3>
|
||||
<p
|
||||
id="detail-error-msg"
|
||||
style="color: #64748b; margin-top: 8px"
|
||||
></p>
|
||||
<button
|
||||
onclick="history.back()"
|
||||
style="
|
||||
margin-top: 20px;
|
||||
padding: 10px 24px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
"
|
||||
>
|
||||
<p id="detail-error-msg"></p>
|
||||
<button type="button" onclick="history.back()" class="detail-back-btn">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content (hidden until loaded) -->
|
||||
<div id="detail-body" style="display: none">
|
||||
<div id="detail-body">
|
||||
<!-- Header -->
|
||||
<div class="post-header">
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
"
|
||||
>
|
||||
<div
|
||||
id="creator-avatar"
|
||||
style="
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
"
|
||||
></div>
|
||||
<div class="detail-creator-row">
|
||||
<div id="creator-avatar"></div>
|
||||
<div>
|
||||
<span
|
||||
id="creator-name"
|
||||
style="font-weight: 600; font-size: 0.95rem"
|
||||
></span>
|
||||
<span
|
||||
id="prompt-date"
|
||||
style="display: block; font-size: 0.8rem; color: #94a3b8"
|
||||
></span>
|
||||
<span id="creator-name"></span>
|
||||
<span id="prompt-date"></span>
|
||||
</div>
|
||||
<div style="margin-left: auto">
|
||||
<div class="detail-actions-right">
|
||||
<span id="tier-badge"></span>
|
||||
<button
|
||||
id="edit-prompt-btn"
|
||||
style="
|
||||
display: none;
|
||||
margin-left: 10px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" id="edit-prompt-btn">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="post-title" id="prompt-title"></h1>
|
||||
@ -157,73 +80,38 @@
|
||||
<!-- Prompt Content (only if accessible) -->
|
||||
<div class="prompt-section" id="prompt-content-section">
|
||||
<h2>PROMPT</h2>
|
||||
<div
|
||||
class="prompt-content"
|
||||
id="prompt-body"
|
||||
style="
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
"
|
||||
></div>
|
||||
<div class="prompt-content" id="prompt-body"></div>
|
||||
</div>
|
||||
|
||||
<!-- Example Output -->
|
||||
<div class="example-section" id="example-section" style="display: none">
|
||||
<div class="example-section" id="example-section">
|
||||
<h2>EXAMPLE OUTPUT</h2>
|
||||
<div class="example-content">
|
||||
<div id="example-output-text" class="example-output-text" style="white-space: pre-wrap"></div>
|
||||
<div id="example-image" class="example-image" style="display: none">
|
||||
<img id="example-image-img" src="" alt="Example output image">
|
||||
<div
|
||||
id="example-output-text"
|
||||
class="example-output-text"
|
||||
></div>
|
||||
<div id="example-image" class="example-image">
|
||||
<img
|
||||
id="example-image-img"
|
||||
src=""
|
||||
alt="Example output image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locked section (shown instead of prompt if no access) -->
|
||||
<div
|
||||
id="locked-section"
|
||||
style="
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 28px;
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="bi bi-lock-fill"
|
||||
style="
|
||||
font-size: 2.5rem;
|
||||
color: #94a3b8;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
"
|
||||
></i>
|
||||
<h3 style="margin-bottom: 8px">
|
||||
<div id="locked-section">
|
||||
<i class="bi bi-lock-fill locked-icon" aria-hidden="true"></i>
|
||||
<h3 class="locked-title">
|
||||
This prompt requires a subscription
|
||||
</h3>
|
||||
<p style="color: #64748b; margin-bottom: 20px">
|
||||
<p class="locked-desc">
|
||||
Subscribe to <strong id="locked-creator"></strong> to access
|
||||
this prompt.
|
||||
</p>
|
||||
<button
|
||||
id="locked-subscribe-btn"
|
||||
style="
|
||||
padding: 12px 28px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
"
|
||||
>
|
||||
<button type="button" id="locked-subscribe-btn">
|
||||
Subscribe <span id="locked-tier-name"></span>
|
||||
</button>
|
||||
</div>
|
||||
@ -238,19 +126,34 @@
|
||||
<h2>REVIEWS</h2>
|
||||
<div class="review-form" id="review-form">
|
||||
<h3>Your review</h3>
|
||||
<div class="review-star-input" id="review-star-input" aria-label="Select rating">
|
||||
<button type="button" data-rating="1">☆</button>
|
||||
<button type="button" data-rating="2">☆</button>
|
||||
<button type="button" data-rating="3">☆</button>
|
||||
<button type="button" data-rating="4">☆</button>
|
||||
<button type="button" data-rating="5">☆</button>
|
||||
<div
|
||||
class="review-star-input"
|
||||
id="review-star-input"
|
||||
role="group"
|
||||
aria-label="Select rating"
|
||||
>
|
||||
<button type="button" aria-pressed="false" aria-label="1 star" data-rating="1">☆</button>
|
||||
<button type="button" aria-pressed="false" aria-label="2 stars" data-rating="2">☆</button>
|
||||
<button type="button" aria-pressed="false" aria-label="3 stars" data-rating="3">☆</button>
|
||||
<button type="button" aria-pressed="false" aria-label="4 stars" data-rating="4">☆</button>
|
||||
<button type="button" aria-pressed="false" aria-label="5 stars" data-rating="5">☆</button>
|
||||
</div>
|
||||
<textarea id="review-comment" maxlength="200" rows="3" placeholder="Write a short comment..."></textarea>
|
||||
<button type="button" id="submit-review-btn">Submit Review</button>
|
||||
<p id="review-message"></p>
|
||||
<textarea
|
||||
id="review-comment"
|
||||
maxlength="200"
|
||||
rows="3"
|
||||
placeholder="Write a short comment..."
|
||||
aria-label="Review comment"
|
||||
aria-describedby="review-comment-hint"
|
||||
></textarea>
|
||||
<p id="review-comment-hint" class="sr-only">Optional comment, maximum 200 characters.</p>
|
||||
<button type="button" id="submit-review-btn">
|
||||
Submit Review
|
||||
</button>
|
||||
<p id="review-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="reviews-list" id="reviews-list">
|
||||
<p style="color:#94a3b8;">Loading reviews...</p>
|
||||
<div class="reviews-list" id="reviews-list" aria-live="polite">
|
||||
<p class="detail-loading-text">Loading reviews...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -266,7 +169,10 @@
|
||||
document.getElementById("sidebar-container").innerHTML = data;
|
||||
document
|
||||
.querySelectorAll("#sidebar-container .sidebar a")
|
||||
.forEach((l) => l.classList.remove("active"));
|
||||
.forEach((l) => {
|
||||
l.classList.remove("active");
|
||||
l.removeAttribute("aria-current");
|
||||
});
|
||||
});
|
||||
fetch("/topbar.html")
|
||||
.then((r) => r.text())
|
||||
@ -331,8 +237,8 @@
|
||||
p.description;
|
||||
document.getElementById("prompt-body").textContent = p.content;
|
||||
document.getElementById("prompt-rating-stat").innerHTML =
|
||||
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes
|
||||
<span style="margin-left:12px;"><i class="bi ${p.isSaved ? 'bi-bookmark-fill' : 'bi-bookmark'}" style="color:#f59e0b;"></i> ${p.saveCount || 0} saves</span>`;
|
||||
`<i class="bi ${p.isLiked ? "bi-heart-fill" : "bi-heart"} detail-heart-icon" aria-hidden="true"></i> ${p.likeCount || 0} likes
|
||||
<span class="detail-bookmark-span"><i class="bi ${p.isSaved ? "bi-bookmark-fill" : "bi-bookmark"} detail-bookmark-icon" aria-hidden="true"></i> ${p.saveCount || 0} saves</span>`;
|
||||
renderExamples(p);
|
||||
renderOwnerActions(p);
|
||||
setupReviewSection(p);
|
||||
@ -340,28 +246,29 @@
|
||||
|
||||
// Tier badge
|
||||
const badge = document.getElementById("tier-badge");
|
||||
if (p.price != null && Number(p.price) > 0) {
|
||||
badge.innerHTML = `<span style="background:#fef3c7;color:#92400e;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">$${Number(p.price).toFixed(2)}</span>`;
|
||||
} else if (p.tierName) {
|
||||
badge.innerHTML = `<span style="background:#f1f5f9;color:#475569;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;"><i class="bi bi-lock-fill"></i> ${p.tierName}</span>`;
|
||||
if (p.tierName) {
|
||||
const price = p.tierMonthlyPrice == null
|
||||
? ""
|
||||
: ` - $${Number(p.tierMonthlyPrice).toFixed(2)}/mo`;
|
||||
badge.innerHTML = `<span class="tier-badge-tier"><i class="bi bi-lock-fill" aria-hidden="true"></i> ${p.tierName}${price}</span>`;
|
||||
} else {
|
||||
badge.innerHTML = `<span style="background:#dcfce7;color:#166534;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">Free</span>`;
|
||||
badge.innerHTML = `<span class="tier-badge-free">Free</span>`;
|
||||
}
|
||||
|
||||
// Rating
|
||||
if (p.averageRating != null) {
|
||||
const stars = Math.round(p.averageRating);
|
||||
document.getElementById("rating-display").innerHTML =
|
||||
`<span style="color:#f59e0b;font-size:1.1rem;">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span>
|
||||
<span style="margin-left:8px;font-weight:600;">${p.averageRating.toFixed(1)}</span>
|
||||
<span style="color:#94a3b8;font-size:0.85rem;margin-left:4px;">/ 5.0 (${p.reviewCount || 0} ${(p.reviewCount || 0) === 1 ? "review" : "reviews"})</span>`;
|
||||
`<span class="rating-stars-display">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span>
|
||||
<span class="rating-value">${p.averageRating.toFixed(1)}</span>
|
||||
<span class="rating-count">/ 5.0 (${p.reviewCount || 0} ${(p.reviewCount || 0) === 1 ? "review" : "reviews"})</span>`;
|
||||
document.getElementById("prompt-rating-stat").innerHTML =
|
||||
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes
|
||||
<span style="margin-left:12px;"><i class="bi ${p.isSaved ? 'bi-bookmark-fill' : 'bi-bookmark'}" style="color:#f59e0b;"></i> ${p.saveCount || 0} saves</span>
|
||||
<span style="margin-left:12px;"><i class="bi bi-star-fill" style="color:#f59e0b;"></i> ${p.averageRating.toFixed(1)} (${p.reviewCount || 0})</span>`;
|
||||
`<i class="bi ${p.isLiked ? "bi-heart-fill" : "bi-heart"} detail-heart-icon" aria-hidden="true"></i> ${p.likeCount || 0} likes
|
||||
<span class="detail-bookmark-span"><i class="bi ${p.isSaved ? "bi-bookmark-fill" : "bi-bookmark"} detail-bookmark-icon" aria-hidden="true"></i> ${p.saveCount || 0} saves</span>
|
||||
<span class="detail-bookmark-span"><i class="bi bi-star-fill detail-bookmark-icon" aria-hidden="true"></i> ${p.averageRating.toFixed(1)} (${p.reviewCount || 0})</span>`;
|
||||
} else {
|
||||
document.getElementById("rating-display").innerHTML =
|
||||
'<span style="color:#94a3b8;font-size:0.9rem;">No ratings yet</span>';
|
||||
'<span class="rating-none">No ratings yet</span>';
|
||||
}
|
||||
|
||||
// Content visibility
|
||||
@ -377,6 +284,7 @@
|
||||
document.getElementById("locked-tier-name").textContent = p.tierName
|
||||
? `— ${p.tierName}`
|
||||
: "";
|
||||
setupLockedSubscription(p);
|
||||
}
|
||||
|
||||
document.getElementById("detail-loading").style.display = "none";
|
||||
@ -384,6 +292,38 @@
|
||||
scrollToHashSection();
|
||||
}
|
||||
|
||||
function setupLockedSubscription(prompt) {
|
||||
const button = document.getElementById("locked-subscribe-btn");
|
||||
if (!button) return;
|
||||
|
||||
button.disabled = prompt.tierLevel == null;
|
||||
button.onclick = async () => {
|
||||
if (prompt.tierLevel == null) return;
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = "Subscribing...";
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(prompt.creatorId)}/${prompt.tierLevel}`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
button.disabled = false;
|
||||
button.textContent = await response.text();
|
||||
return;
|
||||
}
|
||||
|
||||
location.reload();
|
||||
};
|
||||
}
|
||||
|
||||
function scrollToHashSection() {
|
||||
if (!location.hash) return;
|
||||
|
||||
@ -397,9 +337,13 @@
|
||||
|
||||
function setReviewRating(rating) {
|
||||
selectedReviewRating = rating;
|
||||
document.querySelectorAll("#review-star-input button").forEach((button) => {
|
||||
document
|
||||
.querySelectorAll("#review-star-input button")
|
||||
.forEach((button) => {
|
||||
const value = Number(button.dataset.rating);
|
||||
button.textContent = value <= rating ? "★" : "☆";
|
||||
button.setAttribute("aria-pressed", String(value === rating));
|
||||
button.setAttribute("aria-label", `${value} ${value === 1 ? "star" : "stars"}${value === rating ? ", selected" : ""}`);
|
||||
});
|
||||
}
|
||||
|
||||
@ -451,8 +395,11 @@
|
||||
// Keep the review form visible; the API will reject unauthenticated users.
|
||||
}
|
||||
|
||||
document.querySelectorAll("#review-star-input button").forEach((button) => {
|
||||
button.onclick = () => setReviewRating(Number(button.dataset.rating));
|
||||
document
|
||||
.querySelectorAll("#review-star-input button")
|
||||
.forEach((button) => {
|
||||
button.onclick = () =>
|
||||
setReviewRating(Number(button.dataset.rating));
|
||||
});
|
||||
|
||||
submitBtn.onclick = async () => {
|
||||
@ -470,7 +417,8 @@
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
rating: selectedReviewRating,
|
||||
comment: document.getElementById("review-comment").value.trim() || null,
|
||||
comment:
|
||||
document.getElementById("review-comment").value.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -505,12 +453,15 @@
|
||||
|
||||
const reviews = await response.json();
|
||||
if (reviews.length === 0) {
|
||||
list.innerHTML = '<p style="color:#94a3b8;">No reviews yet.</p>';
|
||||
list.innerHTML =
|
||||
'<p class="detail-loading-text">No reviews yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = reviews.map((review) => {
|
||||
const stars = "★".repeat(review.rating) + "☆".repeat(5 - review.rating);
|
||||
list.innerHTML = reviews
|
||||
.map((review) => {
|
||||
const stars =
|
||||
"★".repeat(review.rating) + "☆".repeat(5 - review.rating);
|
||||
return `
|
||||
<article class="review-card">
|
||||
<div class="review-card-header">
|
||||
@ -519,9 +470,10 @@
|
||||
</div>
|
||||
<p class="review-card-comment">${escapeHtml(review.comment || "No comment.")}</p>
|
||||
</article>`;
|
||||
}).join("");
|
||||
})
|
||||
.join("");
|
||||
} catch (error) {
|
||||
list.innerHTML = `<p style="color:#ef4444;">${error.message}</p>`;
|
||||
list.innerHTML = `<p class="detail-error-text">${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,134 +1,212 @@
|
||||
<!-- OnlyPrompt - Profile page:
|
||||
- User profile display with avatar, bio, stats, and prompt cards (personal prompts) -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OnlyPrompt - Profile</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/profile.css">
|
||||
<link rel="stylesheet" href="../css/variables.css" />
|
||||
<link rel="stylesheet" href="../css/base.css" />
|
||||
<link rel="stylesheet" href="../css/sidebar.css" />
|
||||
<link rel="stylesheet" href="../css/login.css" />
|
||||
<link rel="stylesheet" href="../css/topbar.css" />
|
||||
<link rel="stylesheet" href="../css/profile.css" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex:1; margin:40px auto; max-width:950px;">
|
||||
|
||||
<div class="page-body">
|
||||
<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">
|
||||
<img
|
||||
id="profileAvatar"
|
||||
src="../images/content/cat.png"
|
||||
alt="Profile avatar"
|
||||
class="profile-avatar"
|
||||
/>
|
||||
|
||||
<section class="profile-header" style="display:flex;align-items:center;gap:32px;border-bottom:1px solid #e5e7eb;padding-bottom:24px;">
|
||||
|
||||
<img id="profileAvatar" src="../images/content/cat.png" class="profile-avatar" style="width:110px;height:110px;border-radius:50%;object-fit:cover;">
|
||||
|
||||
<div class="profile-info" style="flex:1;">
|
||||
<h1 id="profileDisplayName" style="font-size:2rem;font-weight:700;margin-bottom:4px;">Loading...</h1>
|
||||
<div id="profileSlug" style="color:#64748b;margin-bottom:8px;">
|
||||
@profile <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>
|
||||
<div 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" style="margin-bottom:8px;">
|
||||
Loading profile...
|
||||
</div>
|
||||
<div id="profileBio">Loading profile...</div>
|
||||
|
||||
<div id="profileSpecialities" style="color:#64748b;"></div>
|
||||
<div id="profileStats" style="display:flex;gap:18px;color:#64748b;margin-top:12px;font-size:0.95rem;">
|
||||
<span><strong id="profileRating" style="color:#111827;">0.0</strong> rating</span>
|
||||
<span><strong id="profileSubscribers" style="color:#111827;">0</strong> subscribers</span>
|
||||
<div id="profileSpecialities"></div>
|
||||
<div id="profileStats">
|
||||
<span><strong id="profileRating">0.0</strong> rating</span>
|
||||
<span
|
||||
><strong id="profileSubscribers">0</strong> subscribers</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="profileActions" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<button id="primaryProfileButton" class="login-button" onclick="location.href='settings.html'">Edit Profile</button>
|
||||
<button id="shareProfileButton" class="login-button" style="background:#f3f4f6;color:#111;box-shadow:none;">Share Profile</button>
|
||||
<div id="profileActions">
|
||||
<button
|
||||
type="button"
|
||||
id="primaryProfileButton"
|
||||
class="login-button"
|
||||
onclick="location.href = 'settings.html'"
|
||||
>
|
||||
<i class="bi bi-gear" aria-hidden="true"></i>
|
||||
Edit Profile
|
||||
</button>
|
||||
<button type="button" id="shareProfileButton" class="login-button">
|
||||
<i class="bi bi-share" aria-hidden="true"></i>
|
||||
Share Profile
|
||||
</button>
|
||||
<a
|
||||
id="manageTiersButton"
|
||||
class="login-button"
|
||||
href="subscription-tiers.html"
|
||||
>
|
||||
<i class="bi bi-gem" aria-hidden="true"></i>
|
||||
Manage Tiers
|
||||
</a>
|
||||
<div id="creatorTierList"></div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<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 class="profile-tabs" role="tablist" aria-label="Profile prompt lists">
|
||||
<button
|
||||
type="button"
|
||||
class="profile-tab active"
|
||||
data-tab="mine"
|
||||
id="myPromptsTab"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
aria-controls="profile-prompts-grid"
|
||||
>
|
||||
My Prompts
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-tab"
|
||||
data-tab="favorites"
|
||||
id="favoritesTab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="profile-prompts-grid"
|
||||
>
|
||||
Favorites
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-tab"
|
||||
data-tab="saved"
|
||||
id="savedTab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="profile-prompts-grid"
|
||||
>
|
||||
Saved
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<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 id="profile-prompts-grid" role="tabpanel" aria-live="polite" aria-labelledby="myPromptsTab">
|
||||
<div class="profile-grid-loading">Loading prompts...</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
fetch('/sidebar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
document.getElementById('sidebar-container').innerHTML = data;
|
||||
fetch("/sidebar.html")
|
||||
.then((r) => r.text())
|
||||
.then((data) => {
|
||||
document.getElementById("sidebar-container").innerHTML = data;
|
||||
// Remove 'active' from all sidebar links
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
document
|
||||
.querySelectorAll("#sidebar-container .sidebar a")
|
||||
.forEach((link) => {
|
||||
link.classList.remove("active");
|
||||
link.removeAttribute("aria-current");
|
||||
});
|
||||
// Then set 'active' only on the My Profile link
|
||||
const profileLink = document.querySelector('#sidebar-container a[href="profile.html"]');
|
||||
if (profileLink) profileLink.classList.add('active');
|
||||
const profileLink = document.querySelector(
|
||||
'#sidebar-container a[href="profile.html"]',
|
||||
);
|
||||
if (profileLink) {
|
||||
profileLink.classList.add("active");
|
||||
profileLink.setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
|
||||
fetch('/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
fetch("/topbar.html")
|
||||
.then((r) => r.text())
|
||||
.then(
|
||||
(data) =>
|
||||
(document.getElementById("topbar-container").innerHTML = data),
|
||||
);
|
||||
|
||||
const profileAvatar = document.getElementById('profileAvatar');
|
||||
const profileDisplayName = document.getElementById('profileDisplayName');
|
||||
const profileSlug = document.getElementById('profileSlug');
|
||||
const profileBio = document.getElementById('profileBio');
|
||||
const profileSpecialities = document.getElementById('profileSpecialities');
|
||||
const profileRating = document.getElementById('profileRating');
|
||||
const profileSubscribers = document.getElementById('profileSubscribers');
|
||||
const profilePromptsGrid = document.getElementById('profile-prompts-grid');
|
||||
const myPromptsTab = document.getElementById('myPromptsTab');
|
||||
const favoritesTab = document.getElementById('favoritesTab');
|
||||
const savedTab = document.getElementById('savedTab');
|
||||
const profileActions = document.getElementById('profileActions');
|
||||
const primaryProfileButton = document.getElementById('primaryProfileButton');
|
||||
const shareProfileButton = document.getElementById('shareProfileButton');
|
||||
const profileTabs = document.querySelector('.profile-tabs');
|
||||
const profileAvatar = document.getElementById("profileAvatar");
|
||||
const profileDisplayName = document.getElementById("profileDisplayName");
|
||||
const profileSlug = document.getElementById("profileSlug");
|
||||
const profileBio = document.getElementById("profileBio");
|
||||
const profileSpecialities = document.getElementById(
|
||||
"profileSpecialities",
|
||||
);
|
||||
const profileRating = document.getElementById("profileRating");
|
||||
const profileSubscribers = document.getElementById("profileSubscribers");
|
||||
const profilePromptsGrid = document.getElementById(
|
||||
"profile-prompts-grid",
|
||||
);
|
||||
const myPromptsTab = document.getElementById("myPromptsTab");
|
||||
const favoritesTab = document.getElementById("favoritesTab");
|
||||
const savedTab = document.getElementById("savedTab");
|
||||
const profileActions = document.getElementById("profileActions");
|
||||
const primaryProfileButton = document.getElementById(
|
||||
"primaryProfileButton",
|
||||
);
|
||||
const shareProfileButton = document.getElementById("shareProfileButton");
|
||||
const manageTiersButton = document.getElementById("manageTiersButton");
|
||||
const creatorTierList = document.getElementById("creatorTierList");
|
||||
const profileTabs = document.querySelector(".profile-tabs");
|
||||
const params = new URLSearchParams(location.search);
|
||||
const profileId = params.get('id');
|
||||
const profileId = params.get("id");
|
||||
let ownPrompts = [];
|
||||
let allPrompts = [];
|
||||
let profilePrompts = [];
|
||||
let activeProfileTab = 'mine';
|
||||
let activeProfileTab = "mine";
|
||||
let currentUserId = null;
|
||||
let isOwnProfile = !profileId;
|
||||
let profileLoaded = false;
|
||||
let currentIsFollowing = false;
|
||||
let currentSubscriptionTier = null;
|
||||
let creatorSubscriptionTiers = [];
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { credentials: 'same-origin' });
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
if (response.status === 401) {
|
||||
location.href = '/login';
|
||||
location.href = "/login";
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function renderProfile(profile, fallbackName = 'Profile') {
|
||||
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);
|
||||
profileSlug.innerHTML = `@${profile.user?.userName || profile.slug || "profile"} <i class="bi bi-patch-check-fill profile-badge-icon" aria-hidden="true"></i>`;
|
||||
profileBio.textContent = profile.bio || "No bio yet.";
|
||||
profileSpecialities.textContent =
|
||||
profile.specialities || "No specialities added yet.";
|
||||
profileRating.textContent = Number(profile.averageRating || 0).toFixed(
|
||||
1,
|
||||
);
|
||||
profileSubscribers.textContent = profile.subscribers || 0;
|
||||
|
||||
if (profile.avatarUrl) {
|
||||
@ -140,11 +218,14 @@
|
||||
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);
|
||||
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) {
|
||||
@ -156,19 +237,24 @@
|
||||
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());
|
||||
const creators = await fetchJson("/api/v1/profiles?limit=100");
|
||||
const creator = creators.find(
|
||||
(item) => item.userId?.toLowerCase() === profileId.toLowerCase(),
|
||||
);
|
||||
if (!creator) return;
|
||||
|
||||
renderProfile({
|
||||
renderProfile(
|
||||
{
|
||||
displayName: creator.displayName,
|
||||
slug: creator.slug,
|
||||
bio: creator.bio,
|
||||
avatarUrl: creator.avatarUrl,
|
||||
specialities: null,
|
||||
averageRating: creator.averageRating,
|
||||
subscribers: creator.subscribers
|
||||
}, 'Creator Profile');
|
||||
subscribers: creator.subscribers,
|
||||
},
|
||||
"Creator Profile",
|
||||
);
|
||||
} catch {
|
||||
// Prompt data below still provides a minimal fallback if creator cards fail.
|
||||
}
|
||||
@ -178,33 +264,37 @@
|
||||
try {
|
||||
const currentProfile = await window.loadCurrentProfile();
|
||||
currentUserId = currentProfile.user?.id;
|
||||
isOwnProfile = !profileId || profileId.toLowerCase() === currentUserId?.toLowerCase();
|
||||
isOwnProfile =
|
||||
!profileId ||
|
||||
profileId.toLowerCase() === currentUserId?.toLowerCase();
|
||||
|
||||
if (isOwnProfile) {
|
||||
renderProfile(currentProfile, 'My Profile');
|
||||
renderProfile(currentProfile, "My Profile");
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await fetchJson(`/api/v1/profiles/${encodeURIComponent(profileId)}`);
|
||||
renderProfile(profile, 'Creator Profile');
|
||||
const profile = await fetchJson(
|
||||
`/api/v1/profiles/${encodeURIComponent(profileId)}`,
|
||||
);
|
||||
renderProfile(profile, "Creator Profile");
|
||||
} catch (error) {
|
||||
if (isOwnProfile) {
|
||||
profileDisplayName.textContent = 'Profile unavailable';
|
||||
profileDisplayName.textContent = "Profile unavailable";
|
||||
profileBio.textContent = error.message;
|
||||
} else {
|
||||
profileDisplayName.textContent = 'Loading creator...';
|
||||
profileBio.textContent = '';
|
||||
profileDisplayName.textContent = "Loading creator...";
|
||||
profileBio.textContent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPromptMarked(type, id) {
|
||||
if (type === 'liked') {
|
||||
if (type === "liked") {
|
||||
const prompt = allPrompts.find((item) => item.id === id);
|
||||
return prompt?.isLiked === true;
|
||||
}
|
||||
|
||||
if (type === 'saved') {
|
||||
if (type === "saved") {
|
||||
const prompt = allPrompts.find((item) => item.id === id);
|
||||
return prompt?.isSaved === true;
|
||||
}
|
||||
@ -213,91 +303,177 @@
|
||||
}
|
||||
|
||||
function renderProfilePrompt(prompt, options = {}) {
|
||||
const image = prompt.exampleImageUrl || '../images/content/post1.png';
|
||||
const image = prompt.exampleImageUrl || "../images/content/post1.png";
|
||||
const showEdit = options.showEdit === true;
|
||||
const rating = prompt.averageRating == null ? 'No ratings' : prompt.averageRating.toFixed(1);
|
||||
const rating =
|
||||
prompt.averageRating == null
|
||||
? "No ratings"
|
||||
: prompt.averageRating.toFixed(1);
|
||||
return `
|
||||
<div onclick="location.href='/post-detail?id=${prompt.id}'" style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:18px;display:flex;gap:16px;cursor:pointer;">
|
||||
<img src="${image}" alt="${prompt.title}" style="width:72px;height:72px;border-radius:12px;object-fit:cover;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:700;">${prompt.title}</div>
|
||||
<div style="color:#64748b;margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${prompt.description || 'No description yet.'}</div>
|
||||
<div style="display:flex;gap:16px;color:#64748b;align-items:center;flex-wrap:wrap;">
|
||||
<span><i class="bi bi-star"></i> ${rating}</span>
|
||||
${prompt.creatorName ? `<span>@${prompt.creatorName}</span>` : ''}
|
||||
${showEdit ? `<button onclick="event.stopPropagation(); location.href='/create?id=${prompt.id}'" style="border:none;background:#f1f5f9;color:#334155;border-radius:10px;padding:6px 10px;font-weight:700;cursor:pointer;">Edit</button>` : ''}
|
||||
<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>`;
|
||||
</a>
|
||||
${showEdit ? `<button type="button" onclick="location.href='/create?id=${encodeURIComponent(prompt.id)}'" class="profile-prompt-edit-btn" aria-label="Edit ${prompt.title}">Edit</button>` : ""}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
profilePromptsGrid.innerHTML = `<div class="profile-grid-empty">${emptyText}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
profilePromptsGrid.innerHTML = prompts.map((prompt) => renderProfilePrompt(prompt, options)).join('');
|
||||
profilePromptsGrid.innerHTML = prompts
|
||||
.map((prompt) => renderProfilePrompt(prompt, options))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function updateTabs() {
|
||||
document.querySelectorAll('.profile-tab').forEach((tab) => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === activeProfileTab);
|
||||
document.querySelectorAll(".profile-tab").forEach((tab) => {
|
||||
tab.classList.toggle("active", tab.dataset.tab === activeProfileTab);
|
||||
tab.setAttribute("aria-selected", String(tab.dataset.tab === activeProfileTab));
|
||||
});
|
||||
profilePromptsGrid.setAttribute("aria-labelledby", activeProfileTab === "favorites" ? "favoritesTab" : activeProfileTab === "saved" ? "savedTab" : "myPromptsTab");
|
||||
|
||||
const liked = allPrompts.filter((prompt) => isPromptMarked('liked', prompt.id));
|
||||
const saved = allPrompts.filter((prompt) => isPromptMarked('saved', prompt.id));
|
||||
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.');
|
||||
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 });
|
||||
renderPromptList(ownPrompts, "No prompts yet.", { showEdit: true });
|
||||
}
|
||||
}
|
||||
|
||||
function updateProfileMode() {
|
||||
if (isOwnProfile) {
|
||||
profileActions.style.display = 'flex';
|
||||
primaryProfileButton.textContent = 'Edit Profile';
|
||||
profileActions.style.display = "flex";
|
||||
primaryProfileButton.innerHTML = '<i class="bi bi-gear" aria-hidden="true"></i> Edit Profile';
|
||||
primaryProfileButton.disabled = false;
|
||||
primaryProfileButton.onclick = () => location.href = 'settings.html';
|
||||
profileTabs.style.display = 'flex';
|
||||
primaryProfileButton.onclick = () =>
|
||||
(location.href = "settings.html");
|
||||
manageTiersButton.style.display = "flex";
|
||||
profileTabs.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
profileActions.style.display = 'flex';
|
||||
primaryProfileButton.textContent = currentIsFollowing ? 'Following' : 'Follow';
|
||||
profileActions.style.display = "flex";
|
||||
manageTiersButton.style.display = "none";
|
||||
primaryProfileButton.textContent = currentIsFollowing
|
||||
? currentSubscriptionTier
|
||||
? `Subscribed: ${currentSubscriptionTier.name}`
|
||||
: "Following"
|
||||
: "Follow";
|
||||
primaryProfileButton.disabled = false;
|
||||
primaryProfileButton.onclick = toggleProfileFollow;
|
||||
favoritesTab.style.display = 'none';
|
||||
savedTab.style.display = 'none';
|
||||
favoritesTab.style.display = "none";
|
||||
savedTab.style.display = "none";
|
||||
myPromptsTab.textContent = `Prompts (${profilePrompts.length})`;
|
||||
renderPromptList(profilePrompts, 'No prompts yet.');
|
||||
renderPromptList(profilePrompts, "No prompts yet.");
|
||||
renderCreatorTiers();
|
||||
}
|
||||
|
||||
function renderCreatorTiers() {
|
||||
if (isOwnProfile) {
|
||||
creatorTierList.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!creatorSubscriptionTiers.length) {
|
||||
creatorTierList.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
creatorTierList.innerHTML = `
|
||||
<div class="profile-tier-list">
|
||||
<h3>Subscription Tiers</h3>
|
||||
${creatorSubscriptionTiers
|
||||
.map(
|
||||
(tier) => `
|
||||
<button type="button" class="profile-tier-option ${currentSubscriptionTier?.level === tier.level ? "active" : ""}" data-tier-level="${tier.level}" aria-pressed="${currentSubscriptionTier?.level === tier.level}">
|
||||
<span>
|
||||
<strong>${tier.name}</strong>
|
||||
<small>Level ${tier.level}</small>
|
||||
</span>
|
||||
<b>$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</b>
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`;
|
||||
|
||||
creatorTierList
|
||||
.querySelectorAll("[data-tier-level]")
|
||||
.forEach((button) => {
|
||||
button.addEventListener("click", () =>
|
||||
subscribeToTier(Number(button.dataset.tierLevel)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFollowState() {
|
||||
if (isOwnProfile || !profileId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/subscriptions/${encodeURIComponent(profileId)}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
if (response.status === 401) {
|
||||
location.href = '/login';
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -305,35 +481,84 @@
|
||||
if (!profileId) return;
|
||||
|
||||
primaryProfileButton.disabled = true;
|
||||
const response = await fetch(`/api/v1/subscriptions/${encodeURIComponent(profileId)}`, {
|
||||
method: currentIsFollowing ? 'DELETE' : 'PUT',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: currentIsFollowing ? "DELETE" : "PUT",
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
location.href = '/login';
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const currentSubscribers = Number(profileSubscribers.textContent || 0);
|
||||
const currentSubscribers = Number(
|
||||
profileSubscribers.textContent || 0,
|
||||
);
|
||||
currentIsFollowing = !currentIsFollowing;
|
||||
profileSubscribers.textContent = Math.max(0, currentSubscribers + (currentIsFollowing ? 1 : -1));
|
||||
if (!currentIsFollowing) currentSubscriptionTier = null;
|
||||
profileSubscribers.textContent = Math.max(
|
||||
0,
|
||||
currentSubscribers + (currentIsFollowing ? 1 : -1),
|
||||
);
|
||||
updateProfileMode();
|
||||
} else {
|
||||
primaryProfileButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
shareProfileButton.addEventListener('click', async () => {
|
||||
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.textContent = 'Copied';
|
||||
setTimeout(() => shareProfileButton.textContent = 'Share Profile', 1200);
|
||||
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;
|
||||
}
|
||||
@ -341,29 +566,32 @@
|
||||
|
||||
async function loadOwnPrompts() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/prompts/mine');
|
||||
const response = await fetch("/api/v1/prompts/mine");
|
||||
if (response.status === 401) {
|
||||
location.href = '/login';
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error('Prompts could not be loaded.');
|
||||
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>`;
|
||||
profilePromptsGrid.innerHTML = `<div class="profile-grid-error">${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllPromptReferences() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/prompts?limit=100');
|
||||
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());
|
||||
profilePrompts = allPrompts.filter(
|
||||
(prompt) =>
|
||||
prompt.creatorId?.toLowerCase() === profileId.toLowerCase(),
|
||||
);
|
||||
renderProfileFromPrompt(profilePrompts[0]);
|
||||
updateProfileMode();
|
||||
}
|
||||
@ -372,8 +600,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.profile-tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll(".profile-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
if (!isOwnProfile) {
|
||||
updateProfileMode();
|
||||
return;
|
||||
@ -387,6 +615,7 @@
|
||||
await loadProfile();
|
||||
await loadCreatorCardFallback();
|
||||
await loadFollowState();
|
||||
await loadCreatorTiers();
|
||||
updateProfileMode();
|
||||
if (isOwnProfile) {
|
||||
loadOwnPrompts();
|
||||
|
||||
@ -17,15 +17,16 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex:1; display: flex; flex-direction: column;">
|
||||
<div class="page-body">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="settings-main">
|
||||
<main class="settings-main" id="main-content" tabindex="-1">
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>Settings</h1>
|
||||
@ -33,21 +34,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="settings-tabs">
|
||||
<button class="tab-btn active" data-tab="profile">Profile</button>
|
||||
<button class="tab-btn" data-tab="security">Security</button>
|
||||
<button class="tab-btn" data-tab="notifications">Notifications</button>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||
<button type="button" class="tab-btn active" data-tab="profile" id="profileTabButton" role="tab" aria-selected="true" aria-controls="profileTab">Profile</button>
|
||||
<button type="button" class="tab-btn" data-tab="security" id="securityTabButton" role="tab" aria-selected="false" aria-controls="securityTab">Security</button>
|
||||
<button type="button" class="tab-btn" data-tab="notifications" id="notificationsTabButton" role="tab" aria-selected="false" aria-controls="notificationsTab">Notifications</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Profile -->
|
||||
<div id="profileTab" class="tab-content active">
|
||||
<div id="profileTab" class="tab-content active" role="tabpanel" aria-labelledby="profileTabButton">
|
||||
<form class="settings-form" id="profileSettingsForm">
|
||||
<div class="form-group">
|
||||
<label for="avatar">Profile Picture</label>
|
||||
<div class="avatar-upload">
|
||||
<img src="../images/content/cat.png" alt="Avatar" class="settings-avatar" id="avatarPreview">
|
||||
<img src="../images/content/cat.png" alt="Profile picture preview" class="settings-avatar" id="avatarPreview">
|
||||
<input type="file" id="avatarUpload" accept="image/png, image/jpeg">
|
||||
<button type="button" class="upload-btn">Upload new</button>
|
||||
<button type="button" class="upload-btn" aria-controls="avatarUpload">Upload new</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@ -68,13 +69,13 @@
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="save-btn">Save Changes</button>
|
||||
<p id="profileSaveStatus" style="margin-top:10px;color:#64748b;text-align:center;"></p>
|
||||
<p id="profileSaveStatus" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Security -->
|
||||
<div id="securityTab" class="tab-content">
|
||||
<div id="securityTab" class="tab-content" role="tabpanel" aria-labelledby="securityTabButton" hidden>
|
||||
<form class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="currentPw">Current Password</label>
|
||||
@ -100,7 +101,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Notifications (erweitert) -->
|
||||
<div id="notificationsTab" class="tab-content">
|
||||
<div id="notificationsTab" class="tab-content" role="tabpanel" aria-labelledby="notificationsTabButton" hidden>
|
||||
<form class="settings-form">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
@ -149,10 +150,19 @@
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tabId = btn.getAttribute('data-tab');
|
||||
tabBtns.forEach(b => b.classList.remove('active'));
|
||||
tabBtns.forEach(b => {
|
||||
b.classList.remove('active');
|
||||
b.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
tabContents.forEach(content => content.classList.remove('active'));
|
||||
document.getElementById(`${tabId}Tab`).classList.add('active');
|
||||
btn.setAttribute('aria-selected', 'true');
|
||||
tabContents.forEach(content => {
|
||||
content.classList.remove('active');
|
||||
content.hidden = true;
|
||||
});
|
||||
const selectedTab = document.getElementById(`${tabId}Tab`);
|
||||
selectedTab.classList.add('active');
|
||||
selectedTab.hidden = false;
|
||||
});
|
||||
});
|
||||
|
||||
@ -247,9 +257,13 @@
|
||||
document.getElementById('sidebar-container').innerHTML = data;
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
link.removeAttribute('aria-current');
|
||||
});
|
||||
const settingsLink = document.querySelector('#sidebar-container a[href="settings.html"]');
|
||||
if (settingsLink) settingsLink.classList.add('active');
|
||||
if (settingsLink) {
|
||||
settingsLink.classList.add('active');
|
||||
settingsLink.setAttribute('aria-current', 'page');
|
||||
}
|
||||
});
|
||||
fetch('/topbar.html')
|
||||
.then(r => r.text())
|
||||
|
||||
@ -6,74 +6,81 @@
|
||||
|
||||
<div class="sidebar-shell">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo">
|
||||
<img src="../images/logo_full.png" alt="OnlyPrompt Logo" class="sidebar-logo-full">
|
||||
<img src="../images/logo_icon.png" alt="OnlyPrompt Icon" class="sidebar-logo-icon">
|
||||
<div class="sidebar-logo" aria-label="OnlyPrompt">
|
||||
<img src="../images/logo_full.png" alt="" class="sidebar-logo-full">
|
||||
<img src="../images/logo_icon.png" alt="" class="sidebar-logo-icon">
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar">
|
||||
<nav class="sidebar" id="main-navigation" aria-label="Main navigation" tabindex="-1">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="dashboard.html" class="active">
|
||||
<i class="bi bi-house icon-blue"></i>
|
||||
<li class="mobile-nav-item">
|
||||
<a href="dashboard.html" class="active" aria-current="page">
|
||||
<i class="bi bi-house icon-blue" aria-hidden="true"></i>
|
||||
<span class="nav-text">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<li class="mobile-nav-item">
|
||||
<a href="marketplace.html">
|
||||
<i class="bi bi-shop icon-purple"></i>
|
||||
<i class="bi bi-shop icon-purple" aria-hidden="true"></i>
|
||||
<span class="nav-text">Marketplace</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<li class="mobile-nav-item">
|
||||
<a href="community.html">
|
||||
<i class="bi bi-people icon-pink"></i>
|
||||
<i class="bi bi-people icon-pink" aria-hidden="true"></i>
|
||||
<span class="nav-text">Community</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<li class="mobile-nav-item">
|
||||
<a href="chats.html">
|
||||
<i class="bi bi-chat-dots icon-blue"></i>
|
||||
<i class="bi bi-chat-dots icon-blue" aria-hidden="true"></i>
|
||||
<span class="nav-text">Chats</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<li class="mobile-nav-item">
|
||||
<a href="settings.html">
|
||||
<i class="bi bi-gear icon-purple"></i>
|
||||
<i class="bi bi-gear icon-purple" aria-hidden="true"></i>
|
||||
<span class="nav-text">Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<li class="mobile-nav-item">
|
||||
<a href="profile.html">
|
||||
<i class="bi bi-person icon-pink"></i>
|
||||
<i class="bi bi-person icon-pink" aria-hidden="true"></i>
|
||||
<span class="nav-text">My Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<li class="mobile-nav-item">
|
||||
<a href="create.html">
|
||||
<i class="bi bi-plus-circle-fill icon-blue"></i>
|
||||
<i class="bi bi-plus-circle-fill icon-blue" aria-hidden="true"></i>
|
||||
<span class="nav-text">Create New</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="mobile-nav-item">
|
||||
<a href="subscription-tiers.html">
|
||||
<i class="bi bi-gem icon-purple" aria-hidden="true"></i>
|
||||
<span class="nav-text">Subscriptions</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Logout bottom -->
|
||||
<div class="sidebar-bottom">
|
||||
<form action="/api/v1/auth/logout" method="post">
|
||||
<button type="submit" class="sidebar-logout">
|
||||
<button type="submit" class="sidebar-logout" aria-label="Logout">
|
||||
<div class="logout-left">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
|
||||
<span class="nav-text">Logout</span>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right logout-arrow"></i>
|
||||
<i class="bi bi-chevron-right logout-arrow" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<!-- For responsive design: adapts width for different devices -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- Title shown in browser tab -->
|
||||
<title>OnlyPrompt - Login</title>
|
||||
<title>OnlyPrompt - Sign Up</title>
|
||||
|
||||
<!-- CSS files for variables, base styles, and login page -->
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
@ -20,8 +20,9 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<!-- Main container for the login page (CSS layout) -->
|
||||
<main class="login-page">
|
||||
<main class="login-page" id="main-content" tabindex="-1">
|
||||
<!-- White login card -->
|
||||
<section class="login-card">
|
||||
<!-- Logo container -->
|
||||
@ -37,25 +38,25 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" placeholder="yourname@email.com" required>
|
||||
<input type="email" id="email" name="email" placeholder="yourname@email.com" autocomplete="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="displayName">Display Name (how it will appear to others)</label>
|
||||
<input type="text" id="displayName" name="displayName" placeholder="Enter your display name" required>
|
||||
<input type="text" id="displayName" name="displayName" placeholder="Enter your display name" autocomplete="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="userName">Username</label>
|
||||
<input type="text" id="userName" name="userName" placeholder="Choose a username" required>
|
||||
<input type="text" id="userName" name="userName" placeholder="Choose a username" autocomplete="username" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<!-- Password field with button to show/hide password -->
|
||||
<div class="password-wrapper">
|
||||
<input type="password" id="password" name="password" placeholder="Enter your password" required>
|
||||
<button type="button" id="togglePassword" class="toggle-password">
|
||||
<input type="password" id="password" name="password" placeholder="Enter your password" autocomplete="new-password" required>
|
||||
<button type="button" id="togglePassword" class="toggle-password" aria-controls="password" aria-pressed="false">
|
||||
Show <!-- Click to show/hide password -->
|
||||
</button>
|
||||
</div>
|
||||
@ -73,7 +74,7 @@
|
||||
|
||||
<p class="signup-text">
|
||||
Have an account?
|
||||
<a href="#">Log In</a>
|
||||
<a href="/login">Log In</a>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
389
OnlyPrompt.Frontend/subscription-tiers.html
Normal file
389
OnlyPrompt.Frontend/subscription-tiers.html
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
@ -1,27 +1,29 @@
|
||||
<!--
|
||||
Reusable topbar for OnlyPrompt
|
||||
- Search in the middle
|
||||
- small chat & notification icons
|
||||
- small chat & logout icons
|
||||
- profile avatar on the right
|
||||
-->
|
||||
|
||||
<header class="topbar-shell">
|
||||
<div class="topbar-search">
|
||||
<i class="bi bi-search"></i>
|
||||
<input id="topbarSearchInput" type="search" placeholder="Search">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
<input id="topbarSearchInput" type="search" placeholder="Search" aria-label="Search prompts and creators">
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<button class="topbar-icon-btn" aria-label="Notifications">
|
||||
<i class="bi bi-bell"></i>
|
||||
<form action="/api/v1/auth/logout" method="post" class="topbar-logout-form">
|
||||
<button class="topbar-icon-btn" type="submit" aria-label="Logout" title="Logout">
|
||||
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="topbar-icon-btn" aria-label="Messages">
|
||||
<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>
|
||||
|
||||
<!-- Profile avatar on the right (must be changed with backend) -->
|
||||
<button class="topbar-avatar-btn" aria-label="Profile">
|
||||
<img id="topbarAvatar" src="../images/content/cat.png" alt="Profile Picture" class="topbar-avatar">
|
||||
<button class="topbar-avatar-btn" type="button" aria-label="Profile" title="Profile" onclick="location.href='/profile.html'">
|
||||
<img id="topbarAvatar" src="../images/content/cat.png" alt="" class="topbar-avatar">
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
22
README.md
22
README.md
@ -1,20 +1,24 @@
|
||||
# OnlyPrompt - AI Prompt Marketplace
|
||||
|
||||
## Description
|
||||
OnlyPrompt is a web application where users can create, publish, discover and interact with AI prompts. Users can edit their profiles, follow creators, like and save prompts, write reviews and browse free or paid prompt cards in a marketplace.
|
||||
OnlyPrompt is a web application where users can create, publish, discover and interact with AI prompts. Users can edit their profiles, follow creators, like and save prompts, write reviews and browse free or subscription-based prompt cards in a marketplace.
|
||||
|
||||
This project is built with HTML, CSS and JavaScript.
|
||||
|
||||
## Special Features
|
||||
- 📝 Create, edit and publish AI prompts
|
||||
- 🔍 Browse prompts in a marketplace with category, search and price filters
|
||||
- 🔍 Browse prompts in a marketplace with category, search and tier price filters
|
||||
- 📄 View prompt detail pages with examples, ratings and access states
|
||||
- 💎 Create creator subscription tiers and assign prompts to tier levels
|
||||
- 🔐 Subscribe to creator tiers to unlock matching and lower-level prompts
|
||||
- ⭐ Write reviews with star ratings and comments
|
||||
- ❤️ Like and save prompts
|
||||
- 👥 Follow and discover other creators
|
||||
- 💬 Start chats with creators from the community page and send local messages
|
||||
- 👤 Edit user profiles with display name, username, bio and profile picture
|
||||
- 🌐 View own and public creator profiles
|
||||
- 📱 Responsive layout for desktop and mobile
|
||||
- 📱 Responsive layout for desktop and mobile, including a bottom icon navigation on smartphones
|
||||
- ♿ Accessibility improvements such as keyboard focus states, skip links, labels, ARIA states and live status messages
|
||||
- 🔄 Server communication through a REST API
|
||||
- 💾 Shared data persistence with backend and database
|
||||
|
||||
@ -33,6 +37,13 @@ DB_NAME=onlyprompt
|
||||
DB_PASSWORD=onlyprompt
|
||||
```
|
||||
|
||||
## Local Usage Notes
|
||||
- The community page includes a chat button on creator cards. It opens the chat page and starts a conversation with the selected creator.
|
||||
- The chats page supports selecting conversations, searching creators through the new-chat button and sending messages.
|
||||
- Chat messages are stored in the browser's `localStorage` for the local frontend demo. They are not persisted in PostgreSQL yet.
|
||||
- Keyboard users can use `Tab`, `Shift + Tab`, `Enter` and `Space` to navigate links, buttons, filters, tabs and forms.
|
||||
- On macOS, full keyboard navigation may need to be enabled in System Settings or the browser settings so that `Tab` also focuses links.
|
||||
|
||||
## Technologies, Libraries, Frameworks
|
||||
- HTML5 for page structure
|
||||
- CSS3 with Flexbox/Grid for layout and responsive design
|
||||
@ -53,12 +64,13 @@ AI tools were used as support during development, mainly for debugging, comparin
|
||||
The project uses authentication with a JWT cookie so that protected pages and API endpoints require a logged-in user. User input is validated on the backend for important operations such as registration, profile updates, prompt creation and reviews.
|
||||
|
||||
Known limitations:
|
||||
- Payment and premium access are simulated and are not connected to a real payment provider.
|
||||
- Subscription access is simulated for the semester project and is not connected to a real payment provider.
|
||||
- Chat messages are currently stored locally in the browser and are not connected to a backend chat API.
|
||||
- User-generated content is displayed in the frontend, so XSS prevention is important. The project avoids intentionally executing user input as code, but further output sanitization would be needed for production.
|
||||
- Authentication is implemented for local project use and would need additional hardening for production.
|
||||
|
||||
## Reflection
|
||||
A main challenge was connecting static frontend pages with dynamic backend data while keeping the application usable and consistent. During development, the project evolved from demo pages into a connected application with real profiles, prompts, reviews, likes, saves and creator interactions. We learned how important clear API structures, consistent data models and regular browser testing are when multiple pages depend on the same shared data.
|
||||
A main challenge was connecting static frontend pages with dynamic backend data while keeping the application usable and consistent. During development, the project evolved from demo pages into a connected application with real profiles, prompts, reviews, likes, saves, creator interactions and subscription tiers. We learned how important clear API structures, consistent data models, responsive navigation and regular browser testing are when multiple pages depend on the same shared data.
|
||||
|
||||
## Group members and their roles
|
||||
| Name | Role |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user