Merge pull request 'isabelle' (#2) from isabelle into main

Reviewed-on: #2
This commit is contained in:
Isabelle Nachbaur 2026-06-01 22:24:18 +02:00
commit debe06fb86
39 changed files with 3562 additions and 711 deletions

300
API.md Normal file
View File

@ -0,0 +1,300 @@
# OnlyPrompt API
This file documents the backend endpoints used by the frontend. The backend is a helper service for server communication, authentication and shared data persistence.
Base URL in local Docker setup:
```text
http://localhost:1801
```
Authentication uses a `jwt` cookie set by the login endpoint. Protected endpoints require a logged-in user.
## Auth
### Register
```http
POST /api/v1/auth/register
Content-Type: application/json
```
Request:
```json
{
"displayName": "Isabelle",
"userName": "its_isabelle",
"email": "isabelle@test.com",
"password": "1234"
}
```
Response: created user data or validation errors.
### Login
```http
POST /api/v1/auth/login
Content-Type: application/json
```
Request:
```json
{
"userNameOrEmail": "isabelle@test.com",
"password": "1234"
}
```
Response: sets the auth cookie.
### Current User
```http
GET /api/v1/auth/me
```
Response:
```json
{
"id": "uuid",
"userName": "its_isabelle",
"email": "isabelle@test.com",
"roles": ["User"]
}
```
### Logout
```http
POST /api/v1/auth/logout
```
Deletes the auth cookie and redirects to login.
## Profiles
### Current Profile
```http
GET /api/v1/profiles/self
```
Response:
```json
{
"displayName": "Isabelle",
"slug": "its_isabelle",
"bio": "AI creator",
"avatarUrl": "data:image/png;base64,...",
"specialities": null,
"averageRating": 0,
"subscribers": 0
}
```
### Creator List
```http
GET /api/v1/profiles?sort=popular&limit=50&search=belle
```
Query parameters:
- `sort`: `popular`, `prompts`, `new`, `rating`
- `limit`: number of creators
- `search`: optional search term
Response: list of creator cards including follow state and avatar URL.
### Public Profile
```http
GET /api/v1/profiles/{creatorId}
```
Used by public creator profiles. The response contains display name, slug, bio, avatar URL, average rating and subscriber count.
### Update Profile
```http
PUT /api/v1/profiles
Content-Type: application/json
```
Request:
```json
{
"displayName": "Belle",
"userName": "lady_belle",
"slug": "lady_belle",
"bio": "Prompt creator",
"avatarUrl": "data:image/png;base64,...",
"specialities": null,
"isPublic": true
}
```
Updates profile data used on My Profile, Community and the topbar.
## Prompts
### List Prompts
```http
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.
### Feed
```http
GET /api/v1/feed?sortBy=date&ascending=false&limit=20
```
Used by Dashboard. Returns prompt cards from followed creators, excluding the logged-in user's own prompts.
### Create Prompt
```http
POST /api/v1/prompts
Content-Type: application/json
```
Request:
```json
{
"title": "Luxury Cat Portrait",
"description": "Create a premium cat portrait prompt.",
"content": "Write the full prompt instructions here.",
"category": "art",
"subscriptionTier": null,
"slug": null,
"exampleOutput": "Example output text",
"exampleImageUrl": "data:image/png;base64,...",
"price": null
}
```
Response: created prompt. The frontend redirects to `/post-detail?id={id}`.
### Own Prompts
```http
GET /api/v1/prompts/mine
```
Used by My Profile to show prompts created by the logged-in user.
### Update Prompt
```http
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.
### Prompt Detail
```http
GET /api/v1/prompts/{id}
```
Response includes:
- title and description
- prompt content if accessible
- category
- creator information
- price 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.
### Likes and Saves
```http
PUT /api/v1/prompts/{id}/likes
DELETE /api/v1/prompts/{id}/likes
PUT /api/v1/prompts/{id}/saves
DELETE /api/v1/prompts/{id}/saves
```
Used by Dashboard, prompt detail and profile tabs to store user interactions.
### Reviews
```http
GET /api/v1/prompts/{id}/reviews
PUT /api/v1/prompts/{id}/reviews
```
`PUT` request:
```json
{
"comment": "Helpful prompt",
"rating": 5
}
```
Used on the prompt detail page. Users can review prompts created by other users. Each user can have one review per prompt; submitting again updates the existing review. Reviews are displayed with username, star rating and comment.
## Categories
### Minimal Categories
```http
GET /api/v1/categories/minimal
```
Used by Create New and Marketplace filters.
Response:
```json
[
{
"name": "Art",
"slug": "art"
}
]
```
Default categories are created automatically when the backend starts.
## Subscriptions
### Follow or Subscribe to Creator
```http
GET /api/v1/subscriptions/{creatorId}
PUT /api/v1/subscriptions/{creatorId}
DELETE /api/v1/subscriptions/{creatorId}
```
Used by Community and public profiles to read follow state, follow creators or unfollow creators.
## Error Handling
Common response codes used by the frontend:
- `200 OK`: request succeeded
- `400 Bad Request`: validation error or invalid request data
- `401 Unauthorized`: user is not logged in and should be redirected to login
- `404 Not Found`: requested prompt, profile or subscription was not found
The frontend handles failed requests with visible error states or redirects to login for unauthorized users.

View File

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

View File

@ -1,6 +1,5 @@
using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.ApiModels.Prompt
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiCreatePromptRequest(string Title, string Description, string Content, Identifier Category, int? SubscriptionTier, string Slug);
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);
}

View File

@ -1,4 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.UserProfile
{
public record ApiUserProfile(string DisplayName, string Slug, string? Bio, string AvatarUrl, string? Specialities, double AverageRating, int Subscribers);
public record ApiCreatorCard(Guid UserId, string DisplayName, string Slug, string? Bio, string AvatarUrl, double AverageRating, int Subscribers, int PromptCount, bool IsFollowing);
}

View File

@ -3,6 +3,6 @@ using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.ApiModels.UserProfile
{
public record ApiUpdateProfileRequest([MaxLength(100)] string? DisplayName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic);
public record ApiUpdateProfileRequest([MaxLength(100)] string? DisplayName, [MaxLength(100)][NoWhitespace] string? UserName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic);
public record ApiCreateReviewRequest(string? Comment, [Range(1, 5)] int Rating);
}

View File

@ -81,6 +81,7 @@ namespace OnlyPrompt.Backend.Controllers
AvatarUrl = avatarUrl,
DisplayName = request.DisplayName,
Slug = slug,
IsPublic = true,
},
Roles = [ModelConstants.UserRole],
PasswordHash = null,

View File

@ -15,7 +15,7 @@ namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/categories")]
[Authorize(Roles = ModelConstants.AdminRole, AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class CategoryController : BaseController
{
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
@ -53,6 +53,7 @@ namespace OnlyPrompt.Backend.Controllers
}
[HttpPost]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<Ok<ApiCategory>, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request)
{
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug);
@ -69,6 +70,7 @@ namespace OnlyPrompt.Backend.Controllers
}
[HttpPut("{id}")]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<Ok<ApiCategory>, NotFound<string>, ValidationProblem>> UpdateCategoryAsync(Identifier id, [FromBody] ApiUpdateCategoryRequest request)
{
var category = await _db.Categories.FindByIdentifierAsync(id);
@ -95,6 +97,7 @@ namespace OnlyPrompt.Backend.Controllers
}
[HttpDelete("{id}")]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<NoContent, BadRequest<string>, NotFound<string>>> DeleteCategoryAsync(Identifier id, [FromQuery] Identifier? replaceWith = null)
{
var hasPrompts = await _db.Prompts.AnyAsync(p => p.CategoryId == id.Id);

View File

@ -32,10 +32,8 @@ namespace OnlyPrompt.Backend.Controllers
{
var userId = User.GetUserId();
var query = _db.Prompts
.Where(
x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId)
&& x.CreatorId != userId
);
.Where(x => x.CreatorId != userId)
.Where(x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId));
if (category.HasValue)
query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug);
@ -48,7 +46,9 @@ namespace OnlyPrompt.Backend.Controllers
query = sortBy switch {
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
FeedSortType.Rating => query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 2.5, ascending),
FeedSortType.Rating => ascending
? query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenBy(x => x.Reviews.Count)
: query.OrderByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenByDescending(x => x.Reviews.Count),
_ => query
};
@ -58,12 +58,21 @@ namespace OnlyPrompt.Backend.Controllers
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.SubscriptionTier.Level,
x.SubscriptionTier.Name,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
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)
)).ToArrayAsync();

View File

@ -9,6 +9,7 @@ using OnlyPrompt.Backend.ApiModels.UserProfile;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.Controllers
{
@ -21,17 +22,46 @@ namespace OnlyPrompt.Backend.Controllers
{
{ nameof(UserProfileModel.Slug), new[] { "Slug already exists." } }
});
private static ValidationProblem UserNameExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(UserModel.UserName), new[] { "Username is already taken." } }
});
public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[HttpGet("self")]
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetSelfProfileAsync()
{
var userId = User.GetUserId();
if (userId is null)
return TypedResults.NotFound("Profile not found.");
var profile = await _db.UserProfiles
.Where(up => up.Id == userId.Value)
.Select(up => new ApiUserProfile(
up.DisplayName,
up.Slug,
up.Bio,
up.AvatarUrl,
up.Specialities,
_db.Reviews.Where(r => r.Prompt.CreatorId == up.Id).Average(r => (double?)r.Rating) ?? 0.0,
_db.Subscriptions.Count(s => s.SubscribedToId == up.Id)
))
.FirstOrDefaultAsync();
if (profile is null)
return TypedResults.NotFound("Profile not found.");
return TypedResults.Ok(profile);
}
[HttpGet("{id}")]
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetProfileAsync(Identifier id)
{
var userId = User.GetUserId();
var profile = await _db.UserProfiles.OfIdentifer(id)
.Where(up => up.IsPublic || up.Id == userId)
.ProjectTo<ApiUserProfile>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
@ -41,6 +71,46 @@ namespace OnlyPrompt.Backend.Controllers
return TypedResults.Ok(profile);
}
[HttpGet]
public async Task<ApiCreatorCard[]> GetCreatorsAsync(
[Range(0, int.MaxValue)] int offset = 0,
[Range(1, 100)] int limit = 20,
[FromQuery] string sort = "popular",
[FromQuery] string? search = null
)
{
var userId = User.GetUserId();
var query = _db.UserProfiles.Where(up => up.Id != userId);
if (string.IsNullOrWhiteSpace(search) == false)
query = query.Where(up =>
up.DisplayName.Contains(search) ||
up.Slug.Contains(search) ||
up.User.UserName.Contains(search) ||
(up.Bio != null && up.Bio.Contains(search)));
var projected = query.Select(up => new ApiCreatorCard(
up.Id,
up.DisplayName,
up.Slug,
up.Bio,
up.AvatarUrl,
_db.Reviews.Where(r => r.Prompt.CreatorId == up.Id).Average(r => (double?)r.Rating) ?? 0.0,
_db.Subscriptions.Count(s => s.SubscribedToId == up.Id),
_db.Prompts.Count(p => p.CreatorId == up.Id),
_db.Subscriptions.Any(s => s.SubscribedToId == up.Id && s.SubscriberId == userId)
));
var allCreators = await projected.ToArrayAsync();
return (sort switch
{
"rating" => allCreators.OrderByDescending(c => c.AverageRating),
"new" => allCreators.OrderByDescending(c => c.UserId),
"prompts" => allCreators.OrderByDescending(c => c.PromptCount),
_ => allCreators.OrderByDescending(c => c.Subscribers),
}).Skip(offset).Take(limit).ToArray();
}
[HttpPut]
public async Task<Results<ValidationProblem, NotFound<string>, Ok<ApiUserProfile>>> UpdateProfileAsync([FromBody] ApiUpdateProfileRequest request)
{
@ -48,6 +118,18 @@ namespace OnlyPrompt.Backend.Controllers
if (self is null)
return TypedResults.NotFound("Profile not found.");
var user = await GetUserAsync();
if (user is null)
return TypedResults.NotFound("User not found.");
if (string.IsNullOrEmpty(request.UserName) == false)
{
if (await _db.Users.AnyAsync(u => u.UserName == request.UserName && u.Id != user.Id))
return UserNameExistsProblem;
user.UserName = request.UserName;
}
if (string.IsNullOrEmpty(request.Slug) == false)
{
if (await _db.UserProfiles.AnyAsync(up => up.Slug == request.Slug && up.Id != self.Id))
@ -56,13 +138,13 @@ namespace OnlyPrompt.Backend.Controllers
self.Slug = request.Slug;
}
if(string.IsNullOrEmpty(request.AvatarUrl) == false)
if(request.AvatarUrl is not null)
self.AvatarUrl = request.AvatarUrl;
if(string.IsNullOrEmpty(request.Bio) == false)
if(request.Bio is not null)
self.Bio = request.Bio;
if(string.IsNullOrEmpty(request.Specialities) == false)
if(request.Specialities is not null)
self.Specialities = request.Specialities;
if (string.IsNullOrEmpty(request.DisplayName) == false)
@ -70,7 +152,15 @@ namespace OnlyPrompt.Backend.Controllers
self.IsPublic = request.IsPublic;
await _db.SaveChangesAsync();
var result = _mapper.Map<ApiUserProfile>(self);
var result = new ApiUserProfile(
self.DisplayName,
self.Slug,
self.Bio,
self.AvatarUrl,
self.Specialities,
await _db.Reviews.Where(r => r.Prompt.CreatorId == self.Id).AverageAsync(r => (double?)r.Rating) ?? 0.0,
await _db.Subscriptions.CountAsync(s => s.SubscribedToId == self.Id)
);
return TypedResults.Ok(result);
}

View File

