diff --git a/API.md b/API.md index 36cc0c4..de4d6cd 100644 --- a/API.md +++ b/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 diff --git a/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs b/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs index 8187c54..183e743 100644 --- a/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs +++ b/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs @@ -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); diff --git a/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs b/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs index 54ef693..1d55543 100644 --- a/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs +++ b/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs @@ -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); } diff --git a/OnlyPrompt.Backend/Controllers/FeedController.cs b/OnlyPrompt.Backend/Controllers/FeedController.cs index 19dc043..495fd14 100644 --- a/OnlyPrompt.Backend/Controllers/FeedController.cs +++ b/OnlyPrompt.Backend/Controllers/FeedController.cs @@ -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; diff --git a/OnlyPrompt.Backend/Controllers/PromptController.cs b/OnlyPrompt.Backend/Controllers/PromptController.cs index ebef988..8bacfd6 100644 --- a/OnlyPrompt.Backend/Controllers/PromptController.cs +++ b/OnlyPrompt.Backend/Controllers/PromptController.cs @@ -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(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(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, diff --git a/OnlyPrompt.Backend/Controllers/SubscriptionController.cs b/OnlyPrompt.Backend/Controllers/SubscriptionController.cs index ff247bf..e796129 100644 --- a/OnlyPrompt.Backend/Controllers/SubscriptionController.cs +++ b/OnlyPrompt.Backend/Controllers/SubscriptionController.cs @@ -100,6 +100,35 @@ namespace OnlyPrompt.Backend.Controllers return subscription; } + [HttpGet("tiers")] + public async Task GetOwnSubscriptionTiersAsync() + { + var userId = User.GetUserId(); + return await _db.SubscriptionTiers + .Where(t => t.UserId == userId) + .OrderBy(t => t.Level) + .ProjectTo(_mapper.ConfigurationProvider) + .ToArrayAsync(); + } + + [HttpGet("tiers/{userId}")] + public async Task, NotFound>> 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(_mapper.ConfigurationProvider) + .ToArrayAsync(); + + return TypedResults.Ok(tiers); + } + [HttpDelete("{userId}")] public async Task>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId) { diff --git a/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs index 603e5dc..f5f5719 100644 --- a/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs +++ b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs @@ -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); diff --git a/OnlyPrompt.Frontend/chats.html b/OnlyPrompt.Frontend/chats.html index b6abbd0..48f3145 100644 --- a/OnlyPrompt.Frontend/chats.html +++ b/OnlyPrompt.Frontend/chats.html @@ -1,130 +1,381 @@ - + - - - - OnlyPrompt - Chats - - - - - - - - - - -
- - + + + + OnlyPrompt - Chats + + + + + + + + + + + +
+ -
- -
+
+
-
- -
- - -
-
-

Messages

- +
+ +
+ +
+
+

Messages

+ +
+ +
+
-
- -
- Alex Chen -
-
Alex Chen
-
Hey Sarah! Really loved your last video on minimalism...
+ + +
+
+ +
+
Select a chat
+
+ + Ready +
-
10:17 AM
- -
- Mia Wong -
-
Mia Wong
-
Thanks for the prompt tips! They worked perfectly.
-
-
Yesterday
-
- -
- Tom Rivera -
-
Tom Rivera
-
Let's schedule a call for the collab?
-
-
Yesterday
+
+
+ + +
- - -
-
- Alex Chen -
-
Alex Chen
-
Online
-
-
-
- -
-
Hey Sarah! Really loved your last video on minimalism. Quick question about your workspace layout?
-
10:15 AM
-
- -
-
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!
-
10:16 AM
-
- -
-
Thanks so much! Your aesthetic is exactly what I'm aiming for. Can't wait for your next piece!
-
10:17 AM
-
- -
-
Awesome! Let me know if you need more tips. Enjoy the process! 😊
-
10:18 AM
-
-
-
- - -
-
- -
-
+
+
-
- - + 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(); + } + }); + + diff --git a/OnlyPrompt.Frontend/community.html b/OnlyPrompt.Frontend/community.html index c22c3c6..c30f996 100644 --- a/OnlyPrompt.Frontend/community.html +++ b/OnlyPrompt.Frontend/community.html @@ -1,204 +1,247 @@ - + - - - - OnlyPrompt - Discover Creators - - - - - - - - - - -
+ + + + OnlyPrompt - Discover Creators + + + + + + + + + + + +
+ - +
+
-
- -
- -
- -
-

Discover Creators

-

Follow your favorite prompt artists and get inspired.

-
- -
- - - - -
- -
- - - - - -
-
-
- - - + loadCreators(); + + diff --git a/OnlyPrompt.Frontend/create.html b/OnlyPrompt.Frontend/create.html index c1fc404..fc2e317 100644 --- a/OnlyPrompt.Frontend/create.html +++ b/OnlyPrompt.Frontend/create.html @@ -17,15 +17,16 @@ -
+ +
-
+
-
+

Create AI Prompt

@@ -36,7 +37,7 @@
- +
@@ -61,8 +62,8 @@
- - Use clear, step-by-step instructions for the AI. + + Use clear, step-by-step instructions for the AI.
@@ -74,24 +75,28 @@
- - Upload a PNG or JPG – preview will appear below. -
- -
- - + Access +
+ +
- @@ -99,7 +104,7 @@
-

+

