Compare commits
No commits in common. "main" and "release" have entirely different histories.
341
API.md
341
API.md
@ -1,341 +0,0 @@
|
||||
# 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, tier data, 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}`.
|
||||
|
||||
`subscriptionTier` is the creator's tier level. `null` means the prompt is public/free. `price` is kept as `null` because prompt access is handled through monthly creator tiers.
|
||||
|
||||
### Own Prompts
|
||||
|
||||
```http
|
||||
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, including `subscriptionTier`. 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
|
||||
- tier or free state
|
||||
- example output
|
||||
- example image
|
||||
- average rating and review count
|
||||
- like/save state and counts
|
||||
|
||||
Tier prompts return no detail content for users without a matching creator subscription.
|
||||
|
||||
### 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}
|
||||
PUT /api/v1/subscriptions/{creatorId}/{level}
|
||||
DELETE /api/v1/subscriptions/{creatorId}
|
||||
```
|
||||
|
||||
Used by Community, public profiles and locked prompt details to read follow state, follow creators, subscribe to a monthly tier or unfollow creators.
|
||||
|
||||
- `PUT /api/v1/subscriptions/{creatorId}` follows a creator without a paid tier.
|
||||
- `PUT /api/v1/subscriptions/{creatorId}/{level}` subscribes to one of the creator's tiers. A higher tier gives access to prompts from the same level and lower levels.
|
||||
|
||||
### Subscription Tiers
|
||||
|
||||
```http
|
||||
GET /api/v1/subscriptions/tiers
|
||||
GET /api/v1/subscriptions/tiers/{creatorId}
|
||||
POST /api/v1/subscriptions/tiers
|
||||
PUT /api/v1/subscriptions/tiers/{tierId}
|
||||
DELETE /api/v1/subscriptions/tiers/{tierId}
|
||||
```
|
||||
|
||||
Used by the Subscription Tiers page, Create Prompt and public creator profiles.
|
||||
|
||||
Create request:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Supporter",
|
||||
"monthlyPrice": 4.99,
|
||||
"level": 1,
|
||||
"description": "Access to basic premium prompts."
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Supporter",
|
||||
"level": 1,
|
||||
"monthlyPrice": 4.99,
|
||||
"description": "Access to basic premium prompts."
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
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.
|
||||
@ -1,8 +1,6 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, decimal? TierMonthlyPrice, double? AverageRating, int ReviewCount, bool CanAccess);
|
||||
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, decimal? Price, int LikeCount, bool IsLiked, int SaveCount, bool IsSaved, int? TierLevel, string? TierName, decimal? TierMonthlyPrice, double? AverageRating, int ReviewCount, bool CanAccess);
|
||||
public record 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 ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
|
||||
public record ApiLikeState(int LikeCount, bool IsLiked);
|
||||
public record ApiSaveState(int SaveCount, bool IsSaved);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public record ApiCreatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? Slug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
|
||||
public record ApiUpdatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
|
||||
public record ApiCreatePromptRequest(string Title, string Description, string Content, Identifier Category, int? SubscriptionTier, string Slug);
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@ -3,6 +3,6 @@ using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OnlyPrompt.Backend.ApiModels.UserProfile
|
||||
{
|
||||
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 ApiUpdateProfileRequest([MaxLength(100)] string? DisplayName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic);
|
||||
public record ApiCreateReviewRequest(string? Comment, [Range(1, 5)] int Rating);
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
[Route("api/v1/auth")]
|
||||
public class AuthController : BaseController
|
||||
{
|
||||
private static readonly CookieOptions AuthCookieOptions = new CookieOptions { Secure = false, HttpOnly = true, IsEssential = true, SameSite = SameSiteMode.Lax };
|
||||
private static readonly CookieOptions AuthCookieOptions = new CookieOptions { Secure = true, HttpOnly = true, IsEssential = true };
|
||||
private readonly IPasswordHasher<UserModel> _passwordHasher;
|
||||
private readonly ITokenService _jwtService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
@ -81,7 +81,6 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
AvatarUrl = avatarUrl,
|
||||
DisplayName = request.DisplayName,
|
||||
Slug = slug,
|
||||
IsPublic = true,
|
||||
},
|
||||
Roles = [ModelConstants.UserRole],
|
||||
PasswordHash = null,
|
||||
@ -104,18 +103,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
public RedirectHttpResult Logout()
|
||||
{
|
||||
this.Response.Cookies.Delete("jwt", AuthCookieOptions);
|
||||
return TypedResults.Redirect("/login");
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("me")]
|
||||
public async Task<Results<Ok<ApiUser>, NotFound<string>>> GetCurrentUserAsync()
|
||||
{
|
||||
var user = await GetUserAsync();
|
||||
if (user is null)
|
||||
return TypedResults.NotFound("User not found");
|
||||
|
||||
return TypedResults.Ok(_mapper.Map<ApiUser>(user));
|
||||
return TypedResults.Redirect("login");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/v1/categories")]
|
||||
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
|
||||
[Authorize(Roles = ModelConstants.AdminRole, AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
|
||||
public class CategoryController : BaseController
|
||||
{
|
||||
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
@ -53,7 +53,6 @@ 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);
|
||||
@ -70,7 +69,6 @@ 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);
|
||||
@ -97,7 +95,6 @@ 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);
|
||||
|
||||
@ -32,8 +32,10 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var query = _db.Prompts
|
||||
.Where(x => x.CreatorId != userId)
|
||||
.Where(x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId));
|
||||
.Where(
|
||||
x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId)
|
||||
&& 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);
|
||||
@ -46,9 +48,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
|
||||
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),
|
||||
FeedSortType.Rating => query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 2.5, ascending),
|
||||
_ => query
|
||||
};
|
||||
|
||||
@ -58,23 +58,13 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
.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.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
|
||||
x.SubscriptionTier.Level,
|
||||
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)
|
||||
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
|
||||
)).ToArrayAsync();
|
||||
|
||||
return prompts;
|
||||
|
||||
@ -9,7 +9,6 @@ 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
|
||||
{
|
||||
@ -22,46 +21,17 @@ 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();
|
||||
|
||||
@ -71,46 +41,6 @@ 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)
|
||||
{
|
||||
@ -118,18 +48,6 @@ 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))
|
||||
@ -138,13 +56,13 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
self.Slug = request.Slug;
|
||||
}
|
||||
|
||||
if(request.AvatarUrl is not null)
|
||||
if(string.IsNullOrEmpty(request.AvatarUrl) == false)
|
||||
self.AvatarUrl = request.AvatarUrl;
|
||||
|
||||
if(request.Bio is not null)
|
||||
if(string.IsNullOrEmpty(request.Bio) == false)
|
||||
self.Bio = request.Bio;
|
||||
|
||||
if(request.Specialities is not null)
|
||||
if(string.IsNullOrEmpty(request.Specialities) == false)
|
||||
self.Specialities = request.Specialities;
|
||||
|
||||
if (string.IsNullOrEmpty(request.DisplayName) == false)
|
||||
@ -152,15 +70,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
|
||||
self.IsPublic = request.IsPublic;
|
||||
await _db.SaveChangesAsync();
|
||||
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)
|
||||
);
|
||||
var result = _mapper.Map<ApiUserProfile>(self);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
|
||||
@ -33,251 +33,21 @@ 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.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
|
||||
x.Reviews.Average(r => (double?)r.Rating),
|
||||
x.Reviews.Count,
|
||||
x.CreatorId == userId || 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.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
|
||||
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 _db.Prompts
|
||||
var prompt = await GetAccessiblePrompts(userId.Value)
|
||||
.OfIdentifer(id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (prompt is null)
|
||||
return TypedResults.NotFound("Prompt not found");
|
||||
|
||||
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 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 = null;
|
||||
prompt.SubscriptionTier = null;
|
||||
if (request.SubscriptionTier.HasValue)
|
||||
{
|
||||
var subscriptionTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(
|
||||
t => t.Level == request.SubscriptionTier.Value
|
||||
&& t.UserId == userId
|
||||
);
|
||||
|
||||
if (subscriptionTier is null)
|
||||
return TypedResults.NotFound("Subscription tier not found");
|
||||
|
||||
prompt.SubscriptionTier = subscriptionTier;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = prompt.Prompt, CanAccess = true };
|
||||
var apiPrompt = _mapper.Map<ApiPrompt>(prompt);
|
||||
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)
|
||||
{
|
||||
@ -299,7 +69,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var category = await _db.Categories.FindByIdentifierAsync(new Identifier(request.Category));
|
||||
var category = await _db.Categories.FindByIdentifierAsync(request.Category);
|
||||
if (category is null)
|
||||
return TypedResults.NotFound("Category not found");
|
||||
|
||||
@ -325,9 +95,6 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Prompt = request.Content,
|
||||
ExampleOutput = request.ExampleOutput,
|
||||
ExampleImageUrl = request.ExampleImageUrl,
|
||||
Price = null,
|
||||
CreatorId = userId.Value,
|
||||
SubscriptionTier = subscriptionTier,
|
||||
Category = category,
|
||||
@ -345,10 +112,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var accessiblePrompts = GetAccessiblePrompts(userId!.Value);
|
||||
var reviews = await accessiblePrompts
|
||||
.OfIdentifer(id)
|
||||
.SelectMany(x => x.Reviews)
|
||||
.OrderByDescending(x => x.UpdatedAt)
|
||||
var reviews = await accessiblePrompts.Select(x => x.Reviews)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ProjectTo<ApiReview>(_mapper.ConfigurationProvider)
|
||||
|
||||
@ -26,8 +26,8 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
}
|
||||
|
||||
[HttpPut("{userId}/{level?}")]
|
||||
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> SubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId, int? level = null)
|
||||
[HttpPut("{userId}/{level}")]
|
||||
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> SubscribeAsync(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([FromRoute(Name = "userId")] Identifier subscribeToId)
|
||||
public async Task<ApiSubscription?> GetCurrentSubscriptionAsync(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)
|
||||
@ -100,42 +100,13 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
return subscription;
|
||||
}
|
||||
|
||||
[HttpGet("tiers")]
|
||||
public async Task<ApiSubscriptionTier[]> GetOwnSubscriptionTiersAsync()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
return await _db.SubscriptionTiers
|
||||
.Where(t => t.UserId == userId)
|
||||
.OrderBy(t => t.Level)
|
||||
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
|
||||
.ToArrayAsync();
|
||||
}
|
||||
|
||||
[HttpGet("tiers/{userId}")]
|
||||
public async Task<Results<Ok<ApiSubscriptionTier[]>, NotFound<string>>> GetCreatorSubscriptionTiersAsync([FromRoute(Name = "userId")] Identifier creatorId)
|
||||
{
|
||||
var creatorExists = await _db.Users.AnyAsync(
|
||||
user => creatorId.Id.HasValue ? user.Id == creatorId.Id.Value : user.Profile.Slug == creatorId.Slug
|
||||
);
|
||||
if (creatorExists == false)
|
||||
return TypedResults.NotFound($"No user found with identifier {creatorId}");
|
||||
|
||||
var tiers = await _db.SubscriptionTiers
|
||||
.Where(t => creatorId.Id.HasValue ? t.UserId == creatorId.Id.Value : t.User.Profile.Slug == creatorId.Slug)
|
||||
.OrderBy(t => t.Level)
|
||||
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
|
||||
.ToArrayAsync();
|
||||
|
||||
return TypedResults.Ok(tiers);
|
||||
}
|
||||
|
||||
[HttpDelete("{userId}")]
|
||||
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
|
||||
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync(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();
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -25,17 +25,12 @@ 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; }
|
||||
@ -46,7 +41,5 @@ 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>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@ -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; } = true;
|
||||
public bool IsPublic { get; set; } = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,8 +14,6 @@ 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)
|
||||
{
|
||||
@ -87,36 +85,6 @@ 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -79,18 +79,10 @@ 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()
|
||||
.HasColumnType("text");
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
|
||||
@ -4,7 +4,6 @@ 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;
|
||||
@ -25,8 +24,6 @@ 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>>();
|
||||
@ -83,7 +80,6 @@ var rewrite = new RewriteOptions()
|
||||
.AddRewrite(@"^(?!scalar\/?|api\/?)([^.]+)$", "$1.html", skipRemainingRules: true);
|
||||
|
||||
app.UseRewriter(rewrite);
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
@ -101,111 +97,9 @@ else
|
||||
}
|
||||
|
||||
app.MapControllers();
|
||||
app.MapGet("/", async (HttpContext context) =>
|
||||
{
|
||||
var authResult = await context.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
|
||||
return authResult.Succeeded
|
||||
? Results.Redirect("/dashboard")
|
||||
: Results.Redirect("/login");
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
@ -33,43 +33,21 @@ 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.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
|
||||
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
|
||||
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
|
||||
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
|
||||
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
|
||||
.MapCtorParamFrom(x => x.CanAccess, x => false);
|
||||
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating));
|
||||
|
||||
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.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
|
||||
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
|
||||
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
|
||||
.MapCtorParamFrom(x => x.CanAccess, x => true);
|
||||
|
||||
config.CreateMap<ReviewModel, ApiReview>()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Include Error Detail=true;User ID=onlyprompt;Password=onlyprompt;Host=localhost;Port=2803;Database=onlyprompt;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;"
|
||||
"DefaultConnection": "Include Error Detail=true;User ID=onlyprompt;Password=onlyprompt;Host=localhost;Port=1803;Database=onlyprompt;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "https://onlyprompts.com",
|
||||
|
||||
@ -1,381 +1,129 @@
|
||||
<!-- OnlyPrompt - Chats page:
|
||||
- Direct messaging interface with conversation list and active chat window -->
|
||||
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OnlyPrompt - Chats</title>
|
||||
<link rel="stylesheet" href="../css/variables.css" />
|
||||
<link rel="stylesheet" href="../css/base.css" />
|
||||
<link rel="stylesheet" href="../css/sidebar.css" />
|
||||
<link rel="stylesheet" href="../css/login.css" />
|
||||
<link rel="stylesheet" href="../css/topbar.css" />
|
||||
<link rel="stylesheet" href="../css/chats.css" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OnlyPrompt - Chats</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/chats.css">
|
||||
<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 class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
<div style="flex:1; display: flex; flex-direction: column;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="chats-main" id="main-content" tabindex="-1">
|
||||
<!-- Chat Container: Left column (list) + Right column (active chat) -->
|
||||
<div class="chat-container">
|
||||
<!-- Left Column: Chat Overview -->
|
||||
<div class="chat-list">
|
||||
<div class="chat-list-header">
|
||||
<h2>Messages</h2>
|
||||
<button type="button" class="new-chat-btn" id="newChatBtn" aria-label="New chat" aria-expanded="false" aria-controls="new-chat-panel">
|
||||
<i class="bi bi-pencil-square" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="new-chat-panel" id="new-chat-panel" hidden>
|
||||
<label for="creatorSearch" class="sr-only">Search creator</label>
|
||||
<input type="search" id="creatorSearch" placeholder="Search creator..." autocomplete="off" />
|
||||
<div class="creator-search-results" id="creatorSearchResults" role="listbox" aria-label="Creator results"></div>
|
||||
</div>
|
||||
<div class="chat-list-items" id="chatList" role="list" aria-label="Conversations">
|
||||
</div>
|
||||
<main class="chats-main">
|
||||
<!-- Chat Container: Left column (list) + Right column (active chat) -->
|
||||
<div class="chat-container">
|
||||
|
||||
<!-- Left Column: Chat Overview -->
|
||||
<div class="chat-list">
|
||||
<div class="chat-list-header">
|
||||
<h2>Messages</h2>
|
||||
<button class="new-chat-btn"><i class="bi bi-pencil-square"></i></button>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Active Chat -->
|
||||
<div class="chat-active">
|
||||
<div class="chat-header">
|
||||
<img
|
||||
src="../images/content/cat.png"
|
||||
alt=""
|
||||
class="chat-avatar-large"
|
||||
id="activeChatAvatar"
|
||||
/>
|
||||
<div class="chat-header-info">
|
||||
<div class="chat-header-name" id="activeChatName">Select a chat</div>
|
||||
<div class="chat-header-status">
|
||||
<span class="online-dot" aria-hidden="true"></span>
|
||||
<span id="activeChatStatus">Ready</span>
|
||||
</div>
|
||||
<div class="chat-list-items">
|
||||
<!-- Chat Entry 1 (active) -->
|
||||
<div class="chat-item active">
|
||||
<img src="../images/content/creator2.png" alt="Alex Chen" class="chat-avatar">
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">Alex Chen</div>
|
||||
<div class="chat-last-msg">Hey Sarah! Really loved your last video on minimalism...</div>
|
||||
</div>
|
||||
<div class="chat-time">10:17 AM</div>
|
||||
</div>
|
||||
<div class="chat-messages" id="chatMessages" aria-live="polite" aria-label="Conversation">
|
||||
<!-- Chat Entry 2 -->
|
||||
<div class="chat-item">
|
||||
<img src="../images/content/creator3.png" alt="Mia Wong" class="chat-avatar">
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">Mia Wong</div>
|
||||
<div class="chat-last-msg">Thanks for the prompt tips! They worked perfectly.</div>
|
||||
</div>
|
||||
<div class="chat-time">Yesterday</div>
|
||||
</div>
|
||||
<!-- Chat Entry 3 -->
|
||||
<div class="chat-item">
|
||||
<img src="../images/content/creator4.png" alt="Tom Rivera" class="chat-avatar">
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">Tom Rivera</div>
|
||||
<div class="chat-last-msg">Let's schedule a call for the collab?</div>
|
||||
</div>
|
||||
<div class="chat-time">Yesterday</div>
|
||||
</div>
|
||||
<form class="chat-input-area" id="chatForm">
|
||||
<input type="text" id="messageInput" placeholder="Type your message..." aria-label="Message" autocomplete="off" />
|
||||
<button type="submit" class="send-btn">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</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");
|
||||
link.removeAttribute("aria-current");
|
||||
});
|
||||
// Set 'active' on the Chats link (4th link, index 3)
|
||||
const chatsLink = document.querySelectorAll(
|
||||
"#sidebar-container .sidebar li a",
|
||||
)[3];
|
||||
if (chatsLink) {
|
||||
chatsLink.classList.add("active");
|
||||
chatsLink.setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
|
||||
fetch("/topbar.html")
|
||||
.then((r) => r.text())
|
||||
.then(
|
||||
(data) =>
|
||||
(document.getElementById("topbar-container").innerHTML = data),
|
||||
);
|
||||
|
||||
const STORAGE_KEY = "onlyprompt-chat-conversations";
|
||||
const chatList = document.getElementById("chatList");
|
||||
const chatMessages = document.getElementById("chatMessages");
|
||||
const chatForm = document.getElementById("chatForm");
|
||||
const messageInput = document.getElementById("messageInput");
|
||||
const activeChatAvatar = document.getElementById("activeChatAvatar");
|
||||
const activeChatName = document.getElementById("activeChatName");
|
||||
const activeChatStatus = document.getElementById("activeChatStatus");
|
||||
const newChatBtn = document.getElementById("newChatBtn");
|
||||
const newChatPanel = document.getElementById("new-chat-panel");
|
||||
const creatorSearch = document.getElementById("creatorSearch");
|
||||
const creatorSearchResults = document.getElementById("creatorSearchResults");
|
||||
|
||||
let conversations = loadConversations();
|
||||
let creators = [];
|
||||
let activeConversationId = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function loadConversations() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
return Array.isArray(saved) && saved.length ? saved : demoConversations();
|
||||
} catch {
|
||||
return demoConversations();
|
||||
}
|
||||
}
|
||||
|
||||
function saveConversations() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
|
||||
}
|
||||
|
||||
function demoConversations() {
|
||||
const now = Date.now();
|
||||
return [
|
||||
{
|
||||
id: "demo-alex",
|
||||
userId: "demo-alex",
|
||||
name: "Alex Chen",
|
||||
avatar: "../images/content/creator2.png",
|
||||
updatedAt: now - 60000,
|
||||
messages: [
|
||||
{ from: "them", text: "Hey, I liked your last prompt idea.", createdAt: now - 180000 },
|
||||
{ from: "me", text: "Thanks. Which part was useful?", createdAt: now - 120000 },
|
||||
{ from: "them", text: "The structure was easy to adapt.", createdAt: now - 60000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "demo-mia",
|
||||
userId: "demo-mia",
|
||||
name: "Mia Wong",
|
||||
avatar: "../images/content/creator3.png",
|
||||
updatedAt: now - 86400000,
|
||||
messages: [
|
||||
{ from: "them", text: "Thanks for the prompt tips.", createdAt: now - 86400000 },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const today = new Date();
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function getLastMessage(conversation) {
|
||||
return conversation.messages.at(-1)?.text || "No messages yet.";
|
||||
}
|
||||
|
||||
function renderChatList() {
|
||||
conversations.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
if (!conversations.length) {
|
||||
chatList.innerHTML = '<div class="chat-empty">Start a chat with a creator.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
chatList.innerHTML = conversations
|
||||
.map((conversation) => `
|
||||
<button type="button"
|
||||
class="chat-item ${conversation.id === activeConversationId ? "active" : ""}"
|
||||
data-chat-id="${escapeHtml(conversation.id)}"
|
||||
aria-label="Open chat with ${escapeHtml(conversation.name)}"
|
||||
${conversation.id === activeConversationId ? 'aria-current="true"' : ""}>
|
||||
<img src="${escapeHtml(conversation.avatar)}" alt="" class="chat-avatar" />
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">${escapeHtml(conversation.name)}</div>
|
||||
<div class="chat-last-msg">${escapeHtml(getLastMessage(conversation))}</div>
|
||||
<!-- Right Column: Active Chat (with Alex Chen) -->
|
||||
<div class="chat-active">
|
||||
<div class="chat-header">
|
||||
<img src="../images/content/creator2.png" alt="Alex Chen" class="chat-avatar-large">
|
||||
<div class="chat-header-info">
|
||||
<div class="chat-header-name">Alex Chen</div>
|
||||
<div class="chat-header-status"><span class="online-dot"></span> Online</div>
|
||||
</div>
|
||||
<time class="chat-time" datetime="${new Date(conversation.updatedAt).toISOString()}">${formatTime(conversation.updatedAt)}</time>
|
||||
</button>
|
||||
`)
|
||||
.join("");
|
||||
|
||||
chatList.querySelectorAll("[data-chat-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectConversation(button.dataset.chatId));
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
const conversation = conversations.find((item) => item.id === activeConversationId);
|
||||
if (!conversation) {
|
||||
activeChatAvatar.src = "../images/content/cat.png";
|
||||
activeChatName.textContent = "Select a chat";
|
||||
activeChatStatus.textContent = "Ready";
|
||||
chatMessages.innerHTML = '<div class="chat-empty">Choose a conversation or start a new chat.</div>';
|
||||
messageInput.disabled = true;
|
||||
chatForm.querySelector("button").disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
activeChatAvatar.src = conversation.avatar;
|
||||
activeChatName.textContent = conversation.name;
|
||||
activeChatStatus.textContent = "Online";
|
||||
chatMessages.setAttribute("aria-label", `Conversation with ${conversation.name}`);
|
||||
messageInput.disabled = false;
|
||||
chatForm.querySelector("button").disabled = false;
|
||||
|
||||
chatMessages.innerHTML = conversation.messages
|
||||
.map((message) => `
|
||||
<div class="message ${message.from === "me" ? "sent" : "received"}">
|
||||
<div class="message-bubble">${escapeHtml(message.text)}</div>
|
||||
<time class="message-time" datetime="${new Date(message.createdAt).toISOString()}">${formatTime(message.createdAt)}</time>
|
||||
</div>
|
||||
`)
|
||||
.join("");
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
<div class="chat-messages">
|
||||
<!-- Message from Alex -->
|
||||
<div class="message received">
|
||||
<div class="message-bubble">Hey Sarah! Really loved your last video on minimalism. Quick question about your workspace layout?</div>
|
||||
<div class="message-time">10:15 AM</div>
|
||||
</div>
|
||||
<!-- Reply from Sarah -->
|
||||
<div class="message sent">
|
||||
<div class="message-bubble">Thanks Alex! Appreciate it. Yes, happy to share! The desk is from Article, and the shelving unit is custom-built. Highly recommend a clean setup!</div>
|
||||
<div class="message-time">10:16 AM</div>
|
||||
</div>
|
||||
<!-- Alex replies -->
|
||||
<div class="message received">
|
||||
<div class="message-bubble">Thanks so much! Your aesthetic is exactly what I'm aiming for. Can't wait for your next piece!</div>
|
||||
<div class="message-time">10:17 AM</div>
|
||||
</div>
|
||||
<!-- Sarah replies -->
|
||||
<div class="message sent">
|
||||
<div class="message-bubble">Awesome! Let me know if you need more tips. Enjoy the process! 😊</div>
|
||||
<div class="message-time">10:18 AM</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<input type="text" placeholder="Type your message...">
|
||||
<button class="send-btn">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
function selectConversation(id) {
|
||||
activeConversationId = id;
|
||||
renderChatList();
|
||||
renderMessages();
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
function startConversation(creator) {
|
||||
const id = `user-${creator.userId}`;
|
||||
let conversation = conversations.find((item) => item.id === id);
|
||||
if (!conversation) {
|
||||
conversation = {
|
||||
id,
|
||||
userId: creator.userId,
|
||||
name: creator.displayName,
|
||||
avatar: creator.avatarUrl || "../images/content/cat.png",
|
||||
updatedAt: Date.now(),
|
||||
messages: [],
|
||||
};
|
||||
conversations.push(conversation);
|
||||
saveConversations();
|
||||
}
|
||||
activeConversationId = id;
|
||||
newChatPanel.hidden = true;
|
||||
newChatBtn.setAttribute("aria-expanded", "false");
|
||||
creatorSearch.value = "";
|
||||
renderCreatorResults();
|
||||
renderChatList();
|
||||
renderMessages();
|
||||
messageInput.focus();
|
||||
}
|
||||
|
||||
function addMessage(text) {
|
||||
const conversation = conversations.find((item) => item.id === activeConversationId);
|
||||
if (!conversation || !text.trim()) return;
|
||||
|
||||
conversation.messages.push({
|
||||
from: "me",
|
||||
text: text.trim(),
|
||||
createdAt: Date.now(),
|
||||
<script>
|
||||
fetch('../html/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');
|
||||
});
|
||||
conversation.updatedAt = Date.now();
|
||||
saveConversations();
|
||||
renderChatList();
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
async function loadCreators() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/profiles?limit=100", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok) throw new Error("Creators could not be loaded.");
|
||||
creators = await response.json();
|
||||
} catch {
|
||||
creators = [
|
||||
{ userId: "demo-alex", displayName: "Alex Chen", slug: "alex", avatarUrl: "../images/content/creator2.png" },
|
||||
{ userId: "demo-mia", displayName: "Mia Wong", slug: "mia", avatarUrl: "../images/content/creator3.png" },
|
||||
{ userId: "demo-tom", displayName: "Tom Rivera", slug: "tom", avatarUrl: "../images/content/creator4.png" },
|
||||
];
|
||||
}
|
||||
renderCreatorResults();
|
||||
}
|
||||
|
||||
function renderCreatorResults() {
|
||||
const search = creatorSearch.value.trim().toLowerCase();
|
||||
const results = creators
|
||||
.filter((creator) =>
|
||||
`${creator.displayName || ""} ${creator.slug || ""}`.toLowerCase().includes(search),
|
||||
)
|
||||
.slice(0, 8);
|
||||
|
||||
if (!results.length) {
|
||||
creatorSearchResults.innerHTML = '<div class="creator-result-empty">No creators found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
creatorSearchResults.innerHTML = results
|
||||
.map((creator) => `
|
||||
<button type="button" class="creator-result" data-user-id="${escapeHtml(creator.userId)}" role="option">
|
||||
<img src="${escapeHtml(creator.avatarUrl || "../images/content/cat.png")}" alt="" />
|
||||
<span>
|
||||
<strong>${escapeHtml(creator.displayName)}</strong>
|
||||
<small>@${escapeHtml(creator.slug || "creator")}</small>
|
||||
</span>
|
||||
</button>
|
||||
`)
|
||||
.join("");
|
||||
|
||||
creatorSearchResults.querySelectorAll("[data-user-id]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const creator = creators.find((item) => item.userId === button.dataset.userId);
|
||||
if (creator) startConversation(creator);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openConversationFromUrl() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const userId = params.get("userId");
|
||||
if (!userId) return false;
|
||||
|
||||
startConversation({
|
||||
userId,
|
||||
displayName: params.get("name") || "Creator",
|
||||
slug: "creator",
|
||||
avatarUrl: params.get("avatar") || "../images/content/cat.png",
|
||||
});
|
||||
history.replaceState(null, "", "/chats.html");
|
||||
return true;
|
||||
}
|
||||
|
||||
newChatBtn.addEventListener("click", () => {
|
||||
const willOpen = newChatPanel.hidden;
|
||||
newChatPanel.hidden = !willOpen;
|
||||
newChatBtn.setAttribute("aria-expanded", String(willOpen));
|
||||
if (willOpen) creatorSearch.focus();
|
||||
// Set 'active' on the Chats link (4th link, index 3)
|
||||
const chatsLink = document.querySelectorAll('#sidebar-container .sidebar li a')[3];
|
||||
if (chatsLink) chatsLink.classList.add('active');
|
||||
});
|
||||
|
||||
creatorSearch.addEventListener("input", renderCreatorResults);
|
||||
chatForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
addMessage(messageInput.value);
|
||||
messageInput.value = "";
|
||||
});
|
||||
|
||||
loadCreators().then(() => {
|
||||
if (!openConversationFromUrl()) {
|
||||
activeConversationId = conversations[0]?.id || null;
|
||||
renderChatList();
|
||||
renderMessages();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,247 +1,161 @@
|
||||
<!-- OnlyPrompt - Community page:
|
||||
- Discover creators, follow/unfollow, dynamic via API -->
|
||||
<!-- OnlyPrompt - Marketplace page:
|
||||
- Browse and filter AI prompts with buy/view details buttons and pricing -->
|
||||
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OnlyPrompt - Discover Creators</title>
|
||||
<link rel="stylesheet" href="../css/variables.css" />
|
||||
<link rel="stylesheet" href="../css/base.css" />
|
||||
<link rel="stylesheet" href="../css/sidebar.css" />
|
||||
<link rel="stylesheet" href="../css/login.css" />
|
||||
<link rel="stylesheet" href="../css/topbar.css" />
|
||||
<link rel="stylesheet" href="../css/community.css" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OnlyPrompt - Discover Creators</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/community.css">
|
||||
<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 class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
<div style="flex:1; margin:40px auto; max-width:950px;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="creators-main" id="main-content" tabindex="-1">
|
||||
<div class="creators-header">
|
||||
<h1>Discover Creators</h1>
|
||||
<p>Follow your favorite prompt artists and get inspired.</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-buttons" role="group" aria-label="Sort creators">
|
||||
<button type="button" class="filter-btn active" data-sort="popular" aria-pressed="true">
|
||||
Popular
|
||||
</button>
|
||||
<button type="button" class="filter-btn" data-sort="prompts" aria-pressed="false">Rising</button>
|
||||
<button type="button" class="filter-btn" data-sort="new" aria-pressed="false">New</button>
|
||||
<button type="button" class="filter-btn" data-sort="rating" aria-pressed="false">Top Rated</button>
|
||||
</div>
|
||||
|
||||
<div class="creators-grid" id="creators-grid" aria-live="polite"></div>
|
||||
|
||||
<div id="creators-empty" class="state-empty" role="status" aria-live="polite">
|
||||
<i class="bi bi-people state-icon" aria-hidden="true"></i>
|
||||
<h3 id="creators-empty-title" class="state-title">
|
||||
No creators found
|
||||
</h3>
|
||||
<p id="creators-empty-text">
|
||||
Check back later for new creators to follow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="creators-error" class="state-error" role="alert" aria-live="assertive">
|
||||
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">Could not load creators</h3>
|
||||
<p id="creators-error-msg"></p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// ── Sidebar & Topbar ─────────────────────────────────────────────
|
||||
fetch("/sidebar.html")
|
||||
.then((r) => r.text())
|
||||
.then((data) => {
|
||||
document.getElementById("sidebar-container").innerHTML = data;
|
||||
document
|
||||
.querySelectorAll("#sidebar-container .sidebar a")
|
||||
.forEach((l) => {
|
||||
l.classList.remove("active");
|
||||
l.removeAttribute("aria-current");
|
||||
});
|
||||
const thirdLink = document.querySelectorAll(
|
||||
"#sidebar-container .sidebar li a",
|
||||
)[2];
|
||||
if (thirdLink) {
|
||||
thirdLink.classList.add("active");
|
||||
thirdLink.setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
fetch("/topbar.html")
|
||||
.then((r) => r.text())
|
||||
.then(
|
||||
(data) =>
|
||||
(document.getElementById("topbar-container").innerHTML = data),
|
||||
);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
function renderStars(rating) {
|
||||
if (!rating) return "";
|
||||
const stars = Math.round(rating);
|
||||
return `<span class="creator-stars">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> <span class="creator-stars-value">${rating.toFixed(1)}</span>`;
|
||||
}
|
||||
|
||||
function renderCard(c) {
|
||||
const profileHref = `/profile?id=${encodeURIComponent(c.userId)}`;
|
||||
const chatHref = `/chats.html?userId=${encodeURIComponent(c.userId)}&name=${encodeURIComponent(c.displayName)}&avatar=${encodeURIComponent(c.avatarUrl || "../images/content/cat.png")}`;
|
||||
return `
|
||||
<div class="creator-card">
|
||||
<a class="creator-avatar-link" href="${profileHref}" aria-label="Open profile for ${c.displayName}">
|
||||
<img class="creator-avatar"
|
||||
src="${c.avatarUrl || "../images/content/cat.png"}"
|
||||
alt="${c.displayName}">
|
||||
</a>
|
||||
<div class="creator-info">
|
||||
<h3 class="creator-name"><a href="${profileHref}">${c.displayName}</a></h3>
|
||||
<div class="creator-handle">@${c.slug}</div>
|
||||
<p class="creator-bio">${c.bio ?? "No bio yet."}</p>
|
||||
<div class="creator-stats">
|
||||
<span><i class="bi bi-puzzle" aria-hidden="true"></i> ${c.promptCount} prompts</span>
|
||||
<span><i class="bi bi-people" aria-hidden="true"></i> ${c.subscribers} subscribers</span>
|
||||
${c.averageRating > 0 ? `<span>${renderStars(c.averageRating)}</span>` : ""}
|
||||
</div>
|
||||
<div class="creator-actions">
|
||||
<button type="button" class="follow-btn ${c.isFollowing ? "following" : ""}"
|
||||
data-userid="${c.userId}"
|
||||
data-following="${c.isFollowing}"
|
||||
aria-pressed="${c.isFollowing}"
|
||||
aria-label="${c.isFollowing ? "Unfollow" : "Follow"} ${c.displayName}">
|
||||
${c.isFollowing ? "Following" : "Follow"}
|
||||
</button>
|
||||
<a class="creator-chat-btn" href="${chatHref}" aria-label="Chat with ${c.displayName}">
|
||||
<i class="bi bi-chat-dots" aria-hidden="true"></i>
|
||||
Chat
|
||||
</a>
|
||||
</div>
|
||||
<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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Follow / Unfollow ────────────────────────────────────────────
|
||||
async function toggleFollow(btn) {
|
||||
const userId = btn.dataset.userid;
|
||||
const isFollowing = btn.dataset.following === "true";
|
||||
btn.disabled = true;
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
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.setAttribute("aria-pressed", String(nowFollowing));
|
||||
btn.classList.toggle("following", nowFollowing);
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ── Load Creators ────────────────────────────────────────────────
|
||||
const grid = document.getElementById("creators-grid");
|
||||
const emptyEl = document.getElementById("creators-empty");
|
||||
const emptyTitle = document.getElementById("creators-empty-title");
|
||||
const emptyText = document.getElementById("creators-empty-text");
|
||||
const errorEl = document.getElementById("creators-error");
|
||||
const errorMsg = document.getElementById("creators-error-msg");
|
||||
|
||||
let activeSort = "popular";
|
||||
let currentSearch =
|
||||
new URLSearchParams(location.search).get("search") || "";
|
||||
|
||||
function getSearchTerm() {
|
||||
return currentSearch.trim();
|
||||
}
|
||||
|
||||
async function loadCreators(sort = activeSort) {
|
||||
activeSort = sort;
|
||||
grid.innerHTML = "";
|
||||
emptyEl.style.display = "none";
|
||||
errorEl.style.display = "none";
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
sort,
|
||||
limit: "50",
|
||||
});
|
||||
const search = getSearchTerm();
|
||||
if (search) params.set("search", search);
|
||||
|
||||
const res = await fetch(`/api/v1/profiles?${params}`);
|
||||
if (res.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Server error ${res.status}`);
|
||||
|
||||
const creators = await res.json();
|
||||
if (creators.length === 0) {
|
||||
const search = getSearchTerm();
|
||||
emptyTitle.textContent = search
|
||||
? "No matching creators"
|
||||
: "No creators found";
|
||||
emptyText.textContent = search
|
||||
? `No creator matches "${search}". Try another name or clear the search.`
|
||||
: "Create another local user to see creators here.";
|
||||
emptyEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = creators.map(renderCard).join("");
|
||||
|
||||
grid.querySelectorAll(".follow-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => toggleFollow(btn));
|
||||
});
|
||||
} catch (e) {
|
||||
errorEl.style.display = "block";
|
||||
errorMsg.textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filter buttons ───────────────────────────────────────────────
|
||||
document.querySelectorAll(".filter-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
document
|
||||
.querySelectorAll(".filter-btn")
|
||||
.forEach((b) => {
|
||||
b.classList.remove("active");
|
||||
b.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
btn.classList.add("active");
|
||||
btn.setAttribute("aria-pressed", "true");
|
||||
loadCreators(btn.dataset.sort);
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
fetch('../html/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)
|
||||
const thirdLink = document.querySelectorAll('#sidebar-container .sidebar li a')[2];
|
||||
if (thirdLink) thirdLink.classList.add('active');
|
||||
});
|
||||
|
||||
window.applyCreatorSearch = (value) => {
|
||||
currentSearch = value.trim();
|
||||
loadCreators(activeSort);
|
||||
};
|
||||
|
||||
loadCreators();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -13,31 +13,29 @@
|
||||
<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>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div class="page-body">
|
||||
<div style="flex:1; display: flex; flex-direction: column;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="create-main" id="main-content" tabindex="-1">
|
||||
<main class="create-main">
|
||||
<div class="create-container">
|
||||
<div class="create-header">
|
||||
<h1 id="create-title">Create AI Prompt</h1>
|
||||
<p id="create-subtitle">Design and save custom prompts for your AI workflows.</p>
|
||||
<h1>Create AI Prompt</h1>
|
||||
<p>Design and save custom prompts for your AI workflows.</p>
|
||||
</div>
|
||||
|
||||
<form id="createPromptForm" class="create-form" enctype="multipart/form-data">
|
||||
<!-- Title -->
|
||||
<div class="form-group">
|
||||
<label for="title">Prompt Title *</label>
|
||||
<input type="text" id="title" name="title" placeholder="e.g., Write an inspiring startup story about innovation" autocomplete="off" required>
|
||||
<input type="text" id="title" name="title" placeholder="e.g., Write an inspiring startup story about innovation" required>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@ -62,8 +60,8 @@
|
||||
<!-- Prompt Content -->
|
||||
<div class="form-group">
|
||||
<label for="promptContent">Prompt Content *</label>
|
||||
<textarea id="promptContent" name="promptContent" rows="6" placeholder="Write your prompt instructions here..." aria-describedby="promptContentHint" required></textarea>
|
||||
<small class="form-hint" id="promptContentHint">Use clear, step-by-step instructions for the AI.</small>
|
||||
<textarea id="promptContent" name="promptContent" rows="6" placeholder="Write your prompt instructions here..." required></textarea>
|
||||
<small class="form-hint">Use clear, step-by-step instructions for the AI.</small>
|
||||
</div>
|
||||
|
||||
<!-- Example Output (Text) -->
|
||||
@ -75,36 +73,31 @@
|
||||
<!-- Example Image (optional) -->
|
||||
<div class="form-group">
|
||||
<label for="exampleImage">Example Image (optional)</label>
|
||||
<input type="file" id="exampleImage" name="exampleImage" accept="image/png, image/jpeg, image/jpg" aria-describedby="exampleImageHint">
|
||||
<small class="form-hint" id="exampleImageHint">Upload a PNG or JPG. Preview will appear below.</small>
|
||||
<div id="imagePreview" aria-live="polite">
|
||||
<img id="previewImg" src="#" alt="Selected example image preview">
|
||||
<input type="file" id="exampleImage" name="exampleImage" accept="image/png, image/jpeg, image/jpg">
|
||||
<small class="form-hint">Upload a PNG or JPG – preview will appear below.</small>
|
||||
<div id="imagePreview" style="margin-top: 10px; display: none;">
|
||||
<img id="previewImg" src="#" alt="Preview" style="max-width: 100%; max-height: 200px; border-radius: 12px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing (with toggle) -->
|
||||
<div class="form-group pricing-group">
|
||||
<span class="form-label" id="access-label">Access</span>
|
||||
<div class="pricing-toggle" role="group" aria-labelledby="access-label">
|
||||
<button type="button" id="freeBtn" class="price-option active" aria-pressed="true">Free</button>
|
||||
<button type="button" id="tierBtn" class="price-option" aria-pressed="false">Tier</button>
|
||||
<label>Pricing</label>
|
||||
<div class="pricing-toggle">
|
||||
<button type="button" id="freeBtn" class="price-option active">Free</button>
|
||||
<button type="button" id="paidBtn" class="price-option">Paid</button>
|
||||
</div>
|
||||
<div id="tierField">
|
||||
<label for="subscriptionTier" class="sr-only">Subscription tier</label>
|
||||
<select id="subscriptionTier" name="subscriptionTier">
|
||||
<option value="">No tiers created yet</option>
|
||||
</select>
|
||||
<a class="tier-manage-link" href="subscription-tiers.html">Manage tiers</a>
|
||||
<div id="priceField" style="display: none;">
|
||||
<input type="number" id="price" name="price" step="0.01" min="0" placeholder="Price in USD (e.g., 19.99)">
|
||||
</div>
|
||||
<small class="form-hint">Free prompts are public. Tier prompts require a monthly creator subscription.</small>
|
||||
<small class="form-hint">You can set a price later or keep it free.</small>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submitPromptBtn">Publish Prompt</button>
|
||||
<button type="submit" class="submit-btn">Publish Prompt</button>
|
||||
<button type="button" class="cancel-btn">Cancel</button>
|
||||
</div>
|
||||
<p id="create-status" role="status" aria-live="polite"></p>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@ -114,35 +107,27 @@
|
||||
<script>
|
||||
// Toggle between free and paid
|
||||
const freeBtn = document.getElementById('freeBtn');
|
||||
const tierBtn = document.getElementById('tierBtn');
|
||||
const tierField = document.getElementById('tierField');
|
||||
const tierSelect = document.getElementById('subscriptionTier');
|
||||
const editPromptId = new URLSearchParams(location.search).get('id');
|
||||
const submitPromptBtn = document.getElementById('submitPromptBtn');
|
||||
let ownSubscriptionTiers = [];
|
||||
const paidBtn = document.getElementById('paidBtn');
|
||||
const priceField = document.getElementById('priceField');
|
||||
const priceInput = document.getElementById('price');
|
||||
|
||||
freeBtn.addEventListener('click', () => {
|
||||
freeBtn.classList.add('active');
|
||||
tierBtn.classList.remove('active');
|
||||
freeBtn.setAttribute('aria-pressed', 'true');
|
||||
tierBtn.setAttribute('aria-pressed', 'false');
|
||||
tierField.style.display = 'none';
|
||||
tierSelect.removeAttribute('required');
|
||||
paidBtn.classList.remove('active');
|
||||
priceField.style.display = 'none';
|
||||
priceInput.removeAttribute('required');
|
||||
});
|
||||
tierBtn.addEventListener('click', () => {
|
||||
tierBtn.classList.add('active');
|
||||
paidBtn.addEventListener('click', () => {
|
||||
paidBtn.classList.add('active');
|
||||
freeBtn.classList.remove('active');
|
||||
tierBtn.setAttribute('aria-pressed', 'true');
|
||||
freeBtn.setAttribute('aria-pressed', 'false');
|
||||
tierField.style.display = 'grid';
|
||||
tierSelect.setAttribute('required', 'required');
|
||||
priceField.style.display = 'block';
|
||||
priceInput.setAttribute('required', 'required');
|
||||
});
|
||||
|
||||
// Image preview for example image
|
||||
const imageInput = document.getElementById('exampleImage');
|
||||
const imagePreview = document.getElementById('imagePreview');
|
||||
const previewImg = document.getElementById('previewImg');
|
||||
let exampleImageUrl = '';
|
||||
|
||||
if (imageInput) {
|
||||
imageInput.addEventListener('change', function(event) {
|
||||
@ -150,195 +135,47 @@
|
||||
if (file && (file.type === 'image/png' || file.type === 'image/jpeg' || file.type === 'image/jpg')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
exampleImageUrl = e.target.result;
|
||||
previewImg.src = exampleImageUrl;
|
||||
previewImg.src = e.target.result;
|
||||
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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 loadSubscriptionTiers() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/subscriptions/tiers', {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (response.status === 401) {
|
||||
location.href = '/login';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) return;
|
||||
|
||||
ownSubscriptionTiers = await response.json();
|
||||
if (!ownSubscriptionTiers.length) {
|
||||
tierSelect.innerHTML = '<option value="">Create a tier first</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
tierSelect.innerHTML = ownSubscriptionTiers
|
||||
.map((tier) => `<option value="${tier.level}">${tier.name} - $${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</option>`)
|
||||
.join('');
|
||||
} catch {
|
||||
tierSelect.innerHTML = '<option value="">Tiers could not be loaded</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPromptForEdit() {
|
||||
if (!editPromptId) return;
|
||||
|
||||
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.tierLevel != null) {
|
||||
tierBtn.click();
|
||||
tierSelect.value = String(prompt.tierLevel);
|
||||
} else {
|
||||
freeBtn.click();
|
||||
}
|
||||
|
||||
status.textContent = '';
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('createPromptForm').addEventListener('submit', async (e) => {
|
||||
// Handle form submission (demo only)
|
||||
document.getElementById('createPromptForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById('create-status');
|
||||
const submitBtn = document.querySelector('.submit-btn');
|
||||
status.textContent = editPromptId ? 'Saving...' : 'Publishing...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const isTier = tierBtn.classList.contains('active');
|
||||
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: null,
|
||||
subscriptionTier: isTier ? Number(tierSelect.value) : 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;
|
||||
}
|
||||
alert('Prompt published! (Demo)');
|
||||
// Here you would normally send data to a backend (including the image file)
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// Fetch sidebar and topbar
|
||||
fetch('/sidebar.html')
|
||||
fetch('../html/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');
|
||||
link.removeAttribute('aria-current');
|
||||
});
|
||||
// Optionally set active on "Create New" if it exists, otherwise keep none
|
||||
const createLink = document.querySelector('#sidebar-container a[href="create.html"]');
|
||||
if (createLink) {
|
||||
createLink.classList.add('active');
|
||||
createLink.setAttribute('aria-current', 'page');
|
||||
}
|
||||
if (createLink) createLink.classList.add('active');
|
||||
});
|
||||
|
||||
fetch('/topbar.html')
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
|
||||
Promise.all([loadCategories(), loadSubscriptionTiers()]).then(loadPromptForEdit);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@ -18,58 +18,6 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1000;
|
||||
transform: translateY(-160%);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.skip-link + .skip-link {
|
||||
top: 58px;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid #2563eb;
|
||||
outline-offset: 3px;
|
||||
outline-style: solid !important;
|
||||
outline-width: 3px !important;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Form errors */
|
||||
.form-error {
|
||||
color: red;
|
||||
@ -80,7 +28,7 @@ button:disabled,
|
||||
.form-error ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
list-style: "*";
|
||||
list-style: '*';
|
||||
}
|
||||
|
||||
.form-error li {
|
||||
@ -90,75 +38,4 @@ button:disabled,
|
||||
.form-error li .error {
|
||||
color: red;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
#sidebar-container {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.layout {
|
||||
padding-bottom: 74px;
|
||||
}
|
||||
|
||||
#sidebar-container {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Main content area - flex child that fills remaining space */
|
||||
.page-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Reusable empty / error state components ─────────────────────────── */
|
||||
.state-empty,
|
||||
.state-error {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
.state-empty {
|
||||
color: #64748b;
|
||||
}
|
||||
.state-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
font-size: 3rem;
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,13 @@
|
||||
/* Chats page - Two column layout: chat list + active chat window */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.chats-main {
|
||||
flex: 1;
|
||||
padding: 20px 32px;
|
||||
@ -12,7 +20,7 @@
|
||||
gap: 24px;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 120px); /* Adjust based on topbar height */
|
||||
min-height: 500px;
|
||||
@ -42,81 +50,9 @@
|
||||
.new-chat-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 1.2rem;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.new-chat-btn:hover,
|
||||
.new-chat-btn[aria-expanded="true"] {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.new-chat-panel {
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.new-chat-panel input {
|
||||
width: 100%;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.creator-search-results {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.creator-result {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.creator-result:hover,
|
||||
.creator-result:focus-visible {
|
||||
background: #eef2ff;
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
.creator-result img {
|
||||
border-radius: 50%;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.creator-result span {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.creator-result small,
|
||||
.creator-result-empty,
|
||||
.chat-empty {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.creator-result-empty,
|
||||
.chat-empty {
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-list-items {
|
||||
@ -125,22 +61,15 @@
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
.chat-item:hover,
|
||||
.chat-item:focus-visible {
|
||||
.chat-item:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.chat-item.active {
|
||||
@ -267,7 +196,6 @@
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
@ -280,12 +208,6 @@
|
||||
.chat-input-area input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.chat-input-area input:disabled {
|
||||
background: #f8fafc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: var(--gradient);
|
||||
border: none;
|
||||
@ -300,11 +222,6 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.chats-main {
|
||||
@ -327,4 +244,4 @@
|
||||
.chat-active {
|
||||
height: 500px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,13 @@
|
||||
/* Creators page - Discover creators, filter buttons, creator cards */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.creators-main {
|
||||
background: transparent !important;
|
||||
padding: 20px 32px !important;
|
||||
@ -63,24 +71,15 @@
|
||||
.creator-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.creator-card:hover,
|
||||
.creator-card:focus-within {
|
||||
.creator-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.creator-avatar-link {
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
|
||||
}
|
||||
|
||||
.creator-avatar {
|
||||
@ -99,15 +98,6 @@
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.creator-name a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.creator-name a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.creator-handle {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
@ -133,14 +123,7 @@
|
||||
.creator-stats i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.creator-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.follow-btn,
|
||||
.creator-chat-btn {
|
||||
.follow-btn {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
@ -149,36 +132,12 @@
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.follow-btn:hover,
|
||||
.creator-chat-btn:hover {
|
||||
.follow-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.creator-chat-btn {
|
||||
background: #eef2ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.creators-main {
|
||||
@ -208,17 +167,4 @@
|
||||
.follow-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.creator-actions,
|
||||
.creator-chat-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Star rating in creator cards */
|
||||
.creator-stars {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.creator-stars-value {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,13 @@
|
||||
/* Create page - Form for publishing new AI prompts */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.create-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -14,12 +22,12 @@
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
padding: 32px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.create-container:hover {
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
|
||||
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
@ -49,8 +57,7 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-group label,
|
||||
.form-label {
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@ -70,7 +77,7 @@
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
box-shadow: 0 0 0 3px rgba(124,58,237,0.1);
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 0.75rem;
|
||||
@ -97,49 +104,17 @@
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
#tierField {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
#priceField {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tier-manage-link {
|
||||
color: #3b82f6;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tier-manage-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Image preview */
|
||||
#imagePreview {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
}
|
||||
#imagePreview img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Status message */
|
||||
#create-status {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.submit-btn,
|
||||
.cancel-btn {
|
||||
.submit-btn, .cancel-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
@ -157,8 +132,7 @@
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.submit-btn:hover,
|
||||
.cancel-btn:hover {
|
||||
.submit-btn:hover, .cancel-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@ -178,4 +152,4 @@
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,13 @@
|
||||
/* Feed page - Multi-column grid, square images, like/comment/save actions */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.feed-main {
|
||||
background: transparent !important;
|
||||
padding: 20px 32px !important;
|
||||
@ -63,27 +71,16 @@
|
||||
.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,
|
||||
.post-card:focus-within {
|
||||
.post-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.post-card-link {
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
|
||||
}
|
||||
|
||||
/* Post Header */
|
||||
@ -126,7 +123,7 @@
|
||||
.post-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 10px 0 6px 0;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
.post-description {
|
||||
color: #334155;
|
||||
@ -168,15 +165,6 @@
|
||||
.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;
|
||||
}
|
||||
@ -210,49 +198,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@ -269,4 +214,4 @@
|
||||
.post-actions {
|
||||
padding: 8px 10px 10px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,13 @@
|
||||
/* Marketplace Page - Prompt cards, filter buttons, full width layout */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.marketplace-main {
|
||||
background: transparent !important;
|
||||
padding: 20px 32px !important;
|
||||
@ -91,17 +99,15 @@
|
||||
.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 {
|
||||
@ -115,7 +121,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.prompt-title {
|
||||
@ -146,7 +151,6 @@
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: #f59e0b;
|
||||
text-decoration: none;
|
||||
}
|
||||
.prompt-rating span:first-child i {
|
||||
color: #f59e0b;
|
||||
@ -159,7 +163,7 @@
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
margin: auto 0 4px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.prompt-actions {
|
||||
@ -167,8 +171,7 @@
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.buy-btn,
|
||||
.details-btn {
|
||||
.buy-btn, .details-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
@ -186,8 +189,7 @@
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
.buy-btn:hover,
|
||||
.details-btn:hover {
|
||||
.buy-btn:hover, .details-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@ -220,70 +222,4 @@
|
||||
.prompt-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.market-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.market-card-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.95rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.market-card-time {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.market-card-rating {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.market-rating-none {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.market-rating-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.market-rating-stars {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.buy-btn-locked {
|
||||
background: #ef4444 !important;
|
||||
}
|
||||
.buy-btn-unlocked {
|
||||
background: #10b981 !important;
|
||||
}
|
||||
.market-price-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 20px;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.market-heart-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
.market-bookmark-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.market-save-span {
|
||||
margin-left: 12px;
|
||||
}
|
||||
.details-btn[disabled] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,13 @@
|
||||
/* Post Detail page - Full prompt view, rating, example output, unlock button */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.post-detail-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -13,12 +21,12 @@
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
padding: 32px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.post-detail-container:hover {
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
|
||||
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
@ -126,110 +134,6 @@
|
||||
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;
|
||||
@ -310,191 +214,4 @@
|
||||
.prompt-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Loading / error states ──────────────────────────────────────────── */
|
||||
#detail-loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Smaller state icons for this page */
|
||||
#detail-loading .state-icon,
|
||||
#detail-error .state-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#detail-error-msg {
|
||||
color: #64748b;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detail-back-btn {
|
||||
margin-top: 20px;
|
||||
padding: 10px 24px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Detail body ─────────────────────────────────────────────────────── */
|
||||
#detail-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-creator-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
#creator-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#creator-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
#prompt-date {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.detail-actions-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#edit-prompt-btn {
|
||||
display: none;
|
||||
margin-left: 10px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#prompt-body {
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
#example-section {
|
||||
display: none;
|
||||
}
|
||||
#example-output-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#example-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Locked section ──────────────────────────────────────────────────── */
|
||||
#locked-section {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.locked-icon {
|
||||
font-size: 2.5rem;
|
||||
color: #94a3b8;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.locked-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.locked-desc {
|
||||
color: #64748b;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#locked-subscribe-btn {
|
||||
padding: 12px 28px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.detail-heart-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
.detail-bookmark-span {
|
||||
margin-left: 12px;
|
||||
}
|
||||
.detail-bookmark-icon {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.detail-loading-text {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.detail-error-text {
|
||||
color: #ef4444;
|
||||
}
|
||||
.rating-stars-display {
|
||||
font-size: 1.1rem;
|
||||
color: #f59e0b;
|
||||
}
|
||||
.rating-value {
|
||||
margin-left: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rating-count {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.rating-none {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.tier-badge-tier {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border-radius: 20px;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tier-badge-free {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-radius: 20px;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@ -1,217 +1,14 @@
|
||||
/* Profile Page - Full width layout, darker share button, responsive grid */
|
||||
|
||||
/* ── Profile header ──────────────────────────────────────────────────── */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 24px;
|
||||
/* Force main content container to full width, remove centering and max-width */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* ── Profile avatar ──────────────────────────────────────────────────── */
|
||||
.profile-avatar {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ── Profile info column ─────────────────────────────────────────────── */
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#profileDisplayName {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#profileSlug {
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-badge-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
#profileBio {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#profileSpecialities {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
#profileStats {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
color: #64748b;
|
||||
margin-top: 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
#profileStats strong {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* ── Profile actions column ──────────────────────────────────────────── */
|
||||
#profileActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
#profileActions .login-button {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-tier-list {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.profile-tier-list h3 {
|
||||
font-size: 0.95rem;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.profile-tier-option {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-tier-option.active {
|
||||
background: #eef2ff;
|
||||
border-color: #818cf8;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.profile-tier-option strong,
|
||||
.profile-tier-option small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-tier-option small {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.profile-tier-option b {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Profile tabs ────────────────────────────────────────────────────── */
|
||||
.profile-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
margin: 32px 0 18px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Prompts grid ────────────────────────────────────────────────────── */
|
||||
#profile-prompts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.profile-grid-loading {
|
||||
grid-column: 1 / -1;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
}
|
||||
.profile-grid-empty {
|
||||
grid-column: 1 / -1;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
}
|
||||
.profile-grid-error {
|
||||
grid-column: 1 / -1;
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
}
|
||||
.profile-prompt-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.profile-prompt-card:focus-within,
|
||||
.profile-prompt-card:hover {
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
.profile-prompt-link {
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.profile-prompt-img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.profile-prompt-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.profile-prompt-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
.profile-prompt-desc {
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.profile-prompt-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
color: #64748b;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.profile-prompt-edit-btn {
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Inner spacing for the profile card ─────────────────────────────── */
|
||||
/* Inner spacing for the profile card */
|
||||
.profile-main {
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
@ -219,7 +16,7 @@
|
||||
padding: 20px 32px !important;
|
||||
margin: 0 auto !important;
|
||||
width: 100%;
|
||||
max-width: 1600px; /* Limits content on very large screens, but still wide */
|
||||
max-width: 1600px; /* Limits content on very large screens, but still wide */
|
||||
}
|
||||
|
||||
/* Make prompts grid use more columns on large screens */
|
||||
@ -231,59 +28,22 @@
|
||||
}
|
||||
|
||||
/* Share button: darker background and text */
|
||||
#shareProfileButton {
|
||||
background: #cbd5e1 !important; /* darker gray */
|
||||
.profile-header button:last-child {
|
||||
background: #cbd5e1 !important; /* darker gray */
|
||||
color: #1e293b !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
#manageTiersButton {
|
||||
background: #f3e8ff !important;
|
||||
color: #7c3aed !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #d8b4fe !important;
|
||||
}
|
||||
|
||||
/* Buttons keep rounded corners */
|
||||
.login-button {
|
||||
align-items: center;
|
||||
border-radius: 14px !important;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-button i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.profile-tab {
|
||||
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;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
}
|
||||
|
||||
/* Prompt images: rounded corners */
|
||||
@ -294,9 +54,6 @@
|
||||
/* Avatar remains round */
|
||||
.profile-avatar {
|
||||
border-radius: 50% !important;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* All outer containers stay square */
|
||||
@ -334,4 +91,4 @@ nav {
|
||||
.profile-main section:last-child {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,12 @@
|
||||
/* Settings page - tabs, form styling */
|
||||
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -14,7 +21,7 @@
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
@ -94,7 +101,7 @@
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||
box-shadow: 0 0 0 3px rgba(124,58,237,0.1);
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
@ -174,11 +181,4 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Save status message */
|
||||
#profileSaveStatus {
|
||||
margin-top: 10px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@ -100,22 +100,13 @@
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.sidebar-bottom form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
@ -154,18 +145,7 @@
|
||||
}
|
||||
|
||||
.sidebar .nav-text,
|
||||
.sidebar-logout .nav-text {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sidebar-logout .nav-text,
|
||||
.logout-arrow {
|
||||
display: none;
|
||||
}
|
||||
@ -174,7 +154,6 @@
|
||||
.sidebar-logout {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar a.active {
|
||||
@ -184,45 +163,8 @@
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.sidebar-shell {
|
||||
height: auto;
|
||||
padding: 7px 6px;
|
||||
border-right: none;
|
||||
border-top: 1px solid #eef2f7;
|
||||
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sidebar li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar li.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar a,
|
||||
.sidebar-logout {
|
||||
min-height: 46px;
|
||||
padding: 7px 2px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.sidebar i {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.sidebar a.active {
|
||||
border-right: none;
|
||||
border-top: 3px solid #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,271 +0,0 @@
|
||||
/* Subscription tiers page - manage monthly creator access levels */
|
||||
|
||||
.tiers-main {
|
||||
flex: 1;
|
||||
padding: 20px 32px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tiers-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.tiers-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tiers-header p {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tiers-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 380px) 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.tiers-tabs {
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tiers-tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.tiers-tab.active {
|
||||
border-bottom-color: #3b82f6;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.subscriptions-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tier-panel,
|
||||
.tier-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
.tier-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.tier-panel h2,
|
||||
.tier-list-header h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tier-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tier-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tier-form input,
|
||||
.tier-form textarea {
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 12px;
|
||||
font: inherit;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.tier-form textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.tier-form input:focus,
|
||||
.tier-form textarea:focus {
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tier-form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tier-primary-btn,
|
||||
.tier-secondary-btn,
|
||||
.tier-card button {
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.tier-primary-btn {
|
||||
background: var(--gradient);
|
||||
color: #ffffff;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tier-secondary-btn {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
#tier-status {
|
||||
color: #64748b;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.tier-list-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tier-list-header p {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tiers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.subscriptions-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.subscription-card {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.subscription-card h3 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subscription-card p {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.subscription-price {
|
||||
color: #3b82f6;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tier-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tier-card-top {
|
||||
align-items: start;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tier-card h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tier-level {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tier-price {
|
||||
color: #3b82f6;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tier-desc {
|
||||
color: #475569;
|
||||
line-height: 1.45;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tier-card-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tier-card button {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.tier-delete-btn {
|
||||
color: #dc2626 !important;
|
||||
}
|
||||
|
||||
.tiers-empty,
|
||||
.tiers-error {
|
||||
background: #ffffff;
|
||||
border-radius: 18px;
|
||||
color: #64748b;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tiers-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tiers-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.tiers-main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tiers-tabs {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.subscription-card {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@ -1,35 +1,31 @@
|
||||
/*
|
||||
Topbar styles for OnlyPrompt
|
||||
- sticky on all app pages
|
||||
- search bar fills the available width
|
||||
- logout, messages and profile avatar stay on the right
|
||||
- clean, modern, full-width
|
||||
- search bar centered (expands on full screen), profile avatar always on the right
|
||||
- ONLY search bar and avatar have rounded corners
|
||||
*/
|
||||
|
||||
.topbar-shell {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
justify-content: space-between;
|
||||
padding: 16px 32px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
flex: 1; /* Takes all available space */
|
||||
max-width: none; /* No upper limit, expands freely */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
max-width: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 14px; /* Rounded like login inputs */
|
||||
}
|
||||
|
||||
.topbar-search i {
|
||||
@ -38,17 +34,12 @@
|
||||
}
|
||||
|
||||
.topbar-search input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #334155;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar-search:focus-within {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.topbar-search input::placeholder {
|
||||
@ -56,28 +47,24 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Icons and avatar container */
|
||||
.topbar-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topbar-logout-form {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topbar-icon-btn {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 1.4rem;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 1.4rem;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
@ -87,47 +74,42 @@
|
||||
}
|
||||
|
||||
.topbar-avatar-btn {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.topbar-avatar {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
width: 48px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 50%; /* Avatar round */
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.topbar-shell {
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.topbar-search i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.topbar-avatar {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.topbar-icon-btn {
|
||||
.topbar-icon-btn {
|
||||
font-size: 1.2rem;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
@ -135,14 +117,9 @@
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.topbar-shell {
|
||||
padding: 10px 12px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
padding: 6px 10px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,281 +1,166 @@
|
||||
<!-- 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" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
<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>
|
||||
|
||||
<div class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
<div style="flex:1; margin:40px auto; max-width:950px;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="feed-main" id="main-content" tabindex="-1">
|
||||
<!-- Optional: Feed Header -->
|
||||
<div class="feed-header">
|
||||
<h1>Feed</h1>
|
||||
<p>Latest prompts and inspiration from creators you follow</p>
|
||||
</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 -->
|
||||
<div class="filter-buttons" role="group" aria-label="Sort feed">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn active"
|
||||
data-sort="date"
|
||||
data-ascending="false"
|
||||
aria-pressed="true"
|
||||
>
|
||||
Recent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
data-sort="rating"
|
||||
data-ascending="false"
|
||||
aria-pressed="false"
|
||||
>
|
||||
Top Rated
|
||||
</button>
|
||||
<button type="button" class="filter-btn" data-sort="date" data-ascending="true" aria-pressed="false">
|
||||
Oldest
|
||||
</button>
|
||||
</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 -->
|
||||
<div class="posts-grid" id="posts-grid" aria-live="polite"></div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="feed-empty" class="state-empty" role="status" aria-live="polite">
|
||||
<i class="bi bi-inbox state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">No posts yet</h3>
|
||||
<p>Follow some creators to see their prompts here.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="feed-error" class="state-error" role="alert" aria-live="assertive">
|
||||
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">Could not load feed</h3>
|
||||
<p id="feed-error-msg"></p>
|
||||
</div>
|
||||
</main>
|
||||
</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((link) => {
|
||||
link.classList.remove("active");
|
||||
link.removeAttribute("aria-current");
|
||||
});
|
||||
const firstLink = document.querySelectorAll(
|
||||
"#sidebar-container .sidebar li a",
|
||||
)[0];
|
||||
if (firstLink) {
|
||||
firstLink.classList.add("active");
|
||||
firstLink.setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
|
||||
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" aria-label="${rating.toFixed(1)} out of 5 stars">
|
||||
${'<i class="bi bi-star-fill" aria-hidden="true"></i>'.repeat(stars)}${'<i class="bi bi-star" aria-hidden="true"></i>'.repeat(5 - stars)}
|
||||
<span aria-hidden="true">${rating.toFixed(1)}</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<article class="post-card${locked ? " post-locked" : ""}">
|
||||
<a class="post-card-link" href="${profileUrl(prompt.creatorId)}" aria-label="Open profile for ${prompt.creatorName}">
|
||||
<!-- 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 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"><time datetime="${prompt.timeStamp}">${timeAgo(prompt.timeStamp)}</time></span>
|
||||
<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">
|
||||
${prompt.exampleImageUrl ? `<img class="post-image${locked ? " post-image-locked" : ""}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? " post-image-locked" : ""}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
|
||||
<h3 class="post-title">${prompt.title}</h3>
|
||||
<p class="post-description">${prompt.description || ""}</p>
|
||||
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill" aria-hidden="true"></i> ${prompt.tierName ?? "Subscription"} tier required</p>` : ""}
|
||||
${renderStars(prompt.averageRating)}
|
||||
<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>
|
||||
</a>
|
||||
<div class="post-actions">
|
||||
<button type="button" class="action-btn like-btn ${liked ? "active" : ""}" aria-pressed="${liked}" aria-label="${liked ? "Unlike" : "Like"} ${prompt.title}. ${prompt.likeCount || 0} likes" onclick="toggleLike(event, '${prompt.id}', ${liked})"><i class="bi ${liked ? "bi-heart-fill" : "bi-heart"}" aria-hidden="true"></i> <span>Like (${prompt.likeCount || 0})</span></button>
|
||||
<button type="button" class="action-btn comment-btn" aria-label="Review ${prompt.title}" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat" aria-hidden="true"></i> <span>Review</span></button>
|
||||
<button type="button" class="action-btn share-btn" aria-label="Share ${prompt.title}" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share" aria-hidden="true"></i> <span>Share</span></button>
|
||||
<button type="button" class="action-btn save-btn ${saved ? "active" : ""}" aria-pressed="${saved}" aria-label="${saved ? "Remove saved" : "Save"} ${prompt.title}. ${prompt.saveCount || 0} saves" onclick="toggleSave(event, '${prompt.id}', ${saved})"><i class="bi ${saved ? "bi-bookmark-fill" : "bi-bookmark"}" aria-hidden="true"></i> <span>Save (${prompt.saveCount || 0})</span></button>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
window.toggleLike = async function (event, id, isLiked) {
|
||||
event.stopPropagation();
|
||||
const response = await fetch(`/api/v1/prompts/${id}/likes`, {
|
||||
method: isLiked ? "DELETE" : "PUT",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
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");
|
||||
b.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
btn.classList.add("active");
|
||||
btn.setAttribute("aria-pressed", "true");
|
||||
loadFeed(btn.dataset.sort, btn.dataset.ascending === "true");
|
||||
<!-- 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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
fetch('../html/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 first link (Dashboard) - index 0
|
||||
const firstLink = document.querySelectorAll('#sidebar-container .sidebar li a')[0];
|
||||
if (firstLink) firstLink.classList.add('active');
|
||||
});
|
||||
|
||||
// Initial load
|
||||
loadFeed();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,150 +1,140 @@
|
||||
// LINQ-like Enumerable class wrapping lazy generator chains
|
||||
|
||||
class Enumerable {
|
||||
constructor(iteratorFn) {
|
||||
this._iteratorFn = iteratorFn;
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this._iteratorFn();
|
||||
}
|
||||
|
||||
_chain(generatorFn) {
|
||||
const source = this;
|
||||
return new Enumerable(function* () {
|
||||
yield* generatorFn(source);
|
||||
});
|
||||
}
|
||||
|
||||
where(predicate) {
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source) if (predicate(item)) yield item;
|
||||
});
|
||||
}
|
||||
|
||||
select(selector) {
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source) yield selector(item);
|
||||
});
|
||||
}
|
||||
|
||||
take(count) {
|
||||
count = Math.max(0, count);
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source) {
|
||||
if (count-- <= 0) break;
|
||||
yield item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
skip(count) {
|
||||
count = Math.max(0, count);
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source) {
|
||||
if (count-- > 0) continue;
|
||||
yield item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isLast() {
|
||||
return this._chain(function* (source) {
|
||||
const iter = source[Symbol.iterator]();
|
||||
let current = iter.next();
|
||||
let index = 0;
|
||||
while (!current.done) {
|
||||
const next = iter.next();
|
||||
yield [current.value, next.done, index];
|
||||
current = next;
|
||||
index++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
forEach(action) {
|
||||
for (const item of this) {
|
||||
if (Array.isArray(item)) {
|
||||
action(...item);
|
||||
} else {
|
||||
action(item);
|
||||
}
|
||||
constructor(iteratorFn) {
|
||||
this._iteratorFn = iteratorFn;
|
||||
}
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return Array.from(this);
|
||||
}
|
||||
|
||||
orderByDescending(keySelector) {
|
||||
return this._chain(function* (source) {
|
||||
const items = Array.from(source);
|
||||
items.sort((a, b) => keySelector(b) - keySelector(a));
|
||||
yield* items;
|
||||
});
|
||||
}
|
||||
|
||||
orderBy(keySelector) {
|
||||
return this._chain(function* (source) {
|
||||
const items = Array.from(source);
|
||||
items.sort((a, b) => keySelector(a) - keySelector(b));
|
||||
yield* items;
|
||||
});
|
||||
}
|
||||
|
||||
firstOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
first(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
throw new Error("No elements in sequence.");
|
||||
}
|
||||
|
||||
lastOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
let lastValue = undefined;
|
||||
for (const item of source) lastValue = item;
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
last(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
let lastValue = undefined;
|
||||
let found = false;
|
||||
for (const item of source) {
|
||||
lastValue = item;
|
||||
found = true;
|
||||
[Symbol.iterator]() {
|
||||
return this._iteratorFn();
|
||||
}
|
||||
if (!found) throw new Error("No elements in sequence.");
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
any(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const _ of source) return true;
|
||||
return false;
|
||||
}
|
||||
_chain(generatorFn) {
|
||||
const source = this;
|
||||
return new Enumerable(function* () {
|
||||
yield* generatorFn(source);
|
||||
});
|
||||
}
|
||||
|
||||
all(predicate) {
|
||||
for (const item of this) if (!predicate(item)) return false;
|
||||
return true;
|
||||
}
|
||||
where(predicate) {
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source)
|
||||
if (predicate(item))
|
||||
yield item;
|
||||
});
|
||||
}
|
||||
|
||||
count(predicate) {
|
||||
let count = 0;
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const _ of source) count++;
|
||||
return count;
|
||||
}
|
||||
select(selector) {
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source)
|
||||
yield selector(item);
|
||||
});
|
||||
}
|
||||
|
||||
take(count) {
|
||||
count = Math.max(0, count);
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source) {
|
||||
if (count-- <= 0) break;
|
||||
yield item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
skip(count) {
|
||||
count = Math.max(0, count);
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source) {
|
||||
if (count-- > 0) continue;
|
||||
yield item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isLast() {
|
||||
return this._chain(function* (source) {
|
||||
const iter = source[Symbol.iterator]();
|
||||
let current = iter.next();
|
||||
let index = 0;
|
||||
while (!current.done) {
|
||||
const next = iter.next();
|
||||
yield [current.value, next.done, index];
|
||||
current = next;
|
||||
index++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
forEach(action) {
|
||||
for (const item of this) {
|
||||
if (Array.isArray(item)) {
|
||||
action(...item);
|
||||
} else {
|
||||
action(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return Array.from(this);
|
||||
}
|
||||
|
||||
firstOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
first(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
throw new Error("No elements in sequence.");
|
||||
}
|
||||
|
||||
lastOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
let lastValue = undefined;
|
||||
for (const item of source) lastValue = item;
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
last(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
let lastValue = undefined;
|
||||
let found = false;
|
||||
for (const item of source) {
|
||||
lastValue = item;
|
||||
found = true;
|
||||
}
|
||||
if (!found) throw new Error("No elements in sequence.");
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
any(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const _ of source) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
all(predicate) {
|
||||
for (const item of this)
|
||||
if (!predicate(item))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
count(predicate) {
|
||||
let count = 0;
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const _ of source) count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
Array.prototype.asEnumerable = function () {
|
||||
const arr = this;
|
||||
return new Enumerable(function* () {
|
||||
for (const item of arr) yield item;
|
||||
});
|
||||
};
|
||||
const arr = this;
|
||||
return new Enumerable(function* () {
|
||||
for (const item of arr)
|
||||
yield item;
|
||||
});
|
||||
}
|
||||
@ -1,24 +1,9 @@
|
||||
import { sendFormAsync } from "./shared.js";
|
||||
|
||||
async function redirectIfAlreadySignedIn() {
|
||||
const response = await fetch('/api/v1/auth/me', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.replace('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
function togglePassword() {
|
||||
const passwordInput = document.getElementById('password');
|
||||
const togglePasswordButton = document.getElementById('togglePassword');
|
||||
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
|
||||
passwordInput.type = newInputType;
|
||||
const isVisible = newInputType === 'text';
|
||||
togglePasswordButton.textContent = isVisible ? 'Hide' : 'Show';
|
||||
togglePasswordButton.setAttribute('aria-pressed', String(isVisible));
|
||||
}
|
||||
|
||||
async function submitLoginForm(){
|
||||
@ -33,6 +18,4 @@ const loginForm = document.getElementById('loginForm');
|
||||
loginForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault(); // Prevent the default form submission
|
||||
await submitLoginForm();
|
||||
});
|
||||
|
||||
await redirectIfAlreadySignedIn();
|
||||
});
|
||||
@ -1,556 +0,0 @@
|
||||
import "./linq.js";
|
||||
import { NodeTemplate } from "./node-template.js";
|
||||
|
||||
// ─── Route matching ───
|
||||
|
||||
class RouteMatch {
|
||||
constructor(route, path, params) {
|
||||
this.route = route;
|
||||
this.path = path;
|
||||
this.params = params;
|
||||
this.segmentCount = route.fragments.length;
|
||||
}
|
||||
}
|
||||
|
||||
export class Route {
|
||||
constructor(pattern, componentClass) {
|
||||
this.id = crypto.randomUUID();
|
||||
this.componentClass = componentClass;
|
||||
this.componentDefinition = componentClass.definition;
|
||||
this.fragments = pattern.split("/");
|
||||
}
|
||||
|
||||
match(path) {
|
||||
const parsedUrl = new URL(path, window.location.origin);
|
||||
const params = {};
|
||||
parsedUrl.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
|
||||
const pathFragments = parsedUrl.pathname.split("/");
|
||||
|
||||
for (let i = 0; i < this.fragments.length; i++) {
|
||||
const fragment = this.fragments[i];
|
||||
const pathFragment = pathFragments[i];
|
||||
if (fragment.startsWith(":")) {
|
||||
params[fragment.substring(1)] = pathFragment;
|
||||
continue;
|
||||
} else if (fragment === "*") {
|
||||
continue;
|
||||
} else if (fragment === "**") {
|
||||
return new RouteMatch(this, path, params);
|
||||
} else if (fragment !== pathFragment) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new RouteMatch(this, path, params);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ComponentInput (signal-like) ───
|
||||
|
||||
export class ComponentInput {
|
||||
constructor(defaultValue, options = {}) {
|
||||
this._value = defaultValue;
|
||||
this.transform = options.transform || ((v) => v);
|
||||
this.validate = options.validate || (() => true);
|
||||
this.alias = options.alias || null;
|
||||
this._isComponentInput = true;
|
||||
this._owner = null;
|
||||
const self = this;
|
||||
const fn = function () {
|
||||
return self._value;
|
||||
};
|
||||
return new Proxy(fn, {
|
||||
get(target, prop) {
|
||||
if (prop === "set") return (v) => self._set(v);
|
||||
if (prop === "_isComponentInput") return true;
|
||||
if (prop === "_self") return self;
|
||||
if (prop === "alias") return self.alias;
|
||||
if (prop === Symbol.toPrimitive) return () => self._value;
|
||||
if (prop === "valueOf") return () => self._value;
|
||||
if (prop === "toString") return () => String(self._value);
|
||||
const inner = self._value;
|
||||
if (inner == null) return undefined;
|
||||
const val = inner[prop];
|
||||
return typeof val === "function" ? val.bind(inner) : val;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (self._value != null) {
|
||||
self._value[prop] = value;
|
||||
if (self._owner) self._owner.requestUpdate();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
apply(target, thisArg, args) {
|
||||
return self._value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_set(newValue) {
|
||||
const transformed = this.transform(newValue);
|
||||
if (!this.validate(transformed)) {
|
||||
throw new Error("Invalid value");
|
||||
}
|
||||
this._value = transformed;
|
||||
if (this._owner) this._owner.requestUpdate();
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance](instance) {
|
||||
return instance?._isComponentInput === true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ViewChild ───
|
||||
|
||||
export class ViewChild {
|
||||
constructor(options = { selector: null, id: null, multiple: false }) {
|
||||
this._selector = options.selector;
|
||||
this._id = options.id;
|
||||
this._multiple = options.multiple;
|
||||
this._element = null;
|
||||
this._isViewChild = true;
|
||||
return new Proxy(this, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return target[prop];
|
||||
if (target._element) {
|
||||
const value = target._element[prop];
|
||||
return typeof value === "function"
|
||||
? value.bind(target._element)
|
||||
: value;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_setValue(element) {
|
||||
this._element = element;
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance](instance) {
|
||||
return instance?._isViewChild === true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ComponentDefinition ───
|
||||
|
||||
export class ComponentDefinition {
|
||||
constructor(
|
||||
options = {
|
||||
templatesPath: null,
|
||||
template: null,
|
||||
stylesPath: null,
|
||||
style: null,
|
||||
scriptsPath: null,
|
||||
},
|
||||
) {
|
||||
this.templatesPath = options.templatesPath;
|
||||
this.template = options.template;
|
||||
this.stylesPath = options.stylesPath;
|
||||
this.style = options.style;
|
||||
this.scriptsPath = options.scriptsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EventListener ───
|
||||
|
||||
class EventBinding {
|
||||
constructor(event, element, handler) {
|
||||
this.event = event;
|
||||
this.element = element;
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
attach() {
|
||||
this.element.addEventListener(this.event, this.handler);
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.element.removeEventListener(this.event, this.handler);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Component ───
|
||||
|
||||
export class Component extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this._eventBindings = [];
|
||||
return new Proxy(this, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, target);
|
||||
if (typeof value === "function" && typeof value.bind === "function") {
|
||||
return prop in EventTarget.prototype
|
||||
? value.bind(target)
|
||||
: value.bind(receiver);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (target[prop] instanceof ComponentInput) {
|
||||
target[prop].set(value);
|
||||
return true;
|
||||
} else if (target[prop] instanceof ViewChild) {
|
||||
target[prop]._setValue(value);
|
||||
} else {
|
||||
target[prop] = value;
|
||||
}
|
||||
target.requestUpdate();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
requestUpdate() {
|
||||
this.dispatchEvent(new Event("requestUpdate"));
|
||||
}
|
||||
|
||||
static get definition() {
|
||||
throw new Error("Component definition is not defined");
|
||||
}
|
||||
|
||||
onInit() { }
|
||||
onBeforeRender() { }
|
||||
onAfterRender() { }
|
||||
onDestroy() {
|
||||
this._eventBindings.forEach((b) => b.detach());
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Router init ───
|
||||
|
||||
export function initRouter(outletId, routes) {
|
||||
const outletRef = document.getElementById(outletId);
|
||||
if (!outletRef) {
|
||||
console.error(`Outlet element with id '${outletId}' not found`);
|
||||
return null;
|
||||
}
|
||||
return new Router(outletRef, routes);
|
||||
}
|
||||
|
||||
// ─── Router ───
|
||||
|
||||
export class Router {
|
||||
constructor(outletRef, routes) {
|
||||
this.outletRef = outletRef;
|
||||
this.shadowRoot = outletRef.attachShadow({ mode: "open" });
|
||||
this.routes = routes;
|
||||
this.templateCache = new Map();
|
||||
this.activeController = null;
|
||||
this.activeRenderResult = null;
|
||||
this._boundUpdateHandler = null;
|
||||
this._rendering = false;
|
||||
this._pendingUpdate = false;
|
||||
this.shadowRoot.textContent = "Loading...";
|
||||
navigation.addEventListener("navigate", this.handleNavigate.bind(this));
|
||||
}
|
||||
|
||||
findMatchingRoute(path) {
|
||||
return this.routes
|
||||
.asEnumerable()
|
||||
.select((route) => route.match(path))
|
||||
.where((match) => match !== null)
|
||||
.orderByDescending((match) => match.segmentCount)
|
||||
.firstOrDefault();
|
||||
}
|
||||
|
||||
async handleNavigate(event) {
|
||||
if (event.navigationType === 'replace' && event.destination.url === this.activeRoute) return;
|
||||
const routeMatch = this.findMatchingRoute(event.destination.url);
|
||||
if (!routeMatch) return;
|
||||
|
||||
event.preventDefault();
|
||||
const template = await this.getCachedTemplate(routeMatch.route);
|
||||
const controller = this.createComponentInstance(
|
||||
routeMatch.route.componentClass,
|
||||
routeMatch.params,
|
||||
);
|
||||
|
||||
this.destroyCurrent();
|
||||
this.activeController = controller;
|
||||
this.activeTemplate = template;
|
||||
this.activeRoute = event.destination.url;
|
||||
this.insertIncludes(routeMatch.route);
|
||||
|
||||
this._boundUpdateHandler = this.handleUpdateRequest.bind(this);
|
||||
this.activeController.addEventListener(
|
||||
"requestUpdate",
|
||||
this._boundUpdateHandler,
|
||||
);
|
||||
|
||||
this.renderActiveComponent();
|
||||
history.replaceState({}, "", event.destination.url);
|
||||
}
|
||||
|
||||
destroyCurrent() {
|
||||
if (this.activeController) {
|
||||
this.activeController.removeEventListener(
|
||||
"requestUpdate",
|
||||
this._boundUpdateHandler,
|
||||
);
|
||||
this.activeController.onDestroy();
|
||||
this.activeController = null;
|
||||
}
|
||||
if (this.activeRenderResult) {
|
||||
this.activeRenderResult.destroy();
|
||||
this.activeRenderResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdateRequest() {
|
||||
if (this._rendering) {
|
||||
this._pendingUpdate = true;
|
||||
return;
|
||||
}
|
||||
this.updateActiveComponent();
|
||||
}
|
||||
|
||||
insertIncludes(route) {
|
||||
// Remove previous component styles from shadow root
|
||||
this.shadowRoot
|
||||
.querySelectorAll("link[data-component-style], style[data-component-style]")
|
||||
.forEach((l) => l.remove());
|
||||
|
||||
if (route.componentDefinition.style) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = route.componentDefinition.style;
|
||||
styleEl.setAttribute("data-component-style", "");
|
||||
this.shadowRoot.appendChild(styleEl);
|
||||
}
|
||||
|
||||
const paths = route.componentDefinition.stylesPath;
|
||||
if (!paths) return;
|
||||
const list = Array.isArray(paths) ? paths : [paths];
|
||||
for (const p of list) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = p;
|
||||
link.setAttribute("data-component-style", "");
|
||||
this.shadowRoot.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── First render: build DOM from scratch ───
|
||||
|
||||
renderActiveComponent() {
|
||||
this._rendering = true;
|
||||
this._pendingUpdate = false;
|
||||
|
||||
this.activeController.onBeforeRender();
|
||||
|
||||
const result = this.activeTemplate.render(this.activeController);
|
||||
this.activeRenderResult = result;
|
||||
|
||||
// Clear shadow root content (keep style links), append fragment
|
||||
for (const child of Array.from(this.shadowRoot.childNodes)) {
|
||||
if (
|
||||
child.nodeType !== Node.ELEMENT_NODE ||
|
||||
!child.hasAttribute("data-component-style")
|
||||
) {
|
||||
child.remove();
|
||||
}
|
||||
}
|
||||
this.shadowRoot.appendChild(result.fragment);
|
||||
|
||||
// Wire events + two-way bindings on the live shadow DOM
|
||||
this.wireEvents(this.activeController, this.shadowRoot);
|
||||
this.wireViewChildren(this.activeController, this.shadowRoot);
|
||||
|
||||
this.activeController.onAfterRender();
|
||||
this._rendering = false;
|
||||
|
||||
if (this._pendingUpdate) {
|
||||
this.updateActiveComponent();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Subsequent updates: patch bindings only ───
|
||||
|
||||
updateActiveComponent() {
|
||||
this._rendering = true;
|
||||
this._pendingUpdate = false;
|
||||
|
||||
this.activeController.onBeforeRender();
|
||||
this.activeRenderResult.update(this.activeController);
|
||||
this.activeController.onAfterRender();
|
||||
|
||||
this._rendering = false;
|
||||
|
||||
if (this._pendingUpdate) {
|
||||
this.updateActiveComponent();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ViewChild wiring ───
|
||||
|
||||
wireViewChildren(controller, root) {
|
||||
Object.entries(controller)
|
||||
.filter(([_, value]) => value instanceof ViewChild)
|
||||
.forEach(([key, viewChild]) => {
|
||||
if (viewChild._id) {
|
||||
const el = root.querySelector(`#${viewChild._id}`);
|
||||
if (el) viewChild._setValue(el);
|
||||
} else if (viewChild._selector) {
|
||||
const el = root.querySelector(viewChild._selector);
|
||||
if (el) viewChild._setValue(el);
|
||||
} else {
|
||||
console.warn(`ViewChild ${key}: no selector or id`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Event wiring ───
|
||||
// Supports:
|
||||
// (click)="methodName()" — event binding
|
||||
// [(value)]="propName" — two-way binding (banana-in-a-box)
|
||||
|
||||
wireEvents(controller, root) {
|
||||
const allElements = root.querySelectorAll("*");
|
||||
|
||||
allElements.forEach((element) => {
|
||||
// ─── Two-way bindings from __templateMeta ───
|
||||
if (element.__templateMeta) {
|
||||
for (const meta of element.__templateMeta) {
|
||||
if (meta.bracket === "[()]" && meta.expression) {
|
||||
const domProp = meta.name.replace(/^\(|\)$/g, ""); // strip parens
|
||||
this._bindTwoWay(controller, element, domProp, meta.expression);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event bindings from DOM attributes ───
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
const name = attr.name;
|
||||
|
||||
const eventMatch = name.match(/^\((\w+)\)$/);
|
||||
if (eventMatch) {
|
||||
const eventName = eventMatch[1];
|
||||
const attrValue = attr.value.trim();
|
||||
const methodName = attrValue.endsWith("()")
|
||||
? attrValue.slice(0, -2)
|
||||
: attrValue;
|
||||
|
||||
let binding;
|
||||
if (typeof controller[methodName] === "function") {
|
||||
const handler = controller[methodName].bind(controller);
|
||||
binding = new EventBinding(eventName, element, handler);
|
||||
} else {
|
||||
const fn = new Function("event", attrValue).bind(controller);
|
||||
binding = new EventBinding(eventName, element, fn);
|
||||
}
|
||||
binding.attach();
|
||||
controller._eventBindings.push(binding);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Two-way bind: DOM property ↔ controller field ───
|
||||
// Sets DOM prop from controller on each update.
|
||||
// Listens for input/change events to write back to controller.
|
||||
|
||||
_bindTwoWay(controller, element, domProp, field) {
|
||||
// Controller → DOM: set initial value
|
||||
const getValue = () => {
|
||||
const v = controller[field];
|
||||
return typeof v === "function" ? v() : v;
|
||||
};
|
||||
|
||||
element[domProp] = getValue() ?? "";
|
||||
|
||||
// DOM → Controller: on input (for text) + change (for select/checkbox)
|
||||
const writeBack = () => {
|
||||
const domValue = element[domProp];
|
||||
if (controller[field] instanceof ComponentInput) {
|
||||
controller[field].set(domValue);
|
||||
} else {
|
||||
controller[field] = domValue;
|
||||
}
|
||||
};
|
||||
|
||||
const inputBinding = new EventBinding("input", element, writeBack);
|
||||
const changeBinding = new EventBinding("change", element, writeBack);
|
||||
inputBinding.attach();
|
||||
changeBinding.attach();
|
||||
controller._eventBindings.push(inputBinding, changeBinding);
|
||||
|
||||
// Controller → DOM: patch on every update via RenderResult hook
|
||||
// We piggyback on requestUpdate listener — the attr binding in
|
||||
// node-template handles {{expr}} attrs, but for [(prop)] we need
|
||||
// to sync the DOM *property* (not attribute) on update.
|
||||
const updateBinding = new EventBinding("requestUpdate", controller, () => {
|
||||
const val = getValue() ?? "";
|
||||
if (element[domProp] !== val) element[domProp] = val;
|
||||
});
|
||||
updateBinding.attach();
|
||||
controller._eventBindings.push(updateBinding);
|
||||
}
|
||||
|
||||
// ─── Component instance creation ───
|
||||
|
||||
createComponentInstance(component, params) {
|
||||
const instance = new component();
|
||||
|
||||
// Wire _owner for ComponentInput reactivity
|
||||
for (const key of Object.keys(instance)) {
|
||||
const val = instance[key];
|
||||
if (val instanceof ComponentInput) {
|
||||
val._self._owner = instance;
|
||||
}
|
||||
}
|
||||
|
||||
instance.onInit();
|
||||
|
||||
// Bind route/query params → ComponentInputs (by name or alias)
|
||||
for (const [paramKey, paramValue] of Object.entries(params)) {
|
||||
if (instance[paramKey] instanceof ComponentInput) {
|
||||
instance[paramKey].set(paramValue);
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(instance)) {
|
||||
const val = instance[key];
|
||||
if (val instanceof ComponentInput && val.alias === paramKey) {
|
||||
val.set(paramValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
// ─── Template caching ───
|
||||
|
||||
async getCachedTemplate(route) {
|
||||
if (this.templateCache.has(route.id)) {
|
||||
return this.templateCache.get(route.id);
|
||||
}
|
||||
|
||||
let templateContent = route.componentClass.definition.template;
|
||||
if (!templateContent && route.componentClass.definition.templatesPath) {
|
||||
const response = await fetch(
|
||||
route.componentClass.definition.templatesPath,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return new NodeTemplate(
|
||||
`<div class="error"><h1>Failed to load template</h1><p>${response.status} ${response.statusText}</p></div>`,
|
||||
);
|
||||
}
|
||||
|
||||
templateContent = await response.text();
|
||||
}
|
||||
|
||||
try {
|
||||
const template = new NodeTemplate(templateContent);
|
||||
this.templateCache.set(route.id, template);
|
||||
return template;
|
||||
} catch (error) {
|
||||
return new NodeTemplate(
|
||||
`<div class="error"><h1>Failed to compile template</h1><p>${error.message}</p></div>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,65 +0,0 @@
|
||||
import { initRouter, Route, ViewChild, Component, ComponentInput, ComponentDefinition } from "./node-pwa.js";
|
||||
import { IntInput } from "./utils.js";
|
||||
|
||||
class TestComponent extends Component {
|
||||
static get definition() {
|
||||
return new ComponentDefinition({
|
||||
name: "test-component",
|
||||
template: `<div>
|
||||
<h1>Test Component</h1>
|
||||
<p>This is a test component.</p>
|
||||
<button
|
||||
id="test-button"
|
||||
[class.red-button]="count() % 2 === 0"
|
||||
[class.green-button]="count() % 2 !== 0"
|
||||
class="counter-button"
|
||||
(click)="incrementCount()"
|
||||
>Count: {{count()}}</button>
|
||||
<input type="text" [(value)]="name" placeholder="Enter your name" />
|
||||
<p>Hello, {{name()}}! Counter is {{count()}}</p>
|
||||
</div>`,
|
||||
style: `
|
||||
.red-button {
|
||||
background-color: red;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.green-button {
|
||||
background-color: green;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.counter-button {
|
||||
border-radius: 10px;
|
||||
margin: 15px 30px;
|
||||
font-size: 18px;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.counter-button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
count = new ComponentInput(0, IntInput);
|
||||
name = new ComponentInput("World");
|
||||
|
||||
incrementCount() {
|
||||
const currentCount = this.count();
|
||||
this.count.set(currentCount + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const route1 = new Route("/counter", TestComponent);
|
||||
const route2 = new Route("/counter/:count", TestComponent);
|
||||
initRouter("routerOutlet", [route1, route2]);
|
||||
@ -1,102 +0,0 @@
|
||||
(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 });
|
||||
});
|
||||
}
|
||||
})();
|
||||
@ -1,432 +0,0 @@
|
||||
import "./linq.js";
|
||||
import { Template } from "./template.js";
|
||||
|
||||
class RouteMatch {
|
||||
constructor(route, path, params) {
|
||||
this.route = route;
|
||||
this.path = path;
|
||||
this.params = params;
|
||||
this.segmentCount = route.fragments.length;
|
||||
}
|
||||
}
|
||||
|
||||
export class Route {
|
||||
constructor(pattern, componentClass) {
|
||||
this.id = crypto.randomUUID();
|
||||
this.componentClass = componentClass;
|
||||
this.ComponentDefinition = componentClass.definition;
|
||||
this.fragments = pattern.split("/");
|
||||
}
|
||||
|
||||
match(path) {
|
||||
const parsedUrl = new URL(path, window.location.origin);
|
||||
const params = {};
|
||||
parsedUrl.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
|
||||
const pathFragments = parsedUrl.pathname
|
||||
.split("/")
|
||||
.filter((f) => f.length > 0);
|
||||
|
||||
for (let i = 0; i < this.fragments.length; i++) {
|
||||
const fragment = this.fragments[i];
|
||||
const pathFragment = pathFragments[i];
|
||||
if (fragment.startsWith(":")) {
|
||||
params[fragment.substring(1)] = pathFragment;
|
||||
continue;
|
||||
} else if (fragment === "*") {
|
||||
continue;
|
||||
} else if (fragment === "**") {
|
||||
return new RouteMatch(this, path, params);
|
||||
} else if (fragment !== pathFragment) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new RouteMatch(this, path, params);
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentInput {
|
||||
constructor(defaultValue, options = {}) {
|
||||
this._value = defaultValue;
|
||||
this.transform = options.transform || ((v) => v);
|
||||
this.validate = options.validate || (() => true);
|
||||
this.alias = options.alias || null;
|
||||
this._isComponentInput = true;
|
||||
this._owner = null;
|
||||
const self = this;
|
||||
const fn = function () {
|
||||
return self._value;
|
||||
};
|
||||
return new Proxy(fn, {
|
||||
get(target, prop) {
|
||||
if (prop === "set") return (v) => self._set(v);
|
||||
if (prop === "_isComponentInput") return true;
|
||||
if (prop === "_self") return self;
|
||||
if (prop === "alias") return self.alias;
|
||||
if (prop === Symbol.toPrimitive) return () => self._value;
|
||||
if (prop === "valueOf") return () => self._value;
|
||||
if (prop === "toString") return () => String(self._value);
|
||||
const inner = self._value;
|
||||
if (inner == null) return undefined;
|
||||
const val = inner[prop];
|
||||
return typeof val === "function" ? val.bind(inner) : val;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (self._value != null) {
|
||||
self._value[prop] = value;
|
||||
if (self._owner) self._owner.requestUpdate();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
apply(target, thisArg, args) {
|
||||
return self._value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_set(newValue) {
|
||||
const transformed = this.transform(newValue);
|
||||
if (!this.validate(transformed)) {
|
||||
throw new Error("Invalid value");
|
||||
}
|
||||
this._value = transformed;
|
||||
if (this._owner) this._owner.requestUpdate();
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance](instance) {
|
||||
return instance?._isComponentInput === true;
|
||||
}
|
||||
}
|
||||
|
||||
export class ViewChild {
|
||||
constructor(options = { selector: null, id: null, multiple: false }) {
|
||||
this._selector = options.selector;
|
||||
this._id = options.id;
|
||||
this._multiple = options.multiple;
|
||||
this._element = null;
|
||||
this._isViewChild = true;
|
||||
return new Proxy(this, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return target[prop];
|
||||
if (target._element) {
|
||||
const value = target._element[prop];
|
||||
return typeof value === "function"
|
||||
? value.bind(target._element)
|
||||
: value;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_setValue(element) {
|
||||
this._element = element;
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance](instance) {
|
||||
return instance?._isViewChild === true;
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentDefinition {
|
||||
constructor(
|
||||
options = {
|
||||
templatesPath: null,
|
||||
template: null,
|
||||
stylesPath: null,
|
||||
style: null,
|
||||
scriptsPath: null,
|
||||
},
|
||||
) {
|
||||
this.templatesPath = options.templatesPath;
|
||||
this.template = options.template;
|
||||
this.stylesPath = options.stylesPath;
|
||||
this.scriptsPath = options.scriptsPath;
|
||||
}
|
||||
}
|
||||
|
||||
class EventListener {
|
||||
constructor(event, element, handler) {
|
||||
this.event = event;
|
||||
this.element = element;
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
attach() {
|
||||
this.element.addEventListener(this.event, this.handler);
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.element.removeEventListener(this.event, this.handler);
|
||||
}
|
||||
}
|
||||
|
||||
export class Component extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this._eventListeners = [];
|
||||
return new Proxy(this, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, target);
|
||||
if (typeof value === "function" && typeof value.bind === "function") {
|
||||
return prop in EventTarget.prototype
|
||||
? value.bind(target)
|
||||
: value.bind(receiver);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (target[prop] instanceof ComponentInput) {
|
||||
target[prop].set(value);
|
||||
return true;
|
||||
} else if (target[prop] instanceof ViewChild) {
|
||||
target[prop]._setValue(value);
|
||||
} else {
|
||||
target[prop] = value;
|
||||
}
|
||||
target.requestUpdate();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
requestUpdate() {
|
||||
this.dispatchEvent(new Event("requestUpdate", { component: this }));
|
||||
}
|
||||
|
||||
static get definition() {
|
||||
throw new Error("Component definition is not defined");
|
||||
}
|
||||
|
||||
onInit() { }
|
||||
onBeforeRender() { }
|
||||
onAfterRender() { }
|
||||
onDestroy() {
|
||||
this._eventListeners.forEach((listener) => listener.detach());
|
||||
}
|
||||
}
|
||||
|
||||
export function initRouter(outletId, includeId, routes) {
|
||||
const outletRef = document.getElementById(outletId);
|
||||
const includeRef = document.getElementById(includeId);
|
||||
if (!outletRef) {
|
||||
console.error(`Outlet element with id '${outletId}' not found`);
|
||||
return null;
|
||||
}
|
||||
if (!includeRef) {
|
||||
console.error(`Include element with id '${includeId}' not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Router(outletRef, includeRef, routes);
|
||||
}
|
||||
|
||||
export class Router {
|
||||
constructor(outletRef, includeRef, routes) {
|
||||
this.outletRef = outletRef;
|
||||
this.includeRef = includeRef;
|
||||
this.routes = routes;
|
||||
this.templateCache = new Map();
|
||||
outletRef.innerHTML = "Loading...";
|
||||
includeRef.innerHTML = "";
|
||||
navigation.addEventListener("navigate", this.handleNavigate.bind(this));
|
||||
}
|
||||
|
||||
findMatchingRoute(path) {
|
||||
const routeMatch = this.routes
|
||||
.asEnumerable()
|
||||
.select((route) => route.match(path))
|
||||
.where((match) => match !== null)
|
||||
.orderByDescending((match) => match.segmentCount)
|
||||
.firstOrDefault();
|
||||
|
||||
return routeMatch;
|
||||
}
|
||||
|
||||
async handleNavigate(event) {
|
||||
const routeMatch = this.findMatchingRoute(event.destination.url);
|
||||
if (routeMatch) {
|
||||
event.preventDefault();
|
||||
const template = await this.getCachedTemplate(routeMatch.route);
|
||||
const controller = this.createComponentInstance(
|
||||
routeMatch.route.componentClass,
|
||||
routeMatch.params,
|
||||
);
|
||||
this.destroyCurrent();
|
||||
this.activeController = controller;
|
||||
this.activeTemplate = template;
|
||||
this.insertIncludes(routeMatch.route);
|
||||
this._boundUpdateHandler = this.handleUpdateRequest.bind(this);
|
||||
this.activeController.addEventListener(
|
||||
"requestUpdate",
|
||||
this._boundUpdateHandler,
|
||||
);
|
||||
this.renderActiveComponent();
|
||||
history.pushState(null, "", routeMatch.path);
|
||||
}
|
||||
}
|
||||
|
||||
destroyCurrent() {
|
||||
if (this.activeController) {
|
||||
this.activeController.removeEventListener(
|
||||
"requestUpdate",
|
||||
this._boundUpdateHandler,
|
||||
);
|
||||
this.activeController.onDestroy();
|
||||
this.activeController = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdateRequest(args) {
|
||||
if (this._rendering) {
|
||||
this._pendingUpdate = true;
|
||||
return;
|
||||
} else {
|
||||
this.renderActiveComponent();
|
||||
}
|
||||
}
|
||||
|
||||
insertIncludes(route) {
|
||||
if (Array.isArray(route.ComponentDefinition.stylesPath)) {
|
||||
route.ComponentDefinition.stylesPath.forEach((path) => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = path;
|
||||
this.includeRef.appendChild(link);
|
||||
});
|
||||
} else if (route.ComponentDefinition.stylesPath) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = route.ComponentDefinition.stylesPath;
|
||||
this.includeRef.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
renderActiveComponent() {
|
||||
this._rendering = true;
|
||||
this._pendingUpdate = false;
|
||||
this.activeController.onBeforeRender();
|
||||
const renderedTemplate = this.activeTemplate.render(this.activeController);
|
||||
this.outletRef.innerHTML = renderedTemplate;
|
||||
this.wireEvents(this.activeController);
|
||||
this.wireViewChildren(this.activeController);
|
||||
this.activeController.onAfterRender();
|
||||
this._rendering = false;
|
||||
if (this._pendingUpdate) {
|
||||
this.renderActiveComponent();
|
||||
}
|
||||
}
|
||||
|
||||
wireViewChildren(controller) {
|
||||
const viewChildProps = Object.entries(controller)
|
||||
.filter(([_, value]) => value instanceof ViewChild)
|
||||
.forEach(([key, viewChild]) => {
|
||||
if (viewChild._id) {
|
||||
const element = this.outletRef.querySelector(`#${viewChild._id}`);
|
||||
if (element) viewChild._setValue(element);
|
||||
} else if (viewChild._selector) {
|
||||
const element = this.outletRef.querySelector(viewChild._selector);
|
||||
if (element) viewChild._setValue(element);
|
||||
} else {
|
||||
console.warn(`ViewChild ${key} has no selector or id defined`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wireEvents(controller) {
|
||||
const allElements = this.outletRef.querySelectorAll("*");
|
||||
allElements.forEach((element) => {
|
||||
element.getAttributeNames().forEach((attr) => {
|
||||
const match = attr.match(/^\((\w+)\)$/);
|
||||
if (!match) return;
|
||||
|
||||
const eventName = match[1];
|
||||
const attrValue = element.getAttribute(attr).trim();
|
||||
const methodName = attrValue.endsWith("()")
|
||||
? attrValue.slice(0, -2)
|
||||
: attrValue;
|
||||
|
||||
let listener;
|
||||
if (typeof controller[methodName] === "function") {
|
||||
// Named method — wire directly
|
||||
const handler = controller[methodName].bind(controller);
|
||||
listener = new EventListener(eventName, element, handler);
|
||||
} else {
|
||||
const fn = new Function("event", attrValue).bind(controller);
|
||||
listener = new EventListener(eventName, element, fn);
|
||||
}
|
||||
listener.attach();
|
||||
|
||||
controller._eventListeners.push(listener);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createComponentInstance(component, params) {
|
||||
const instance = new component();
|
||||
|
||||
for (const key of Object.keys(instance)) {
|
||||
const val = instance[key];
|
||||
if (val instanceof ComponentInput) {
|
||||
val._self._owner = instance;
|
||||
}
|
||||
}
|
||||
|
||||
instance.onInit();
|
||||
|
||||
for (const [paramKey, paramValue] of Object.entries(params)) {
|
||||
if (instance[paramKey] instanceof ComponentInput) {
|
||||
instance[paramKey].set(paramValue);
|
||||
continue;
|
||||
}
|
||||
for (const key of Object.keys(instance)) {
|
||||
const val = instance[key];
|
||||
if (val instanceof ComponentInput && val.alias === paramKey) {
|
||||
val.set(paramValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
async getCachedTemplate(route) {
|
||||
if (this.templateCache.has(route.id)) {
|
||||
return this.templateCache.get(route.id);
|
||||
}
|
||||
|
||||
let templateContent = route.componentClass.definition.template;
|
||||
if (!templateContent && route.componentClass.definition.templatesPath) {
|
||||
const response = await fetch(
|
||||
route.componentClass.definition.templatesPath,
|
||||
);
|
||||
|
||||
if (response.ok === false) {
|
||||
return new Template(`
|
||||
<div class="error">
|
||||
<h1>Failed to load template</h1>
|
||||
<p>${response.status} ${response.statusText}</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
templateContent = await response.text();
|
||||
}
|
||||
|
||||
try {
|
||||
const template = new Template(templateContent);
|
||||
this.templateCache.set(route.id, template);
|
||||
return template;
|
||||
} catch (error) {
|
||||
return new Template(`
|
||||
<div class="error">
|
||||
<h1>Failed to compile template</h1>
|
||||
<p>${error.message}</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,115 +1,99 @@
|
||||
import "./linq.js";
|
||||
import { Template } from "./template.js";
|
||||
import './linq.js'
|
||||
import { Template } from './template.js';
|
||||
|
||||
export function formToObject(form) {
|
||||
const data = new FormData(form);
|
||||
const object = {};
|
||||
data.forEach((value, key) => {
|
||||
setNestedValue(object, key, value);
|
||||
});
|
||||
return object;
|
||||
const data = new FormData(form);
|
||||
const object = {};
|
||||
data.forEach((value, key) => {
|
||||
setNestedValue(object, key, value);
|
||||
});
|
||||
return object;
|
||||
}
|
||||
|
||||
function setNestedValue(obj, path, value) {
|
||||
path
|
||||
.split(".")
|
||||
.asEnumerable()
|
||||
.isLast()
|
||||
.forEach((key, isLast) => {
|
||||
if (isLast) {
|
||||
obj[key] = value;
|
||||
} else {
|
||||
if (!obj[key]) {
|
||||
obj[key] = {};
|
||||
}
|
||||
path.split('.').asEnumerable()
|
||||
.isLast()
|
||||
.forEach((key, isLast) => {
|
||||
if (isLast) {
|
||||
obj[key] = value;
|
||||
}
|
||||
else {
|
||||
if (!obj[key]) {
|
||||
obj[key] = {};
|
||||
}
|
||||
|
||||
obj = obj[key];
|
||||
}
|
||||
});
|
||||
obj = obj[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendFormAsync(form, url, method) {
|
||||
url = url || form.action;
|
||||
method = method || form.method || "post";
|
||||
const data = formToObject(form);
|
||||
const response = await sendJsonAsync(url, data, method);
|
||||
if (response.ok && response.redirected) {
|
||||
window.location.href = response.url;
|
||||
return null;
|
||||
}
|
||||
url = url || form.action;
|
||||
method = method || form.method || 'post';
|
||||
const data = formToObject(form);
|
||||
const response = await sendJsonAsync(url, data, method);
|
||||
if (response.ok && response.redirected) {
|
||||
window.location.href = response.url;
|
||||
return null;
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (
|
||||
response.ok == false &&
|
||||
handleValidationError(response, responseText, form)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const responseText = await response.text();
|
||||
if (response.ok == false && handleValidationError(response, responseText, form)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.ok == false) {
|
||||
handleGenericFormError(response, responseText, form);
|
||||
return null;
|
||||
} else {
|
||||
return responseText.length == 0 ? null : JSON.parse(responseText);
|
||||
}
|
||||
if (response.ok == false) {
|
||||
handleGenericFormError(response, responseText, form);
|
||||
return null;
|
||||
} else {
|
||||
return responseText.length == 0 ? null : JSON.parse(responseText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendJsonAsync(url, data, method = "post") {
|
||||
const response = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
export async function sendJsonAsync(url, data, method = 'post') {
|
||||
const response = await fetch(url, {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return response;
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function postAndRenderAsync(url, data, template, targetElement) {
|
||||
const response = await sendJsonAsync(url, data);
|
||||
if (response.ok) {
|
||||
const responseText = await response.text();
|
||||
targetElement.innerHTML = template.render(
|
||||
responseText.length == 0 ? undefined : JSON.parse(responseText),
|
||||
);
|
||||
}
|
||||
const response = await sendJsonAsync(url, data);
|
||||
if (response.ok) {
|
||||
const responseText = await response.text();
|
||||
targetElement.innerHTML = template.render(responseText.length == 0 ? undefined : JSON.parse(responseText));
|
||||
}
|
||||
}
|
||||
|
||||
export async function postFormAndRenderAsync(
|
||||
url,
|
||||
form,
|
||||
template,
|
||||
targetElement,
|
||||
) {
|
||||
const object = formToObject(form);
|
||||
const data = await postFormAsync(url, object, template, targetElement);
|
||||
if (data) {
|
||||
targetElement.innerHTML = template.render(data);
|
||||
}
|
||||
export async function postFormAndRenderAsync(url, form, template, targetElement) {
|
||||
const object = formToObject(form);
|
||||
const data = await postFormAsync(url, object, template, targetElement);
|
||||
if (data) {
|
||||
targetElement.innerHTML = template.render(data);
|
||||
}
|
||||
}
|
||||
|
||||
const genericFormErrorTemplate = new Template(`
|
||||
<div class="form-error" role="alert" aria-live="assertive">
|
||||
{{ $this }}
|
||||
<div class="form-error">
|
||||
An error occurred while submitting the form. Please try again later.
|
||||
{{ $this }}
|
||||
</div>
|
||||
`);
|
||||
|
||||
function handleGenericFormError(response, responseText, form) {
|
||||
if (!response.ok) {
|
||||
// Remove all existing form-level errors before adding a new one
|
||||
form.querySelectorAll(":scope > .form-error").forEach((el) => el.remove());
|
||||
let message = responseText;
|
||||
try {
|
||||
message = JSON.parse(responseText);
|
||||
} catch (_) {}
|
||||
const html = genericFormErrorTemplate.render(message);
|
||||
form.insertAdjacentHTML("beforeend", html);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const html = genericFormErrorTemplate.render(responseText);
|
||||
form.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
}
|
||||
|
||||
const validationErrorTemplate = new Template(`
|
||||
<div class="form-error" role="alert" aria-live="assertive">
|
||||
<div class="form-error">
|
||||
<ul>
|
||||
@for(error of $this) {
|
||||
<li class="error">{{error}}</li>
|
||||
@ -119,7 +103,7 @@ const validationErrorTemplate = new Template(`
|
||||
`);
|
||||
|
||||
const unknownInputErrorTemplate = new Template(`
|
||||
<div class="form-error" role="alert" aria-live="assertive">
|
||||
<div class="form-error">
|
||||
<p>An error occurred with the following fields:</p>
|
||||
@for(field, errors of Object.entries($this)) {
|
||||
<ul>
|
||||
@ -132,49 +116,45 @@ const unknownInputErrorTemplate = new Template(`
|
||||
`);
|
||||
|
||||
function toCamelCase(str) {
|
||||
str = str.replace(/([-_][a-z])/gi, (match) => {
|
||||
return match.toUpperCase().replace("-", "").replace("_", "");
|
||||
});
|
||||
str = str.replace(/([-_][a-z])/gi, (match) => {
|
||||
return match.toUpperCase()
|
||||
.replace('-', '')
|
||||
.replace('_', '');
|
||||
});
|
||||
|
||||
str = str[0].toLowerCase() + str.substring(1);
|
||||
return str;
|
||||
str = str[0].toLowerCase() + str.substring(1);
|
||||
return str;
|
||||
}
|
||||
|
||||
|
||||
function handleValidationError(response, responseText, form) {
|
||||
if (response.status !== 400) return false;
|
||||
const responseObject = JSON.parse(responseText);
|
||||
const unknownInputErrors = {};
|
||||
if (
|
||||
responseObject.type ===
|
||||
"https://tools.ietf.org/html/rfc9110#section-15.5.1" &&
|
||||
responseObject.errors
|
||||
) {
|
||||
for (const [field, messages] of Object.entries(responseObject.errors)) {
|
||||
const input = form.querySelector(`[name="${toCamelCase(field)}"]`);
|
||||
if (input) {
|
||||
const parent = input.parentElement;
|
||||
const errorHtml = validationErrorTemplate.render(messages);
|
||||
let errorContainer = parent.querySelector(".form-error"); // Check if an error container already exists
|
||||
if (errorContainer) {
|
||||
errorContainer.outerHTML = errorHtml; // Replace existing error container
|
||||
} else {
|
||||
parent.insertAdjacentHTML("beforeend", errorHtml);
|
||||
if (response.status !== 400) return false;
|
||||
const responseObject = JSON.parse(responseText);
|
||||
const unknownInputErrors = {};
|
||||
if (responseObject.type === 'https://tools.ietf.org/html/rfc9110#section-15.5.1' && responseObject.errors) {
|
||||
for (const [field, messages] of Object.entries(responseObject.errors)) {
|
||||
const input = form.querySelector(`[name="${toCamelCase(field)}"]`);
|
||||
if (input) {
|
||||
const parent = input.parentElement;
|
||||
const errorHtml = validationErrorTemplate.render(messages);
|
||||
let errorContainer = parent.querySelector('.form-error'); // Check if an error container already exists
|
||||
if (errorContainer) {
|
||||
errorContainer.outerHTML = errorHtml; // Replace existing error container
|
||||
} else {
|
||||
parent.insertAdjacentHTML('beforeend', errorHtml);
|
||||
}
|
||||
} else {
|
||||
unknownInputErrors[field] = messages;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
unknownInputErrors[field] = messages;
|
||||
}
|
||||
|
||||
if (Object.keys(unknownInputErrors).length > 0) {
|
||||
const html = unknownInputErrorTemplate.render(unknownInputErrors);
|
||||
form.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Object.keys(unknownInputErrors).length > 0) {
|
||||
form
|
||||
.querySelectorAll(":scope > .form-error")
|
||||
.forEach((el) => el.remove());
|
||||
const html = unknownInputErrorTemplate.render(unknownInputErrors);
|
||||
form.insertAdjacentHTML("beforeend", html);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -5,20 +5,8 @@ async function signupAsync(params) {
|
||||
await sendFormAsync(form);
|
||||
}
|
||||
|
||||
function togglePassword() {
|
||||
const passwordInput = document.getElementById('password');
|
||||
const togglePasswordButton = document.getElementById('togglePassword');
|
||||
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
|
||||
passwordInput.type = newInputType;
|
||||
const isVisible = newInputType === 'text';
|
||||
togglePasswordButton.textContent = isVisible ? 'Hide' : 'Show';
|
||||
togglePasswordButton.setAttribute('aria-pressed', String(isVisible));
|
||||
}
|
||||
|
||||
document.getElementById('togglePassword')?.addEventListener('click', togglePassword);
|
||||
|
||||
const signupForm = document.getElementById('signupForm');
|
||||
signupForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault(); // Prevent the default form submission
|
||||
await signupAsync();
|
||||
});
|
||||
});
|
||||
@ -1,476 +1,455 @@
|
||||
import "./linq.js";
|
||||
import './linq.js'
|
||||
|
||||
function selectPath(data, expression) {
|
||||
if (!expression) return data;
|
||||
try {
|
||||
return new Function("$data", `with($data) { return (${expression}); }`)(
|
||||
data,
|
||||
);
|
||||
} catch {
|
||||
console.error(`Error evaluating Template expression: ${expression}`);
|
||||
return undefined;
|
||||
}
|
||||
if (!expression) return data;
|
||||
try {
|
||||
return new Function('$data', `with($data) { return (${expression}); }`)(data);
|
||||
} catch {
|
||||
console.error(`Error evaluating Template expression: ${expression}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class ScopeDict {
|
||||
constructor(initial = {}) {
|
||||
this._entries = { ...initial };
|
||||
}
|
||||
|
||||
set(name, value) {
|
||||
if (name.startsWith("$")) {
|
||||
throw new Error(
|
||||
`Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`,
|
||||
);
|
||||
constructor(initial = {}) {
|
||||
this._entries = { ...initial };
|
||||
}
|
||||
this._entries[name] = value;
|
||||
}
|
||||
|
||||
setBuiltin(name, value) {
|
||||
this._entries[name] = value;
|
||||
}
|
||||
set(name, value) {
|
||||
if (name.startsWith('$')) {
|
||||
throw new Error(`Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`);
|
||||
}
|
||||
this._entries[name] = value;
|
||||
}
|
||||
|
||||
has(name) {
|
||||
return name in this._entries;
|
||||
}
|
||||
setBuiltin(name, value) {
|
||||
this._entries[name] = value;
|
||||
}
|
||||
|
||||
get(name) {
|
||||
return this._entries[name];
|
||||
}
|
||||
has(name) {
|
||||
return name in this._entries;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new ScopeDict(this._entries);
|
||||
}
|
||||
get(name) {
|
||||
return this._entries[name];
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new ScopeDict(this._entries);
|
||||
}
|
||||
}
|
||||
|
||||
function createScopeProxy(data, scope) {
|
||||
if (data === null || data === undefined || typeof data !== "object") {
|
||||
scope.setBuiltin("$this", data);
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === "$this") return data;
|
||||
if (scope.has(prop)) return scope.get(prop);
|
||||
return undefined;
|
||||
if (data === null || data === undefined || typeof data !== 'object') {
|
||||
scope.setBuiltin('$this', data);
|
||||
return new Proxy({}, {
|
||||
get(_target, prop) {
|
||||
if (prop === '$this') return data;
|
||||
if (scope.has(prop)) return scope.get(prop);
|
||||
return undefined;
|
||||
},
|
||||
has(_target, prop) {
|
||||
return prop === '$this' || scope.has(prop);
|
||||
}
|
||||
});
|
||||
}
|
||||
scope.setBuiltin('$this', data);
|
||||
return new Proxy(data, {
|
||||
get(target, prop) {
|
||||
if (prop === '$this') return data;
|
||||
if (scope.has(prop)) return scope.get(prop);
|
||||
return target[prop];
|
||||
},
|
||||
has(_target, prop) {
|
||||
return prop === "$this" || scope.has(prop);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
scope.setBuiltin("$this", data);
|
||||
return new Proxy(data, {
|
||||
get(target, prop) {
|
||||
if (prop === "$this") return data;
|
||||
if (scope.has(prop)) return scope.get(prop);
|
||||
return target[prop];
|
||||
},
|
||||
has(target, prop) {
|
||||
return prop === "$this" || scope.has(prop) || prop in target;
|
||||
},
|
||||
});
|
||||
has(target, prop) {
|
||||
return prop === '$this' || scope.has(prop) || prop in target;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function skipString(str, i) {
|
||||
const quote = str[i];
|
||||
i++;
|
||||
while (i < str.length && str[i] !== quote) {
|
||||
if (str[i] === "\\") i++;
|
||||
const quote = str[i];
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
while (i < str.length && str[i] !== quote) {
|
||||
if (str[i] === '\\') i++;
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function findClosingParen(str, openPos) {
|
||||
let depth = 1;
|
||||
let i = openPos + 1;
|
||||
while (i < str.length && depth > 0) {
|
||||
if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
|
||||
i = skipString(str, i) + 1;
|
||||
continue;
|
||||
let depth = 1;
|
||||
let i = openPos + 1;
|
||||
while (i < str.length && depth > 0) {
|
||||
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
||||
i = skipString(str, i) + 1;
|
||||
continue;
|
||||
}
|
||||
if (str[i] === '(') depth++;
|
||||
else if (str[i] === ')') depth--;
|
||||
if (depth > 0) i++;
|
||||
}
|
||||
if (str[i] === "(") depth++;
|
||||
else if (str[i] === ")") depth--;
|
||||
if (depth > 0) i++;
|
||||
}
|
||||
if (depth !== 0) throw new Error("Unclosed ( in template");
|
||||
return i;
|
||||
if (depth !== 0) throw new Error('Unclosed ( in template');
|
||||
return i;
|
||||
}
|
||||
|
||||
function findClosingBrace(str, openPos) {
|
||||
let depth = 1;
|
||||
let i = openPos + 1;
|
||||
while (i < str.length && depth > 0) {
|
||||
if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
|
||||
i = skipString(str, i) + 1;
|
||||
continue;
|
||||
let depth = 1;
|
||||
let i = openPos + 1;
|
||||
while (i < str.length && depth > 0) {
|
||||
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
||||
i = skipString(str, i) + 1;
|
||||
continue;
|
||||
}
|
||||
if (str[i] === '{' && str[i + 1] === '{') {
|
||||
const end = str.indexOf('}}', i + 2);
|
||||
if (end === -1) throw new Error('Unclosed {{ }} in template');
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
if (str[i] === '{') depth++;
|
||||
else if (str[i] === '}') depth--;
|
||||
if (depth > 0) i++;
|
||||
}
|
||||
if (str[i] === "{" && str[i + 1] === "{") {
|
||||
const end = str.indexOf("}}", i + 2);
|
||||
if (end === -1) throw new Error("Unclosed {{ }} in template");
|
||||
i = end + 2;
|
||||
continue;
|
||||
if (depth !== 0) throw new Error('Unclosed { in template');
|
||||
return i;
|
||||
}
|
||||
|
||||
function findTopLevelComma(str) {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
||||
i = skipString(str, i);
|
||||
continue;
|
||||
}
|
||||
if (str[i] === '(') depth++;
|
||||
else if (str[i] === ')') depth--;
|
||||
else if (str[i] === ',' && depth === 0) return i;
|
||||
}
|
||||
if (str[i] === "{") depth++;
|
||||
else if (str[i] === "}") depth--;
|
||||
if (depth > 0) i++;
|
||||
}
|
||||
if (depth !== 0) throw new Error("Unclosed { in template");
|
||||
return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function formatNumber(value, spec) {
|
||||
const hasThousands = spec.includes(",");
|
||||
const dotIdx = spec.indexOf(".");
|
||||
const hasThousands = spec.includes(',');
|
||||
const dotIdx = spec.indexOf('.');
|
||||
|
||||
let decimals = 0;
|
||||
if (dotIdx !== -1) {
|
||||
decimals = spec.length - dotIdx - 1;
|
||||
}
|
||||
|
||||
let result = value.toFixed(decimals);
|
||||
|
||||
if (hasThousands) {
|
||||
const parts = result.split(".");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
result = parts.join(".");
|
||||
}
|
||||
|
||||
if (!spec.includes(".") && !hasThousands) {
|
||||
const padLength = spec.replace(/[^0]/g, "").length;
|
||||
if (padLength > 0) {
|
||||
const isNeg = value < 0;
|
||||
let abs = Math.abs(Math.round(value)).toString();
|
||||
abs = abs.padStart(padLength, "0");
|
||||
result = isNeg ? "-" + abs : abs;
|
||||
let decimals = 0;
|
||||
if (dotIdx !== -1) {
|
||||
decimals = spec.length - dotIdx - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
let result = value.toFixed(decimals);
|
||||
|
||||
if (hasThousands) {
|
||||
const parts = result.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
result = parts.join('.');
|
||||
}
|
||||
|
||||
if (!spec.includes('.') && !hasThousands) {
|
||||
const padLength = spec.replace(/[^0]/g, '').length;
|
||||
if (padLength > 0) {
|
||||
const isNeg = value < 0;
|
||||
let abs = Math.abs(Math.round(value)).toString();
|
||||
abs = abs.padStart(padLength, '0');
|
||||
result = isNeg ? '-' + abs : abs;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatDate(date, spec) {
|
||||
const pad = (n, len = 2) => String(n).padStart(len, "0");
|
||||
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
||||
|
||||
const tokens = {
|
||||
yyyy: date.getFullYear(),
|
||||
yy: String(date.getFullYear()).slice(-2),
|
||||
MM: pad(date.getMonth() + 1),
|
||||
M: date.getMonth() + 1,
|
||||
dd: pad(date.getDate()),
|
||||
d: date.getDate(),
|
||||
HH: pad(date.getHours()),
|
||||
H: date.getHours(),
|
||||
hh: pad(date.getHours() % 12 || 12),
|
||||
h: date.getHours() % 12 || 12,
|
||||
mm: pad(date.getMinutes()),
|
||||
m: date.getMinutes(),
|
||||
ss: pad(date.getSeconds()),
|
||||
s: date.getSeconds(),
|
||||
fff: pad(date.getMilliseconds(), 3),
|
||||
};
|
||||
const tokens = {
|
||||
'yyyy': date.getFullYear(),
|
||||
'yy': String(date.getFullYear()).slice(-2),
|
||||
'MM': pad(date.getMonth() + 1),
|
||||
'M': date.getMonth() + 1,
|
||||
'dd': pad(date.getDate()),
|
||||
'd': date.getDate(),
|
||||
'HH': pad(date.getHours()),
|
||||
'H': date.getHours(),
|
||||
'hh': pad(date.getHours() % 12 || 12),
|
||||
'h': date.getHours() % 12 || 12,
|
||||
'mm': pad(date.getMinutes()),
|
||||
'm': date.getMinutes(),
|
||||
'ss': pad(date.getSeconds()),
|
||||
's': date.getSeconds(),
|
||||
'fff': pad(date.getMilliseconds(), 3),
|
||||
};
|
||||
|
||||
let result = spec;
|
||||
for (const [token, value] of Object.entries(tokens).sort(
|
||||
(a, b) => b[0].length - a[0].length,
|
||||
)) {
|
||||
result = result.replaceAll(token, String(value));
|
||||
}
|
||||
let result = spec;
|
||||
for (const [token, value] of Object.entries(tokens).sort((a, b) => b[0].length - a[0].length)) {
|
||||
result = result.replaceAll(token, String(value));
|
||||
}
|
||||
|
||||
return result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function createFormatter(formatSpec) {
|
||||
return (value) => {
|
||||
if (value == null) return "";
|
||||
if (value instanceof Date) return formatDate(value, formatSpec);
|
||||
if (typeof value === "number") return formatNumber(value, formatSpec);
|
||||
return String(value);
|
||||
};
|
||||
return (value) => {
|
||||
if (value == null) return '';
|
||||
if (value instanceof Date) return formatDate(value, formatSpec);
|
||||
if (typeof value === 'number') return formatNumber(value, formatSpec);
|
||||
return String(value);
|
||||
};
|
||||
}
|
||||
|
||||
class StaticTemplatePart {
|
||||
constructor(text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
render(_data) {
|
||||
return this.text;
|
||||
}
|
||||
class StaticTemplatePart {
|
||||
constructor(text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
render(_data) {
|
||||
return this.text;
|
||||
}
|
||||
}
|
||||
|
||||
class VariableTemplatePart {
|
||||
constructor(contextPath, formatter) {
|
||||
this.contextPath = contextPath;
|
||||
this.formatter = formatter || ((x) => x);
|
||||
}
|
||||
constructor(contextPath, formatter) {
|
||||
this.contextPath = contextPath;
|
||||
this.formatter = formatter || (x => x);
|
||||
}
|
||||
|
||||
render(data) {
|
||||
return this.formatter(selectPath(data, this.contextPath));
|
||||
}
|
||||
render(data) {
|
||||
return this.formatter(selectPath(data, this.contextPath));
|
||||
}
|
||||
}
|
||||
|
||||
class LetTemplatePart {
|
||||
constructor(name, expression) {
|
||||
this.name = name;
|
||||
this.expression = expression;
|
||||
}
|
||||
constructor(name, expression) {
|
||||
this.name = name;
|
||||
this.expression = expression;
|
||||
}
|
||||
|
||||
declareScoped(scope, data) {
|
||||
scope.set(this.name, selectPath(data, this.expression));
|
||||
}
|
||||
declareScoped(scope, data) {
|
||||
scope.set(this.name, selectPath(data, this.expression));
|
||||
}
|
||||
|
||||
render(_data) {
|
||||
return "";
|
||||
}
|
||||
render(_data) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
class ListTemplatePart {
|
||||
constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
|
||||
this.varNames = varNames;
|
||||
this.contextPath = contextPath;
|
||||
this.formatter = formatter || ((x) => x);
|
||||
this.innerTemplate = innerTemplate;
|
||||
this.emptyTemplate = emptyTemplate;
|
||||
}
|
||||
|
||||
render(data) {
|
||||
const list = this.formatter(selectPath(data, this.contextPath));
|
||||
const arr = Array.isArray(list) ? list : Array.from(list);
|
||||
|
||||
if (arr.length === 0) {
|
||||
return this.emptyTemplate ? this.emptyTemplate.render(data) : "";
|
||||
constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
|
||||
this.varNames = varNames;
|
||||
this.contextPath = contextPath;
|
||||
this.formatter = formatter || (x => x);
|
||||
this.innerTemplate = innerTemplate;
|
||||
this.emptyTemplate = emptyTemplate;
|
||||
}
|
||||
|
||||
let result = "";
|
||||
const count = arr.length;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const scope = new ScopeDict();
|
||||
const item = arr[index];
|
||||
if (this.varNames.length === 1) {
|
||||
scope.setBuiltin(this.varNames[0], item);
|
||||
} else {
|
||||
for (let v = 0; v < this.varNames.length; v++) {
|
||||
scope.setBuiltin(this.varNames[v], item[v]);
|
||||
render(data) {
|
||||
const list = this.formatter(selectPath(data, this.contextPath));
|
||||
const arr = Array.isArray(list) ? list : Array.from(list);
|
||||
|
||||
if (arr.length === 0) {
|
||||
return this.emptyTemplate ? this.emptyTemplate.render(data) : '';
|
||||
}
|
||||
}
|
||||
scope.setBuiltin("$index", index);
|
||||
scope.setBuiltin("$first", index === 0);
|
||||
scope.setBuiltin("$last", index === count - 1);
|
||||
scope.setBuiltin("$even", index % 2 === 0);
|
||||
scope.setBuiltin("$odd", index % 2 === 1);
|
||||
scope.setBuiltin("$count", count);
|
||||
result += this.innerTemplate.render(data, scope);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
let result = '';
|
||||
const count = arr.length;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const scope = new ScopeDict();
|
||||
const item = arr[index];
|
||||
if (this.varNames.length === 1) {
|
||||
scope.setBuiltin(this.varNames[0], item);
|
||||
} else {
|
||||
for (let v = 0; v < this.varNames.length; v++) {
|
||||
scope.setBuiltin(this.varNames[v], item[v]);
|
||||
}
|
||||
}
|
||||
scope.setBuiltin('$index', index);
|
||||
scope.setBuiltin('$first', index === 0);
|
||||
scope.setBuiltin('$last', index === count - 1);
|
||||
scope.setBuiltin('$even', index % 2 === 0);
|
||||
scope.setBuiltin('$odd', index % 2 === 1);
|
||||
scope.setBuiltin('$count', count);
|
||||
result += this.innerTemplate.render(data, scope);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class ConditionalTemplatePart {
|
||||
constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
|
||||
this.contextPath = contextPath;
|
||||
this.formatter = formatter || ((x) => x);
|
||||
this.condition = condition;
|
||||
this.trueTemplate = trueTemplate;
|
||||
this.falseTemplate = falseTemplate;
|
||||
}
|
||||
|
||||
render(data) {
|
||||
const selectedData = this.formatter(selectPath(data, this.contextPath));
|
||||
if (this.condition(selectedData)) {
|
||||
return this.trueTemplate ? this.trueTemplate.render(data) : "";
|
||||
} else {
|
||||
return this.falseTemplate ? this.falseTemplate.render(data) : "";
|
||||
constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
|
||||
this.contextPath = contextPath;
|
||||
this.formatter = formatter || (x => x);
|
||||
this.condition = condition;
|
||||
this.trueTemplate = trueTemplate;
|
||||
this.falseTemplate = falseTemplate;
|
||||
}
|
||||
|
||||
render(data) {
|
||||
const selectedData = this.formatter(selectPath(data, this.contextPath));
|
||||
if (this.condition(selectedData)) {
|
||||
return this.trueTemplate ? this.trueTemplate.render(data) : '';
|
||||
} else {
|
||||
return this.falseTemplate ? this.falseTemplate.render(data) : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Template {
|
||||
constructor(templateString) {
|
||||
if (templateString != null) {
|
||||
this.parts = this._parseBlock(templateString);
|
||||
}
|
||||
}
|
||||
|
||||
_parseBlock(str) {
|
||||
const parts = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < str.length) {
|
||||
// {{ expression }} or {{ expression:format }}
|
||||
if (str[i] === "{" && str[i + 1] === "{") {
|
||||
const end = str.indexOf("}}", i + 2);
|
||||
if (end === -1) throw new Error("Unclosed {{ }}");
|
||||
const content = str.substring(i + 2, end).trim();
|
||||
|
||||
let expression,
|
||||
formatter = null;
|
||||
const colonIdx = content.lastIndexOf(":");
|
||||
if (colonIdx > 0) {
|
||||
const possibleFormat = content.substring(colonIdx + 1).trim();
|
||||
if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
|
||||
expression = content.substring(0, colonIdx).trim();
|
||||
formatter = createFormatter(possibleFormat);
|
||||
} else {
|
||||
expression = content;
|
||||
}
|
||||
} else {
|
||||
expression = content;
|
||||
constructor(templateString) {
|
||||
if (templateString != null) {
|
||||
this.parts = this._parseBlock(templateString);
|
||||
}
|
||||
|
||||
parts.push(new VariableTemplatePart(expression, formatter));
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// @for(item of expression){...} @empty{...}
|
||||
if (str.startsWith("@for(", i)) {
|
||||
const parenOpen = i + 4;
|
||||
const parenClose = findClosingParen(str, parenOpen);
|
||||
const forClause = str.substring(parenOpen + 1, parenClose).trim();
|
||||
|
||||
const ofIdx = forClause.indexOf(" of ");
|
||||
if (ofIdx === -1)
|
||||
throw new Error('@for missing "of": @for(item of expression)');
|
||||
const varNames = forClause
|
||||
.substring(0, ofIdx)
|
||||
.split(",")
|
||||
.map((v) => v.trim());
|
||||
const expression = forClause.substring(ofIdx + 4).trim();
|
||||
|
||||
const braceOpen = str.indexOf("{", parenClose + 1);
|
||||
if (braceOpen === -1) throw new Error("@for missing block");
|
||||
const braceClose = findClosingBrace(str, braceOpen);
|
||||
const blockContent = str.substring(braceOpen + 1, braceClose);
|
||||
|
||||
const innerTemplate = new Template(null);
|
||||
innerTemplate.parts = this._parseBlock(blockContent);
|
||||
|
||||
let emptyTemplate = null;
|
||||
let afterBlock = braceClose + 1;
|
||||
|
||||
const remaining = str.substring(afterBlock);
|
||||
const emptyMatch = remaining.match(/^\s*@empty\s*\{/);
|
||||
if (emptyMatch) {
|
||||
const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1;
|
||||
const emptyBraceClose = findClosingBrace(str, emptyBraceOpen);
|
||||
const emptyContent = str.substring(
|
||||
emptyBraceOpen + 1,
|
||||
emptyBraceClose,
|
||||
);
|
||||
|
||||
emptyTemplate = new Template(null);
|
||||
emptyTemplate.parts = this._parseBlock(emptyContent);
|
||||
afterBlock = emptyBraceClose + 1;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
new ListTemplatePart(
|
||||
varNames,
|
||||
expression,
|
||||
innerTemplate,
|
||||
emptyTemplate,
|
||||
),
|
||||
);
|
||||
i = afterBlock;
|
||||
continue;
|
||||
}
|
||||
|
||||
// @if(expression){...}@else{...}
|
||||
if (str.startsWith("@if(", i)) {
|
||||
const parenOpen = i + 3;
|
||||
const parenClose = findClosingParen(str, parenOpen);
|
||||
const expression = str.substring(parenOpen + 1, parenClose).trim();
|
||||
|
||||
const trueBraceOpen = str.indexOf("{", parenClose + 1);
|
||||
if (trueBraceOpen === -1) throw new Error("@if missing block");
|
||||
const trueBraceClose = findClosingBrace(str, trueBraceOpen);
|
||||
const trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
|
||||
|
||||
const trueTemplate = new Template(null);
|
||||
trueTemplate.parts = this._parseBlock(trueContent);
|
||||
|
||||
let falseTemplate = null;
|
||||
let afterBlock = trueBraceClose + 1;
|
||||
|
||||
const remaining = str.substring(afterBlock);
|
||||
const elseMatch = remaining.match(/^\s*@else\s*\{/);
|
||||
if (elseMatch) {
|
||||
const falseBraceOpen = afterBlock + elseMatch[0].length - 1;
|
||||
const falseBraceClose = findClosingBrace(str, falseBraceOpen);
|
||||
const falseContent = str.substring(
|
||||
falseBraceOpen + 1,
|
||||
falseBraceClose,
|
||||
);
|
||||
|
||||
falseTemplate = new Template(null);
|
||||
falseTemplate.parts = this._parseBlock(falseContent);
|
||||
afterBlock = falseBraceClose + 1;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
new ConditionalTemplatePart(
|
||||
expression,
|
||||
(val) => !!val,
|
||||
trueTemplate,
|
||||
falseTemplate,
|
||||
),
|
||||
);
|
||||
i = afterBlock;
|
||||
continue;
|
||||
}
|
||||
|
||||
// @let name = expression (ends at newline or end of string)
|
||||
if (str.startsWith("@let ", i)) {
|
||||
const lineEnd = str.indexOf("\n", i);
|
||||
const end = lineEnd === -1 ? str.length : lineEnd;
|
||||
const declaration = str.substring(i + 5, end).trim();
|
||||
const eqIdx = declaration.indexOf("=");
|
||||
if (eqIdx === -1) throw new Error("Invalid @let: missing =");
|
||||
|
||||
const name = declaration.substring(0, eqIdx).trim();
|
||||
const expression = declaration.substring(eqIdx + 1).trim();
|
||||
|
||||
parts.push(new LetTemplatePart(name, expression));
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Static text — collect until next special token
|
||||
let end = i;
|
||||
while (end < str.length) {
|
||||
if (str[end] === "{" && str[end + 1] === "{") break;
|
||||
if (
|
||||
str[end] === "@" &&
|
||||
(str.startsWith("@for(", end) ||
|
||||
str.startsWith("@if(", end) ||
|
||||
str.startsWith("@let ", end))
|
||||
)
|
||||
break;
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end > i) {
|
||||
parts.push(new StaticTemplatePart(str.substring(i, end)));
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
_parseBlock(str) {
|
||||
const parts = [];
|
||||
let i = 0;
|
||||
|
||||
render(data, parentScope = new ScopeDict()) {
|
||||
const scope = parentScope.clone();
|
||||
const proxy = createScopeProxy(data, scope);
|
||||
while (i < str.length) {
|
||||
// {{ expression }} or {{ expression:format }}
|
||||
if (str[i] === '{' && str[i + 1] === '{') {
|
||||
const end = str.indexOf('}}', i + 2);
|
||||
if (end === -1) throw new Error('Unclosed {{ }}');
|
||||
const content = str.substring(i + 2, end).trim();
|
||||
|
||||
let result = "";
|
||||
for (const part of this.parts) {
|
||||
part.declareScoped?.(scope, proxy);
|
||||
result += part.render(proxy);
|
||||
let expression, formatter = null;
|
||||
const colonIdx = content.lastIndexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
const possibleFormat = content.substring(colonIdx + 1).trim();
|
||||
if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
|
||||
expression = content.substring(0, colonIdx).trim();
|
||||
formatter = createFormatter(possibleFormat);
|
||||
} else {
|
||||
expression = content;
|
||||
}
|
||||
} else {
|
||||
expression = content;
|
||||
}
|
||||
|
||||
parts.push(new VariableTemplatePart(expression, formatter));
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// @for(item of expression){...} @empty{...}
|
||||
if (str.startsWith('@for(', i)) {
|
||||
const parenOpen = i + 4;
|
||||
const parenClose = findClosingParen(str, parenOpen);
|
||||
const forClause = str.substring(parenOpen + 1, parenClose).trim();
|
||||
|
||||
const ofIdx = forClause.indexOf(' of ');
|
||||
if (ofIdx === -1) throw new Error('@for missing "of": @for(item of expression)');
|
||||
const varNames = forClause.substring(0, ofIdx).split(',').map(v => v.trim());
|
||||
const expression = forClause.substring(ofIdx + 4).trim();
|
||||
|
||||
const braceOpen = str.indexOf('{', parenClose + 1);
|
||||
if (braceOpen === -1) throw new Error('@for missing block');
|
||||
const braceClose = findClosingBrace(str, braceOpen);
|
||||
const blockContent = str.substring(braceOpen + 1, braceClose);
|
||||
|
||||
const innerTemplate = new Template(null);
|
||||
innerTemplate.parts = this._parseBlock(blockContent);
|
||||
|
||||
let emptyTemplate = null;
|
||||
let afterBlock = braceClose + 1;
|
||||
|
||||
const remaining = str.substring(afterBlock);
|
||||
const emptyMatch = remaining.match(/^\s*@empty\s*\{/);
|
||||
if (emptyMatch) {
|
||||
const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1;
|
||||
const emptyBraceClose = findClosingBrace(str, emptyBraceOpen);
|
||||
const emptyContent = str.substring(emptyBraceOpen + 1, emptyBraceClose);
|
||||
|
||||
emptyTemplate = new Template(null);
|
||||
emptyTemplate.parts = this._parseBlock(emptyContent);
|
||||
afterBlock = emptyBraceClose + 1;
|
||||
}
|
||||
|
||||
parts.push(new ListTemplatePart(varNames, expression, innerTemplate, emptyTemplate));
|
||||
i = afterBlock;
|
||||
continue;
|
||||
}
|
||||
|
||||
// @if(expression){...}@else{...}
|
||||
if (str.startsWith('@if(', i)) {
|
||||
const parenOpen = i + 3;
|
||||
const parenClose = findClosingParen(str, parenOpen);
|
||||
const expression = str.substring(parenOpen + 1, parenClose).trim();
|
||||
|
||||
const trueBraceOpen = str.indexOf('{', parenClose + 1);
|
||||
if (trueBraceOpen === -1) throw new Error('@if missing block');
|
||||
const trueBraceClose = findClosingBrace(str, trueBraceOpen);
|
||||
const trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
|
||||
|
||||
const trueTemplate = new Template(null);
|
||||
trueTemplate.parts = this._parseBlock(trueContent);
|
||||
|
||||
let falseTemplate = null;
|
||||
let afterBlock = trueBraceClose + 1;
|
||||
|
||||
const remaining = str.substring(afterBlock);
|
||||
const elseMatch = remaining.match(/^\s*@else\s*\{/);
|
||||
if (elseMatch) {
|
||||
const falseBraceOpen = afterBlock + elseMatch[0].length - 1;
|
||||
const falseBraceClose = findClosingBrace(str, falseBraceOpen);
|
||||
const falseContent = str.substring(falseBraceOpen + 1, falseBraceClose);
|
||||
|
||||
falseTemplate = new Template(null);
|
||||
falseTemplate.parts = this._parseBlock(falseContent);
|
||||
afterBlock = falseBraceClose + 1;
|
||||
}
|
||||
|
||||
parts.push(new ConditionalTemplatePart(expression, val => !!val, trueTemplate, falseTemplate));
|
||||
i = afterBlock;
|
||||
continue;
|
||||
}
|
||||
|
||||
// @let name = expression (ends at newline or end of string)
|
||||
if (str.startsWith('@let ', i)) {
|
||||
const lineEnd = str.indexOf('\n', i);
|
||||
const end = lineEnd === -1 ? str.length : lineEnd;
|
||||
const declaration = str.substring(i + 5, end).trim();
|
||||
const eqIdx = declaration.indexOf('=');
|
||||
if (eqIdx === -1) throw new Error('Invalid @let: missing =');
|
||||
|
||||
const name = declaration.substring(0, eqIdx).trim();
|
||||
const expression = declaration.substring(eqIdx + 1).trim();
|
||||
|
||||
parts.push(new LetTemplatePart(name, expression));
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Static text — collect until next special token
|
||||
let end = i;
|
||||
while (end < str.length) {
|
||||
if (str[end] === '{' && str[end + 1] === '{') break;
|
||||
if (str[end] === '@' && (
|
||||
str.startsWith('@for(', end) ||
|
||||
str.startsWith('@if(', end) ||
|
||||
str.startsWith('@let ', end)
|
||||
)) break;
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end > i) {
|
||||
parts.push(new StaticTemplatePart(str.substring(i, end)));
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
render(data, parentScope = new ScopeDict()) {
|
||||
const scope = parentScope.clone();
|
||||
const proxy = createScopeProxy(data, scope);
|
||||
|
||||
let result = '';
|
||||
for (const part of this.parts) {
|
||||
part.declareScoped?.(scope, proxy);
|
||||
result += part.render(proxy);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
import {
|
||||
initRouter,
|
||||
Route,
|
||||
ViewChild,
|
||||
Component,
|
||||
ComponentInput,
|
||||
ComponentDefinition,
|
||||
} from "./pwa.js";
|
||||
|
||||
class TestComponent extends Component {
|
||||
static get definition() {
|
||||
return new ComponentDefinition({
|
||||
name: "test-component",
|
||||
template: `<div>
|
||||
<h1>Test Component</h1>
|
||||
<p>This is a test component.</p>
|
||||
<button
|
||||
id="test-button"
|
||||
(click)="incrementCount()"
|
||||
[class.green-button]="count % 2 !== 0"
|
||||
[class.red-button]="count % 2 === 0"
|
||||
>Count: {{count}}</button>
|
||||
</div>`,
|
||||
style: `
|
||||
.red-button {
|
||||
background-color: red;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.green-button {
|
||||
background-color: green;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
count = new ComponentInput(0);
|
||||
|
||||
incrementCount() {
|
||||
this.count++;
|
||||
}
|
||||
}
|
||||
|
||||
const route1 = new Route("/counter", TestComponent);
|
||||
const route2 = new Route("/counter/:count", TestComponent);
|
||||
initRouter("routerOutlet", "styleOutlet", [route1, route2]);
|
||||
@ -1,204 +0,0 @@
|
||||
// ─── parseDate (C# DateTime.ParseExact style) ───
|
||||
// Parses a date string using an exact format pattern.
|
||||
// Supported tokens:
|
||||
// yyyy — 4-digit year
|
||||
// yy — 2-digit year (assumes 2000+)
|
||||
// MM — 2-digit month (01–12)
|
||||
// M — 1 or 2-digit month
|
||||
// dd — 2-digit day (01–31)
|
||||
// d — 1 or 2-digit day
|
||||
// HH — 2-digit hour 24h (00–23)
|
||||
// H — 1 or 2-digit hour 24h
|
||||
// hh — 2-digit hour 12h (01–12)
|
||||
// h — 1 or 2-digit hour 12h
|
||||
// mm — 2-digit minute (00–59)
|
||||
// m — 1 or 2-digit minute
|
||||
// ss — 2-digit second (00–59)
|
||||
// s — 1 or 2-digit second
|
||||
// fff — 3-digit milliseconds
|
||||
// ff — 2-digit (tenths + hundredths)
|
||||
// f — 1-digit (tenths)
|
||||
// tt — AM/PM
|
||||
// t — A/P
|
||||
// Z — literal 'Z' (UTC marker)
|
||||
// K — timezone offset (+HH:mm, -HH:mm, or Z)
|
||||
//
|
||||
// All other characters are matched literally.
|
||||
// Returns Date on success, null on failure.
|
||||
|
||||
const TOKEN_ORDER = [
|
||||
"yyyy", "yy",
|
||||
"MM", "M",
|
||||
"dd", "d",
|
||||
"HH", "H", "hh", "h",
|
||||
"mm", "m",
|
||||
"ss", "s",
|
||||
"fff", "ff", "f",
|
||||
"tt", "t",
|
||||
"K", "Z",
|
||||
];
|
||||
|
||||
const TOKEN_PATTERNS = {
|
||||
yyyy: "(\\d{4})",
|
||||
yy: "(\\d{2})",
|
||||
MM: "(\\d{2})",
|
||||
M: "(\\d{1,2})",
|
||||
dd: "(\\d{2})",
|
||||
d: "(\\d{1,2})",
|
||||
HH: "(\\d{2})",
|
||||
H: "(\\d{1,2})",
|
||||
hh: "(\\d{2})",
|
||||
h: "(\\d{1,2})",
|
||||
mm: "(\\d{2})",
|
||||
m: "(\\d{1,2})",
|
||||
ss: "(\\d{2})",
|
||||
s: "(\\d{1,2})",
|
||||
fff: "(\\d{3})",
|
||||
ff: "(\\d{2})",
|
||||
f: "(\\d{1})",
|
||||
tt: "(AM|PM|am|pm)",
|
||||
t: "([AaPp])",
|
||||
K: "(Z|[+-]\\d{2}:\\d{2})",
|
||||
Z: "(Z)",
|
||||
};
|
||||
|
||||
function buildFormatRegex(format) {
|
||||
const groups = [];
|
||||
let regexStr = "";
|
||||
let i = 0;
|
||||
|
||||
while (i < format.length) {
|
||||
let matched = false;
|
||||
for (const token of TOKEN_ORDER) {
|
||||
if (format.startsWith(token, i)) {
|
||||
groups.push(token);
|
||||
regexStr += TOKEN_PATTERNS[token];
|
||||
i += token.length;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
// Escape literal character
|
||||
regexStr += format[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { regex: new RegExp("^" + regexStr + "$"), groups };
|
||||
}
|
||||
|
||||
export function parseDate(input, format) {
|
||||
if (input == null || format == null) return null;
|
||||
if (input instanceof Date) return input;
|
||||
|
||||
const str = String(input);
|
||||
const { regex, groups } = buildFormatRegex(format);
|
||||
const match = str.match(regex);
|
||||
if (!match) return null;
|
||||
|
||||
let year = 0, month = 1, day = 1;
|
||||
let hour = 0, minute = 0, second = 0, ms = 0;
|
||||
let isPM = false, isAM = false;
|
||||
let tzOffset = null; // minutes
|
||||
|
||||
for (let g = 0; g < groups.length; g++) {
|
||||
const token = groups[g];
|
||||
const val = match[g + 1];
|
||||
|
||||
switch (token) {
|
||||
case "yyyy": year = parseInt(val, 10); break;
|
||||
case "yy": year = 2000 + parseInt(val, 10); break;
|
||||
case "MM":
|
||||
case "M": month = parseInt(val, 10); break;
|
||||
case "dd":
|
||||
case "d": day = parseInt(val, 10); break;
|
||||
case "HH":
|
||||
case "H": hour = parseInt(val, 10); break;
|
||||
case "hh":
|
||||
case "h": hour = parseInt(val, 10); break;
|
||||
case "mm":
|
||||
case "m": minute = parseInt(val, 10); break;
|
||||
case "ss":
|
||||
case "s": second = parseInt(val, 10); break;
|
||||
case "fff": ms = parseInt(val, 10); break;
|
||||
case "ff": ms = parseInt(val, 10) * 10; break;
|
||||
case "f": ms = parseInt(val, 10) * 100; break;
|
||||
case "tt": isPM = val.toUpperCase() === "PM"; isAM = val.toUpperCase() === "AM"; break;
|
||||
case "t": isPM = val.toUpperCase() === "P"; isAM = val.toUpperCase() === "A"; break;
|
||||
case "Z": tzOffset = 0; break;
|
||||
case "K":
|
||||
if (val === "Z") {
|
||||
tzOffset = 0;
|
||||
} else {
|
||||
const sign = val[0] === "+" ? 1 : -1;
|
||||
const [h, m] = val.substring(1).split(":").map(Number);
|
||||
tzOffset = sign * (h * 60 + m);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 12h → 24h conversion
|
||||
if (isPM && hour < 12) hour += 12;
|
||||
if (isAM && hour === 12) hour = 0;
|
||||
|
||||
if (tzOffset !== null) {
|
||||
// Build UTC date, apply offset
|
||||
const utcMs = Date.UTC(year, month - 1, day, hour, minute, second, ms);
|
||||
return new Date(utcMs - tzOffset * 60000);
|
||||
}
|
||||
|
||||
return new Date(year, month - 1, day, hour, minute, second, ms);
|
||||
}
|
||||
|
||||
export function formatDate(date, spec) {
|
||||
const pad = (n, l = 2) => String(n).padStart(l, "0");
|
||||
const tokens = {
|
||||
yyyy: date.getFullYear(),
|
||||
yy: String(date.getFullYear()).slice(-2),
|
||||
MM: pad(date.getMonth() + 1),
|
||||
M: date.getMonth() + 1,
|
||||
dd: pad(date.getDate()),
|
||||
d: date.getDate(),
|
||||
HH: pad(date.getHours()),
|
||||
H: date.getHours(),
|
||||
hh: pad(date.getHours() % 12 || 12),
|
||||
h: date.getHours() % 12 || 12,
|
||||
mm: pad(date.getMinutes()),
|
||||
m: date.getMinutes(),
|
||||
ss: pad(date.getSeconds()),
|
||||
s: date.getSeconds(),
|
||||
fff: pad(date.getMilliseconds(), 3),
|
||||
};
|
||||
let result = spec;
|
||||
for (const [tok, val] of Object.entries(tokens).sort(
|
||||
(a, b) => b[0].length - a[0].length,
|
||||
)) {
|
||||
result = result.replaceAll(tok, String(val));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Input transform presets ───
|
||||
|
||||
export const IntInput = { transform: parseInt };
|
||||
export const FloatInput = { transform: parseFloat };
|
||||
export const BoolInput = {
|
||||
transform: (v) => {
|
||||
if (typeof v === "boolean") return v;
|
||||
if (typeof v === "string") {
|
||||
if (v.toLowerCase() === "true") return true;
|
||||
if (v.toLowerCase() === "false") return false;
|
||||
}
|
||||
return Boolean(v);
|
||||
},
|
||||
};
|
||||
|
||||
export function DateInput(format) {
|
||||
return {
|
||||
transform: (v) => parseDate(v, format),
|
||||
};
|
||||
}
|
||||
|
||||
export const IsoDateInput = DateInput("yyyy-MM-ddTHH:mm:ssK");
|
||||
@ -20,9 +20,8 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<!-- Main container for the login page (CSS layout) -->
|
||||
<main class="login-page" id="main-content" tabindex="-1">
|
||||
<main class="login-page">
|
||||
<!-- White login card -->
|
||||
<section class="login-card">
|
||||
<!-- Logo container -->
|
||||
@ -56,10 +55,9 @@
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
>
|
||||
<button type="button" id="togglePassword" class="toggle-password" aria-controls="password" aria-pressed="false">
|
||||
<button type="button" id="togglePassword" class="toggle-password">
|
||||
Show <!-- Click to show/hide password -->
|
||||
</button>
|
||||
</div>
|
||||
@ -77,4 +75,4 @@
|
||||
<script type="module" src="js/login.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@ -1,361 +1,237 @@
|
||||
<!-- OnlyPrompt - Marketplace page: dynamic prompts, category filter, sort and tier access -->
|
||||
<!-- 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 -->
|
||||
|
||||
<!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" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
<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) {
|
||||
.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 class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
<div style="flex:1; margin:40px auto; max-width:950px;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="marketplace-main" id="main-content" tabindex="-1">
|
||||
<!-- Header -->
|
||||
<div class="marketplace-header">
|
||||
<h1>Marketplace</h1>
|
||||
<p>Browse and discover high-quality AI prompts</p>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- Filter + Sort Row -->
|
||||
<div class="filter-sort-row">
|
||||
<div class="filter-buttons" id="category-filters" role="group" aria-label="Filter prompts by category">
|
||||
<button type="button" class="filter-btn active" data-category="" aria-pressed="true">All</button>
|
||||
<!-- 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>
|
||||
</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 Tier Price</option>
|
||||
<option value="price|false">Highest Tier Price</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Prompts Grid -->
|
||||
<div class="prompts-grid" id="prompts-grid" aria-live="polite"></div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="market-empty" class="state-empty" role="status" aria-live="polite">
|
||||
<i class="bi bi-bag-x state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">No prompts found</h3>
|
||||
<p>Try a different category or search term.</p>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="market-error" class="state-error" role="alert" aria-live="assertive">
|
||||
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
|
||||
<h3 class="state-title">Could not load prompts</h3>
|
||||
<p id="market-error-msg"></p>
|
||||
<!-- 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>
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// ── Sidebar & Topbar ──────────────────────────────────────────────
|
||||
fetch("/sidebar.html")
|
||||
.then((r) => r.text())
|
||||
.then((data) => {
|
||||
document.getElementById("sidebar-container").innerHTML = data;
|
||||
document
|
||||
.querySelectorAll("#sidebar-container .sidebar a")
|
||||
.forEach((l) => {
|
||||
l.classList.remove("active");
|
||||
l.removeAttribute("aria-current");
|
||||
});
|
||||
const link = document.querySelectorAll(
|
||||
"#sidebar-container .sidebar li a",
|
||||
)[1];
|
||||
if (link) {
|
||||
link.classList.add("active");
|
||||
link.setAttribute("aria-current", "page");
|
||||
}
|
||||
<script>
|
||||
fetch('../html/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');
|
||||
});
|
||||
fetch("/topbar.html")
|
||||
.then((r) => r.text())
|
||||
.then(
|
||||
(data) =>
|
||||
(document.getElementById("topbar-container").innerHTML = data),
|
||||
);
|
||||
// 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');
|
||||
});
|
||||
|
||||
// ── 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 href =
|
||||
promptId && !locked
|
||||
? `/post-detail?id=${encodeURIComponent(promptId)}#rating-section`
|
||||
: "";
|
||||
if (rating == null)
|
||||
return href
|
||||
? `<a href="${href}" title="View reviews" class="market-rating-none market-rating-clickable">No reviews yet</a>`
|
||||
: `<span class="market-rating-none">No reviews yet</span>`;
|
||||
const stars = Math.round(rating);
|
||||
const label = reviewCount === 1 ? "review" : "reviews";
|
||||
const content = `<span class="market-rating-stars" aria-hidden="true">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> <span aria-label="${rating.toFixed(1)} out of 5 stars">${rating.toFixed(1)}</span> (${reviewCount} ${label})`;
|
||||
return href
|
||||
? `<a class="prompt-rating market-rating-clickable" href="${href}" title="View reviews">${content}</a>`
|
||||
: `<span class="prompt-rating">${content}</span>`;
|
||||
}
|
||||
|
||||
function promptPrice(prompt) {
|
||||
if (prompt.tierLevel) {
|
||||
const price = prompt.tierMonthlyPrice == null
|
||||
? ""
|
||||
: ` - $${Number(prompt.tierMonthlyPrice).toFixed(2)}/mo`;
|
||||
return `${prompt.tierName || `Tier ${prompt.tierLevel}`}${price}`;
|
||||
}
|
||||
return "Free";
|
||||
}
|
||||
|
||||
function getNumericPrice(prompt) {
|
||||
if (prompt.tierMonthlyPrice != null) return Number(prompt.tierMonthlyPrice);
|
||||
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 locked = p.canAccess === false;
|
||||
const img =
|
||||
p.exampleImageUrl ||
|
||||
p._img ||
|
||||
MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
|
||||
return `
|
||||
<div class="prompt-card">
|
||||
<img src="${img}" alt="${p.title}" class="prompt-img">
|
||||
<div class="prompt-info">
|
||||
<div class="market-card-header">
|
||||
<div class="market-card-avatar">${p.creatorName.charAt(0).toUpperCase()}</div>
|
||||
<span class="prompt-author">@${p.creatorName}</span>
|
||||
<span class="market-card-time"><time datetime="${p.timeStamp}">${timeAgo(p.timeStamp)}</time></span>
|
||||
</div>
|
||||
<h3 class="prompt-title">${p.title}</h3>
|
||||
<p class="prompt-description">${p.description || "No description yet."}</p>
|
||||
<div class="market-card-rating">${renderStars(p.averageRating, p.reviewCount || 0, p.id, locked)}</div>
|
||||
<div class="prompt-price">${promptPrice(p)}</div>
|
||||
<div class="prompt-actions">
|
||||
${
|
||||
locked
|
||||
? `<button type="button" class="buy-btn buy-btn-locked" aria-label="Subscribe to unlock ${p.title}" onclick='subscribeToPromptTier(${JSON.stringify(p)})'><i class="bi bi-lock-fill" aria-hidden="true"></i> Subscribe</button>`
|
||||
: `<button type="button" class="buy-btn buy-btn-unlocked" aria-label="Access ${p.title}" onclick="location.href='/post-detail?id=${p.id}'">Access <i class="bi bi-unlock-fill" aria-hidden="true"></i></button>`
|
||||
}
|
||||
${
|
||||
locked
|
||||
? `<button type="button" class="details-btn" disabled aria-label="Details for ${p.title} are locked"><i class="bi bi-lock-fill" aria-hidden="true"></i> Details</button>`
|
||||
: `<button type="button" class="details-btn" aria-label="View details for ${p.title}" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</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.type = "button";
|
||||
btn.className = "filter-btn";
|
||||
btn.dataset.category = c.slug;
|
||||
btn.setAttribute("aria-pressed", "false");
|
||||
btn.textContent = c.name;
|
||||
btn.addEventListener("click", () => {
|
||||
document
|
||||
.querySelectorAll("#category-filters .filter-btn")
|
||||
.forEach((b) => {
|
||||
b.classList.remove("active");
|
||||
b.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
btn.classList.add("active");
|
||||
btn.setAttribute("aria-pressed", "true");
|
||||
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");
|
||||
b.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
this.classList.add("active");
|
||||
this.setAttribute("aria-pressed", "true");
|
||||
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();
|
||||
|
||||
window.subscribeToPromptTier = async function (prompt) {
|
||||
if (!prompt.tierLevel) return;
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(prompt.creatorId)}/${prompt.tierLevel}`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
loadPrompts();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────
|
||||
await loadCategories();
|
||||
await loadPrompts();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,532 +1,114 @@
|
||||
<!-- OnlyPrompt - Post Detail: dynamic prompt loaded via API -->
|
||||
<!-- OnlyPrompt - Settings page:
|
||||
- User preferences with tabs for profile, security, and notifications -->
|
||||
|
||||
<!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" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
<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>
|
||||
|
||||
<div class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
<div style="flex:1; display: flex; flex-direction: column;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="post-detail-main" id="main-content" tabindex="-1">
|
||||
<div class="post-detail-container" id="detail-content">
|
||||
<!-- Loading -->
|
||||
<div id="detail-loading">
|
||||
<i class="bi bi-hourglass-split state-icon" aria-hidden="true"></i>
|
||||
<p>Loading prompt...</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div id="detail-error" class="state-error" role="alert" aria-live="assertive">
|
||||
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
|
||||
<h3 id="detail-error-title">Prompt not found</h3>
|
||||
<p id="detail-error-msg"></p>
|
||||
<button type="button" onclick="history.back()" class="detail-back-btn">
|
||||
Go Back
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<!-- Content (hidden until loaded) -->
|
||||
<div id="detail-body">
|
||||
<!-- Header -->
|
||||
<div class="post-header">
|
||||
<div class="detail-creator-row">
|
||||
<div id="creator-avatar"></div>
|
||||
<div>
|
||||
<span id="creator-name"></span>
|
||||
<span id="prompt-date"></span>
|
||||
</div>
|
||||
<div class="detail-actions-right">
|
||||
<span id="tier-badge"></span>
|
||||
<button type="button" id="edit-prompt-btn">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>
|
||||
<!-- 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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- 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"></div>
|
||||
</div>
|
||||
|
||||
<!-- Example Output -->
|
||||
<div class="example-section" id="example-section">
|
||||
<h2>EXAMPLE OUTPUT</h2>
|
||||
<div class="example-content">
|
||||
<div
|
||||
id="example-output-text"
|
||||
class="example-output-text"
|
||||
></div>
|
||||
<div id="example-image" class="example-image">
|
||||
<img
|
||||
id="example-image-img"
|
||||
src=""
|
||||
alt="Example output image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locked section (shown instead of prompt if no access) -->
|
||||
<div id="locked-section">
|
||||
<i class="bi bi-lock-fill locked-icon" aria-hidden="true"></i>
|
||||
<h3 class="locked-title">
|
||||
This prompt requires a subscription
|
||||
</h3>
|
||||
<p class="locked-desc">
|
||||
Subscribe to <strong id="locked-creator"></strong> to access
|
||||
this prompt.
|
||||
</p>
|
||||
<button type="button" id="locked-subscribe-btn">
|
||||
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"
|
||||
role="group"
|
||||
aria-label="Select rating"
|
||||
>
|
||||
<button type="button" aria-pressed="false" aria-label="1 star" data-rating="1">☆</button>
|
||||
<button type="button" aria-pressed="false" aria-label="2 stars" data-rating="2">☆</button>
|
||||
<button type="button" aria-pressed="false" aria-label="3 stars" data-rating="3">☆</button>
|
||||
<button type="button" aria-pressed="false" aria-label="4 stars" data-rating="4">☆</button>
|
||||
<button type="button" aria-pressed="false" aria-label="5 stars" data-rating="5">☆</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="review-comment"
|
||||
maxlength="200"
|
||||
rows="3"
|
||||
placeholder="Write a short comment..."
|
||||
aria-label="Review comment"
|
||||
aria-describedby="review-comment-hint"
|
||||
></textarea>
|
||||
<p id="review-comment-hint" class="sr-only">Optional comment, maximum 200 characters.</p>
|
||||
<button type="button" id="submit-review-btn">
|
||||
Submit Review
|
||||
</button>
|
||||
<p id="review-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="reviews-list" id="reviews-list" aria-live="polite">
|
||||
<p class="detail-loading-text">Loading reviews...</p>
|
||||
</div>
|
||||
<!-- Optional example image (if uploaded in create) -->
|
||||
<div class="example-image" style="display: none;">
|
||||
<img src="#" alt="Example Output Image">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Unlock / Buy Section -->
|
||||
<div class="unlock-section">
|
||||
<button class="unlock-btn">Unlock Full Prompt • $19.99</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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");
|
||||
l.removeAttribute("aria-current");
|
||||
});
|
||||
<script>
|
||||
// Fetch sidebar and topbar
|
||||
fetch('../html/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');
|
||||
});
|
||||
fetch("/topbar.html")
|
||||
.then((r) => r.text())
|
||||
.then(
|
||||
(data) =>
|
||||
(document.getElementById("topbar-container").innerHTML = data),
|
||||
);
|
||||
// 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');
|
||||
});
|
||||
|
||||
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"} detail-heart-icon" aria-hidden="true"></i> ${p.likeCount || 0} likes
|
||||
<span class="detail-bookmark-span"><i class="bi ${p.isSaved ? "bi-bookmark-fill" : "bi-bookmark"} detail-bookmark-icon" aria-hidden="true"></i> ${p.saveCount || 0} saves</span>`;
|
||||
renderExamples(p);
|
||||
renderOwnerActions(p);
|
||||
setupReviewSection(p);
|
||||
loadReviews(p.id);
|
||||
|
||||
// Tier badge
|
||||
const badge = document.getElementById("tier-badge");
|
||||
if (p.tierName) {
|
||||
const price = p.tierMonthlyPrice == null
|
||||
? ""
|
||||
: ` - $${Number(p.tierMonthlyPrice).toFixed(2)}/mo`;
|
||||
badge.innerHTML = `<span class="tier-badge-tier"><i class="bi bi-lock-fill" aria-hidden="true"></i> ${p.tierName}${price}</span>`;
|
||||
} else {
|
||||
badge.innerHTML = `<span class="tier-badge-free">Free</span>`;
|
||||
}
|
||||
|
||||
// Rating
|
||||
if (p.averageRating != null) {
|
||||
const stars = Math.round(p.averageRating);
|
||||
document.getElementById("rating-display").innerHTML =
|
||||
`<span class="rating-stars-display">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span>
|
||||
<span class="rating-value">${p.averageRating.toFixed(1)}</span>
|
||||
<span class="rating-count">/ 5.0 (${p.reviewCount || 0} ${(p.reviewCount || 0) === 1 ? "review" : "reviews"})</span>`;
|
||||
document.getElementById("prompt-rating-stat").innerHTML =
|
||||
`<i class="bi ${p.isLiked ? "bi-heart-fill" : "bi-heart"} detail-heart-icon" aria-hidden="true"></i> ${p.likeCount || 0} likes
|
||||
<span class="detail-bookmark-span"><i class="bi ${p.isSaved ? "bi-bookmark-fill" : "bi-bookmark"} detail-bookmark-icon" aria-hidden="true"></i> ${p.saveCount || 0} saves</span>
|
||||
<span class="detail-bookmark-span"><i class="bi bi-star-fill detail-bookmark-icon" aria-hidden="true"></i> ${p.averageRating.toFixed(1)} (${p.reviewCount || 0})</span>`;
|
||||
} else {
|
||||
document.getElementById("rating-display").innerHTML =
|
||||
'<span class="rating-none">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}`
|
||||
: "";
|
||||
setupLockedSubscription(p);
|
||||
}
|
||||
|
||||
document.getElementById("detail-loading").style.display = "none";
|
||||
document.getElementById("detail-body").style.display = "block";
|
||||
scrollToHashSection();
|
||||
}
|
||||
|
||||
function setupLockedSubscription(prompt) {
|
||||
const button = document.getElementById("locked-subscribe-btn");
|
||||
if (!button) return;
|
||||
|
||||
button.disabled = prompt.tierLevel == null;
|
||||
button.onclick = async () => {
|
||||
if (prompt.tierLevel == null) return;
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = "Subscribing...";
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(prompt.creatorId)}/${prompt.tierLevel}`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
button.disabled = false;
|
||||
button.textContent = await response.text();
|
||||
return;
|
||||
}
|
||||
|
||||
location.reload();
|
||||
};
|
||||
}
|
||||
|
||||
function scrollToHashSection() {
|
||||
if (!location.hash) return;
|
||||
|
||||
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 ? "★" : "☆";
|
||||
button.setAttribute("aria-pressed", String(value === rating));
|
||||
button.setAttribute("aria-label", `${value} ${value === 1 ? "star" : "stars"}${value === rating ? ", selected" : ""}`);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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 class="detail-loading-text">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 class="detail-error-text">${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>
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,627 +1,111 @@
|
||||
<!-- OnlyPrompt - Profile page:
|
||||
- User profile display with avatar, bio, stats, and prompt cards (personal prompts) -->
|
||||
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OnlyPrompt - Profile</title>
|
||||
<link rel="stylesheet" href="../css/variables.css" />
|
||||
<link rel="stylesheet" href="../css/base.css" />
|
||||
<link rel="stylesheet" href="../css/sidebar.css" />
|
||||
<link rel="stylesheet" href="../css/login.css" />
|
||||
<link rel="stylesheet" href="../css/topbar.css" />
|
||||
<link rel="stylesheet" href="../css/profile.css" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OnlyPrompt - Profile</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/profile.css">
|
||||
<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 class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
<div style="flex:1; margin:40px auto; max-width:950px;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="login-card profile-main" id="main-content" tabindex="-1">
|
||||
<section class="profile-header">
|
||||
<img
|
||||
id="profileAvatar"
|
||||
src="../images/content/cat.png"
|
||||
alt="Profile avatar"
|
||||
class="profile-avatar"
|
||||
/>
|
||||
|
||||
<div class="profile-info">
|
||||
<h1 id="profileDisplayName">Loading...</h1>
|
||||
<div id="profileSlug">
|
||||
@profile
|
||||
<i class="bi bi-patch-check-fill profile-badge-icon" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div id="profileBio">Loading profile...</div>
|
||||
|
||||
<div id="profileSpecialities"></div>
|
||||
<div id="profileStats">
|
||||
<span><strong id="profileRating">0.0</strong> rating</span>
|
||||
<span
|
||||
><strong id="profileSubscribers">0</strong> subscribers</span
|
||||
>
|
||||
</div>
|
||||
<main class="login-card profile-main" style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:24px;">
|
||||
|
||||
<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;">
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div id="profileActions">
|
||||
<button
|
||||
type="button"
|
||||
id="primaryProfileButton"
|
||||
class="login-button"
|
||||
onclick="location.href = 'settings.html'"
|
||||
>
|
||||
<i class="bi bi-gear" aria-hidden="true"></i>
|
||||
Edit Profile
|
||||
</button>
|
||||
<button type="button" id="shareProfileButton" class="login-button">
|
||||
<i class="bi bi-share" aria-hidden="true"></i>
|
||||
Share Profile
|
||||
</button>
|
||||
<a
|
||||
id="manageTiersButton"
|
||||
class="login-button"
|
||||
href="subscription-tiers.html"
|
||||
>
|
||||
<i class="bi bi-gem" aria-hidden="true"></i>
|
||||
Manage Tiers
|
||||
</a>
|
||||
<div id="creatorTierList"></div>
|
||||
<div style="margin-bottom:8px;">
|
||||
🐾 Cat prompt creator | Nap enthusiast | AI Cat Content Curator<br>
|
||||
Chasing lasers and building purrfect prompts.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="profile-tabs" role="tablist" aria-label="Profile prompt lists">
|
||||
<button
|
||||
type="button"
|
||||
class="profile-tab active"
|
||||
data-tab="mine"
|
||||
id="myPromptsTab"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
aria-controls="profile-prompts-grid"
|
||||
>
|
||||
My Prompts
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-tab"
|
||||
data-tab="favorites"
|
||||
id="favoritesTab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="profile-prompts-grid"
|
||||
>
|
||||
Favorites
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-tab"
|
||||
data-tab="saved"
|
||||
id="savedTab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="profile-prompts-grid"
|
||||
>
|
||||
Saved
|
||||
</button>
|
||||
</nav>
|
||||
<div style="color:#64748b;">Cat City, Dreamland 🐈</div>
|
||||
</div>
|
||||
|
||||
<section id="profile-prompts-grid" role="tabpanel" aria-live="polite" aria-labelledby="myPromptsTab">
|
||||
<div class="profile-grid-loading">Loading prompts...</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<div 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>
|
||||
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
</main>
|
||||
</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");
|
||||
link.removeAttribute("aria-current");
|
||||
});
|
||||
// Then set 'active' only on the My Profile link
|
||||
const profileLink = document.querySelector(
|
||||
'#sidebar-container a[href="profile.html"]',
|
||||
);
|
||||
if (profileLink) {
|
||||
profileLink.classList.add("active");
|
||||
profileLink.setAttribute("aria-current", "page");
|
||||
}
|
||||
<script>
|
||||
fetch('../html/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');
|
||||
});
|
||||
|
||||
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 manageTiersButton = document.getElementById("manageTiersButton");
|
||||
const creatorTierList = document.getElementById("creatorTierList");
|
||||
const profileTabs = document.querySelector(".profile-tabs");
|
||||
const params = new URLSearchParams(location.search);
|
||||
const profileId = params.get("id");
|
||||
let ownPrompts = [];
|
||||
let allPrompts = [];
|
||||
let profilePrompts = [];
|
||||
let activeProfileTab = "mine";
|
||||
let currentUserId = null;
|
||||
let isOwnProfile = !profileId;
|
||||
let profileLoaded = false;
|
||||
let currentIsFollowing = false;
|
||||
let currentSubscriptionTier = null;
|
||||
let creatorSubscriptionTiers = [];
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function renderProfile(profile, fallbackName = "Profile") {
|
||||
profileDisplayName.textContent = profile.displayName || fallbackName;
|
||||
profileSlug.innerHTML = `@${profile.user?.userName || profile.slug || "profile"} <i class="bi bi-patch-check-fill profile-badge-icon" aria-hidden="true"></i>`;
|
||||
profileBio.textContent = profile.bio || "No bio yet.";
|
||||
profileSpecialities.textContent =
|
||||
profile.specialities || "No specialities added yet.";
|
||||
profileRating.textContent = Number(profile.averageRating || 0).toFixed(
|
||||
1,
|
||||
);
|
||||
profileSubscribers.textContent = profile.subscribers || 0;
|
||||
|
||||
if (profile.avatarUrl) {
|
||||
profileAvatar.src = profile.avatarUrl;
|
||||
}
|
||||
profileLoaded = true;
|
||||
}
|
||||
|
||||
function renderProfileFromPrompt(prompt) {
|
||||
if (!prompt || profileLoaded) return;
|
||||
|
||||
profileDisplayName.textContent =
|
||||
prompt.creatorName || "Creator Profile";
|
||||
profileSlug.innerHTML = `@${prompt.creatorName || "creator"} <i class="bi bi-patch-check-fill profile-badge-icon" aria-hidden="true"></i>`;
|
||||
profileBio.textContent = "No bio yet.";
|
||||
profileSpecialities.textContent = "";
|
||||
profileRating.textContent = Number(prompt.averageRating || 0).toFixed(
|
||||
1,
|
||||
);
|
||||
profileSubscribers.textContent = 0;
|
||||
|
||||
if (prompt.creatorAvatarUrl) {
|
||||
profileAvatar.src = prompt.creatorAvatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
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 `
|
||||
<article class="profile-prompt-card">
|
||||
<a class="profile-prompt-link" href="/post-detail?id=${encodeURIComponent(prompt.id)}" aria-label="Open prompt ${prompt.title}">
|
||||
<img src="${image}" alt="${prompt.title}" class="profile-prompt-img">
|
||||
<div class="profile-prompt-body">
|
||||
<div class="profile-prompt-title">${prompt.title}</div>
|
||||
<div class="profile-prompt-desc">${prompt.description || "No description yet."}</div>
|
||||
<div class="profile-prompt-meta">
|
||||
<span><i class="bi bi-star" aria-hidden="true"></i> ${rating}</span>
|
||||
${prompt.creatorName ? `<span>@${prompt.creatorName}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
${showEdit ? `<button type="button" onclick="location.href='/create?id=${encodeURIComponent(prompt.id)}'" class="profile-prompt-edit-btn" aria-label="Edit ${prompt.title}">Edit</button>` : ""}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderPromptList(prompts, emptyText, options = {}) {
|
||||
if (!prompts.length) {
|
||||
profilePromptsGrid.innerHTML = `<div class="profile-grid-empty">${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);
|
||||
tab.setAttribute("aria-selected", String(tab.dataset.tab === activeProfileTab));
|
||||
});
|
||||
profilePromptsGrid.setAttribute("aria-labelledby", activeProfileTab === "favorites" ? "favoritesTab" : activeProfileTab === "saved" ? "savedTab" : "myPromptsTab");
|
||||
|
||||
const liked = allPrompts.filter((prompt) =>
|
||||
isPromptMarked("liked", prompt.id),
|
||||
);
|
||||
const saved = allPrompts.filter((prompt) =>
|
||||
isPromptMarked("saved", prompt.id),
|
||||
);
|
||||
|
||||
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.innerHTML = '<i class="bi bi-gear" aria-hidden="true"></i> Edit Profile';
|
||||
primaryProfileButton.disabled = false;
|
||||
primaryProfileButton.onclick = () =>
|
||||
(location.href = "settings.html");
|
||||
manageTiersButton.style.display = "flex";
|
||||
profileTabs.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
profileActions.style.display = "flex";
|
||||
manageTiersButton.style.display = "none";
|
||||
primaryProfileButton.textContent = currentIsFollowing
|
||||
? currentSubscriptionTier
|
||||
? `Subscribed: ${currentSubscriptionTier.name}`
|
||||
: "Following"
|
||||
: "Follow";
|
||||
primaryProfileButton.disabled = false;
|
||||
primaryProfileButton.onclick = toggleProfileFollow;
|
||||
favoritesTab.style.display = "none";
|
||||
savedTab.style.display = "none";
|
||||
myPromptsTab.textContent = `Prompts (${profilePrompts.length})`;
|
||||
renderPromptList(profilePrompts, "No prompts yet.");
|
||||
renderCreatorTiers();
|
||||
}
|
||||
|
||||
function renderCreatorTiers() {
|
||||
if (isOwnProfile) {
|
||||
creatorTierList.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!creatorSubscriptionTiers.length) {
|
||||
creatorTierList.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
creatorTierList.innerHTML = `
|
||||
<div class="profile-tier-list">
|
||||
<h3>Subscription Tiers</h3>
|
||||
${creatorSubscriptionTiers
|
||||
.map(
|
||||
(tier) => `
|
||||
<button type="button" class="profile-tier-option ${currentSubscriptionTier?.level === tier.level ? "active" : ""}" data-tier-level="${tier.level}" aria-pressed="${currentSubscriptionTier?.level === tier.level}">
|
||||
<span>
|
||||
<strong>${tier.name}</strong>
|
||||
<small>Level ${tier.level}</small>
|
||||
</span>
|
||||
<b>$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</b>
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`;
|
||||
|
||||
creatorTierList
|
||||
.querySelectorAll("[data-tier-level]")
|
||||
.forEach((button) => {
|
||||
button.addEventListener("click", () =>
|
||||
subscribeToTier(Number(button.dataset.tierLevel)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadFollowState() {
|
||||
if (isOwnProfile || !profileId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = response.ok ? await response.json() : null;
|
||||
currentIsFollowing = subscription !== null;
|
||||
currentSubscriptionTier = subscription?.currentTier || null;
|
||||
updateProfileMode();
|
||||
} catch {
|
||||
currentIsFollowing = false;
|
||||
currentSubscriptionTier = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCreatorTiers() {
|
||||
if (isOwnProfile || !profileId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/tiers/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) return;
|
||||
creatorSubscriptionTiers = await response.json();
|
||||
renderCreatorTiers();
|
||||
} catch {
|
||||
creatorSubscriptionTiers = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleProfileFollow() {
|
||||
if (!profileId) return;
|
||||
|
||||
primaryProfileButton.disabled = true;
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: currentIsFollowing ? "DELETE" : "PUT",
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const currentSubscribers = Number(
|
||||
profileSubscribers.textContent || 0,
|
||||
);
|
||||
currentIsFollowing = !currentIsFollowing;
|
||||
if (!currentIsFollowing) currentSubscriptionTier = null;
|
||||
profileSubscribers.textContent = Math.max(
|
||||
0,
|
||||
currentSubscribers + (currentIsFollowing ? 1 : -1),
|
||||
);
|
||||
updateProfileMode();
|
||||
} else {
|
||||
primaryProfileButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToTier(level) {
|
||||
if (!profileId) return;
|
||||
|
||||
creatorTierList
|
||||
.querySelectorAll("button")
|
||||
.forEach((button) => (button.disabled = true));
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/subscriptions/${encodeURIComponent(profileId)}/${level}`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
|
||||
creatorTierList
|
||||
.querySelectorAll("button")
|
||||
.forEach((button) => (button.disabled = false));
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const wasFollowing = currentIsFollowing;
|
||||
currentIsFollowing = true;
|
||||
currentSubscriptionTier =
|
||||
creatorSubscriptionTiers.find((tier) => tier.level === level) || null;
|
||||
if (!wasFollowing) {
|
||||
profileSubscribers.textContent =
|
||||
Number(profileSubscribers.textContent || 0) + 1;
|
||||
}
|
||||
updateProfileMode();
|
||||
}
|
||||
|
||||
shareProfileButton.addEventListener("click", async () => {
|
||||
const url = isOwnProfile
|
||||
? `${location.origin}/profile.html`
|
||||
: `${location.origin}/profile.html?id=${encodeURIComponent(profileId)}`;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
shareProfileButton.innerHTML = '<i class="bi bi-check2" aria-hidden="true"></i> Copied';
|
||||
setTimeout(
|
||||
() => (shareProfileButton.innerHTML = '<i class="bi bi-share" aria-hidden="true"></i> Share Profile'),
|
||||
1200,
|
||||
);
|
||||
} catch {
|
||||
location.href = url;
|
||||
}
|
||||
// Then set 'active' only on the My Profile link
|
||||
const profileLink = document.querySelector('#sidebar-container a[href="profile.html"]');
|
||||
if (profileLink) profileLink.classList.add('active');
|
||||
});
|
||||
|
||||
async function loadOwnPrompts() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/prompts/mine");
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("Prompts could not be loaded.");
|
||||
|
||||
ownPrompts = await response.json();
|
||||
updateTabs();
|
||||
} catch (error) {
|
||||
profilePromptsGrid.innerHTML = `<div class="profile-grid-error">${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllPromptReferences() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/prompts?limit=100");
|
||||
if (!response.ok) return;
|
||||
allPrompts = await response.json();
|
||||
if (isOwnProfile) {
|
||||
updateTabs();
|
||||
} else {
|
||||
profilePrompts = allPrompts.filter(
|
||||
(prompt) =>
|
||||
prompt.creatorId?.toLowerCase() === profileId.toLowerCase(),
|
||||
);
|
||||
renderProfileFromPrompt(profilePrompts[0]);
|
||||
updateProfileMode();
|
||||
}
|
||||
} catch {
|
||||
// Favorites and saved stay empty if prompts cannot be loaded.
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".profile-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
if (!isOwnProfile) {
|
||||
updateProfileMode();
|
||||
return;
|
||||
}
|
||||
activeProfileTab = tab.dataset.tab;
|
||||
updateTabs();
|
||||
});
|
||||
});
|
||||
|
||||
(async function initProfilePage() {
|
||||
await loadProfile();
|
||||
await loadCreatorCardFallback();
|
||||
await loadFollowState();
|
||||
await loadCreatorTiers();
|
||||
updateProfileMode();
|
||||
if (isOwnProfile) {
|
||||
loadOwnPrompts();
|
||||
}
|
||||
loadAllPromptReferences();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -13,20 +13,18 @@
|
||||
<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>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div class="page-body">
|
||||
<div style="flex:1; display: flex; flex-direction: column;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="settings-main" id="main-content" tabindex="-1">
|
||||
<main class="settings-main">
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>Settings</h1>
|
||||
@ -34,30 +32,30 @@
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||
<button type="button" class="tab-btn active" data-tab="profile" id="profileTabButton" role="tab" aria-selected="true" aria-controls="profileTab">Profile</button>
|
||||
<button type="button" class="tab-btn" data-tab="security" id="securityTabButton" role="tab" aria-selected="false" aria-controls="securityTab">Security</button>
|
||||
<button type="button" class="tab-btn" data-tab="notifications" id="notificationsTabButton" role="tab" aria-selected="false" aria-controls="notificationsTab">Notifications</button>
|
||||
<div class="settings-tabs">
|
||||
<button class="tab-btn active" data-tab="profile">Profile</button>
|
||||
<button class="tab-btn" data-tab="security">Security</button>
|
||||
<button class="tab-btn" data-tab="notifications">Notifications</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Profile -->
|
||||
<div id="profileTab" class="tab-content active" role="tabpanel" aria-labelledby="profileTabButton">
|
||||
<form class="settings-form" id="profileSettingsForm">
|
||||
<div id="profileTab" class="tab-content active">
|
||||
<form class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="avatar">Profile Picture</label>
|
||||
<div class="avatar-upload">
|
||||
<img src="../images/content/cat.png" alt="Profile picture preview" class="settings-avatar" id="avatarPreview">
|
||||
<img src="../images/content/cat.png" alt="Avatar" class="settings-avatar" id="avatarPreview">
|
||||
<input type="file" id="avatarUpload" accept="image/png, image/jpeg">
|
||||
<button type="button" class="upload-btn" aria-controls="avatarUpload">Upload new</button>
|
||||
<button type="button" class="upload-btn">Upload new</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="displayName">Display Name</label>
|
||||
<input type="text" id="displayName" placeholder="Display name">
|
||||
<input type="text" id="displayName" placeholder="Sunny the Cat">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" placeholder="username" required>
|
||||
<input type="text" id="username" placeholder="@username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="bio">Bio</label>
|
||||
@ -65,17 +63,16 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" placeholder="your@email.com" readonly>
|
||||
<input type="email" id="email" placeholder="your@email.com">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="save-btn">Save Changes</button>
|
||||
<p id="profileSaveStatus" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Security -->
|
||||
<div id="securityTab" class="tab-content" role="tabpanel" aria-labelledby="securityTabButton" hidden>
|
||||
<div id="securityTab" class="tab-content">
|
||||
<form class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="currentPw">Current Password</label>
|
||||
@ -101,7 +98,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Notifications (erweitert) -->
|
||||
<div id="notificationsTab" class="tab-content" role="tabpanel" aria-labelledby="notificationsTabButton" hidden>
|
||||
<div id="notificationsTab" class="tab-content">
|
||||
<form class="settings-form">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
@ -150,45 +147,14 @@
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tabId = btn.getAttribute('data-tab');
|
||||
tabBtns.forEach(b => {
|
||||
b.classList.remove('active');
|
||||
b.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
tabBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-selected', 'true');
|
||||
tabContents.forEach(content => {
|
||||
content.classList.remove('active');
|
||||
content.hidden = true;
|
||||
});
|
||||
const selectedTab = document.getElementById(`${tabId}Tab`);
|
||||
selectedTab.classList.add('active');
|
||||
selectedTab.hidden = false;
|
||||
tabContents.forEach(content => content.classList.remove('active'));
|
||||
document.getElementById(`${tabId}Tab`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
// Avatar preview (simple)
|
||||
const avatarUpload = document.getElementById('avatarUpload');
|
||||
const avatarPreview = document.getElementById('avatarPreview');
|
||||
if (avatarUpload) {
|
||||
@ -197,53 +163,15 @@
|
||||
if (file && (file.type === 'image/png' || file.type === 'image/jpeg')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(ev) {
|
||||
currentAvatarUrl = ev.target.result;
|
||||
avatarPreview.src = currentAvatarUrl;
|
||||
avatarPreview.src = ev.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 => {
|
||||
// Demo form submits for all forms
|
||||
document.querySelectorAll('.settings-form').forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
alert('Settings saved (demo)');
|
||||
@ -251,25 +179,19 @@
|
||||
});
|
||||
|
||||
// Fetch sidebar and topbar
|
||||
fetch('/sidebar.html')
|
||||
fetch('../html/sidebar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
document.getElementById('sidebar-container').innerHTML = data;
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
link.removeAttribute('aria-current');
|
||||
});
|
||||
const settingsLink = document.querySelector('#sidebar-container a[href="settings.html"]');
|
||||
if (settingsLink) {
|
||||
settingsLink.classList.add('active');
|
||||
settingsLink.setAttribute('aria-current', 'page');
|
||||
}
|
||||
if (settingsLink) settingsLink.classList.add('active');
|
||||
});
|
||||
fetch('/topbar.html')
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
|
||||
loadProfileSettings();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@ -6,82 +6,73 @@
|
||||
|
||||
<div class="sidebar-shell">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-logo" aria-label="OnlyPrompt">
|
||||
<img src="../images/logo_full.png" alt="" class="sidebar-logo-full">
|
||||
<img src="../images/logo_icon.png" alt="" class="sidebar-logo-icon">
|
||||
<div class="sidebar-logo">
|
||||
<img src="../images/logo_full.png" alt="OnlyPrompt Logo" class="sidebar-logo-full">
|
||||
<img src="../images/logo_icon.png" alt="OnlyPrompt Icon" class="sidebar-logo-icon">
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar" id="main-navigation" aria-label="Main navigation" tabindex="-1">
|
||||
<nav class="sidebar">
|
||||
<ul>
|
||||
<li class="mobile-nav-item">
|
||||
<a href="dashboard.html" class="active" aria-current="page">
|
||||
<i class="bi bi-house icon-blue" aria-hidden="true"></i>
|
||||
<li>
|
||||
<a href="dashboard.html" class="active">
|
||||
<i class="bi bi-house icon-blue"></i>
|
||||
<span class="nav-text">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="mobile-nav-item">
|
||||
<li>
|
||||
<a href="marketplace.html">
|
||||
<i class="bi bi-shop icon-purple" aria-hidden="true"></i>
|
||||
<i class="bi bi-shop icon-purple"></i>
|
||||
<span class="nav-text">Marketplace</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="mobile-nav-item">
|
||||
<li>
|
||||
<a href="community.html">
|
||||
<i class="bi bi-people icon-pink" aria-hidden="true"></i>
|
||||
<i class="bi bi-people icon-pink"></i>
|
||||
<span class="nav-text">Community</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="mobile-nav-item">
|
||||
<li>
|
||||
<a href="chats.html">
|
||||
<i class="bi bi-chat-dots icon-blue" aria-hidden="true"></i>
|
||||
<i class="bi bi-chat-dots icon-blue"></i>
|
||||
<span class="nav-text">Chats</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="mobile-nav-item">
|
||||
<li>
|
||||
<a href="settings.html">
|
||||
<i class="bi bi-gear icon-purple" aria-hidden="true"></i>
|
||||
<i class="bi bi-gear icon-purple"></i>
|
||||
<span class="nav-text">Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="mobile-nav-item">
|
||||
<li>
|
||||
<a href="profile.html">
|
||||
<i class="bi bi-person icon-pink" aria-hidden="true"></i>
|
||||
<i class="bi bi-person icon-pink"></i>
|
||||
<span class="nav-text">My Profile</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="mobile-nav-item">
|
||||
<li>
|
||||
<a href="create.html">
|
||||
<i class="bi bi-plus-circle-fill icon-blue" aria-hidden="true"></i>
|
||||
<i class="bi bi-plus-circle-fill icon-blue"></i>
|
||||
<span class="nav-text">Create New</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="mobile-nav-item">
|
||||
<a href="subscription-tiers.html">
|
||||
<i class="bi bi-gem icon-purple" aria-hidden="true"></i>
|
||||
<span class="nav-text">Subscriptions</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Logout bottom -->
|
||||
<div class="sidebar-bottom">
|
||||
<form action="/api/v1/auth/logout" method="post">
|
||||
<button type="submit" class="sidebar-logout" aria-label="Logout">
|
||||
<a href="login.html" class="sidebar-logout">
|
||||
<div class="logout-left">
|
||||
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
<span class="nav-text">Logout</span>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right logout-arrow" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
<i class="bi bi-chevron-right logout-arrow"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -11,7 +11,7 @@
|
||||
<!-- For responsive design: adapts width for different devices -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- Title shown in browser tab -->
|
||||
<title>OnlyPrompt - Sign Up</title>
|
||||
<title>OnlyPrompt - Login</title>
|
||||
|
||||
<!-- CSS files for variables, base styles, and login page -->
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
@ -20,9 +20,8 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<!-- Main container for the login page (CSS layout) -->
|
||||
<main class="login-page" id="main-content" tabindex="-1">
|
||||
<main class="login-page">
|
||||
<!-- White login card -->
|
||||
<section class="login-card">
|
||||
<!-- Logo container -->
|
||||
@ -38,25 +37,20 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" placeholder="yourname@email.com" autocomplete="email" required>
|
||||
<input type="email" id="email" name="email" placeholder="yourname@email.com" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="displayName">Display Name (how it will appear to others)</label>
|
||||
<input type="text" id="displayName" name="displayName" placeholder="Enter your display name" autocomplete="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="userName">Username</label>
|
||||
<input type="text" id="userName" name="userName" placeholder="Choose a username" autocomplete="username" required>
|
||||
<input type="text" id="displayName" name="displayName" placeholder="Enter your display name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<!-- Password field with button to show/hide password -->
|
||||
<div class="password-wrapper">
|
||||
<input type="password" id="password" name="password" placeholder="Enter your password" autocomplete="new-password" required>
|
||||
<button type="button" id="togglePassword" class="toggle-password" aria-controls="password" aria-pressed="false">
|
||||
<input type="password" id="password" name="password" placeholder="Enter your password" required>
|
||||
<button type="button" id="togglePassword" class="toggle-password">
|
||||
Show <!-- Click to show/hide password -->
|
||||
</button>
|
||||
</div>
|
||||
@ -74,7 +68,7 @@
|
||||
|
||||
<p class="signup-text">
|
||||
Have an account?
|
||||
<a href="/login">Log In</a>
|
||||
<a href="#">Log In</a>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
@ -83,4 +77,4 @@
|
||||
<script type="module" src="js/signup.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@ -1,389 +0,0 @@
|
||||
<!-- OnlyPrompt - Subscription tiers page: create and manage monthly creator tiers -->
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OnlyPrompt - Subscription Tiers</title>
|
||||
<link rel="stylesheet" href="../css/variables.css" />
|
||||
<link rel="stylesheet" href="../css/base.css" />
|
||||
<link rel="stylesheet" href="../css/sidebar.css" />
|
||||
<link rel="stylesheet" href="../css/topbar.css" />
|
||||
<link rel="stylesheet" href="../css/subscription-tiers.css" />
|
||||
<script src="../js/profile-shared.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<div class="layout">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div class="page-body">
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="tiers-main" id="main-content" tabindex="-1">
|
||||
<header class="tiers-header">
|
||||
<h1>Subscription Tiers</h1>
|
||||
<p>Create monthly access levels for your paid prompts.</p>
|
||||
</header>
|
||||
|
||||
<nav class="tiers-tabs" role="tablist" aria-label="Subscription tier sections">
|
||||
<button type="button" class="tiers-tab active" data-tab="manage" id="manageTiersTab" role="tab" aria-selected="true" aria-controls="manage-tab-panel">
|
||||
My Tiers
|
||||
</button>
|
||||
<button type="button" class="tiers-tab" data-tab="subscriptions" id="subscriptionsTiersTab" role="tab" aria-selected="false" aria-controls="subscriptions-tab-panel">
|
||||
My Subscriptions
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="tiers-layout" id="manage-tab-panel" role="tabpanel" aria-labelledby="manageTiersTab">
|
||||
<article class="tier-panel">
|
||||
<h2 id="tier-form-title">Create Tier</h2>
|
||||
<form id="tier-form" class="tier-form">
|
||||
<label>
|
||||
Tier Name
|
||||
<input
|
||||
id="tier-name"
|
||||
type="text"
|
||||
placeholder="Supporter"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Level
|
||||
<input id="tier-level" type="number" min="1" step="1" value="1" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Monthly Price
|
||||
<input
|
||||
id="tier-price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="4.99"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Description
|
||||
<textarea
|
||||
id="tier-description"
|
||||
placeholder="Access to basic premium prompts."
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="tier-form-actions">
|
||||
<button type="submit" class="tier-primary-btn" id="tier-submit-btn">
|
||||
Save Tier
|
||||
</button>
|
||||
<button type="button" class="tier-secondary-btn" id="tier-reset-btn">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<p id="tier-status" role="status" aria-live="polite"></p>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<section>
|
||||
<div class="tier-list-header">
|
||||
<h2>Your Tiers</h2>
|
||||
<p>Higher levels include access to prompts from lower levels.</p>
|
||||
</div>
|
||||
<div class="tiers-grid" id="tiers-grid" aria-live="polite">
|
||||
<div class="tiers-empty">Loading tiers...</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="subscriptions-panel" id="subscriptions-tab-panel" role="tabpanel" aria-labelledby="subscriptionsTiersTab" hidden>
|
||||
<div class="tier-list-header">
|
||||
<h2>Your Subscriptions</h2>
|
||||
<p>Creators you follow or support with a monthly tier.</p>
|
||||
</div>
|
||||
<div class="subscriptions-grid" id="subscriptions-grid" aria-live="polite">
|
||||
<div class="tiers-empty">Loading subscriptions...</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
fetch("/sidebar.html")
|
||||
.then((r) => r.text())
|
||||
.then((data) => {
|
||||
document.getElementById("sidebar-container").innerHTML = data;
|
||||
document
|
||||
.querySelectorAll("#sidebar-container .sidebar a")
|
||||
.forEach((link) => {
|
||||
link.classList.remove("active");
|
||||
link.removeAttribute("aria-current");
|
||||
});
|
||||
const tiersLink = document.querySelector(
|
||||
'#sidebar-container a[href="subscription-tiers.html"]',
|
||||
);
|
||||
if (tiersLink) {
|
||||
tiersLink.classList.add("active");
|
||||
tiersLink.setAttribute("aria-current", "page");
|
||||
}
|
||||
});
|
||||
|
||||
fetch("/topbar.html")
|
||||
.then((r) => r.text())
|
||||
.then(
|
||||
(data) =>
|
||||
(document.getElementById("topbar-container").innerHTML = data),
|
||||
);
|
||||
|
||||
const tierForm = document.getElementById("tier-form");
|
||||
const tierFormTitle = document.getElementById("tier-form-title");
|
||||
const tierName = document.getElementById("tier-name");
|
||||
const tierLevel = document.getElementById("tier-level");
|
||||
const tierPrice = document.getElementById("tier-price");
|
||||
const tierDescription = document.getElementById("tier-description");
|
||||
const tierStatus = document.getElementById("tier-status");
|
||||
const tiersGrid = document.getElementById("tiers-grid");
|
||||
const subscriptionsGrid = document.getElementById("subscriptions-grid");
|
||||
const manageTabPanel = document.getElementById("manage-tab-panel");
|
||||
const subscriptionsTabPanel = document.getElementById(
|
||||
"subscriptions-tab-panel",
|
||||
);
|
||||
const resetBtn = document.getElementById("tier-reset-btn");
|
||||
let editingTierId = null;
|
||||
let tiers = [];
|
||||
let subscriptions = [];
|
||||
|
||||
function setActiveTab(tabName) {
|
||||
document.querySelectorAll(".tiers-tab").forEach((tab) => {
|
||||
tab.classList.toggle("active", tab.dataset.tab === tabName);
|
||||
tab.setAttribute("aria-selected", String(tab.dataset.tab === tabName));
|
||||
});
|
||||
manageTabPanel.style.display = tabName === "manage" ? "grid" : "none";
|
||||
manageTabPanel.hidden = tabName !== "manage";
|
||||
subscriptionsTabPanel.style.display =
|
||||
tabName === "subscriptions" ? "block" : "none";
|
||||
subscriptionsTabPanel.hidden = tabName !== "subscriptions";
|
||||
|
||||
if (tabName === "subscriptions") loadSubscriptions();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingTierId = null;
|
||||
tierFormTitle.textContent = "Create Tier";
|
||||
tierForm.reset();
|
||||
tierLevel.value = tiers.length ? Math.max(...tiers.map((t) => t.level)) + 1 : 1;
|
||||
tierStatus.textContent = "";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function getFriendlyTierError(response) {
|
||||
const fallback = `Server error ${response.status}`;
|
||||
const text = await response.text();
|
||||
if (!text) return fallback;
|
||||
|
||||
try {
|
||||
const error = JSON.parse(text);
|
||||
const messages = error.errors
|
||||
? Object.values(error.errors).flat()
|
||||
: [error.title || fallback];
|
||||
|
||||
return messages
|
||||
.map((message) =>
|
||||
message === "Tier with this level already exists."
|
||||
? "A tier with this level already exists. Please choose another level."
|
||||
: message,
|
||||
)
|
||||
.join(" ");
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTiers() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/subscriptions/tiers", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
||||
tiers = await response.json();
|
||||
renderTiers();
|
||||
if (!editingTierId) resetForm();
|
||||
} catch (error) {
|
||||
tiersGrid.innerHTML = `<div class="tiers-error">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const response = await fetch("/api/v1/subscriptions", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
||||
|
||||
subscriptions = await response.json();
|
||||
renderSubscriptions();
|
||||
} catch (error) {
|
||||
subscriptionsGrid.innerHTML = `<div class="tiers-error">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubscriptions() {
|
||||
if (!subscriptions.length) {
|
||||
subscriptionsGrid.innerHTML =
|
||||
'<div class="tiers-empty">No subscriptions yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
subscriptionsGrid.innerHTML = subscriptions
|
||||
.map((subscription) => {
|
||||
const tier = subscription.currentTier;
|
||||
return `
|
||||
<article class="subscription-card">
|
||||
<div>
|
||||
<h3>${escapeHtml(subscription.subscribedToName)}</h3>
|
||||
<p>${tier ? `${escapeHtml(tier.name)} - Level ${tier.level}` : "Following without tier"}</p>
|
||||
</div>
|
||||
<div class="subscription-price">
|
||||
${tier ? `$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo` : "Free"}
|
||||
</div>
|
||||
</article>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTiers() {
|
||||
if (!tiers.length) {
|
||||
tiersGrid.innerHTML = '<div class="tiers-empty">No tiers yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tiersGrid.innerHTML = tiers
|
||||
.map(
|
||||
(tier) => `
|
||||
<article class="tier-card">
|
||||
<div class="tier-card-top">
|
||||
<div>
|
||||
<h3>${escapeHtml(tier.name)}</h3>
|
||||
<div class="tier-level">Level ${tier.level}</div>
|
||||
</div>
|
||||
<div class="tier-price">$${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</div>
|
||||
</div>
|
||||
<p class="tier-desc">${escapeHtml(tier.description || "No description yet.")}</p>
|
||||
<div class="tier-card-actions">
|
||||
<button type="button" data-edit="${tier.id}" aria-label="Edit ${escapeHtml(tier.name)} tier">Edit</button>
|
||||
<button type="button" data-delete="${tier.id}" class="tier-delete-btn" aria-label="Delete ${escapeHtml(tier.name)} tier">Delete</button>
|
||||
</div>
|
||||
</article>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
tiersGrid.querySelectorAll("[data-edit]").forEach((button) => {
|
||||
button.addEventListener("click", () => editTier(button.dataset.edit));
|
||||
});
|
||||
tiersGrid.querySelectorAll("[data-delete]").forEach((button) => {
|
||||
button.addEventListener("click", () => deleteTier(button.dataset.delete));
|
||||
});
|
||||
}
|
||||
|
||||
function editTier(id) {
|
||||
const tier = tiers.find((item) => item.id === id);
|
||||
if (!tier) return;
|
||||
|
||||
editingTierId = id;
|
||||
tierFormTitle.textContent = "Edit Tier";
|
||||
tierName.value = tier.name || "";
|
||||
tierLevel.value = tier.level || 1;
|
||||
tierPrice.value = tier.monthlyPrice || 0;
|
||||
tierDescription.value = tier.description || "";
|
||||
tierStatus.textContent = "";
|
||||
}
|
||||
|
||||
async function deleteTier(id) {
|
||||
const tier = tiers.find((item) => item.id === id);
|
||||
if (!tier || !confirm(`Delete ${tier.name}?`)) return;
|
||||
|
||||
const response = await fetch(`/api/v1/subscriptions/tiers/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
tierStatus.textContent = await getFriendlyTierError(response);
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
loadTiers();
|
||||
}
|
||||
|
||||
tierForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
tierStatus.textContent = "Saving...";
|
||||
|
||||
const payload = {
|
||||
name: tierName.value.trim(),
|
||||
level: Number(tierLevel.value),
|
||||
monthlyPrice: Number(tierPrice.value),
|
||||
description: tierDescription.value.trim() || null,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
editingTierId
|
||||
? `/api/v1/subscriptions/tiers/${editingTierId}`
|
||||
: "/api/v1/subscriptions/tiers",
|
||||
{
|
||||
method: editingTierId ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
tierStatus.textContent = await getFriendlyTierError(response);
|
||||
return;
|
||||
}
|
||||
|
||||
tierStatus.textContent = "Tier saved.";
|
||||
editingTierId = null;
|
||||
await loadTiers();
|
||||
});
|
||||
|
||||
resetBtn.addEventListener("click", resetForm);
|
||||
document.querySelectorAll(".tiers-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => setActiveTab(tab.dataset.tab));
|
||||
});
|
||||
setActiveTab("manage");
|
||||
loadTiers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,28 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OnlyPrompt - Test</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<div id="styleOutlet">
|
||||
|
||||
</div>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Test Site</h1>
|
||||
<a href="/counter/1">Go to count 1</a>
|
||||
<a href="/counter/2">Go to count 2</a>
|
||||
<a href="/counter/3">Go to count 3</a>
|
||||
<div id="routerOutlet">
|
||||
Hello There
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./js/node-test.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,29 +1,27 @@
|
||||
<!--
|
||||
Reusable topbar for OnlyPrompt
|
||||
- Search in the middle
|
||||
- small chat & logout icons
|
||||
- small chat & notification icons
|
||||
- profile avatar on the right
|
||||
-->
|
||||
|
||||
<header class="topbar-shell">
|
||||
<div class="topbar-search">
|
||||
<i class="bi bi-search" aria-hidden="true"></i>
|
||||
<input id="topbarSearchInput" type="search" placeholder="Search" aria-label="Search prompts and creators">
|
||||
<i class="bi bi-search"></i>
|
||||
<input type="text" placeholder="Search">
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<form action="/api/v1/auth/logout" method="post" class="topbar-logout-form">
|
||||
<button class="topbar-icon-btn" type="submit" aria-label="Logout" title="Logout">
|
||||
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
<button class="topbar-icon-btn" type="button" aria-label="Messages" title="Messages" onclick="location.href='/chats.html'">
|
||||
<i class="bi bi-chat-dots" aria-hidden="true"></i>
|
||||
<button class="topbar-icon-btn" aria-label="Notifications">
|
||||
<i class="bi bi-bell"></i>
|
||||
</button>
|
||||
<button class="topbar-icon-btn" aria-label="Messages">
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
</button>
|
||||
|
||||
<!-- Profile avatar on the right (must be changed with backend) -->
|
||||
<button class="topbar-avatar-btn" type="button" aria-label="Profile" title="Profile" onclick="location.href='/profile.html'">
|
||||
<img id="topbarAvatar" src="../images/content/cat.png" alt="" class="topbar-avatar">
|
||||
<button class="topbar-avatar-btn" aria-label="Profile">
|
||||
<img src="../images/content/cat.png" alt="Profile Picture" class="topbar-avatar">
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
81
README.md
81
README.md
@ -1,26 +1,21 @@
|
||||
# OnlyPrompt - AI Prompt Marketplace
|
||||
|
||||
## Description
|
||||
OnlyPrompt is a web application where users can create, publish, discover and interact with AI prompts. Users can edit their profiles, follow creators, like and save prompts, write reviews and browse free or subscription-based prompt cards in a marketplace.
|
||||
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.
|
||||
|
||||
This project is built with HTML, CSS and JavaScript.
|
||||
This project is built with with HTML, CSS and Java Script.
|
||||
|
||||
## Special Features
|
||||
- 📝 Create, edit and publish AI prompts
|
||||
- 🔍 Browse prompts in a marketplace with category, search and tier price filters
|
||||
- 📄 View prompt detail pages with examples, ratings and access states
|
||||
- 💎 Create creator subscription tiers and assign prompts to tier levels
|
||||
- 🔐 Subscribe to creator tiers to unlock matching and lower-level prompts
|
||||
- ⭐ Write reviews with star ratings and comments
|
||||
- ❤️ Like and save prompts
|
||||
- 👥 Follow and discover other creators
|
||||
- 💬 Start chats with creators from the community page and send local messages
|
||||
- 👤 Edit user profiles with display name, username, bio and profile picture
|
||||
- 🌐 View own and public creator profiles
|
||||
- 📱 Responsive layout for desktop and mobile, including a bottom icon navigation on smartphones
|
||||
- ♿ Accessibility improvements such as keyboard focus states, skip links, labels, ARIA states and live status messages
|
||||
- 🔄 Server communication through a REST API
|
||||
- 💾 Shared data persistence with backend and database
|
||||
- 📝 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)
|
||||
|
||||
|
||||
## Installment (for local use)
|
||||
@ -37,45 +32,19 @@ DB_NAME=onlyprompt
|
||||
DB_PASSWORD=onlyprompt
|
||||
```
|
||||
|
||||
## Local Usage Notes
|
||||
- The community page includes a chat button on creator cards. It opens the chat page and starts a conversation with the selected creator.
|
||||
- The chats page supports selecting conversations, searching creators through the new-chat button and sending messages.
|
||||
- Chat messages are stored in the browser's `localStorage` for the local frontend demo. They are not persisted in PostgreSQL yet.
|
||||
- Keyboard users can use `Tab`, `Shift + Tab`, `Enter` and `Space` to navigate links, buttons, filters, tabs and forms.
|
||||
- On macOS, full keyboard navigation may need to be enabled in System Settings or the browser settings so that `Tab` also focuses links.
|
||||
|
||||
## Technologies, Libraries, Frameworks
|
||||
- HTML5 for page structure
|
||||
- CSS3 with Flexbox/Grid for layout and responsive design
|
||||
- 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:
|
||||
- Subscription access is simulated for the semester project and is not connected to a real payment provider.
|
||||
- Chat messages are currently stored locally in the browser and are not connected to a backend chat API.
|
||||
- User-generated content is displayed in the frontend, so XSS prevention is important. The project avoids intentionally executing user input as code, but further output sanitization would be needed for production.
|
||||
- Authentication is implemented for local project use and would need additional hardening for production.
|
||||
|
||||
## Reflection
|
||||
A main challenge was connecting static frontend pages with dynamic backend data while keeping the application usable and consistent. During development, the project evolved from demo pages into a connected application with real profiles, prompts, reviews, likes, saves, creator interactions and subscription tiers. We learned how important clear API structures, consistent data models, responsive navigation and regular browser testing are when multiple pages depend on the same shared data.
|
||||
- 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)
|
||||
|
||||
## Group members and their roles
|
||||
| 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 |
|
||||
- Thuvaraka Yogarajah
|
||||
- Isabelle Nachbaur
|
||||
- Florian Klessascheck
|
||||
- Abdul Geylani Semiz
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user