@ -33,21 +33,239 @@ namespace OnlyPrompt.Backend.Controllers
);
}
[HttpGet]
public async Task<ApiMinimalPrompt[]> GetPromptsAsync(
[Range(0, int.MaxValue)][FromQuery] int offset = 0,
[Range(1, 100)][FromQuery] int limit = 20,
[FromQuery] FeedSortType sortBy = FeedSortType.Date,
[FromQuery] bool ascending = false,
[FromQuery] Identifier? category = null,
[FromQuery] string? search = null
)
{
var userId = User.GetUserId();
var query = _db.Prompts
.Where(x => x.CreatorId != userId);
if (category.HasValue)
query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(x => x.Title.Contains(search) || x.Description.Contains(search));
query = sortBy switch
{
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
FeedSortType.Rating => ascending
? query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenBy(x => x.Reviews.Count)
: query.OrderByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenByDescending(x => x.Reviews.Count),
_ => query
};
var prompts = await query
.Skip(offset)
.Take(limit)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
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)))
)).ToArrayAsync();
return prompts;
}
[HttpGet("mine")]
public async Task<ApiMinimalPrompt[]> GetOwnPromptsAsync()
{
var userId = User.GetUserId();
var prompts = await _db.Prompts
.Where(x => x.CreatorId == userId)
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
true
)).ToArrayAsync();
return prompts;
}
[HttpGet("{id}")]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> GetPromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await GetAccessiblePrompts(userId.Value)
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
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
{
Content = canAccess ? prompt.Prompt : null,
CanAccess = canAccess,
IsLiked = prompt.Likes.Any(l => l.UserId == userId),
LikeCount = prompt.Likes.Count,
IsSaved = prompt.Saves.Any(s => s.UserId == userId),
SaveCount = prompt.Saves.Count
};
return TypedResults.Ok(apiPrompt);
}
[HttpPut("{id}")]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> UpdatePromptAsync(Identifier id, [FromBody] ApiUpdatePromptRequest request)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync(p => p.CreatorId == userId);
if (prompt is null)
return TypedResults.NotFound("Prompt not found or no permission");
var apiPrompt = _mapper.Map<ApiPrompt>(prompt);
var category = await _db.Categories.FindByIdentifierAsync(new Identifier(request.Category));
if (category is null)
return TypedResults.NotFound("Category not found");
prompt.Title = request.Title;
prompt.Description = request.Description;
prompt.Prompt = request.Content;
prompt.Category = category;
prompt.ExampleOutput = request.ExampleOutput;
prompt.ExampleImageUrl = request.ExampleImageUrl;
prompt.Price = request.Price;
await _db.SaveChangesAsync();
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = prompt.Prompt, CanAccess = true };
return TypedResults.Ok(apiPrompt);
}
[HttpPut("{id}/likes")]
public async Task<Results<Ok<ApiLikeState>, NotFound<string>>> LikePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
var exists = await _db.PromptLikes.AnyAsync(l => l.PromptId == prompt.Id && l.UserId == userId);
if (exists == false)
{
_db.PromptLikes.Add(new PromptLikeModel
{
PromptId = prompt.Id,
UserId = userId!.Value
});
await _db.SaveChangesAsync();
}
var count = await _db.PromptLikes.CountAsync(l => l.PromptId == prompt.Id);
return TypedResults.Ok(new ApiLikeState(count, true));
}
[HttpDelete("{id}/likes")]
public async Task<Results<Ok<ApiLikeState>, NotFound<string>>> UnlikePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
await _db.PromptLikes
.Where(l => l.PromptId == prompt.Id && l.UserId == userId)
.ExecuteDeleteAsync();
var count = await _db.PromptLikes.CountAsync(l => l.PromptId == prompt.Id);
return TypedResults.Ok(new ApiLikeState(count, false));
}
[HttpPut("{id}/saves")]
public async Task<Results<Ok<ApiSaveState>, NotFound<string>>> SavePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
var exists = await _db.PromptSaves.AnyAsync(s => s.PromptId == prompt.Id && s.UserId == userId);
if (exists == false)
{
_db.PromptSaves.Add(new PromptSaveModel
{
PromptId = prompt.Id,
UserId = userId!.Value
});
await _db.SaveChangesAsync();
}
var count = await _db.PromptSaves.CountAsync(s => s.PromptId == prompt.Id);
return TypedResults.Ok(new ApiSaveState(count, true));
}
[HttpDelete("{id}/saves")]
public async Task<Results<Ok<ApiSaveState>, NotFound<string>>> UnsavePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
await _db.PromptSaves
.Where(s => s.PromptId == prompt.Id && s.UserId == userId)
.ExecuteDeleteAsync();
var count = await _db.PromptSaves.CountAsync(s => s.PromptId == prompt.Id);
return TypedResults.Ok(new ApiSaveState(count, false));
}
[HttpDelete("{id}")]
public async Task<Results<NoContent, NotFound<string>>> DeletePromptAsync(Identifier id)
{
@ -69,7 +287,7 @@ namespace OnlyPrompt.Backend.Controllers
{
var userId = User.GetUserId();
var category = await _db.Categories.FindByIdentifierAsync(request.Category);
var category = await _db.Categories.FindByIdentifierAsync(new Identifier(request.Category));
if (category is null)
return TypedResults.NotFound("Category not found");
@ -95,6 +313,9 @@ namespace OnlyPrompt.Backend.Controllers
Title = request.Title,
Description = request.Description,
Prompt = request.Content,
ExampleOutput = request.ExampleOutput,
ExampleImageUrl = request.ExampleImageUrl,
Price = request.Price,
CreatorId = userId.Value,
SubscriptionTier = subscriptionTier,
Category = category,
@ -112,7 +333,10 @@ namespace OnlyPrompt.Backend.Controllers
{
var userId = User.GetUserId();
var accessiblePrompts = GetAccessiblePrompts(userId!.Value);
var reviews = await accessiblePrompts.Select(x => x.Reviews)
var reviews = await accessiblePrompts
.OfIdentifer(id)
.SelectMany(x => x.Reviews)
.OrderByDescending(x => x.UpdatedAt)
.Skip(offset)
.Take(limit)
.ProjectTo<ApiReview>(_mapper.ConfigurationProvider)

View File

@ -26,8 +26,8 @@ namespace OnlyPrompt.Backend.Controllers
{
}
[HttpPut("{userId}/{level}")]
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> SubscribeAsync(Identifier subscribeToId, int? level = null)
[HttpPut("{userId}/{level?}")]
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> SubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId, int? level = null)
{
var userId = User.GetUserId();
var subscribeTo = await _db.Users.Include(x => x.SubscriptionTiers.Where(st => st.Level == level))
@ -46,7 +46,7 @@ namespace OnlyPrompt.Backend.Controllers
return TypedResults.NotFound($"No subscription tier found for user {subscribeToId} with level {level.Value}");
var existingSubscription = await _db.Subscriptions.FirstOrDefaultAsync(
sub => subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug
sub => (subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug)
&& sub.SubscriberId == userId
);
@ -86,12 +86,12 @@ namespace OnlyPrompt.Backend.Controllers
}
[HttpGet("{userId}")]
public async Task<ApiSubscription?> GetCurrentSubscriptionAsync(Identifier subscribeToId)
public async Task<ApiSubscription?> GetCurrentSubscriptionAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
{
var userId = User.GetUserId();
var subscription = await _db.Subscriptions
.Where(
sub => subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug
sub => (subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug)
&& sub.SubscriberId == userId
)
.ProjectTo<ApiSubscription>(_mapper.ConfigurationProvider)
@ -101,12 +101,12 @@ namespace OnlyPrompt.Backend.Controllers
}
[HttpDelete("{userId}")]
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync(Identifier subscribeToId)
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
{
var userId = User.GetUserId();
var count = await _db.Subscriptions
.Where(
sub => subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug
sub => (subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug)
&& sub.SubscriberId == userId
)
.ExecuteDeleteAsync();

View File

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
public class PromptLikeModel
{
[ForeignKey(nameof(Prompt))]
public Guid PromptId { get; set; }
public virtual PromptModel Prompt { get; set; }
[ForeignKey(nameof(User))]
public Guid UserId { get; set; }
public virtual UserModel User { get; set; }
}
}

View File

@ -25,12 +25,17 @@ namespace OnlyPrompt.Backend.Database.Models
[MaxLength(200)]
public required string Title { get; set; }
[MaxLength(4000)]
public required string Prompt { get; set; }
[MaxLength(1000)]
public required string Description { get; set; }
public string? ExampleOutput { get; set; }
public string? ExampleImageUrl { get; set; }
public decimal? Price { get; set; }
[MaxLength(ModelConstants.MaxSlugLength)]
[Column(TypeName = "citext")]
public required string Slug { get; set; }
@ -41,5 +46,7 @@ namespace OnlyPrompt.Backend.Database.Models
[DeleteBehavior(DeleteBehavior.SetNull)]
public virtual SubscriptionTierModel? SubscriptionTier { get; set; }
public virtual IList<ReviewModel> Reviews { get; set; } = new List<ReviewModel>();
public virtual IList<PromptLikeModel> Likes { get; set; } = new List<PromptLikeModel>();
public virtual IList<PromptSaveModel> Saves { get; set; } = new List<PromptSaveModel>();
}
}

View File

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
public class PromptSaveModel
{
[ForeignKey(nameof(Prompt))]
public Guid PromptId { get; set; }
public virtual PromptModel Prompt { get; set; }
[ForeignKey(nameof(User))]
public Guid UserId { get; set; }
public virtual UserModel User { get; set; }
}
}

View File

@ -24,6 +24,6 @@ namespace OnlyPrompt.Backend.Database.Models
public string? Specialities { get; set; }
public virtual UserModel User { get; set; }
public bool IsPublic { get; set; } = false;
public bool IsPublic { get; set; } = true;
}
}

View File