@@ -109,23 +114,28 @@ diff --git a/OnlyPrompt.Frontend/css/base.css b/OnlyPrompt.Frontend/css/base.css index 493f50e..be14a92 100644 --- a/OnlyPrompt.Frontend/css/base.css +++ b/OnlyPrompt.Frontend/css/base.css @@ -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 { @@ -38,4 +90,75 @@ body { .form-error li .error { color: red; font-style: italic; -} \ No newline at end of file +} + +/* ── 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; +} diff --git a/OnlyPrompt.Frontend/css/chats.css b/OnlyPrompt.Frontend/css/chats.css index e5205ca..5fbefdb 100644 --- a/OnlyPrompt.Frontend/css/chats.css +++ b/OnlyPrompt.Frontend/css/chats.css @@ -1,13 +1,5 @@ /* Chats page - Two column layout: chat list + active chat window */ -/* Full width layout */ -.layout > div[style*="flex:1"] { - margin: 0 !important; - max-width: 100% !important; - padding: 0 !important; - width: 100% !important; -} - .chats-main { flex: 1; padding: 20px 32px; @@ -20,7 +12,7 @@ gap: 24px; background: #fff; border-radius: 18px; - box-shadow: 0 2px 8px rgba(59,130,246,0.06); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06); overflow: hidden; height: calc(100vh - 120px); /* Adjust based on topbar height */ min-height: 500px; @@ -50,9 +42,81 @@ .new-chat-btn { background: none; border: none; + border-radius: 999px; font-size: 1.2rem; color: #3b82f6; cursor: pointer; + padding: 8px; +} + +.new-chat-btn:hover, +.new-chat-btn[aria-expanded="true"] { + background: #eef2ff; +} + +.new-chat-panel { + border-bottom: 1px solid #eef2f7; + padding: 14px 16px; +} + +.new-chat-panel input { + width: 100%; + border: 1px solid #dbe2ea; + border-radius: 12px; + padding: 10px 12px; + font: inherit; +} + +.creator-search-results { + display: grid; + gap: 8px; + margin-top: 10px; + max-height: 260px; + overflow-y: auto; +} + +.creator-result { + align-items: center; + background: #f8fafc; + border: 1px solid transparent; + border-radius: 12px; + color: inherit; + cursor: pointer; + display: flex; + gap: 10px; + padding: 10px; + text-align: left; + width: 100%; +} + +.creator-result:hover, +.creator-result:focus-visible { + background: #eef2ff; + border-color: #c7d2fe; +} + +.creator-result img { + border-radius: 50%; + height: 36px; + object-fit: cover; + width: 36px; +} + +.creator-result span { + display: grid; +} + +.creator-result small, +.creator-result-empty, +.chat-empty { + color: #64748b; + font-size: 0.82rem; +} + +.creator-result-empty, +.chat-empty { + padding: 18px; + text-align: center; } .chat-list-items { @@ -61,15 +125,22 @@ } .chat-item { + background: transparent; + border: 0; + border-bottom: 1px solid #f0f2f5; + color: inherit; display: flex; align-items: center; gap: 12px; + font: inherit; + text-align: left; padding: 16px 20px; cursor: pointer; transition: background 0.2s; - border-bottom: 1px solid #f0f2f5; + width: 100%; } -.chat-item:hover { +.chat-item:hover, +.chat-item:focus-visible { background: #f8fafc; } .chat-item.active { @@ -196,6 +267,7 @@ padding: 16px 24px; border-top: 1px solid #eef2f7; background: #fff; + margin: 0; } .chat-input-area input { flex: 1; @@ -208,6 +280,12 @@ .chat-input-area input:focus { border-color: #3b82f6; } + +.chat-input-area input:disabled { + background: #f8fafc; + cursor: not-allowed; +} + .send-btn { background: var(--gradient); border: none; @@ -222,6 +300,11 @@ opacity: 0.85; } +.send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* Responsive */ @media (max-width: 768px) { .chats-main { @@ -244,4 +327,4 @@ .chat-active { height: 500px; } -} \ No newline at end of file +} diff --git a/OnlyPrompt.Frontend/css/community.css b/OnlyPrompt.Frontend/css/community.css index a6fa1c9..f50a344 100644 --- a/OnlyPrompt.Frontend/css/community.css +++ b/OnlyPrompt.Frontend/css/community.css @@ -1,13 +1,5 @@ /* Creators page - Discover creators, filter buttons, creator cards */ -/* Full width layout */ -.layout > div[style*="flex:1"] { - margin: 0 !important; - max-width: 100% !important; - padding: 0 !important; - width: 100% !important; -} - .creators-main { background: transparent !important; padding: 20px 32px !important; @@ -71,15 +63,24 @@ .creator-card { background: #fff; border-radius: 18px; - box-shadow: 0 2px 8px rgba(59,130,246,0.06); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06); padding: 20px; display: flex; gap: 16px; - transition: transform 0.2s, box-shadow 0.2s; + transition: + transform 0.2s, + box-shadow 0.2s; } -.creator-card:hover { +.creator-card:hover, +.creator-card:focus-within { transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(59,130,246,0.12); + box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12); +} + +.creator-avatar-link { + border-radius: 50%; + display: inline-flex; + flex-shrink: 0; } .creator-avatar { @@ -98,6 +99,15 @@ font-weight: 700; margin-bottom: 4px; } + +.creator-name a { + color: inherit; + text-decoration: none; +} + +.creator-name a:hover { + text-decoration: underline; +} .creator-handle { color: #64748b; font-size: 0.85rem; @@ -123,7 +133,14 @@ .creator-stats i { margin-right: 4px; } -.follow-btn { +.creator-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.follow-btn, +.creator-chat-btn { background: var(--gradient); color: white; border: none; @@ -132,11 +149,25 @@ font-size: 0.8rem; font-weight: 600; cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + justify-content: center; + min-height: 34px; + text-decoration: none; transition: opacity 0.2s; } -.follow-btn:hover { + +.follow-btn:hover, +.creator-chat-btn:hover { opacity: 0.85; } + +.creator-chat-btn { + background: #eef2ff; + color: #2563eb; +} + .follow-btn.following { background: transparent; border: 2px solid #94a3b8; @@ -177,4 +208,17 @@ .follow-btn { width: 100%; } -} \ No newline at end of file + .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; +} diff --git a/OnlyPrompt.Frontend/css/create.css b/OnlyPrompt.Frontend/css/create.css index 516c29a..86bb617 100644 --- a/OnlyPrompt.Frontend/css/create.css +++ b/OnlyPrompt.Frontend/css/create.css @@ -1,13 +1,5 @@ /* Create page - Form for publishing new AI prompts */ -/* Full width layout */ -.layout > div[style*="flex:1"] { - margin: 0 !important; - max-width: 100% !important; - padding: 0 !important; - width: 100% !important; -} - .create-main { flex: 1; display: flex; @@ -22,12 +14,12 @@ width: 100%; background: #fff; border-radius: 18px; - box-shadow: 0 2px 8px rgba(59,130,246,0.06); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06); padding: 32px; transition: box-shadow 0.2s; } .create-container:hover { - box-shadow: 0 8px 20px rgba(59,130,246,0.12); + box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12); } /* Header */ @@ -57,7 +49,8 @@ flex-direction: column; gap: 8px; } -.form-group label { +.form-group label, +.form-label { font-weight: 600; font-size: 0.95rem; } @@ -77,7 +70,7 @@ .form-group select:focus { outline: none; border-color: #7c3aed; - box-shadow: 0 0 0 3px rgba(124,58,237,0.1); + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); } .form-hint { font-size: 0.75rem; @@ -104,17 +97,49 @@ background: var(--gradient); color: white; } -#priceField { +#tierField { + display: none; + gap: 8px; margin-top: 8px; } +.tier-manage-link { + color: #3b82f6; + font-size: 0.85rem; + font-weight: 700; + text-decoration: none; +} + +.tier-manage-link:hover { + text-decoration: underline; +} + +/* Image preview */ +#imagePreview { + margin-top: 10px; + display: none; +} +#imagePreview img { + max-width: 100%; + max-height: 200px; + border-radius: 12px; +} + +/* Status message */ +#create-status { + text-align: center; + color: #64748b; + margin: 0; +} + /* Buttons */ .form-actions { display: flex; gap: 16px; margin-top: 8px; } -.submit-btn, .cancel-btn { +.submit-btn, +.cancel-btn { flex: 1; border: none; padding: 12px; @@ -132,7 +157,8 @@ background: #f1f5f9; color: #475569; } -.submit-btn:hover, .cancel-btn:hover { +.submit-btn:hover, +.cancel-btn:hover { opacity: 0.85; } @@ -152,4 +178,4 @@ .form-actions { flex-direction: column; } -} \ No newline at end of file +} diff --git a/OnlyPrompt.Frontend/css/dashboard.css b/OnlyPrompt.Frontend/css/dashboard.css index 233010f..a18ce49 100644 --- a/OnlyPrompt.Frontend/css/dashboard.css +++ b/OnlyPrompt.Frontend/css/dashboard.css @@ -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; diff --git a/OnlyPrompt.Frontend/css/marketplace.css b/OnlyPrompt.Frontend/css/marketplace.css index 370e3c9..218cc4d 100644 --- a/OnlyPrompt.Frontend/css/marketplace.css +++ b/OnlyPrompt.Frontend/css/marketplace.css @@ -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; } diff --git a/OnlyPrompt.Frontend/css/post-detail.css b/OnlyPrompt.Frontend/css/post-detail.css index 45e20f9..053cad8 100644 --- a/OnlyPrompt.Frontend/css/post-detail.css +++ b/OnlyPrompt.Frontend/css/post-detail.css @@ -1,13 +1,5 @@ /* Post Detail page - Full prompt view, rating, example output, unlock button */ -/* Full width layout */ -.layout > div[style*="flex:1"] { - margin: 0 !important; - max-width: 100% !important; - padding: 0 !important; - width: 100% !important; -} - .post-detail-main { flex: 1; display: flex; @@ -21,12 +13,12 @@ width: 100%; background: #fff; border-radius: 18px; - box-shadow: 0 2px 8px rgba(59,130,246,0.06); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06); padding: 32px; transition: box-shadow 0.2s; } .post-detail-container:hover { - box-shadow: 0 8px 20px rgba(59,130,246,0.12); + box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12); } /* Header */ @@ -319,3 +311,190 @@ padding: 16px; } } + +/* ── Loading / error states ──────────────────────────────────────────── */ +#detail-loading { + text-align: center; + padding: 60px 20px; + color: #64748b; +} + +/* Smaller state icons for this page */ +#detail-loading .state-icon, +#detail-error .state-icon { + font-size: 2.5rem; + margin-bottom: 12px; +} + +#detail-error-msg { + color: #64748b; + margin-top: 8px; +} + +.detail-back-btn { + margin-top: 20px; + padding: 10px 24px; + background: #6366f1; + color: #fff; + border: none; + border-radius: 10px; + font-weight: 600; + cursor: pointer; +} + +/* ── Detail body ─────────────────────────────────────────────────────── */ +#detail-body { + display: none; +} + +.detail-creator-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +#creator-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + background: #6366f1; + color: #fff; + font-weight: 700; + font-size: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +#creator-name { + font-weight: 600; + font-size: 0.95rem; +} + +#prompt-date { + display: block; + font-size: 0.8rem; + color: #94a3b8; +} + +.detail-actions-right { + margin-left: auto; +} + +#edit-prompt-btn { + display: none; + margin-left: 10px; + padding: 6px 14px; + border: none; + border-radius: 10px; + background: #f1f5f9; + color: #334155; + font-weight: 700; + cursor: pointer; +} + +#prompt-body { + white-space: pre-wrap; + font-family: monospace; + background: #f8fafc; + border-radius: 10px; + padding: 16px; + font-size: 0.9rem; + line-height: 1.7; +} + +#example-section { + display: none; +} +#example-output-text { + white-space: pre-wrap; +} +#example-image { + display: none; +} + +/* ── Locked section ──────────────────────────────────────────────────── */ +#locked-section { + display: none; + text-align: center; + padding: 40px 20px; + background: #f8fafc; + border-radius: 12px; + margin-bottom: 28px; +} + +.locked-icon { + font-size: 2.5rem; + color: #94a3b8; + display: block; + margin-bottom: 12px; +} + +.locked-title { + margin-bottom: 8px; +} +.locked-desc { + color: #64748b; + margin-bottom: 20px; +} + +#locked-subscribe-btn { + padding: 12px 28px; + background: #6366f1; + color: #fff; + border: none; + border-radius: 10px; + font-weight: 600; + cursor: pointer; + font-size: 1rem; +} +.detail-heart-icon { + color: #ef4444; +} +.detail-bookmark-span { + margin-left: 12px; +} +.detail-bookmark-icon { + color: #f59e0b; +} +.detail-loading-text { + color: #94a3b8; +} +.detail-error-text { + color: #ef4444; +} +.rating-stars-display { + font-size: 1.1rem; + color: #f59e0b; +} +.rating-value { + margin-left: 8px; + font-weight: 600; +} +.rating-count { + font-size: 0.85rem; + color: #94a3b8; + margin-left: 4px; +} +.rating-none { + font-size: 0.9rem; + color: #94a3b8; +} +.tier-badge-tier { + background: #f1f5f9; + color: #475569; + border-radius: 20px; + padding: 4px 14px; + font-size: 0.8rem; + font-weight: 600; +} +.tier-badge-free { + background: #dcfce7; + color: #166534; + border-radius: 20px; + padding: 4px 14px; + font-size: 0.8rem; + font-weight: 600; +} diff --git a/OnlyPrompt.Frontend/css/profile.css b/OnlyPrompt.Frontend/css/profile.css index 22a1bac..5aca19f 100644 --- a/OnlyPrompt.Frontend/css/profile.css +++ b/OnlyPrompt.Frontend/css/profile.css @@ -1,14 +1,217 @@ /* Profile Page - Full width layout, darker share button, responsive grid */ -/* Force main content container to full width, remove centering and max-width */ -.layout > div[style*="flex:1"] { - margin: 0 !important; - max-width: 100% !important; - padding: 0 !important; - width: 100% !important; +/* ── Profile header ──────────────────────────────────────────────────── */ +.profile-header { + display: flex; + align-items: center; + gap: 32px; + border-bottom: 1px solid #e5e7eb; + padding-bottom: 24px; } -/* Inner spacing for the profile card */ +/* ── Profile avatar ──────────────────────────────────────────────────── */ +.profile-avatar { + width: 110px; + height: 110px; + object-fit: cover; +} + +/* ── Profile info column ─────────────────────────────────────────────── */ +.profile-info { + flex: 1; +} + +#profileDisplayName { + font-size: 2rem; + font-weight: 700; + margin-bottom: 4px; +} + +#profileSlug { + color: #64748b; + margin-bottom: 8px; +} + +.profile-badge-icon { + color: #3b82f6; +} + +#profileBio { + margin-bottom: 8px; +} +#profileSpecialities { + color: #64748b; +} + +#profileStats { + display: flex; + gap: 18px; + color: #64748b; + margin-top: 12px; + font-size: 0.95rem; +} +#profileStats strong { + color: #111827; +} + +/* ── Profile actions column ──────────────────────────────────────────── */ +#profileActions { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 220px; +} + +#profileActions .login-button { + box-sizing: border-box; + width: 100%; +} + +.profile-tier-list { + background: #ffffff; + border: 1px solid #eef2f7; + border-radius: 16px; + display: grid; + gap: 10px; + margin-top: 6px; + padding: 14px; +} + +.profile-tier-list h3 { + font-size: 0.95rem; + margin: 0 0 4px; +} + +.profile-tier-option { + align-items: center; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + color: #334155; + cursor: pointer; + display: flex; + gap: 12px; + justify-content: space-between; + padding: 10px 12px; + text-align: left; +} + +.profile-tier-option.active { + background: #eef2ff; + border-color: #818cf8; + color: #2563eb; +} + +.profile-tier-option strong, +.profile-tier-option small { + display: block; +} + +.profile-tier-option small { + color: #64748b; + font-size: 0.75rem; + margin-top: 2px; +} + +.profile-tier-option b { + white-space: nowrap; +} + +/* ── Profile tabs ────────────────────────────────────────────────────── */ +.profile-tabs { + display: flex; + gap: 24px; + border-bottom: 2px solid #e5e7eb; + margin: 32px 0 18px 0; + flex-wrap: wrap; +} + +/* ── Prompts grid ────────────────────────────────────────────────────── */ +#profile-prompts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 24px; +} + +.profile-grid-loading { + grid-column: 1 / -1; + color: #64748b; + text-align: center; + padding: 28px; +} +.profile-grid-empty { + grid-column: 1 / -1; + color: #64748b; + text-align: center; + padding: 28px; +} +.profile-grid-error { + grid-column: 1 / -1; + color: #ef4444; + text-align: center; + padding: 28px; +} +.profile-prompt-card { + background: #fff; + border-radius: 18px; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06); + padding: 18px; + display: flex; + gap: 16px; + align-items: flex-start; +} +.profile-prompt-card:focus-within, +.profile-prompt-card:hover { + box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12); +} +.profile-prompt-link { + color: inherit; + display: flex; + flex: 1; + gap: 16px; + min-width: 0; + text-decoration: none; +} +.profile-prompt-img { + width: 72px; + height: 72px; + border-radius: 12px; + object-fit: cover; +} +.profile-prompt-body { + flex: 1; + min-width: 0; +} +.profile-prompt-title { + font-weight: 700; +} +.profile-prompt-desc { + color: #64748b; + margin-bottom: 8px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.profile-prompt-meta { + display: flex; + gap: 16px; + color: #64748b; + align-items: center; + flex-wrap: wrap; +} +.profile-prompt-edit-btn { + border: none; + background: #f1f5f9; + color: #334155; + border-radius: 10px; + padding: 6px 10px; + font-weight: 700; + cursor: pointer; + flex-shrink: 0; +} + +/* ── Inner spacing for the profile card ─────────────────────────────── */ .profile-main { background: transparent !important; border-radius: 0 !important; @@ -16,7 +219,7 @@ padding: 20px 32px !important; margin: 0 auto !important; width: 100%; - max-width: 1600px; /* Limits content on very large screens, but still wide */ + max-width: 1600px; /* Limits content on very large screens, but still wide */ } /* Make prompts grid use more columns on large screens */ @@ -28,16 +231,32 @@ } /* Share button: darker background and text */ -.profile-header button:last-child { - background: #cbd5e1 !important; /* darker gray */ +#shareProfileButton { + background: #cbd5e1 !important; /* darker gray */ color: #1e293b !important; box-shadow: none !important; border: none !important; } +#manageTiersButton { + background: #f3e8ff !important; + color: #7c3aed !important; + box-shadow: none !important; + border: 1px solid #d8b4fe !important; +} + /* Buttons keep rounded corners */ .login-button { + align-items: center; border-radius: 14px !important; + display: flex; + gap: 8px; + justify-content: center; + text-decoration: none; +} + +.login-button i { + font-size: 1rem; } .profile-tab { @@ -64,7 +283,7 @@ /* Prompt cards: rounded corners */ .profile-main section > div { border-radius: 18px !important; - box-shadow: 0 2px 8px rgba(59,130,246,0.06); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06); } /* Prompt images: rounded corners */ @@ -75,6 +294,9 @@ /* Avatar remains round */ .profile-avatar { border-radius: 50% !important; + width: 110px; + height: 110px; + object-fit: cover; } /* All outer containers stay square */ diff --git a/OnlyPrompt.Frontend/css/settings.css b/OnlyPrompt.Frontend/css/settings.css index 8ef259c..eb89712 100644 --- a/OnlyPrompt.Frontend/css/settings.css +++ b/OnlyPrompt.Frontend/css/settings.css @@ -1,12 +1,5 @@ /* Settings page - tabs, form styling */ -.layout > div[style*="flex:1"] { - margin: 0 !important; - max-width: 100% !important; - padding: 0 !important; - width: 100% !important; -} - .settings-main { flex: 1; display: flex; @@ -21,7 +14,7 @@ width: 100%; background: #fff; border-radius: 18px; - box-shadow: 0 2px 8px rgba(59,130,246,0.06); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06); padding: 32px; } @@ -101,7 +94,7 @@ .form-group textarea:focus { outline: none; border-color: #7c3aed; - box-shadow: 0 0 0 3px rgba(124,58,237,0.1); + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); } .checkbox-label { display: flex; @@ -181,4 +174,11 @@ flex-direction: column; align-items: flex-start; } -} \ No newline at end of file +} + +/* Save status message */ +#profileSaveStatus { + margin-top: 10px; + color: #64748b; + text-align: center; +} diff --git a/OnlyPrompt.Frontend/css/sidebar.css b/OnlyPrompt.Frontend/css/sidebar.css index 75bbd9e..7c71fd7 100644 --- a/OnlyPrompt.Frontend/css/sidebar.css +++ b/OnlyPrompt.Frontend/css/sidebar.css @@ -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; } } diff --git a/OnlyPrompt.Frontend/css/subscription-tiers.css b/OnlyPrompt.Frontend/css/subscription-tiers.css new file mode 100644 index 0000000..288ff4e --- /dev/null +++ b/OnlyPrompt.Frontend/css/subscription-tiers.css @@ -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; + } +} diff --git a/OnlyPrompt.Frontend/css/topbar.css b/OnlyPrompt.Frontend/css/topbar.css index 7f9d710..2caf8cd 100644 --- a/OnlyPrompt.Frontend/css/topbar.css +++ b/OnlyPrompt.Frontend/css/topbar.css @@ -1,31 +1,35 @@ /* Topbar styles for OnlyPrompt - - clean, modern, full-width - - search bar centered (expands on full screen), profile avatar always on the right - - ONLY search bar and avatar have rounded corners + - sticky on all app pages + - search bar fills the available width + - logout, messages and profile avatar stay on the right */ .topbar-shell { - width: 100%; + align-items: center; background: #ffffff; border-bottom: 1px solid #eef2f7; - padding: 16px 32px; + box-sizing: border-box; display: flex; - align-items: center; - justify-content: space-between; gap: 18px; + justify-content: space-between; + padding: 16px 32px; + position: sticky; + top: 0; + width: 100%; + z-index: 90; } .topbar-search { - flex: 1; /* Takes all available space */ - max-width: none; /* No upper limit, expands freely */ - display: flex; align-items: center; - gap: 12px; background: #f8fafc; border: 1px solid #e2e8f0; + border-radius: 14px; + display: flex; + flex: 1; + gap: 12px; + max-width: none; padding: 10px 20px; - border-radius: 14px; /* Rounded like login inputs */ } .topbar-search i { @@ -34,12 +38,17 @@ } .topbar-search input { - width: 100%; - border: none; - outline: none; background: transparent; - font-size: 0.95rem; + border: none; color: #334155; + font-size: 0.95rem; + outline: none; + width: 100%; +} + +.topbar-search:focus-within { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); } .topbar-search input::placeholder { @@ -47,24 +56,28 @@ font-weight: 400; } -/* Icons and avatar container */ .topbar-actions { - display: flex; align-items: center; + display: flex; gap: 16px; } +.topbar-logout-form { + display: flex; + margin: 0; +} + .topbar-icon-btn { + align-items: center; background: transparent; border: none; - font-size: 1.4rem; + border-radius: 50%; color: #475569; cursor: pointer; - padding: 8px; display: flex; - align-items: center; + font-size: 1.4rem; justify-content: center; - border-radius: 50%; + padding: 8px; transition: background 0.2s, color 0.2s; } @@ -74,42 +87,47 @@ } .topbar-avatar-btn { - border: none; + align-items: center; background: transparent; - padding: 0; + border: none; cursor: pointer; display: flex; - align-items: center; justify-content: center; + padding: 0; } .topbar-avatar { - width: 48px; + border: 1px solid #e2e8f0; + border-radius: 50%; height: 48px; object-fit: cover; - border: 1px solid #e2e8f0; - border-radius: 50%; /* Avatar round */ + width: 48px; } -/* Responsive adjustments */ @media (max-width: 768px) { .topbar-shell { + gap: 12px; padding: 12px 20px; } + .topbar-search { padding: 8px 16px; } + .topbar-search i { font-size: 1.1rem; } + .topbar-avatar { - width: 40px; height: 40px; + width: 40px; } - .topbar-icon-btn { + + .topbar-icon-btn { font-size: 1.2rem; padding: 6px; } + .topbar-actions { gap: 8px; } @@ -117,9 +135,14 @@ @media (max-width: 480px) { .topbar-shell { - padding: 10px 16px; + padding: 10px 12px; } + .topbar-search { - padding: 6px 12px; + padding: 6px 10px; } -} \ No newline at end of file + + .topbar-actions { + gap: 4px; + } +} diff --git a/OnlyPrompt.Frontend/dashboard.html b/OnlyPrompt.Frontend/dashboard.html index 4b05d4d..8a5eff6 100644 --- a/OnlyPrompt.Frontend/dashboard.html +++ b/OnlyPrompt.Frontend/dashboard.html @@ -20,16 +20,14 @@ /> -
+ +
-
+
-
+

Feed

@@ -37,62 +35,44 @@
-
+
-
-
+
-
- -

No posts yet

+
+ +

No posts yet

Follow some creators to see their prompts here.

-
- -

Could not load feed

+
@@ -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 ` - ${''.repeat(stars)}${''.repeat(5 - stars)} - ${rating.toFixed(1)} + return ` + ${''.repeat(stars)}${''.repeat(5 - stars)} + `; } @@ -161,35 +147,37 @@ const liked = prompt.isLiked; const saved = prompt.isSaved; return ` -
-
- ${prompt.creatorName} +
+
- - - - + + + +
-
`; + `; } - window.toggleLike = async function(event, id, isLiked) { + window.toggleLike = async function (event, id, isLiked) { event.stopPropagation(); const response = await fetch(`/api/v1/prompts/${id}/likes`, { method: isLiked ? "DELETE" : "PUT", - credentials: "same-origin" + credentials: "same-origin", }); if (response.status === 401) { @@ -200,26 +188,28 @@ if (!response.ok) return; loadFeed( document.querySelector(".filter-btn.active")?.dataset.sort || "date", - document.querySelector(".filter-btn.active")?.dataset.ascending === "true" + document.querySelector(".filter-btn.active")?.dataset.ascending === + "true", ); }; - window.toggleFeedState = function(event, type, id) { + window.toggleFeedState = function (event, type, id) { event.stopPropagation(); const key = `prompt-${type}-${id}`; const next = localStorage.getItem(key) !== "true"; localStorage.setItem(key, next); loadFeed( document.querySelector(".filter-btn.active")?.dataset.sort || "date", - document.querySelector(".filter-btn.active")?.dataset.ascending === "true" + document.querySelector(".filter-btn.active")?.dataset.ascending === + "true", ); }; - window.toggleSave = async function(event, id, isSaved) { + window.toggleSave = async function (event, id, isSaved) { event.stopPropagation(); const response = await fetch(`/api/v1/prompts/${id}/saves`, { method: isSaved ? "DELETE" : "PUT", - credentials: "same-origin" + credentials: "same-origin", }); if (response.status === 401) { @@ -230,13 +220,16 @@ if (!response.ok) return; loadFeed( document.querySelector(".filter-btn.active")?.dataset.sort || "date", - document.querySelector(".filter-btn.active")?.dataset.ascending === "true" + document.querySelector(".filter-btn.active")?.dataset.ascending === + "true", ); }; - window.sharePrompt = function(event, id) { + window.sharePrompt = function (event, id) { event.stopPropagation(); - navigator.clipboard.writeText(`${location.origin}/post-detail?id=${id}`); + navigator.clipboard.writeText( + `${location.origin}/post-detail?id=${id}`, + ); }; async function loadFeed(sortBy = "date", ascending = false) { @@ -271,8 +264,12 @@ btn.addEventListener("click", () => { document .querySelectorAll(".filter-btn") - .forEach((b) => b.classList.remove("active")); + .forEach((b) => { + b.classList.remove("active"); + b.setAttribute("aria-pressed", "false"); + }); btn.classList.add("active"); + btn.setAttribute("aria-pressed", "true"); loadFeed(btn.dataset.sort, btn.dataset.ascending === "true"); }); }); diff --git a/OnlyPrompt.Frontend/js/login.js b/OnlyPrompt.Frontend/js/login.js index 9bd5223..95f64f5 100644 --- a/OnlyPrompt.Frontend/js/login.js +++ b/OnlyPrompt.Frontend/js/login.js @@ -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(){ diff --git a/OnlyPrompt.Frontend/js/shared.js b/OnlyPrompt.Frontend/js/shared.js index 13d0c7b..bd2c3ed 100644 --- a/OnlyPrompt.Frontend/js/shared.js +++ b/OnlyPrompt.Frontend/js/shared.js @@ -1,99 +1,115 @@ -import './linq.js' -import { Template } from './template.js'; +import "./linq.js"; +import { Template } from "./template.js"; export function formToObject(form) { - const data = new FormData(form); - const object = {}; - data.forEach((value, key) => { - setNestedValue(object, key, value); - }); - return object; + const data = new FormData(form); + const object = {}; + data.forEach((value, key) => { + setNestedValue(object, key, value); + }); + return object; } function setNestedValue(obj, path, value) { - path.split('.').asEnumerable() - .isLast() - .forEach((key, isLast) => { - if (isLast) { - obj[key] = value; - } - else { - if (!obj[key]) { - obj[key] = {}; - } + path + .split(".") + .asEnumerable() + .isLast() + .forEach((key, isLast) => { + if (isLast) { + obj[key] = value; + } else { + if (!obj[key]) { + obj[key] = {}; + } - obj = obj[key]; - } - }); + obj = obj[key]; + } + }); } export async function sendFormAsync(form, url, method) { - url = url || form.action; - method = method || form.method || 'post'; - const data = formToObject(form); - const response = await sendJsonAsync(url, data, method); - if (response.ok && response.redirected) { - window.location.href = response.url; - return null; - } + url = url || form.action; + method = method || form.method || "post"; + const data = formToObject(form); + const response = await sendJsonAsync(url, data, method); + if (response.ok && response.redirected) { + window.location.href = response.url; + return null; + } - const responseText = await response.text(); - if (response.ok == false && handleValidationError(response, responseText, form)) { - return null; - } + const responseText = await response.text(); + if ( + response.ok == false && + handleValidationError(response, responseText, form) + ) { + return null; + } - if (response.ok == false) { - handleGenericFormError(response, responseText, form); - return null; - } else { - return responseText.length == 0 ? null : JSON.parse(responseText); - } + if (response.ok == false) { + handleGenericFormError(response, responseText, form); + return null; + } else { + return responseText.length == 0 ? null : JSON.parse(responseText); + } } -export async function sendJsonAsync(url, data, method = 'post') { - const response = await fetch(url, { - method: method.toUpperCase(), - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); +export async function sendJsonAsync(url, data, method = "post") { + const response = await fetch(url, { + method: method.toUpperCase(), + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); - return response; + return response; } export async function postAndRenderAsync(url, data, template, targetElement) { - const response = await sendJsonAsync(url, data); - if (response.ok) { - const responseText = await response.text(); - targetElement.innerHTML = template.render(responseText.length == 0 ? undefined : JSON.parse(responseText)); - } + const response = await sendJsonAsync(url, data); + if (response.ok) { + const responseText = await response.text(); + targetElement.innerHTML = template.render( + responseText.length == 0 ? undefined : JSON.parse(responseText), + ); + } } -export async function postFormAndRenderAsync(url, form, template, targetElement) { - const object = formToObject(form); - const data = await postFormAsync(url, object, template, targetElement); - if (data) { - targetElement.innerHTML = template.render(data); - } +export async function postFormAndRenderAsync( + url, + form, + template, + targetElement, +) { + const object = formToObject(form); + const data = await postFormAsync(url, object, template, targetElement); + if (data) { + targetElement.innerHTML = template.render(data); + } } const genericFormErrorTemplate = new Template(` -
- An error occurred while submitting the form. Please try again later. - {{ $this }} + `); function handleGenericFormError(response, responseText, form) { - if (!response.ok) { - const html = genericFormErrorTemplate.render(responseText); - form.insertAdjacentHTML('beforeend', html); - } + if (!response.ok) { + // Remove all existing form-level errors before adding a new one + form.querySelectorAll(":scope > .form-error").forEach((el) => el.remove()); + let message = responseText; + try { + message = JSON.parse(responseText); + } catch (_) {} + const html = genericFormErrorTemplate.render(message); + form.insertAdjacentHTML("beforeend", html); + } } const validationErrorTemplate = new Template(` -
+
+
-
- - + + function renderCreatorTiers() { + if (isOwnProfile) { + creatorTierList.innerHTML = ""; + return; + } + + if (!creatorSubscriptionTiers.length) { + creatorTierList.innerHTML = ""; + return; + } + + creatorTierList.innerHTML = ` +
+

Subscription Tiers

+ ${creatorSubscriptionTiers + .map( + (tier) => ` + + `, + ) + .join("")} +
`; + + creatorTierList + .querySelectorAll("[data-tier-level]") + .forEach((button) => { + button.addEventListener("click", () => + subscribeToTier(Number(button.dataset.tierLevel)), + ); + }); + } + + async function loadFollowState() { + if (isOwnProfile || !profileId) return; + + try { + const response = await fetch( + `/api/v1/subscriptions/${encodeURIComponent(profileId)}`, + { + credentials: "same-origin", + }, + ); + if (response.status === 401) { + location.href = "/login"; + return; + } + + const subscription = response.ok ? await response.json() : null; + currentIsFollowing = subscription !== null; + currentSubscriptionTier = subscription?.currentTier || null; + updateProfileMode(); + } catch { + currentIsFollowing = false; + currentSubscriptionTier = null; + } + } + + async function loadCreatorTiers() { + if (isOwnProfile || !profileId) return; + + try { + const response = await fetch( + `/api/v1/subscriptions/tiers/${encodeURIComponent(profileId)}`, + { + credentials: "same-origin", + }, + ); + if (response.status === 401) { + location.href = "/login"; + return; + } + if (!response.ok) return; + creatorSubscriptionTiers = await response.json(); + renderCreatorTiers(); + } catch { + creatorSubscriptionTiers = []; + } + } + + async function toggleProfileFollow() { + if (!profileId) return; + + primaryProfileButton.disabled = true; + const response = await fetch( + `/api/v1/subscriptions/${encodeURIComponent(profileId)}`, + { + method: currentIsFollowing ? "DELETE" : "PUT", + credentials: "same-origin", + }, + ); + + if (response.status === 401) { + location.href = "/login"; + return; + } + + if (response.ok) { + const currentSubscribers = Number( + profileSubscribers.textContent || 0, + ); + currentIsFollowing = !currentIsFollowing; + if (!currentIsFollowing) currentSubscriptionTier = null; + profileSubscribers.textContent = Math.max( + 0, + currentSubscribers + (currentIsFollowing ? 1 : -1), + ); + updateProfileMode(); + } else { + primaryProfileButton.disabled = false; + } + } + + async function subscribeToTier(level) { + if (!profileId) return; + + creatorTierList + .querySelectorAll("button") + .forEach((button) => (button.disabled = true)); + + const response = await fetch( + `/api/v1/subscriptions/${encodeURIComponent(profileId)}/${level}`, + { + method: "PUT", + credentials: "same-origin", + }, + ); + + if (response.status === 401) { + location.href = "/login"; + return; + } + + creatorTierList + .querySelectorAll("button") + .forEach((button) => (button.disabled = false)); + + if (!response.ok) return; + + const wasFollowing = currentIsFollowing; + currentIsFollowing = true; + currentSubscriptionTier = + creatorSubscriptionTiers.find((tier) => tier.level === level) || null; + if (!wasFollowing) { + profileSubscribers.textContent = + Number(profileSubscribers.textContent || 0) + 1; + } + updateProfileMode(); + } + + shareProfileButton.addEventListener("click", async () => { + const url = isOwnProfile + ? `${location.origin}/profile.html` + : `${location.origin}/profile.html?id=${encodeURIComponent(profileId)}`; + + try { + await navigator.clipboard.writeText(url); + shareProfileButton.innerHTML = ' Copied'; + setTimeout( + () => (shareProfileButton.innerHTML = ' Share Profile'), + 1200, + ); + } catch { + location.href = url; + } + }); + + async function loadOwnPrompts() { + try { + const response = await fetch("/api/v1/prompts/mine"); + if (response.status === 401) { + location.href = "/login"; + return; + } + if (!response.ok) throw new Error("Prompts could not be loaded."); + + ownPrompts = await response.json(); + updateTabs(); + } catch (error) { + profilePromptsGrid.innerHTML = `
${error.message}
`; + } + } + + async function loadAllPromptReferences() { + try { + const response = await fetch("/api/v1/prompts?limit=100"); + if (!response.ok) return; + allPrompts = await response.json(); + if (isOwnProfile) { + updateTabs(); + } else { + profilePrompts = allPrompts.filter( + (prompt) => + prompt.creatorId?.toLowerCase() === profileId.toLowerCase(), + ); + renderProfileFromPrompt(profilePrompts[0]); + updateProfileMode(); + } + } catch { + // Favorites and saved stay empty if prompts cannot be loaded. + } + } + + document.querySelectorAll(".profile-tab").forEach((tab) => { + tab.addEventListener("click", () => { + if (!isOwnProfile) { + updateProfileMode(); + return; + } + activeProfileTab = tab.dataset.tab; + updateTabs(); + }); + }); + + (async function initProfilePage() { + await loadProfile(); + await loadCreatorCardFallback(); + await loadFollowState(); + await loadCreatorTiers(); + updateProfileMode(); + if (isOwnProfile) { + loadOwnPrompts(); + } + loadAllPromptReferences(); + })(); + + diff --git a/OnlyPrompt.Frontend/settings.html b/OnlyPrompt.Frontend/settings.html index 0476e08..b221c17 100644 --- a/OnlyPrompt.Frontend/settings.html +++ b/OnlyPrompt.Frontend/settings.html @@ -17,15 +17,16 @@ -
+ +
-
+
-
+

Settings

@@ -33,21 +34,21 @@
-
- - - +
+ + +
-
+
- Avatar + Profile picture preview - +
@@ -68,13 +69,13 @@
-

+

-
+