@ -14,6 +14,8 @@ namespace OnlyPrompt.Backend.Database
public DbSet<SubscriptionTierModel> SubscriptionTiers { get; set; }
public DbSet<SubscriptionModel> Subscriptions { get; set; }
public DbSet<ReviewModel> Reviews { get; set; }
public DbSet<PromptLikeModel> PromptLikes { get; set; }
public DbSet<PromptSaveModel> PromptSaves { get; set; }
public OnlyPromptContext(DbContextOptions<OnlyPromptContext> options) : base(options)
{
@ -85,6 +87,36 @@ namespace OnlyPrompt.Backend.Database
.WithMany(t => t.Subscriptions)
.HasForeignKey(e => e.SubscriptionTierId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<PromptLikeModel>(entity =>
{
entity.HasKey(e => new { e.PromptId, e.UserId });
entity.HasOne(e => e.Prompt)
.WithMany(p => p.Likes)
.HasForeignKey(e => e.PromptId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<PromptSaveModel>(entity =>
{
entity.HasKey(e => new { e.PromptId, e.UserId });
entity.HasOne(e => e.Prompt)
.WithMany(p => p.Saves)
.HasForeignKey(e => e.PromptId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class PromptCreateDetails : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExampleImageUrl",
table: "Prompts",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExampleOutput",
table: "Prompts",
type: "text",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Price",
table: "Prompts",
type: "numeric",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExampleImageUrl",
table: "Prompts");
migrationBuilder.DropColumn(
name: "ExampleOutput",
table: "Prompts");
migrationBuilder.DropColumn(
name: "Price",
table: "Prompts");
}
}
}

View File

@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class PromptLikes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PromptLikes",
columns: table => new
{
PromptId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PromptLikes", x => new { x.PromptId, x.UserId });
table.ForeignKey(
name: "FK_PromptLikes_Prompts_PromptId",
column: x => x.PromptId,
principalTable: "Prompts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PromptLikes_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PromptLikes_UserId",
table: "PromptLikes",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PromptLikes");
}
}
}

View File

@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class PromptSaves : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PromptSaves",
columns: table => new
{
PromptId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PromptSaves", x => new { x.PromptId, x.UserId });
table.ForeignKey(
name: "FK_PromptSaves_Prompts_PromptId",
column: x => x.PromptId,
principalTable: "Prompts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PromptSaves_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PromptSaves_UserId",
table: "PromptSaves",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PromptSaves");
}
}
}

View File

@ -79,10 +79,18 @@ namespace OnlyPrompt.Backend.Migrations
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("ExampleImageUrl")
.HasColumnType("text");
b.Property<string>("ExampleOutput")
.HasColumnType("text");
b.Property<decimal?>("Price")
.HasColumnType("numeric");
b.Property<string>("Prompt")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()

View File

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Web;
@ -24,6 +25,8 @@ builder.Services.AddDbContext<OnlyPromptContext>(opts =>
{
opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
opts.UseLazyLoadingProxies();
opts.ConfigureWarnings(warnings =>
warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
});
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
@ -109,5 +112,100 @@ app.MapFallbackToFile("/login.html");
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
await db.Database.MigrateAsync();
await EnsurePromptDetailColumnsAsync(db);
await EnsurePromptLikesTableAsync(db);
await EnsurePromptSavesTableAsync(db);
await SeedDefaultCategoriesAsync(db);
app.Run();
static async Task EnsurePromptDetailColumnsAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "ExampleImageUrl" text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "ExampleOutput" text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "Price" numeric;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ALTER COLUMN "Prompt" TYPE text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ALTER COLUMN "ExampleOutput" TYPE text;
""");
}
static async Task EnsurePromptLikesTableAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "PromptLikes" (
"PromptId" uuid NOT NULL,
"UserId" uuid NOT NULL,
CONSTRAINT "PK_PromptLikes" PRIMARY KEY ("PromptId", "UserId"),
CONSTRAINT "FK_PromptLikes_Prompts_PromptId" FOREIGN KEY ("PromptId") REFERENCES "Prompts" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_PromptLikes_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS "IX_PromptLikes_UserId" ON "PromptLikes" ("UserId");
""");
}
static async Task EnsurePromptSavesTableAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "PromptSaves" (
"PromptId" uuid NOT NULL,
"UserId" uuid NOT NULL,
CONSTRAINT "PK_PromptSaves" PRIMARY KEY ("PromptId", "UserId"),
CONSTRAINT "FK_PromptSaves_Prompts_PromptId" FOREIGN KEY ("PromptId") REFERENCES "Prompts" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_PromptSaves_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS "IX_PromptSaves_UserId" ON "PromptSaves" ("UserId");
""");
}
static async Task SeedDefaultCategoriesAsync(OnlyPromptContext db)
{
var defaults = new[]
{
("creative-writing", "Creative Writing"),
("coding", "Coding"),
("art", "Art"),
("marketing", "Marketing"),
("video", "Video"),
("data", "Data")
};
foreach (var (slug, name) in defaults)
{
if (await db.Categories.AnyAsync(c => c.Slug == slug))
continue;
db.Categories.Add(new CategoryModel
{
Id = Guid.CreateVersion7(),
Slug = slug,
Name = name,
Description = $"{name} prompts"
});
}
await db.SaveChangesAsync();
}

View File

@ -33,21 +33,41 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.Content, x => x.Prompt)
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.MapCtorParamFrom(x => x.CategoryName, x => x.Category.Name)
.MapCtorParamFrom(x => x.CategorySlug, x => x.Category.Slug)
.MapCtorParamFrom(x => x.ExampleOutput, x => x.ExampleOutput)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.Price, x => x.Price)
.MapCtorParamFrom(x => x.LikeCount, x => x.Likes.Count)
.MapCtorParamFrom(x => x.IsLiked, x => false)
.MapCtorParamFrom(x => x.SaveCount, x => x.Saves.Count)
.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.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));
.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 => false);
config.CreateMap<PromptModel, ApiMinimalPrompt>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Title, x => x.Title)
.MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.CreatorAvatarUrl, x => x.Creator.Profile.AvatarUrl)
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.Price, x => x.Price)
.MapCtorParamFrom(x => x.LikeCount, x => x.Likes.Count)
.MapCtorParamFrom(x => x.IsLiked, x => false)
.MapCtorParamFrom(x => x.SaveCount, x => x.Saves.Count)
.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.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
.MapCtorParamFrom(x => x.CanAccess, x => true);
config.CreateMap<ReviewModel, ApiReview>()

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/chats.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
@ -126,4 +127,4 @@
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>
</html>

View File

@ -1,5 +1,5 @@
<!-- OnlyPrompt - Marketplace page:
- Browse and filter AI prompts with buy/view details buttons and pricing -->
<!-- OnlyPrompt - Community page:
- Discover creators, follow/unfollow, dynamic via API -->
<!DOCTYPE html>
<html lang="en">
@ -13,149 +13,192 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/community.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<div id="topbar-container"></div>
<main class="creators-main">
<!-- Header / Titel -->
<div class="creators-header">
<h1>Discover Creators</h1>
<p>Follow your favorite prompt artists and get inspired.</p>
</div>
<!-- Filter Buttons -->
<div class="filter-buttons">
<button class="filter-btn active">Popular</button>
<button class="filter-btn">Rising</button>
<button class="filter-btn">New</button>
<button class="filter-btn">Top Rated</button>
<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>
<!-- Creators Grid -->
<div class="creators-grid">
<!-- Creator Card 1 -->
<div class="creator-card">
<img src="../images/content/creator1.png" alt="Sarah Jenkins" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Sarah Jenkins</h3>
<div class="creator-handle">@sarahj</div>
<p class="creator-bio">AI Explorer | Prompt Curator | Exploring creativity through generative AI.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 42 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.9</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<div class="creators-grid" id="creators-grid"></div>
<!-- Creator Card 2 -->
<div class="creator-card">
<img src="../images/content/creator2.png" alt="Alex Chen" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Alex Chen</h3>
<div class="creator-handle">@alexchen</div>
<p class="creator-bio">Digital artist & prompt engineer. Creating surreal landscapes.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 87 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.8</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<!-- Creator Card 3 -->
<div class="creator-card">
<img src="../images/content/creator3.png" alt="Mia Wong" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Mia Wong</h3>
<div class="creator-handle">@miawong</div>
<p class="creator-bio">Midjourney master | UI/UX prompts | Design systems.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 124 prompts</span>
<span><i class="bi bi-star-fill"></i> 5.0</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<!-- Creator Card 4 -->
<div class="creator-card">
<img src="../images/content/creator4.png" alt="Tom Rivera" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Tom Rivera</h3>
<div class="creator-handle">@tomrivera</div>
<p class="creator-bio">3D artist | Character design | Sci-fi & fantasy prompts.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 33 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.7</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<!-- Creator Card 5 -->
<div class="creator-card">
<img src="../images/content/creator5.png" alt="Emma Watson" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Emma Watson</h3>
<div class="creator-handle">@emmawatson</div>
<p class="creator-bio">Watercolor & pet portraits | Whimsical art prompts.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 56 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.9</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<!-- Creator Card 6 -->
<div class="creator-card">
<img src="../images/content/creator6.png" alt="Liam O'Brien" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Liam O'Brien</h3>
<div class="creator-handle">@liamob</div>
<p class="creator-bio">Minimalist logo designer | Brand identity prompts.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 28 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.6</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<div id="creators-empty" style="display:none; text-align:center; padding:60px 20px; color:#64748b;">
<i class="bi bi-people" style="font-size:3rem; display:block; margin-bottom:16px;"></i>
<h3 id="creators-empty-title" style="margin-bottom:8px;">No creators found</h3>
<p id="creators-empty-text">Check back later for new creators to follow.</p>
</div>
<div id="creators-error" style="display:none; text-align:center; padding:60px 20px; color:#ef4444;">
<i class="bi bi-exclamation-circle" style="font-size:3rem; display:block; margin-bottom:16px;"></i>
<h3 style="margin-bottom:8px;">Could not load creators</h3>
<p id="creators-error-msg"></p>
</div>
</main>
</div>
</div>
<script>
<script type="module">
// ── Sidebar & Topbar ─────────────────────────────────────────────
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');
});
// Set 'active' on the third link (Community)
document.querySelectorAll('#sidebar-container .sidebar a').forEach(l => l.classList.remove('active'));
const thirdLink = document.querySelectorAll('#sidebar-container .sidebar li a')[2];
if (thirdLink) thirdLink.classList.add('active');
});
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
// ── Helpers ──────────────────────────────────────────────────────
function renderStars(rating) {
if (!rating) return '';
const stars = Math.round(rating);
return `<span style="color:#f59e0b">${'★'.repeat(stars)}${'☆'.repeat(5 - stars)}</span> <span style="color:#64748b;font-size:0.8rem">${rating.toFixed(1)}</span>`;
}
function renderCard(c) {
return `
<div class="creator-card">
<img class="creator-avatar"
src="${c.avatarUrl || '../images/content/cat.png'}"
alt="${c.displayName}"
style="cursor:pointer"
onclick="location.href='/profile?id=${c.userId}'">
<div class="creator-info">
<h3 class="creator-name"
style="cursor:pointer"
onclick="location.href='/profile?id=${c.userId}'">${c.displayName}</h3>
<div class="creator-handle">@${c.slug}</div>
<p class="creator-bio">${c.bio ?? 'No bio yet.'}</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> ${c.promptCount} prompts</span>
<span><i class="bi bi-people"></i> ${c.subscribers}</span>
${c.averageRating > 0 ? `<span>${renderStars(c.averageRating)}</span>` : ''}
</div>
<button class="follow-btn ${c.isFollowing ? 'following' : ''}"
data-userid="${c.userId}"
data-following="${c.isFollowing}">
${c.isFollowing ? 'Following' : 'Follow'}
</button>
</div>
</div>`;
}
// ── Follow / Unfollow ────────────────────────────────────────────
async function toggleFollow(btn) {
const userId = btn.dataset.userid;
const isFollowing = btn.dataset.following === 'true';
btn.disabled = true;
const res = await fetch(`/api/v1/subscriptions/${userId}`, {
method: isFollowing ? 'DELETE' : 'PUT',
credentials: 'same-origin'
});
if (res.status === 401) { location.href = '/login'; return; }
if (res.ok) {
const nowFollowing = !isFollowing;
btn.dataset.following = nowFollowing;
btn.textContent = nowFollowing ? 'Following' : 'Follow';
btn.classList.toggle('following', nowFollowing);
}
btn.disabled = false;
}
// ── Load Creators ────────────────────────────────────────────────
const grid = document.getElementById('creators-grid');
const emptyEl = document.getElementById('creators-empty');
const emptyTitle = document.getElementById('creators-empty-title');
const emptyText = document.getElementById('creators-empty-text');
const errorEl = document.getElementById('creators-error');
const errorMsg = document.getElementById('creators-error-msg');
let activeSort = 'popular';
let currentSearch = new URLSearchParams(location.search).get('search') || '';
function getSearchTerm() {
return currentSearch.trim();
}
async function loadCreators(sort = activeSort) {
activeSort = sort;
grid.innerHTML = '';
emptyEl.style.display = 'none';
errorEl.style.display = 'none';
try {
const params = new URLSearchParams({
sort,
limit: '50'
});
const search = getSearchTerm();
if (search) params.set('search', search);
const res = await fetch(`/api/v1/profiles?${params}`);
if (res.status === 401) { location.href = '/login'; return; }
if (!res.ok) throw new Error(`Server error ${res.status}`);
const creators = await res.json();
if (creators.length === 0) {
const search = getSearchTerm();
emptyTitle.textContent = search ? 'No matching creators' : 'No creators found';
emptyText.textContent = search
? `No creator matches "${search}". Try another name or clear the search.`
: 'Create another local user to see creators here.';
emptyEl.style.display = 'block';
return;
}
grid.innerHTML = creators.map(renderCard).join('');
grid.querySelectorAll('.follow-btn').forEach(btn => {
btn.addEventListener('click', () => toggleFollow(btn));
});
} catch (e) {
errorEl.style.display = 'block';
errorMsg.textContent = e.message;
}
}
// ── Filter buttons ───────────────────────────────────────────────
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadCreators(btn.dataset.sort);
});
});
window.applyCreatorSearch = (value) => {
currentSearch = value.trim();
loadCreators(activeSort);
};
loadCreators();
</script>
</body>
</html>
</html>

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/create.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>
@ -27,8 +28,8 @@
<main class="create-main">
<div class="create-container">
<div class="create-header">
<h1>Create AI Prompt</h1>
<p>Design and save custom prompts for your AI workflows.</p>
<h1 id="create-title">Create AI Prompt</h1>
<p id="create-subtitle">Design and save custom prompts for your AI workflows.</p>
</div>
<form id="createPromptForm" class="create-form" enctype="multipart/form-data">
@ -95,9 +96,10 @@
<!-- Submit Button -->
<div class="form-actions">
<button type="submit" class="submit-btn">Publish Prompt</button>
<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>
</form>
</div>
</main>
@ -110,6 +112,8 @@
const paidBtn = document.getElementById('paidBtn');
const priceField = document.getElementById('priceField');
const priceInput = document.getElementById('price');
const editPromptId = new URLSearchParams(location.search).get('id');
const submitPromptBtn = document.getElementById('submitPromptBtn');
freeBtn.addEventListener('click', () => {
freeBtn.classList.add('active');
@ -128,6 +132,7 @@
const imageInput = document.getElementById('exampleImage');
const imagePreview = document.getElementById('imagePreview');
const previewImg = document.getElementById('previewImg');
let exampleImageUrl = '';
if (imageInput) {
imageInput.addEventListener('change', function(event) {
@ -135,25 +140,143 @@
if (file && (file.type === 'image/png' || file.type === 'image/jpeg' || file.type === 'image/jpg')) {
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
exampleImageUrl = e.target.result;
previewImg.src = exampleImageUrl;
imagePreview.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
imagePreview.style.display = 'none';
previewImg.src = '#';
exampleImageUrl = '';
if (file) alert('Please upload a PNG or JPG image.');
}
});
}
// Handle form submission (demo only)
document.getElementById('createPromptForm').addEventListener('submit', (e) => {
async function loadCategories() {
const categorySelect = document.getElementById('category');
try {
const response = await fetch('/api/v1/categories/minimal');
if (!response.ok) return;
const categories = await response.json();
if (!categories.length) return;
categorySelect.innerHTML = categories
.map((category) => `<option value="${category.slug}">${category.name}</option>`)
.join('');
} catch {
// Keep the static fallback categories.
}
}
async function loadPromptForEdit() {
if (!editPromptId) return;
document.getElementById('create-title').textContent = 'Edit AI Prompt';
document.getElementById('create-subtitle').textContent = 'Update your published prompt.';
submitPromptBtn.textContent = 'Save Changes';
const status = document.getElementById('create-status');
status.textContent = 'Loading prompt...';
try {
const response = await fetch(`/api/v1/prompts/${editPromptId}`);
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) throw new Error('Prompt could not be loaded.');
const prompt = await response.json();
document.getElementById('title').value = prompt.title || '';
document.getElementById('description').value = prompt.description || '';
document.getElementById('category').value = prompt.categorySlug || document.getElementById('category').value;
document.getElementById('promptContent').value = prompt.content || '';
document.getElementById('exampleOutput').value = prompt.exampleOutput || '';
exampleImageUrl = prompt.exampleImageUrl || '';
if (exampleImageUrl) {
previewImg.src = exampleImageUrl;
imagePreview.style.display = 'block';
}
if (prompt.price != null && Number(prompt.price) > 0) {
paidBtn.click();
priceInput.value = Number(prompt.price);
} else {
freeBtn.click();
}
status.textContent = '';
} catch (error) {
status.textContent = error.message;
}
}
// Handle form submission
document.getElementById('createPromptForm').addEventListener('submit', async (e) => {
e.preventDefault();
alert('Prompt published! (Demo)');
// Here you would normally send data to a backend (including the image file)
const status = document.getElementById('create-status');
const submitBtn = document.querySelector('.submit-btn');
status.textContent = editPromptId ? 'Saving...' : 'Publishing...';
submitBtn.disabled = true;
try {
const isPaid = paidBtn.classList.contains('active');
const price = isPaid ? Number(priceInput.value || 0) : null;
const payload = {
title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(),
category: document.getElementById('category').value,
content: document.getElementById('promptContent').value.trim(),
exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
exampleImageUrl: exampleImageUrl || null,
price,
subscriptionTier: null,
slug: null
};
const response = await fetch(editPromptId ? `/api/v1/prompts/${editPromptId}` : '/api/v1/prompts', {
method: editPromptId ? '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) {
const error = await response.text();
throw new Error(getCreateErrorMessage(error, response.status));
}
const prompt = await response.json();
location.href = `/post-detail?id=${prompt.id}`;
} catch (error) {
status.textContent = error.message;
submitBtn.disabled = false;
}
});
function getCreateErrorMessage(errorText, status) {
if (!errorText) return `Server error ${status}`;
try {
const error = JSON.parse(errorText);
const messages = error.errors
? Object.values(error.errors).flat()
: [error.title || errorText];
return messages.join(' ');
} catch {
return errorText;
}
}
// Cancel button (go back)
document.querySelector('.cancel-btn').addEventListener('click', () => {
window.history.back();
@ -176,6 +299,8 @@
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
loadCategories().then(loadPromptForEdit);
</script>
</body>
</html>
</html>

View File

@ -137,6 +137,16 @@
.follow-btn:hover {
opacity: 0.85;
}
.follow-btn.following {
background: transparent;
border: 2px solid #94a3b8;
color: #64748b;
}
.follow-btn.following:hover {
border-color: #ef4444;
color: #ef4444;
opacity: 1;
}
/* Responsive */
@media (max-width: 768px) {

View File

@ -71,16 +71,18 @@
.post-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);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
transition:
transform 0.2s,
box-shadow 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
}
/* Post Header */
@ -165,6 +167,15 @@
.action-btn i {
font-size: 1.1rem;
}
.action-btn.active {
font-weight: 700;
}
.like-btn.active {
color: #ef4444;
}
.save-btn.active {
color: #f59e0b;
}
.like-btn:hover {
color: #ef4444;
}
@ -198,6 +209,49 @@
}
}
/* Avatar initials fallback */
.post-avatar-initials {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary, #6366f1);
color: #fff;
font-weight: 700;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Locked post */
.post-locked {
opacity: 0.75;
}
.post-image-locked {
filter: blur(6px);
pointer-events: none;
}
.post-locked-msg {
color: #94a3b8;
font-size: 0.85rem;
margin: 6px 0 0;
}
/* Star rating */
.post-rating {
display: flex;
align-items: center;
gap: 3px;
color: #f59e0b;
font-size: 0.85rem;
margin-top: 6px;
}
.post-rating span {
color: #64748b;
margin-left: 4px;
}
@media (max-width: 480px) {
.feed-main {
padding: 12px !important;
@ -214,4 +268,4 @@
.post-actions {
padding: 8px 10px 10px 10px;
}
}
}

View File

@ -99,15 +99,17 @@
.prompt-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);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
transition:
transform 0.2s,
box-shadow 0.2s;
display: flex;
flex-direction: column;
}
.prompt-card:hover {
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);
}
.prompt-img {
@ -171,7 +173,8 @@
gap: 12px;
margin-top: 8px;
}
.buy-btn, .details-btn {
.buy-btn,
.details-btn {
flex: 1;
border: none;
border-radius: 14px;
@ -189,7 +192,8 @@
background: #f1f5f9;
color: #334155;
}
.buy-btn:hover, .details-btn:hover {
.buy-btn:hover,
.details-btn:hover {
opacity: 0.85;
}
@ -222,4 +226,28 @@
.prompt-actions {
flex-direction: column;
}
}
}
/* Payment method buttons */
.pay-method-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: #1e293b;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
text-align: left;
}
.pay-method-btn:hover {
border-color: #6366f1;
background: #f5f3ff;
}

View File

@ -134,6 +134,110 @@
margin-right: 6px;
}
/* Reviews */
.reviews-section {
margin-top: 18px;
}
.reviews-section h2 {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 28px;
}
.review-form {
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: 16px;
padding: 22px;
margin-bottom: 28px;
}
.review-form h3 {
font-size: 1.2rem;
margin-bottom: 16px;
}
.review-star-input {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.review-star-input button {
border: none;
background: transparent;
color: #f59e0b;
cursor: pointer;
font-family: inherit;
font-size: 2.4rem;
line-height: 1;
padding: 0 2px;
}
.review-form textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 12px;
color: #111827;
display: block;
font: inherit;
font-size: 1rem;
line-height: 1.5;
margin-bottom: 16px;
min-height: 96px;
padding: 14px 16px;
resize: vertical;
}
.review-form textarea:focus {
border-color: #a855f7;
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.14);
outline: none;
}
.review-form button#submit-review-btn {
background: var(--gradient);
border: none;
border-radius: 14px;
color: #fff;
cursor: pointer;
font: inherit;
font-size: 1rem;
font-weight: 700;
padding: 12px 24px;
}
.review-form button#submit-review-btn:disabled {
cursor: wait;
opacity: 0.7;
}
#review-message {
color: #64748b;
font-size: 0.9rem;
margin-top: 12px;
}
.reviews-list {
display: grid;
gap: 14px;
}
.review-card {
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 16px 18px;
}
.review-card-header {
align-items: center;
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.review-card-user {
font-weight: 700;
}
.review-card-stars {
color: #f59e0b;
font-size: 1.2rem;
letter-spacing: 1px;
margin-left: auto;
}
.review-card-comment {
color: #334155;
line-height: 1.45;
margin: 0;
}
/* Example Output Section */
.example-section {
margin-bottom: 32px;
@ -214,4 +318,4 @@
.prompt-content {
padding: 16px;
}
}
}

View File

@ -40,6 +40,27 @@
border-radius: 14px !important;
}
.profile-tab {
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 10px 0;
border-bottom: 2px solid transparent;
font-size: 1rem;
}
.profile-tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.profile-tab:hover:not(.active) {
color: #334155;
}
/* Prompt cards: rounded corners */
.profile-main section > div {
border-radius: 18px !important;
@ -91,4 +112,4 @@ nav {
.profile-main section:last-child {
grid-template-columns: 1fr !important;
}
}
}

View File

@ -1,166 +1,284 @@
<!-- OnlyPrompt - Feed page:
- Social media style post feed with likes, comments, saves, and share actions (following/foryou tabs) -->
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Feed</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/dashboard.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);">
<div id="sidebar-container"></div>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Feed</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/dashboard.css" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
</head>
<body>
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<div id="sidebar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<div id="topbar-container"></div>
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="feed-main">
<!-- Optional: Feed Header -->
<div class="feed-header">
<h1>Feed</h1>
<p>Latest prompts and inspiration from creators you follow</p>
</div>
<!-- Filter Buttons (optional) -->
<div class="filter-buttons">
<button class="filter-btn active">For You</button>
<button class="filter-btn">Following</button>
<button class="filter-btn">Trending</button>
<button class="filter-btn">Recent</button>
</div>
<!-- Posts Grid (einfach als Liste / Grid hier als Grid wie Marketplace) -->
<div class="posts-grid">
<!-- Post 1 -->
<div class="post-card" onclick="location.href='post-detail.html?id=1'">
<div class="post-header">
<img src="../images/content/creator1.png" alt="Sarah Jenkins" class="post-avatar">
<div class="post-author">
<span class="post-name">Sarah Jenkins</span>
<span class="post-handle">@sarahj</span>
</div>
<span class="post-date">2 hours ago</span>
</div>
<div class="post-content">
<h3 class="post-title">Conceptual Landscape Art</h3>
<p class="post-description">Enchanting, vintage, antique vibes. A journey through surreal landscapes.</p>
<img src="../images/content/feed1.png" alt="Conceptual Landscape" class="post-image">
</div>
<!-- Like, Comment, Share, Save Buttons -->
<div class="post-actions">
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
</div>
<main class="feed-main">
<!-- Optional: Feed Header -->
<div class="feed-header">
<h1>Feed</h1>
<p>Latest prompts and inspiration from creators you follow</p>
</div>
<!-- Post 2 -->
<div class="post-card" onclick="location.href='post-detail.html?id=2'">
<div class="post-header">
<img src="../images/content/creator2.png" alt="Alex Chen" class="post-avatar">
<div class="post-author">
<span class="post-name">Alex Chen</span>
<span class="post-handle">@alexchen</span>
</div>
<span class="post-date">Yesterday</span>
</div>
<div class="post-content">
<h3 class="post-title">Minimalist Logo Design</h3>
<p class="post-description">Clean, modern, minimalist logo for tech startups.</p>
<img src="../images/content/feed2.png" alt="Minimalist Logo" class="post-image">
</div>
<!-- Like, Comment, Share, Save Buttons -->
<div class="post-actions">
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
</div>
<!-- Filter Buttons -->
<div class="filter-buttons">
<button
class="filter-btn active"
data-sort="date"
data-ascending="false"
>
Recent
</button>
<button
class="filter-btn"
data-sort="rating"
data-ascending="false"
>
Top Rated
</button>
<button class="filter-btn" data-sort="date" data-ascending="true">
Oldest
</button>
</div>
<!-- Post 3 -->
<div class="post-card" onclick="location.href='post-detail.html?id=3'">
<div class="post-header">
<img src="../images/content/creator3.png" alt="Mia Wong" class="post-avatar">
<div class="post-author">
<span class="post-name">Mia Wong</span>
<span class="post-handle">@miawong</span>
</div>
<span class="post-date">3 days ago</span>
</div>
<div class="post-content">
<h3 class="post-title">Futuristic Cityscape</h3>
<p class="post-description">Cyberpunk neon city with flying cars and rain.</p>
<img src="../images/content/feed3.png" alt="Cityscape" class="post-image">
</div>
<!-- Like, Comment, Share, Save Buttons -->
<div class="post-actions">
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
</div>
<!-- Posts Grid -->
<div class="posts-grid" id="posts-grid"></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>
<p>Follow some creators to see their prompts here.</p>
</div>
<!-- Post 4 -->
<div class="post-card" onclick="location.href='post-detail.html?id=4'">
<div class="post-header">
<img src="../images/content/creator4.png" alt="Tom Rivera" class="post-avatar">
<div class="post-author">
<span class="post-name">Tom Rivera</span>
<span class="post-handle">@tomrivera</span>
</div>
<span class="post-date">5 days ago</span>
</div>
<div class="post-content">
<h3 class="post-title">Watercolor Pet Portrait</h3>
<p class="post-description">Soft watercolor style, cute pet portrait.</p>
<img src="../images/content/feed4.png" alt="Watercolor Pet" class="post-image">
</div>
<!-- Like, Comment, Share, Save Buttons -->
<div class="post-actions">
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
</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>
<p id="feed-error-msg"></p>
</div>
</div>
</main>
</main>
</div>
</div>
</div>
<script>
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
// Remove 'active' from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active');
<script 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((link) => link.classList.remove("active"));
const firstLink = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[0];
if (firstLink) firstLink.classList.add("active");
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
// ── Feed ──────────────────────────────────────────────────────────
const grid = document.getElementById("posts-grid");
const emptyEl = document.getElementById("feed-empty");
const errorEl = document.getElementById("feed-error");
const errorMsg = document.getElementById("feed-error-msg");
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(dateStr).toLocaleDateString();
}
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>
</span>`;
}
function feedImg(id) {
return `/images/content/feed${(parseInt(id.slice(-1), 16) % 4) + 1}.png`;
}
function profileUrl(userId) {
return `/profile.html?id=${encodeURIComponent(userId)}`;
}
function renderCard(prompt) {
const locked = !prompt.canAccess;
const liked = prompt.isLiked;
const saved = prompt.isSaved;
return `
<div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='${profileUrl(prompt.creatorId)}'">
<div class="post-header">
<img class="post-avatar" src="${prompt.creatorAvatarUrl || '../images/content/cat.png'}" alt="${prompt.creatorName}">
<div class="post-author">
<span class="post-name">${prompt.creatorName}</span>
</div>
<span class="post-date">${timeAgo(prompt.timeStamp)}</span>
</div>
<div class="post-content">
${prompt.exampleImageUrl ? `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
<h3 class="post-title" style="margin-top:10px">${prompt.title}</h3>
<p class="post-description">${prompt.description || ''}</p>
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? 'Paid'} tier required</p>` : ''}
${renderStars(prompt.averageRating)}
</div>
<div class="post-actions">
<button class="action-btn like-btn ${liked ? 'active' : ''}" onclick="toggleLike(event, '${prompt.id}', ${liked})"><i class="bi ${liked ? 'bi-heart-fill' : 'bi-heart'}"></i> <span>Like (${prompt.likeCount || 0})</span></button>
<button class="action-btn comment-btn" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat"></i> <span>Review</span></button>
<button class="action-btn share-btn" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share"></i> <span>Share</span></button>
<button class="action-btn save-btn ${saved ? 'active' : ''}" onclick="toggleSave(event, '${prompt.id}', ${saved})"><i class="bi ${saved ? 'bi-bookmark-fill' : 'bi-bookmark'}"></i> <span>Save (${prompt.saveCount || 0})</span></button>
</div>
</div>`;
}
window.toggleLike = async function(event, id, isLiked) {
event.stopPropagation();
const response = await fetch(`/api/v1/prompts/${id}/likes`, {
method: isLiked ? "DELETE" : "PUT",
credentials: "same-origin"
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) return;
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
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"
);
};
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"
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) return;
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
window.sharePrompt = function(event, id) {
event.stopPropagation();
navigator.clipboard.writeText(`${location.origin}/post-detail?id=${id}`);
};
async function loadFeed(sortBy = "date", ascending = false) {
grid.innerHTML = "";
emptyEl.style.display = "none";
errorEl.style.display = "none";
try {
const res = await fetch(
`/api/v1/feed?sortBy=${sortBy}&ascending=${ascending}&limit=20`,
);
if (res.status === 401) {
location.href = "/login";
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
const prompts = await res.json();
if (prompts.length === 0) {
emptyEl.style.display = "block";
return;
}
grid.innerHTML = prompts.map(renderCard).join("");
} catch (e) {
errorEl.style.display = "block";
errorMsg.textContent = e.message;
}
}
// ── Filter buttons ────────────────────────────────────────────────
document.querySelectorAll(".filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document
.querySelectorAll(".filter-btn")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
loadFeed(btn.dataset.sort, btn.dataset.ascending === "true");
});
// Set 'active' on the first link (Dashboard) - index 0
const firstLink = document.querySelectorAll('#sidebar-container .sidebar li a')[0];
if (firstLink) firstLink.classList.add('active');
});
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>
// Initial load
loadFeed();
</script>
</body>
</html>

View File

@ -0,0 +1,102 @@
(function () {
let currentProfilePromise = null;
let currentProfile = null;
async function fetchJson(url) {
const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) {
location.href = "/login";
return null;
}
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
}
async function loadCurrentProfile(force = false) {
if (!force && currentProfilePromise) return currentProfilePromise;
currentProfilePromise = Promise.all([
fetchJson("/api/v1/profiles/self"),
fetchJson("/api/v1/auth/me"),
]).then(([profile, user]) => {
currentProfile = { ...profile, user };
return currentProfile;
});
return currentProfilePromise;
}
function applyTopbarProfile(profile) {
const avatar = document.getElementById("topbarAvatar");
if (!avatar || !profile) return;
if (profile.avatarUrl) avatar.src = profile.avatarUrl;
avatar.alt = profile.displayName || profile.user?.userName || "Profile";
avatar.title = profile.displayName || profile.user?.userName || "Profile";
}
function wireTopbarSearch() {
const input = document.getElementById("topbarSearchInput");
if (!input || input.dataset.searchReady === "true") return;
input.dataset.searchReady = "true";
const params = new URLSearchParams(location.search);
if (location.pathname.includes("community") && params.has("search")) {
input.value = params.get("search") || "";
}
let searchTimeout;
input.addEventListener("input", () => {
if (!location.pathname.includes("community")) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (typeof window.applyCreatorSearch === "function") {
window.applyCreatorSearch(input.value);
}
}, 300);
});
input.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
if (location.pathname.includes("marketplace")) return;
const search = input.value.trim();
location.href = search
? `/community?search=${encodeURIComponent(search)}`
: "/community";
});
}
async function hydrateTopbarProfile() {
try {
const profile = currentProfile || (await loadCurrentProfile());
applyTopbarProfile(profile);
} catch {
// Keep the fallback avatar if the profile cannot be loaded.
}
}
function setCurrentProfile(profile) {
currentProfile = profile;
currentProfilePromise = Promise.resolve(profile);
applyTopbarProfile(profile);
}
window.loadCurrentProfile = loadCurrentProfile;
window.hydrateTopbarProfile = hydrateTopbarProfile;
window.setCurrentProfile = setCurrentProfile;
const observer = new MutationObserver(() => {
if (document.getElementById("topbarAvatar")) hydrateTopbarProfile();
wireTopbarSearch();
});
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
} else {
document.addEventListener("DOMContentLoaded", () => {
observer.observe(document.body, { childList: true, subtree: true });
});
}
})();

View File

@ -1,237 +1,667 @@
<!-- OnlyPrompt - Marketplace page:
- Browse and filter AI prompts with buy/view details buttons and pricing
- Added sort dropdown (Best Rating, Price Low to High, Price High to Low) - UI only -->
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort, crypto payment modal -->
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Marketplace</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/marketplace.css">
<link 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) {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Marketplace</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/marketplace.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"
/>
<style>
/* Additional inline style for sort dropdown can be moved to marketplace.css */
.filter-sort-row {
flex-direction: column;
align-items: stretch;
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 {
align-self: flex-start;
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;
}
}
</style>
</head>
<body>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
.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)"
>
<div id="sidebar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<div id="topbar-container"></div>
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="marketplace-main">
<!-- Header -->
<div class="marketplace-header">
<h1>Marketplace</h1>
<p>Browse and discover high-quality AI prompts</p>
</div>
<!-- Filter + Sort Row -->
<div class="filter-sort-row">
<div class="filter-buttons">
<button class="filter-btn active">All</button>
<button class="filter-btn">Writing</button>
<button class="filter-btn">Coding</button>
<button class="filter-btn">Art</button>
<button class="filter-btn">Marketing</button>
<button class="filter-btn">Video</button>
<button class="filter-btn">Data</button>
<main class="marketplace-main">
<!-- Header -->
<div class="marketplace-header">
<h1>Marketplace</h1>
<p>Browse and discover high-quality AI prompts</p>
</div>
<select class="sort-dropdown" aria-label="Sort prompts">
<option value="best">Best Rating</option>
<option value="price_low">Price: Low to High</option>
<option value="price_high">Price: High to Low</option>
</select>
</div>
<!-- Prompts Grid -->
<div class="prompts-grid">
<!-- Prompt Card 1 -->
<div class="prompt-card">
<img src="../images/content/market1.png" alt="Creative Blog Post Generator" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Creative Blog Post Generator</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Generate engaging blog posts in minutes to captivate your audience.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.8</span>
<span>(124 reviews)</span>
</div>
<div class="prompt-price">$19.99</div>
<div class="prompt-actions">
<button class="buy-btn">Buy Now</button>
<button class="details-btn">View Details</button>
</div>
<!-- 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>
<select
class="sort-dropdown"
id="sort-select"
aria-label="Sort prompts"
>
<option value="date|false">Newest</option>
<option value="date|true">Oldest</option>
<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>
</select>
</div>
<!-- Prompt Card 2 -->
<div class="prompt-card">
<img src="../images/content/market2.png" alt="Python Code Assistant" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Python Code Assistant</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Efficiently debug and write Python code with AI assistance.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.7</span>
<span>(98 reviews)</span>
</div>
<div class="prompt-price">$19.99</div>
<div class="prompt-actions">
<button class="buy-btn">Buy Now</button>
<button class="details-btn">View Details</button>
</div>
</div>
<!-- Prompts Grid -->
<div class="prompts-grid" id="prompts-grid"></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>
<p>Try a different category or search term.</p>
</div>
<!-- Prompt Card 3 -->
<div class="prompt-card">
<img src="../images/content/market3.png" alt="Digital Art Style Guide" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Digital Art Style Guide</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Create stunning digital art styles with this comprehensive guide.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.8</span>
<span>(156 reviews)</span>
</div>
<div class="prompt-price">$19.99</div>
<div class="prompt-actions">
<button class="buy-btn">Buy Now</button>
<button class="details-btn">View Details</button>
</div>
</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>
<p id="market-error-msg"></p>
</div>
<!-- Prompt Card 4 -->
<div class="prompt-card">
<img src="../images/content/market4.png" alt="Marketing Copywriter" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Marketing Copywriter</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Generate engaging marketing copy in minutes.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.8</span>
<span>(112 reviews)</span>
</div>
<div class="prompt-price">$19.99</div>
<div class="prompt-actions">
<button class="buy-btn">Buy Now</button>
<button class="details-btn">View Details</button>
</div>
</div>
</div>
<!-- Prompt Card 5 -->
<div class="prompt-card">
<img src="../images/content/market5.png" alt="Midjourney Image Prompts" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">Midjourney Image Prompts</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Curated prompts for stunning Midjourney images.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.7</span>
<span>(203 reviews)</span>
</div>
<div class="prompt-price">$19.99</div>
<div class="prompt-actions">
<button class="buy-btn">Buy Now</button>
<button class="details-btn">View Details</button>
</div>
</div>
</div>
<!-- Prompt Card 6 -->
<div class="prompt-card">
<img src="../images/content/market6.png" alt="UI Design Assistant" class="prompt-img">
<div class="prompt-info">
<h3 class="prompt-title">UI Design Assistant</h3>
<div class="prompt-author">@JaneDoe</div>
<p class="prompt-description">Generate UI components and design systems with AI.</p>
<div class="prompt-rating">
<span><i class="bi bi-star-fill"></i> 4.9</span>
<span>(87 reviews)</span>
</div>
<div class="prompt-price">$19.99</div>
<div class="prompt-actions">
<button class="buy-btn">Buy Now</button>
<button class="details-btn">View Details</button>
</div>
</div>
</div>
</div>
</main>
</main>
</div>
</div>
</div>
<script>
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
// Remove 'active' from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active');
<!-- ── Payment Modal ─────────────────────────────────────────────── -->
<div
id="payment-overlay"
style="
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
"
>
<div
style="
background: #fff;
border-radius: 16px;
padding: 32px;
max-width: 460px;
width: 90%;
position: relative;
max-height: 90vh;
overflow-y: auto;
"
>
<button
onclick="closePayment()"
style="
position: absolute;
top: 14px;
right: 18px;
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
color: #64748b;
"
>
&#x2715;
</button>
<!-- Step 1: Choose method -->
<div id="pay-step-1">
<h2 style="margin-bottom: 4px">Subscribe to access</h2>
<p
id="pay-prompt-title"
style="color: #6366f1; font-weight: 600; margin-bottom: 16px"
></p>
<p style="color: #64748b; margin-bottom: 24px">
Choose a payment method to unlock this prompt:
</p>
<div style="display: flex; flex-direction: column; gap: 12px">
<button class="pay-method-btn" onclick="selectCrypto('btc')">
<span style="font-size: 1.4rem"></span> Bitcoin (BTC)
<span
id="price-btc"
style="margin-left: auto; font-size: 0.85rem; color: #94a3b8"
></span>
</button>
<button class="pay-method-btn" onclick="selectCrypto('eth')">
<span style="font-size: 1.4rem">Ξ</span> Ethereum (ETH)
<span
id="price-eth"
style="margin-left: auto; font-size: 0.85rem; color: #94a3b8"
></span>
</button>
<button class="pay-method-btn" onclick="selectCrypto('sol')">
<span style="font-size: 1.4rem"></span> Solana (SOL)
<span
id="price-sol"
style="margin-left: auto; font-size: 0.85rem; color: #94a3b8"
></span>
</button>
<button class="pay-method-btn" onclick="selectCrypto('usdt')">
<span style="font-size: 1.4rem"></span> USDT (TRC-20)
<span
id="price-usdt"
style="margin-left: auto; font-size: 0.85rem; color: #94a3b8"
></span>
</button>
</div>
<p
style="
margin-top: 20px;
font-size: 0.8rem;
color: #94a3b8;
text-align: center;
"
>
<i class="bi bi-shield-lock-fill"></i> Payments are processed
on-chain. No account needed.
</p>
</div>
<!-- Step 2: Send payment -->
<div id="pay-step-2" style="display: none">
<button
onclick="backToStep1()"
style="
background: none;
border: none;
color: #6366f1;
cursor: pointer;
margin-bottom: 16px;
font-size: 0.9rem;
"
>
&#8592; Back
</button>
<h2 id="pay-crypto-title" style="margin-bottom: 8px"></h2>
<p style="color: #64748b; margin-bottom: 8px">
Send exactly <strong id="pay-amount"></strong> to:
</p>
<div
style="
background: #f1f5f9;
border-radius: 10px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
"
>
<code
id="pay-address"
style="font-size: 0.85rem; word-break: break-all; flex: 1"
></code>
<button
onclick="copyAddress()"
title="Copy"
style="
background: none;
border: none;
cursor: pointer;
color: #6366f1;
font-size: 1.1rem;
"
>
<i class="bi bi-clipboard"></i>
</button>
</div>
<p style="font-size: 0.78rem; color: #94a3b8; margin-bottom: 20px">
⚠️ Only send the exact amount. Payments are non-refundable.
</p>
<div
style="
background: #fef9c3;
border: 1px solid #fde68a;
border-radius: 10px;
padding: 12px 14px;
font-size: 0.82rem;
color: #92400e;
margin-bottom: 20px;
"
>
<i class="bi bi-info-circle-fill"></i> After sending, click the
button below to confirm. Access will be granted once the transaction
is verified.
</div>
<button
onclick="confirmPayment()"
style="
width: 100%;
padding: 12px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
"
>
I've sent the payment ✓
</button>
</div>
<!-- Step 3: Success -->
<div
id="pay-step-3"
style="display: none; text-align: center; padding: 20px 0"
>
<i
class="bi bi-check-circle-fill"
style="
font-size: 3.5rem;
color: #10b981;
display: block;
margin-bottom: 16px;
"
></i>
<h2 style="margin-bottom: 8px">Payment received!</h2>
<p style="color: #64748b; margin-bottom: 24px">
Your access is being activated. This usually takes 12 minutes.
</p>
<button
onclick="closePayment()"
style="
padding: 12px 28px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
"
>
Done
</button>
</div>
</div>
</div>
<script type="module">
// ── Sidebar & Topbar ──────────────────────────────────────────────
fetch("/sidebar.html")
.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 link = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[1];
if (link) link.classList.add("active");
});
// Set 'active' on the second link (Marketplace) - index 1
const secondLink = document.querySelectorAll('#sidebar-container .sidebar li a')[1];
if (secondLink) secondLink.classList.add('active');
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>
// ── State ─────────────────────────────────────────────────────────
let activeCategory = "";
let searchTimeout;
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
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;"`
: "";
if (rating == null)
return `<span${target} style="color:#94a3b8;font-size:0.8rem;${promptId && !locked ? 'cursor:pointer;' : ''}">No reviews yet</span>`;
const stars = Math.round(rating);
const label = reviewCount === 1 ? "review" : "reviews";
return `<span class="prompt-rating"${target}><span style="color:#f59e0b">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> ${rating.toFixed(1)} (${reviewCount} ${label})</span>`;
}
function promptPrice(prompt) {
if (prompt.price != null && Number(prompt.price) > 0) {
return `$${Number(prompt.price).toFixed(2)}`;
}
if (prompt.tierLevel) return `$${(prompt.tierLevel * 4.99).toFixed(2)}/mo`;
if (prompt.canAccess === false) return "Paid";
return "Free";
}
function getNumericPrice(prompt) {
if (prompt.price != null && Number(prompt.price) > 0) {
return Number(prompt.price);
}
if (prompt.tierLevel) return prompt.tierLevel * 4.99;
return 0;
}
function applyMarketplaceSort(prompts, sortBy, ascending) {
if (sortBy === "free") {
return prompts.filter((prompt) => getNumericPrice(prompt) === 0);
}
if (sortBy === "price") {
const direction = ascending === "true" ? 1 : -1;
return prompts
.slice()
.sort((a, b) => (getNumericPrice(a) - getNumericPrice(b)) * direction);
}
return prompts;
}
const MARKET_IMAGES = [
"/images/content/market1.png",
"/images/content/market2.png",
"/images/content/market3.png",
"/images/content/market4.png",
"/images/content/market5.png",
"/images/content/market6.png",
];
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];
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>
<span class="prompt-author">@${p.creatorName}</span>
<span style="margin-left:auto;font-size:0.75rem;color:#94a3b8;">${timeAgo(p.timeStamp)}</span>
</div>
<h3 class="prompt-title">${p.title}</h3>
<p class="prompt-description">${p.description || 'No description yet.'}</p>
<div style="margin-bottom:12px;">${renderStars(p.averageRating, p.reviewCount || 0, p.id, locked)}</div>
<div class="prompt-price">${promptPrice(p)}</div>
<div class="prompt-actions">
${
locked
? `<button class="buy-btn" style="background:#ef4444;" onclick='openPayment(${JSON.stringify(p)})'><i class="bi bi-lock-fill"></i> Pay</button>`
: `<button class="buy-btn" style="background:#10b981;" onclick="location.href='/post-detail?id=${p.id}'">Access <i class="bi bi-unlock-fill"></i></button>`
}
${
locked
? `<button class="details-btn" disabled style="opacity:.45;cursor:not-allowed;"><i class="bi bi-lock-fill"></i> Details</button>`
: `<button class="details-btn" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>`
}
</div>
</div>
</div>`;
}
async function loadPrompts() {
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 [sortBy, ascending] = document
.getElementById("sort-select")
.value.split("|");
grid.innerHTML = "";
emptyEl.style.display = "none";
errorEl.style.display = "none";
cardIndex = 0;
try {
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)}`;
const res = await fetch(url);
if (res.status === 401) {
location.href = "/login";
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
let prompts = applyMarketplaceSort(await res.json(), sortBy, ascending);
if (prompts.length === 0) {
emptyEl.style.display = "block";
return;
}
grid.innerHTML = prompts.map(renderCard).join("");
} catch (e) {
document.getElementById("market-error-msg").textContent =
e.message || "Please check if the backend is running.";
errorEl.style.display = "block";
}
}
async function loadCategories() {
try {
const res = await fetch("/api/v1/categories/minimal");
if (!res.ok) return;
const cats = await res.json();
const container = document.getElementById("category-filters");
cats.forEach((c) => {
const btn = document.createElement("button");
btn.className = "filter-btn";
btn.dataset.category = c.slug;
btn.textContent = c.name;
btn.addEventListener("click", () => {
document
.querySelectorAll("#category-filters .filter-btn")
.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
activeCategory = c.slug;
loadPrompts();
});
container.appendChild(btn);
});
} catch {}
}
// ── Category filter (All) ──────────────────────────────────────────
document
.querySelector("#category-filters .filter-btn")
.addEventListener("click", function () {
document
.querySelectorAll("#category-filters .filter-btn")
.forEach((b) => b.classList.remove("active"));
this.classList.add("active");
activeCategory = "";
loadPrompts();
});
document
.getElementById("sort-select")
.addEventListener("change", loadPrompts);
function wireMarketplaceTopbarSearch() {
const input = document.getElementById("topbarSearchInput");
if (!input || input.dataset.marketplaceSearchReady === "true") return;
input.dataset.marketplaceSearchReady = "true";
input.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadPrompts, 400);
});
input.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
loadPrompts();
});
}
const topbarObserver = new MutationObserver(wireMarketplaceTopbarSearch);
topbarObserver.observe(document.body, { childList: true, subtree: true });
wireMarketplaceTopbarSearch();
// Make openPayment global
window.openPayment = openPayment;
// ── Payment Modal ──────────────────────────────────────────────────
const CRYPTO_ADDRESSES = {
btc: "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf1N",
eth: "0x742d35Cc6634C0532925a3b8D4C9B8E4D8F2b1a",
sol: "7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV1",
usdt: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
};
const CRYPTO_NAMES = {
btc: "Bitcoin (BTC)",
eth: "Ethereum (ETH)",
sol: "Solana (SOL)",
usdt: "USDT TRC-20",
};
let currentPrompt = null;
let currentCrypto = null;
function openPayment(prompt) {
currentPrompt = prompt;
const usd = prompt.price != null && Number(prompt.price) > 0
? Number(prompt.price)
: prompt.tierLevel ? prompt.tierLevel * 4.99 : 0;
document.getElementById("pay-prompt-title").textContent = prompt.title;
document.getElementById("price-btc").textContent =
`≈ ${(usd / 67000).toFixed(6)} BTC`;
document.getElementById("price-eth").textContent =
`≈ ${(usd / 3200).toFixed(5)} ETH`;
document.getElementById("price-sol").textContent =
`≈ ${(usd / 145).toFixed(3)} SOL`;
document.getElementById("price-usdt").textContent =
`${usd.toFixed(2)} USDT`;
document.getElementById("pay-step-1").style.display = "block";
document.getElementById("pay-step-2").style.display = "none";
document.getElementById("pay-step-3").style.display = "none";
document.getElementById("payment-overlay").style.display = "flex";
}
window.selectCrypto = function (crypto) {
currentCrypto = crypto;
const usd = currentPrompt.tierLevel
? currentPrompt.tierLevel * 4.99
: 0;
const amounts = {
btc: `${(usd / 67000).toFixed(6)} BTC`,
eth: `${(usd / 3200).toFixed(5)} ETH`,
sol: `${(usd / 145).toFixed(3)} SOL`,
usdt: `${usd.toFixed(2)} USDT`,
};
document.getElementById("pay-crypto-title").textContent =
CRYPTO_NAMES[crypto];
document.getElementById("pay-amount").textContent = amounts[crypto];
document.getElementById("pay-address").textContent =
CRYPTO_ADDRESSES[crypto];
document.getElementById("pay-step-1").style.display = "none";
document.getElementById("pay-step-2").style.display = "block";
};
window.backToStep1 = function () {
document.getElementById("pay-step-1").style.display = "block";
document.getElementById("pay-step-2").style.display = "none";
};
window.copyAddress = function () {
navigator.clipboard.writeText(CRYPTO_ADDRESSES[currentCrypto]);
};
window.confirmPayment = function () {
document.getElementById("pay-step-2").style.display = "none";
document.getElementById("pay-step-3").style.display = "block";
};
window.closePayment = function () {
document.getElementById("payment-overlay").style.display = "none";
};
document
.getElementById("payment-overlay")
.addEventListener("click", function (e) {
if (e.target === this) closePayment();
});
// ── Init ───────────────────────────────────────────────────────────
await loadCategories();
await loadPrompts();
</script>
</body>
</html>

View File

@ -1,114 +1,580 @@
<!-- OnlyPrompt - Settings page:
- User preferences with tabs for profile, security, and notifications -->
<!-- OnlyPrompt - Post Detail: dynamic prompt loaded via API -->
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Post Detail</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/post-detail.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);">
<div id="sidebar-container"></div>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OnlyPrompt - Post Detail</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/post-detail.css" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
</head>
<body>
<div
class="layout"
style="display: flex; min-height: 100vh; background: var(--bg)"
>
<div id="sidebar-container"></div>
<div style="flex:1; display: flex; flex-direction: column;">
<div id="topbar-container"></div>
<div style="flex: 1; display: flex; flex-direction: column">
<div id="topbar-container"></div>
<main class="post-detail-main">
<div class="post-detail-container">
<!-- Header -->
<div class="post-header">
<h1 class="post-title">Marketing Guru: Campaign Strategist</h1>
<div class="post-meta">
<span class="category">Marketing</span>
<span class="category">Business</span>
<span class="category">Copywriting</span>
<span class="updated">Updated: Oct 26, 2023</span>
<main class="post-detail-main">
<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>
<p>Loading prompt...</p>
</div>
<div class="post-stats">
<span><i class="bi bi-bookmark-star"></i> 2,109 Saves</span>
<span><i class="bi bi-eye"></i> 14.5k Views</span>
</div>
</div>
<!-- Prompt Content Section -->
<div class="prompt-section">
<h2>PROMPT</h2>
<div class="prompt-content">
<p>You are an expert marketing strategist and copywriter. I will provide a product name, target audience, and key benefits. Your goal is to create a comprehensive marketing campaign plan, including:</p>
<ul>
<li>Campaign objectives</li>
<li>Target Audience: [User Input]</li>
<li>Expected Output: A detailed marketing plan with channel strategy, messaging, and sample copy</li>
<li>Budget considerations and KPIs</li>
</ul>
<!-- 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>
<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;
"
>
Go Back
</button>
</div>
</div>
<!-- Rating & Like -->
<div class="rating-section">
<div class="rating-stars">
<span><i class="bi bi-star-fill"></i> 4.9</span>
<span><i class="bi bi-star-fill"></i> 4.8 (241 Ratings)</span>
</div>
<button class="like-btn"><i class="bi bi-heart"></i> Like (212)</button>
</div>
<!-- Example Output Section -->
<div class="example-section">
<h2>EXAMPLE OUTPUT</h2>
<div class="example-content">
<h3>Generated Strategy 16px</h3>
<div class="example-output-text">
<p><strong>Output: EcoWare Campaign</strong></p>
<p><strong>Campaign Overview</strong><br>Target Persona: Sarah Jenkins, 28, Eco-conscious</p>
<p><strong>Key Messaging</strong><br>Channel Strategy<br>Sample Copy<br>Social<br>Email</p>
<!-- Content (hidden until loaded) -->
<div id="detail-body" style="display: none">
<!-- 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>
<span
id="creator-name"
style="font-weight: 600; font-size: 0.95rem"
></span>
<span
id="prompt-date"
style="display: block; font-size: 0.8rem; color: #94a3b8"
></span>
</div>
<div style="margin-left: auto">
<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>
</div>
</div>
<h1 class="post-title" id="prompt-title"></h1>
<div class="post-meta">
<span class="category" id="prompt-category"></span>
<span class="updated" id="prompt-updated"></span>
</div>
<div class="post-stats">
<span id="prompt-rating-stat"></span>
</div>
</div>
<!-- Optional example image (if uploaded in create) -->
<div class="example-image" style="display: none;">
<img src="#" alt="Example Output Image">
<!-- Description -->
<div class="prompt-section" id="desc-section">
<h2>DESCRIPTION</h2>
<div class="prompt-content">
<p id="prompt-description"></p>
</div>
</div>
<!-- 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>
<!-- Example Output -->
<div class="example-section" id="example-section" style="display: none">
<h2>EXAMPLE OUTPUT</h2>
<div class="example-content">
<div id="example-output-text" class="example-output-text" style="white-space: pre-wrap"></div>
<div id="example-image" class="example-image" style="display: none">
<img id="example-image-img" src="" alt="Example output image">
</div>
</div>
</div>
<!-- Locked section (shown instead of prompt if no access) -->
<div
id="locked-section"
style="
display: none;
text-align: center;
padding: 40px 20px;
background: #f8fafc;
border-radius: 12px;
margin-bottom: 28px;
"
>
<i
class="bi bi-lock-fill"
style="
font-size: 2.5rem;
color: #94a3b8;
display: block;
margin-bottom: 12px;
"
></i>
<h3 style="margin-bottom: 8px">
This prompt requires a subscription
</h3>
<p style="color: #64748b; margin-bottom: 20px">
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;
"
>
Subscribe <span id="locked-tier-name"></span>
</button>
</div>
<!-- Rating -->
<div class="rating-section" id="rating-section">
<div class="rating-stars" id="rating-display"></div>
</div>
<!-- Reviews -->
<div class="reviews-section" id="reviews-section">
<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>
<textarea id="review-comment" maxlength="200" rows="3" placeholder="Write a short comment..."></textarea>
<button type="button" id="submit-review-btn">Submit Review</button>
<p id="review-message"></p>
</div>
<div class="reviews-list" id="reviews-list">
<p style="color:#94a3b8;">Loading reviews...</p>
</div>
</div>
</div>
</div>
<!-- Unlock / Buy Section -->
<div class="unlock-section">
<button class="unlock-btn">Unlock Full Prompt • $19.99</button>
</div>
</div>
</main>
</main>
</div>
</div>
</div>
<script>
// Fetch sidebar and topbar
fetch('/sidebar.html')
.then(r => r.text())
.then(data => {
document.getElementById('sidebar-container').innerHTML = data;
// Remove active class from all sidebar links
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
link.classList.remove('active');
<script type="module">
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"));
});
// Optionally set active on a relevant link (e.g., Marketplace)
const marketplaceLink = document.querySelector('#sidebar-container a[href="marketplace.html"]');
if (marketplaceLink) marketplaceLink.classList.add('active');
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>
const params = new URLSearchParams(location.search);
const id = params.get("id");
let selectedReviewRating = 0;
if (!id) {
showError("No prompt ID provided", "Add ?id=... to the URL.");
} else {
loadPrompt(id);
}
async function loadPrompt(id) {
try {
const res = await fetch(`/api/v1/prompts/${id}`);
if (res.status === 401) {
location.href = "/login";
return;
}
if (res.status === 404) {
showError(
"Prompt not found",
"This prompt does not exist or you do not have access.",
);
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
const p = await res.json();
renderPrompt(p);
} catch (e) {
showError("Could not load prompt", e.message);
}
}
function renderPrompt(p) {
document.title = `${p.title} — OnlyPrompt`;
document.getElementById("creator-avatar").textContent = p.creatorName
.charAt(0)
.toUpperCase();
document.getElementById("creator-name").textContent = p.creatorName;
document.getElementById("prompt-date").textContent = new Date(
p.timeStamp,
).toLocaleDateString("de-CH", {
year: "numeric",
month: "long",
day: "numeric",
});
document.getElementById("prompt-title").textContent = p.title;
document.getElementById("prompt-category").textContent =
p.categoryName || "Uncategorized";
document.getElementById("prompt-updated").textContent =
`Updated: ${new Date(p.timeStamp).toLocaleDateString()}`;
document.getElementById("prompt-description").textContent =
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>`;
renderExamples(p);
renderOwnerActions(p);
setupReviewSection(p);
loadReviews(p.id);
// 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>`;
} else {
badge.innerHTML = `<span style="background:#dcfce7;color:#166534;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">Free</span>`;
}
// Rating
if (p.averageRating != null) {
const stars = Math.round(p.averageRating);
document.getElementById("rating-display").innerHTML =
`<span style="color:#f59e0b;font-size:1.1rem;">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span>
<span style="margin-left:8px;font-weight:600;">${p.averageRating.toFixed(1)}</span>
<span style="color:#94a3b8;font-size:0.85rem;margin-left:4px;">/ 5.0 (${p.reviewCount || 0} ${(p.reviewCount || 0) === 1 ? "review" : "reviews"})</span>`;
document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes
<span style="margin-left:12px;"><i class="bi ${p.isSaved ? 'bi-bookmark-fill' : 'bi-bookmark'}" style="color:#f59e0b;"></i> ${p.saveCount || 0} saves</span>
<span style="margin-left:12px;"><i class="bi bi-star-fill" style="color:#f59e0b;"></i> ${p.averageRating.toFixed(1)} (${p.reviewCount || 0})</span>`;
} else {
document.getElementById("rating-display").innerHTML =
'<span style="color:#94a3b8;font-size:0.9rem;">No ratings yet</span>';
}
// Content visibility
if (p.content) {
document.getElementById("prompt-content-section").style.display =
"block";
document.getElementById("locked-section").style.display = "none";
} else {
document.getElementById("prompt-content-section").style.display =
"none";
document.getElementById("locked-section").style.display = "block";
document.getElementById("locked-creator").textContent = p.creatorName;
document.getElementById("locked-tier-name").textContent = p.tierName
? `— ${p.tierName}`
: "";
}
document.getElementById("detail-loading").style.display = "none";
document.getElementById("detail-body").style.display = "block";
scrollToHashSection();
}
function scrollToHashSection() {
if (!location.hash) return;
const target = document.querySelector(location.hash);
if (!target) return;
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
function setReviewRating(rating) {
selectedReviewRating = rating;
document.querySelectorAll("#review-star-input button").forEach((button) => {
const value = Number(button.dataset.rating);
button.textContent = value <= rating ? "★" : "☆";
});
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function setupReviewSection(prompt) {
const form = document.getElementById("review-form");
const message = document.getElementById("review-message");
const submitBtn = document.getElementById("submit-review-btn");
selectedReviewRating = 0;
setReviewRating(0);
document.getElementById("review-star-input").style.display = "flex";
document.getElementById("review-comment").style.display = "block";
submitBtn.style.display = "inline-block";
document.getElementById("review-comment").value = "";
message.textContent = "";
form.style.display = "block";
if (!prompt.content) {
document.getElementById("review-star-input").style.display = "none";
document.getElementById("review-comment").style.display = "none";
submitBtn.style.display = "none";
message.textContent = "Unlock this prompt before writing a review.";
return;
}
try {
const response = await fetch("/api/v1/auth/me", {
credentials: "same-origin",
});
if (!response.ok) return;
const user = await response.json();
if (user.id === prompt.creatorId) {
document.getElementById("review-star-input").style.display = "none";
document.getElementById("review-comment").style.display = "none";
submitBtn.style.display = "none";
message.textContent = "You cannot review your own prompt.";
return;
}
} catch {
// 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));
});
submitBtn.onclick = async () => {
if (!selectedReviewRating) {
message.textContent = "Please select a star rating.";
return;
}
submitBtn.disabled = true;
message.textContent = "Saving review...";
const response = await fetch(`/api/v1/prompts/${prompt.id}/reviews`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
rating: selectedReviewRating,
comment: document.getElementById("review-comment").value.trim() || null,
}),
});
if (response.status === 401) {
location.href = "/login";
return;
}
submitBtn.disabled = false;
if (!response.ok) {
message.textContent = await response.text();
return;
}
message.textContent = "Review saved.";
await loadPrompt(prompt.id);
};
}
async function loadReviews(promptId) {
const list = document.getElementById("reviews-list");
try {
const response = await fetch(`/api/v1/prompts/${promptId}/reviews`, {
credentials: "same-origin",
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) throw new Error(`Server error ${response.status}`);
const reviews = await response.json();
if (reviews.length === 0) {
list.innerHTML = '<p style="color:#94a3b8;">No reviews yet.</p>';
return;
}
list.innerHTML = reviews.map((review) => {
const stars = "★".repeat(review.rating) + "☆".repeat(5 - review.rating);
return `
<article class="review-card">
<div class="review-card-header">
<span class="review-card-user">@${escapeHtml(review.creatorName)}</span>
<span class="review-card-stars">${stars}</span>
</div>
<p class="review-card-comment">${escapeHtml(review.comment || "No comment.")}</p>
</article>`;
}).join("");
} catch (error) {
list.innerHTML = `<p style="color:#ef4444;">${error.message}</p>`;
}
}
async function renderOwnerActions(p) {
try {
const response = await fetch("/api/v1/auth/me", {
credentials: "same-origin",
});
if (!response.ok) return;
const user = await response.json();
if (user.id !== p.creatorId) return;
const editBtn = document.getElementById("edit-prompt-btn");
editBtn.style.display = "inline-block";
editBtn.onclick = () => {
location.href = `/create?id=${p.id}`;
};
} catch {
// Keep edit hidden if the current user cannot be loaded.
}
}
function renderExamples(p) {
const section = document.getElementById("example-section");
const text = document.getElementById("example-output-text");
const imageWrap = document.getElementById("example-image");
const image = document.getElementById("example-image-img");
if (!p.exampleOutput && !p.exampleImageUrl) {
section.style.display = "none";
return;
}
section.style.display = "block";
text.textContent = p.exampleOutput || "";
text.style.display = p.exampleOutput ? "block" : "none";
if (p.exampleImageUrl) {
image.src = p.exampleImageUrl;
imageWrap.style.display = "block";
} else {
image.removeAttribute("src");
imageWrap.style.display = "none";
}
}
function showError(title, msg) {
document.getElementById("detail-loading").style.display = "none";
document.getElementById("detail-error").style.display = "block";
document.getElementById("detail-error-title").textContent = title;
document.getElementById("detail-error-msg").textContent = msg;
}
</script>
</body>
</html>

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/profile.css">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
@ -28,61 +29,40 @@
<section class="profile-header" style="display:flex;align-items:center;gap:32px;border-bottom:1px solid #e5e7eb;padding-bottom:24px;">
<img src="../images/content/cat.png" class="profile-avatar" style="width:110px;height:110px;border-radius:50%;object-fit:cover;">
<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 style="font-size:2rem;font-weight:700;margin-bottom:4px;">Sunny the Cat</h1>
<div style="color:#64748b;margin-bottom:8px;">
@sunny_the_cat <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>
<h1 id="profileDisplayName" style="font-size:2rem;font-weight:700;margin-bottom:4px;">Loading...</h1>
<div id="profileSlug" style="color:#64748b;margin-bottom:8px;">
@profile <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>
</div>
<div style="margin-bottom:8px;">
🐾 Cat prompt creator | Nap enthusiast | AI Cat Content Curator<br>
Chasing lasers and building purrfect prompts.
<div id="profileBio" style="margin-bottom:8px;">
Loading profile...
</div>
<div style="color:#64748b;">Cat City, Dreamland 🐈</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>
</div>
<div style="display:flex;flex-direction:column;gap:10px;">
<button class="login-button">Edit Profile</button>
<button class="login-button" style="background:#f3f4f6;color:#111;box-shadow:none;">Share Profile</button>
<div id="profileActions" style="display:flex;flex-direction:column;gap:10px;">
<button id="primaryProfileButton" class="login-button" onclick="location.href='settings.html'">Edit Profile</button>
<button id="shareProfileButton" class="login-button" style="background:#f3f4f6;color:#111;box-shadow:none;">Share Profile</button>
</div>
</section>
<nav style="display:flex;gap:24px;border-bottom:2px solid #e5e7eb;margin:32px 0 18px 0;">
<a href="#" style="padding:10px 0;color:#3b82f6;font-weight:600;border-bottom:2px solid #3b82f6;">My Prompts (2)</a>
<a href="#" style="padding:10px 0;color:#64748b;">Favorites (15)</a>
<a href="#" style="padding:10px 0;color:#64748b;">Saved (7)</a>
<nav class="profile-tabs" style="display:flex;gap:24px;border-bottom:2px solid #e5e7eb;margin:32px 0 18px 0;flex-wrap:wrap;">
<button type="button" class="profile-tab active" data-tab="mine" id="myPromptsTab">My Prompts</button>
<button type="button" class="profile-tab" data-tab="favorites" id="favoritesTab">Favorites</button>
<button type="button" class="profile-tab" data-tab="saved" id="savedTab">Saved</button>
</nav>
<section style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px;">
<div style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:18px;display:flex;gap:16px;">
<img src="../images/content/post1.png" style="width:60px;height:60px;border-radius:12px;">
<div>
<div style="font-weight:700;">Galactic Catventure</div>
<div style="color:#64748b;margin-bottom:6px;">A cosmic journey of a curious cat exploring the stars.</div>
<div style="display:flex;gap:16px;color:#64748b;">
<span><i class="bi bi-heart"></i> 128</span>
<span><i class="bi bi-chat"></i> 15</span>
</div>
</div>
</div>
<div style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:18px;display:flex;gap:16px;">
<img src="../images/content/post2.png" style="width:60px;height:60px;border-radius:12px;">
<div>
<div style="font-weight:700;">Minimalist Cat Logo</div>
<div style="color:#64748b;margin-bottom:6px;">Sleek logo design for feline fans.</div>
<div style="display:flex;gap:16px;color:#64748b;">
<span><i class="bi bi-heart"></i> 54</span>
<span><i class="bi bi-chat"></i> 6</span>
</div>
</div>
</div>
<section id="profile-prompts-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px;">
<div style="grid-column:1/-1;color:#64748b;text-align:center;padding:28px;">Loading prompts...</div>
</section>
</main>
@ -106,6 +86,313 @@
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
const profileAvatar = document.getElementById('profileAvatar');
const profileDisplayName = document.getElementById('profileDisplayName');
const profileSlug = document.getElementById('profileSlug');
const profileBio = document.getElementById('profileBio');
const profileSpecialities = document.getElementById('profileSpecialities');
const profileRating = document.getElementById('profileRating');
const profileSubscribers = document.getElementById('profileSubscribers');
const profilePromptsGrid = document.getElementById('profile-prompts-grid');
const myPromptsTab = document.getElementById('myPromptsTab');
const favoritesTab = document.getElementById('favoritesTab');
const savedTab = document.getElementById('savedTab');
const profileActions = document.getElementById('profileActions');
const primaryProfileButton = document.getElementById('primaryProfileButton');
const shareProfileButton = document.getElementById('shareProfileButton');
const profileTabs = document.querySelector('.profile-tabs');
const params = new URLSearchParams(location.search);
const profileId = params.get('id');
let ownPrompts = [];
let allPrompts = [];
let profilePrompts = [];
let activeProfileTab = 'mine';
let currentUserId = null;
let isOwnProfile = !profileId;
let profileLoaded = false;
let currentIsFollowing = false;
async function fetchJson(url) {
const response = await fetch(url, { credentials: 'same-origin' });
if (response.status === 401) {
location.href = '/login';
return null;
}
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
}
function renderProfile(profile, fallbackName = 'Profile') {
profileDisplayName.textContent = profile.displayName || fallbackName;
profileSlug.innerHTML = `@${profile.user?.userName || profile.slug || 'profile'} <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>`;
profileBio.textContent = profile.bio || 'No bio yet.';
profileSpecialities.textContent = profile.specialities || 'No specialities added yet.';
profileRating.textContent = Number(profile.averageRating || 0).toFixed(1);
profileSubscribers.textContent = profile.subscribers || 0;
if (profile.avatarUrl) {
profileAvatar.src = profile.avatarUrl;
}
profileLoaded = true;
}
function renderProfileFromPrompt(prompt) {
if (!prompt || profileLoaded) return;
profileDisplayName.textContent = prompt.creatorName || 'Creator Profile';
profileSlug.innerHTML = `@${prompt.creatorName || 'creator'} <i class="bi bi-patch-check-fill" style="color:#3b82f6;"></i>`;
profileBio.textContent = 'No bio yet.';
profileSpecialities.textContent = '';
profileRating.textContent = Number(prompt.averageRating || 0).toFixed(1);
profileSubscribers.textContent = 0;
if (prompt.creatorAvatarUrl) {
profileAvatar.src = prompt.creatorAvatarUrl;
}
}
async function loadCreatorCardFallback() {
if (isOwnProfile || profileLoaded || !profileId) return;
try {
const creators = await fetchJson('/api/v1/profiles?limit=100');
const creator = creators.find((item) => item.userId?.toLowerCase() === profileId.toLowerCase());
if (!creator) return;
renderProfile({
displayName: creator.displayName,
slug: creator.slug,
bio: creator.bio,
avatarUrl: creator.avatarUrl,
specialities: null,
averageRating: creator.averageRating,
subscribers: creator.subscribers
}, 'Creator Profile');
} catch {
// Prompt data below still provides a minimal fallback if creator cards fail.
}
}
async function loadProfile() {
try {
const currentProfile = await window.loadCurrentProfile();
currentUserId = currentProfile.user?.id;
isOwnProfile = !profileId || profileId.toLowerCase() === currentUserId?.toLowerCase();
if (isOwnProfile) {
renderProfile(currentProfile, 'My Profile');
return;
}
const profile = await fetchJson(`/api/v1/profiles/${encodeURIComponent(profileId)}`);
renderProfile(profile, 'Creator Profile');
} catch (error) {
if (isOwnProfile) {
profileDisplayName.textContent = 'Profile unavailable';
profileBio.textContent = error.message;
} else {
profileDisplayName.textContent = 'Loading creator...';
profileBio.textContent = '';
}
}
}
function isPromptMarked(type, id) {
if (type === 'liked') {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isLiked === true;
}
if (type === 'saved') {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isSaved === true;
}
return false;
}
function renderProfilePrompt(prompt, options = {}) {
const image = prompt.exampleImageUrl || '../images/content/post1.png';
const showEdit = options.showEdit === true;
const rating = prompt.averageRating == null ? 'No ratings' : prompt.averageRating.toFixed(1);
return `
<div onclick="location.href='/post-detail?id=${prompt.id}'" style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:18px;display:flex;gap:16px;cursor:pointer;">
<img src="${image}" alt="${prompt.title}" style="width:72px;height:72px;border-radius:12px;object-fit:cover;">
<div style="flex:1;min-width:0;">
<div style="font-weight:700;">${prompt.title}</div>
<div style="color:#64748b;margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${prompt.description || 'No description yet.'}</div>
<div style="display:flex;gap:16px;color:#64748b;align-items:center;flex-wrap:wrap;">
<span><i class="bi bi-star"></i> ${rating}</span>
${prompt.creatorName ? `<span>@${prompt.creatorName}</span>` : ''}
${showEdit ? `<button onclick="event.stopPropagation(); location.href='/create?id=${prompt.id}'" style="border:none;background:#f1f5f9;color:#334155;border-radius:10px;padding:6px 10px;font-weight:700;cursor:pointer;">Edit</button>` : ''}
</div>
</div>
</div>`;
}
function renderPromptList(prompts, emptyText, options = {}) {
if (!prompts.length) {
profilePromptsGrid.innerHTML = `<div style="grid-column:1/-1;color:#64748b;text-align:center;padding:28px;">${emptyText}</div>`;
return;
}
profilePromptsGrid.innerHTML = prompts.map((prompt) => renderProfilePrompt(prompt, options)).join('');
}
function updateTabs() {
document.querySelectorAll('.profile-tab').forEach((tab) => {
tab.classList.toggle('active', tab.dataset.tab === activeProfileTab);
});
const liked = allPrompts.filter((prompt) => isPromptMarked('liked', prompt.id));
const saved = allPrompts.filter((prompt) => isPromptMarked('saved', prompt.id));
myPromptsTab.textContent = `My Prompts (${ownPrompts.length})`;
favoritesTab.textContent = `Favorites (${liked.length})`;
savedTab.textContent = `Saved (${saved.length})`;
if (activeProfileTab === 'favorites') {
renderPromptList(liked, 'No liked prompts yet.');
} else if (activeProfileTab === 'saved') {
renderPromptList(saved, 'No saved prompts yet.');
} else {
renderPromptList(ownPrompts, 'No prompts yet.', { showEdit: true });
}
}
function updateProfileMode() {
if (isOwnProfile) {
profileActions.style.display = 'flex';
primaryProfileButton.textContent = 'Edit Profile';
primaryProfileButton.disabled = false;
primaryProfileButton.onclick = () => location.href = 'settings.html';
profileTabs.style.display = 'flex';
return;
}
profileActions.style.display = 'flex';
primaryProfileButton.textContent = currentIsFollowing ? 'Following' : 'Follow';
primaryProfileButton.disabled = false;
primaryProfileButton.onclick = toggleProfileFollow;
favoritesTab.style.display = 'none';
savedTab.style.display = 'none';
myPromptsTab.textContent = `Prompts (${profilePrompts.length})`;
renderPromptList(profilePrompts, 'No prompts yet.');
}
async function loadFollowState() {
if (isOwnProfile || !profileId) return;
try {
const response = await fetch(`/api/v1/subscriptions/${encodeURIComponent(profileId)}`, {
credentials: 'same-origin'
});
if (response.status === 401) {
location.href = '/login';
return;
}
const subscription = response.ok ? await response.json() : null;
currentIsFollowing = subscription !== null;
updateProfileMode();
} catch {
currentIsFollowing = false;
}
}
async function toggleProfileFollow() {
if (!profileId) return;
primaryProfileButton.disabled = true;
const response = await fetch(`/api/v1/subscriptions/${encodeURIComponent(profileId)}`, {
method: currentIsFollowing ? 'DELETE' : 'PUT',
credentials: 'same-origin'
});
if (response.status === 401) {
location.href = '/login';
return;
}
if (response.ok) {
const currentSubscribers = Number(profileSubscribers.textContent || 0);
currentIsFollowing = !currentIsFollowing;
profileSubscribers.textContent = Math.max(0, currentSubscribers + (currentIsFollowing ? 1 : -1));
updateProfileMode();
} else {
primaryProfileButton.disabled = false;
}
}
shareProfileButton.addEventListener('click', async () => {
const url = isOwnProfile
? `${location.origin}/profile.html`
: `${location.origin}/profile.html?id=${encodeURIComponent(profileId)}`;
try {
await navigator.clipboard.writeText(url);
shareProfileButton.textContent = 'Copied';
setTimeout(() => shareProfileButton.textContent = 'Share Profile', 1200);
} catch {
location.href = url;
}
});
async function loadOwnPrompts() {
try {
const response = await fetch('/api/v1/prompts/mine');
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) throw new Error('Prompts could not be loaded.');
ownPrompts = await response.json();
updateTabs();
} catch (error) {
profilePromptsGrid.innerHTML = `<div style="grid-column:1/-1;color:#ef4444;text-align:center;padding:28px;">${error.message}</div>`;
}
}
async function loadAllPromptReferences() {
try {
const response = await fetch('/api/v1/prompts?limit=100');
if (!response.ok) return;
allPrompts = await response.json();
if (isOwnProfile) {
updateTabs();
} else {
profilePrompts = allPrompts.filter((prompt) => prompt.creatorId?.toLowerCase() === profileId.toLowerCase());
renderProfileFromPrompt(profilePrompts[0]);
updateProfileMode();
}
} catch {
// Favorites and saved stay empty if prompts cannot be loaded.
}
}
document.querySelectorAll('.profile-tab').forEach((tab) => {
tab.addEventListener('click', () => {
if (!isOwnProfile) {
updateProfileMode();
return;
}
activeProfileTab = tab.dataset.tab;
updateTabs();
});
});
(async function initProfilePage() {
await loadProfile();
await loadCreatorCardFallback();
await loadFollowState();
updateProfileMode();
if (isOwnProfile) {
loadOwnPrompts();
}
loadAllPromptReferences();
})();
</script>
</body>
</html>
</html>

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/settings.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>
@ -40,7 +41,7 @@
<!-- Tab Content: Profile -->
<div id="profileTab" class="tab-content active">
<form class="settings-form">
<form class="settings-form" id="profileSettingsForm">
<div class="form-group">
<label for="avatar">Profile Picture</label>
<div class="avatar-upload">
@ -51,11 +52,11 @@
</div>
<div class="form-group">
<label for="displayName">Display Name</label>
<input type="text" id="displayName" placeholder="Sunny the Cat">
<input type="text" id="displayName" placeholder="Display name">
</div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" placeholder="@username">
<input type="text" id="username" placeholder="username" required>
</div>
<div class="form-group">
<label for="bio">Bio</label>
@ -63,10 +64,11 @@
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" placeholder="your@email.com">
<input type="email" id="email" placeholder="your@email.com" readonly>
</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>
</div>
</form>
</div>
@ -154,7 +156,29 @@
});
});
// Avatar preview (simple)
let currentAvatarUrl = '';
let currentSlug = '';
function setProfileForm(profile) {
document.getElementById('displayName').value = profile.displayName || '';
document.getElementById('username').value = profile.user?.userName || '';
document.getElementById('email').value = profile.user?.email || '';
document.getElementById('bio').value = profile.bio || '';
currentAvatarUrl = profile.avatarUrl || '../images/content/cat.png';
currentSlug = profile.slug || profile.user?.userName || '';
avatarPreview.src = currentAvatarUrl;
}
async function loadProfileSettings() {
try {
const profile = await window.loadCurrentProfile();
setProfileForm(profile);
} catch (error) {
document.getElementById('profileSaveStatus').textContent = 'Profile could not be loaded.';
}
}
// Avatar preview and save as data URL
const avatarUpload = document.getElementById('avatarUpload');
const avatarPreview = document.getElementById('avatarPreview');
if (avatarUpload) {
@ -163,15 +187,53 @@
if (file && (file.type === 'image/png' || file.type === 'image/jpeg')) {
const reader = new FileReader();
reader.onload = function(ev) {
avatarPreview.src = ev.target.result;
currentAvatarUrl = ev.target.result;
avatarPreview.src = currentAvatarUrl;
};
reader.readAsDataURL(file);
}
});
}
// Demo form submits for all forms
document.querySelectorAll('.settings-form').forEach(form => {
document.getElementById('profileSettingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('profileSaveStatus');
status.textContent = 'Saving...';
try {
const userName = document.getElementById('username').value.trim();
const response = await fetch('/api/v1/profiles', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
displayName: document.getElementById('displayName').value.trim(),
userName,
slug: userName || currentSlug,
bio: document.getElementById('bio').value.trim(),
avatarUrl: currentAvatarUrl,
specialities: null,
isPublic: true
})
});
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) throw new Error('Profile could not be saved.');
const savedProfile = await window.loadCurrentProfile(true);
window.setCurrentProfile(savedProfile);
setProfileForm(savedProfile);
status.textContent = 'Saved.';
} catch (error) {
status.textContent = error.message;
}
});
// Demo form submits for forms that are not connected yet
document.querySelectorAll('.settings-form:not(#profileSettingsForm)').forEach(form => {
form.addEventListener('submit', (e) => {
e.preventDefault();
alert('Settings saved (demo)');
@ -192,6 +254,8 @@
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
loadProfileSettings();
</script>
</body>
</html>
</html>

View File

@ -45,6 +45,11 @@
<input type="text" id="displayName" name="displayName" placeholder="Enter your display name" required>
</div>
<div class="form-group">
<label for="userName">Username</label>
<input type="text" id="userName" name="userName" placeholder="Choose a username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<!-- Password field with button to show/hide password -->
@ -77,4 +82,4 @@
<script type="module" src="js/signup.js"></script>
</body>
</html>
</html>

View File

@ -8,7 +8,7 @@
<header class="topbar-shell">
<div class="topbar-search">
<i class="bi bi-search"></i>
<input type="text" placeholder="Search">
<input id="topbarSearchInput" type="search" placeholder="Search">
</div>
<div class="topbar-actions">
@ -21,7 +21,7 @@
<!-- Profile avatar on the right (must be changed with backend) -->
<button class="topbar-avatar-btn" aria-label="Profile">
<img src="../images/content/cat.png" alt="Profile Picture" class="topbar-avatar">
<img id="topbarAvatar" src="../images/content/cat.png" alt="Profile Picture" class="topbar-avatar">
</button>
</div>
</header>
</header>

View File

@ -1,21 +1,22 @@
# OnlyPrompt - AI Prompt Marketplace
## Description
OnlyPrompt is a frontend web applications where people can publish and sell their high-quality AI prompts. User can browse, filter, rate and unlock premium features such as private chats with creators for exclusive AI tips.
OnlyPrompt is a web application where users can create, publish, discover and interact with AI prompts. Users can edit their profiles, follow creators, like and save prompts, write reviews and browse free or paid prompt cards in a marketplace.
This project is built with with HTML, CSS and Java Script.
This project is built with HTML, CSS and JavaScript.
## Special Features
- 📝 Create and publish new prompts
- 🔍 Live search and category filtering
- 💬 Premium chat simulation with creators
- 🔒 Unlock premium prompts (payment simulation)
- ⭐ Rating system
- ❤️ Save favorites (LocalStorage)
- 📱 Fully responsive design (mobile & desktop)
- 🌐 External API integration (e.g., RandomUser API for creator profiles)
- 💾 Client-side data persistence
- 🔄 Basic server communication (optional file-based backend)
- 📝 Create, edit and publish AI prompts
- 🔍 Browse prompts in a marketplace with category, search and price filters
- 📄 View prompt detail pages with examples, ratings and access states
- ⭐ Write reviews with star ratings and comments
- ❤️ Like and save prompts
- 👥 Follow and discover other creators
- 👤 Edit user profiles with display name, username, bio and profile picture
- 🌐 View own and public creator profiles
- 📱 Responsive layout for desktop and mobile
- 🔄 Server communication through a REST API
- 💾 Shared data persistence with backend and database
## Installment (for local use)
@ -33,18 +34,36 @@ DB_PASSWORD=onlyprompt
```
## Technologies, Libraries, Frameworks
- HTML5 (Structure)
- CSS3 + Bootstrap + PureCSS (Styling & responsive layout)
- Vanilla JavaScript (Application logic)
- JavaScript Libraries Axios, JQuery
- ASP.NET Core (Backend)
- Postgres (Backend Datastorage)
- Docker (Deployment)
- HTML5 for page structure
- CSS3 with Flexbox/Grid for layout and responsive design
- Bootstrap Icons for UI icons
- Vanilla JavaScript for DOM manipulation, events and API calls
- Fetch API for asynchronous server communication
- ASP.NET Core as helper backend
- PostgreSQL for shared data persistence
- Docker for local development and deployment
## Technical Decisions
The frontend is built with plain HTML, CSS and Vanilla JavaScript because the project focuses on understanding core frontend concepts without using a JavaScript framework. The backend is used as a helper service for authentication, shared data persistence and REST API communication. Docker is used so that the project can be started consistently on different machines.
## Use of AI Tools
AI tools were used as support during development, mainly for debugging, comparing implementation approaches and improving documentation wording. Generated suggestions were reviewed, adapted and tested by the team before being included in the project.
## Security Considerations
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.
- 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.
## Group members and their roles
- Thuvaraka Yogarajah
- Isabelle Nachbaur
- Florian Klessascheck
- Abdul Geylani Semiz
| Name | Role |
| --- | --- |
| **Thuvaraka Yogarajah** | Initial project structure, HTML layout, marketplace/review functions and API backend scaffold |
| **Isabelle Nachbaur** | Frontend pages, UI design, prompt creation flow, marketplace/profile features |
| **Florian Klessascheck** | Backend setup, authentication, database integration, Docker setup and utility/PWA experiments |
| **Abdul Geylani Semiz** | Dynamic API integration for dashboard feed, marketplace, post detail and community creator features |