Connect prompt creation flow and document API
This commit is contained in:
parent
863cf3e0ef
commit
691bc72681
247
API.md
Normal file
247
API.md
Normal file
@ -0,0 +1,247 @@
|
||||
# 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Feed
|
||||
|
||||
```http
|
||||
GET /api/v1/feed?sortBy=date&ascending=false&limit=20
|
||||
```
|
||||
|
||||
Used by Dashboard. Returns prompt cards with title, description, creator info, avatar and example image.
|
||||
|
||||
### 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}`.
|
||||
|
||||
### Prompt Detail
|
||||
|
||||
```http
|
||||
GET /api/v1/prompts/{id}
|
||||
```
|
||||
|
||||
Response includes:
|
||||
|
||||
- title and description
|
||||
- prompt content if accessible
|
||||
- category
|
||||
- creator information
|
||||
- price or free state
|
||||
- example output
|
||||
- example image
|
||||
- rating data
|
||||
|
||||
### Reviews
|
||||
|
||||
```http
|
||||
GET /api/v1/prompts/{id}/reviews
|
||||
PUT /api/v1/prompts/{id}/reviews
|
||||
```
|
||||
|
||||
`PUT` request:
|
||||
|
||||
```json
|
||||
{
|
||||
"comment": "Helpful prompt",
|
||||
"rating": 5
|
||||
}
|
||||
```
|
||||
|
||||
Used for user feedback on prompts.
|
||||
|
||||
## 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
|
||||
PUT /api/v1/subscriptions/{creatorId}
|
||||
DELETE /api/v1/subscriptions/{creatorId}
|
||||
```
|
||||
|
||||
Used by Community to follow or unfollow creators.
|
||||
@ -1,6 +1,6 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
|
||||
public record ApiMinimalPrompt(Guid Id, string Title, DateTime TimeStamp, Guid CreatorId, string CreatorName, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
|
||||
public record ApiPrompt(Guid Id, string Title, string Description, string? Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CategoryName, string CategorySlug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
|
||||
public record ApiMinimalPrompt(Guid Id, string Title, string Description, DateTime TimeStamp, Guid CreatorId, string CreatorName, string CreatorAvatarUrl, string? ExampleImageUrl, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
|
||||
public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public record ApiCreatePromptRequest(string Title, string Description, string Content, Identifier Category, int? SubscriptionTier, string Slug);
|
||||
public record ApiCreatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? Slug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/v1/categories")]
|
||||
[Authorize(Roles = ModelConstants.AdminRole, AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
|
||||
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
|
||||
public class CategoryController : BaseController
|
||||
{
|
||||
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
@ -53,6 +53,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = ModelConstants.AdminRole)]
|
||||
public async Task<Results<Ok<ApiCategory>, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request)
|
||||
{
|
||||
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug);
|
||||
@ -69,6 +70,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
[Authorize(Roles = ModelConstants.AdminRole)]
|
||||
public async Task<Results<Ok<ApiCategory>, NotFound<string>, ValidationProblem>> UpdateCategoryAsync(Identifier id, [FromBody] ApiUpdateCategoryRequest request)
|
||||
{
|
||||
var category = await _db.Categories.FindByIdentifierAsync(id);
|
||||
@ -95,6 +97,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = ModelConstants.AdminRole)]
|
||||
public async Task<Results<NoContent, BadRequest<string>, NotFound<string>>> DeleteCategoryAsync(Identifier id, [FromQuery] Identifier? replaceWith = null)
|
||||
{
|
||||
var hasPrompts = await _db.Prompts.AnyAsync(p => p.CategoryId == id.Id);
|
||||
|
||||
@ -31,11 +31,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var query = _db.Prompts
|
||||
.Where(
|
||||
x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId)
|
||||
&& x.CreatorId != userId
|
||||
);
|
||||
var query = _db.Prompts.AsQueryable();
|
||||
|
||||
if (category.HasValue)
|
||||
query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug);
|
||||
@ -58,11 +54,14 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
.Select(x => new ApiMinimalPrompt(
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.Description,
|
||||
x.UpdatedAt,
|
||||
x.CreatorId,
|
||||
x.Creator.Profile.DisplayName,
|
||||
x.SubscriptionTier.Level,
|
||||
x.SubscriptionTier.Name,
|
||||
x.Creator.Profile.AvatarUrl,
|
||||
x.ExampleImageUrl,
|
||||
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
|
||||
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
|
||||
x.Reviews.Average(r => (double?)r.Rating),
|
||||
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
|
||||
)).ToArrayAsync();
|
||||
|
||||
@ -65,11 +65,14 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
.Select(x => new ApiMinimalPrompt(
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.Description,
|
||||
x.UpdatedAt,
|
||||
x.CreatorId,
|
||||
x.Creator.Profile.DisplayName,
|
||||
x.SubscriptionTier.Level,
|
||||
x.SubscriptionTier.Name,
|
||||
x.Creator.Profile.AvatarUrl,
|
||||
x.ExampleImageUrl,
|
||||
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
|
||||
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
|
||||
x.Reviews.Average(r => (double?)r.Rating),
|
||||
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
|
||||
)).ToArrayAsync();
|
||||
@ -115,7 +118,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var category = await _db.Categories.FindByIdentifierAsync(request.Category);
|
||||
var category = await _db.Categories.FindByIdentifierAsync(new Identifier(request.Category));
|
||||
if (category is null)
|
||||
return TypedResults.NotFound("Category not found");
|
||||
|
||||
@ -141,6 +144,9 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Prompt = request.Content,
|
||||
ExampleOutput = request.ExampleOutput,
|
||||
ExampleImageUrl = request.ExampleImageUrl,
|
||||
Price = request.Price,
|
||||
CreatorId = userId.Value,
|
||||
SubscriptionTier = subscriptionTier,
|
||||
Category = category,
|
||||
|
||||
@ -31,6 +31,13 @@ namespace OnlyPrompt.Backend.Database.Models
|
||||
[MaxLength(1000)]
|
||||
public required string Description { get; set; }
|
||||
|
||||
[MaxLength(4000)]
|
||||
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; }
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
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: "character varying(4000)",
|
||||
maxLength: 4000,
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -79,6 +79,16 @@ namespace OnlyPrompt.Backend.Migrations
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("ExampleImageUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ExampleOutput")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<decimal?>("Price")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
|
||||
@ -109,5 +109,54 @@ app.MapFallbackToFile("/login.html");
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
await EnsurePromptDetailColumnsAsync(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" character varying(4000);
|
||||
""");
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
ALTER TABLE "Prompts"
|
||||
ADD COLUMN IF NOT EXISTS "Price" numeric;
|
||||
""");
|
||||
}
|
||||
|
||||
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,6 +33,11 @@ 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.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
|
||||
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
|
||||
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
|
||||
@ -43,9 +48,12 @@ namespace OnlyPrompt.Backend.Utils
|
||||
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.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
|
||||
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
|
||||
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
|
||||
|
||||
@ -99,6 +99,7 @@
|
||||
<button type="submit" class="submit-btn">Publish Prompt</button>
|
||||
<button type="button" class="cancel-btn">Cancel</button>
|
||||
</div>
|
||||
<p id="create-status" style="text-align:center;color:#64748b;margin:0;"></p>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@ -129,6 +130,7 @@
|
||||
const imageInput = document.getElementById('exampleImage');
|
||||
const imagePreview = document.getElementById('imagePreview');
|
||||
const previewImg = document.getElementById('previewImg');
|
||||
let exampleImageUrl = '';
|
||||
|
||||
if (imageInput) {
|
||||
imageInput.addEventListener('change', function(event) {
|
||||
@ -136,25 +138,99 @@
|
||||
if (file && (file.type === 'image/png' || file.type === 'image/jpeg' || file.type === 'image/jpg')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
previewImg.src = e.target.result;
|
||||
exampleImageUrl = e.target.result;
|
||||
previewImg.src = exampleImageUrl;
|
||||
imagePreview.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
imagePreview.style.display = 'none';
|
||||
previewImg.src = '#';
|
||||
exampleImageUrl = '';
|
||||
if (file) alert('Please upload a PNG or JPG image.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle form submission (demo only)
|
||||
document.getElementById('createPromptForm').addEventListener('submit', (e) => {
|
||||
async function loadCategories() {
|
||||
const categorySelect = document.getElementById('category');
|
||||
try {
|
||||
const response = await fetch('/api/v1/categories/minimal');
|
||||
if (!response.ok) return;
|
||||
|
||||
const categories = await response.json();
|
||||
if (!categories.length) return;
|
||||
|
||||
categorySelect.innerHTML = categories
|
||||
.map((category) => `<option value="${category.slug}">${category.name}</option>`)
|
||||
.join('');
|
||||
} catch {
|
||||
// Keep the static fallback categories.
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('createPromptForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
alert('Prompt published! (Demo)');
|
||||
// Here you would normally send data to a backend (including the image file)
|
||||
const status = document.getElementById('create-status');
|
||||
const submitBtn = document.querySelector('.submit-btn');
|
||||
status.textContent = 'Publishing...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const isPaid = paidBtn.classList.contains('active');
|
||||
const price = isPaid ? Number(priceInput.value || 0) : null;
|
||||
const payload = {
|
||||
title: document.getElementById('title').value.trim(),
|
||||
description: document.getElementById('description').value.trim(),
|
||||
category: document.getElementById('category').value,
|
||||
content: document.getElementById('promptContent').value.trim(),
|
||||
exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
|
||||
exampleImageUrl: exampleImageUrl || null,
|
||||
price,
|
||||
subscriptionTier: null,
|
||||
slug: null
|
||||
};
|
||||
|
||||
const response = await fetch('/api/v1/prompts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
location.href = '/login';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(getCreateErrorMessage(error, response.status));
|
||||
}
|
||||
|
||||
const prompt = await response.json();
|
||||
location.href = `/post-detail?id=${prompt.id}`;
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
function getCreateErrorMessage(errorText, status) {
|
||||
if (!errorText) return `Server error ${status}`;
|
||||
|
||||
try {
|
||||
const error = JSON.parse(errorText);
|
||||
const messages = error.errors
|
||||
? Object.values(error.errors).flat()
|
||||
: [error.title || errorText];
|
||||
|
||||
return messages.join(' ');
|
||||
} catch {
|
||||
return errorText;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel button (go back)
|
||||
document.querySelector('.cancel-btn').addEventListener('click', () => {
|
||||
window.history.back();
|
||||
@ -177,6 +253,8 @@
|
||||
fetch('/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
|
||||
loadCategories();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -167,6 +167,15 @@
|
||||
.action-btn i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.action-btn.active {
|
||||
font-weight: 700;
|
||||
}
|
||||
.like-btn.active {
|
||||
color: #ef4444;
|
||||
}
|
||||
.save-btn.active {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.like-btn:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@ -151,33 +151,51 @@
|
||||
function feedImg(id) {
|
||||
return `/images/content/feed${(parseInt(id.slice(-1), 16) % 4) + 1}.png`;
|
||||
}
|
||||
function creatorImg(id) {
|
||||
return `/images/content/creator${(parseInt(id.slice(-1), 16) % 6) + 1}.png`;
|
||||
}
|
||||
|
||||
function renderCard(prompt) {
|
||||
const locked = !prompt.canAccess;
|
||||
const liked = localStorage.getItem(`prompt-liked-${prompt.id}`) === "true";
|
||||
const saved = localStorage.getItem(`prompt-saved-${prompt.id}`) === "true";
|
||||
return `
|
||||
<div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='/post-detail?id=${prompt.id}'">
|
||||
<div class="post-header">
|
||||
<img class="post-avatar" src="${creatorImg(prompt.creatorId)}" alt="${prompt.creatorName}">
|
||||
<img class="post-avatar" src="${prompt.creatorAvatarUrl || '../images/content/cat.png'}" alt="${prompt.creatorName}">
|
||||
<div class="post-author">
|
||||
<span class="post-name">${prompt.creatorName}</span>
|
||||
</div>
|
||||
<span class="post-date">${timeAgo(prompt.timeStamp)}</span>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<img class="post-image${locked ? ' post-image-locked' : ''}" src="${feedImg(prompt.id)}" alt="${prompt.title}">
|
||||
${prompt.exampleImageUrl ? `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? ' post-image-locked' : ''}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
|
||||
<h3 class="post-title" style="margin-top:10px">${prompt.title}</h3>
|
||||
<p class="post-description">${prompt.description || ''}</p>
|
||||
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill"></i> ${prompt.tierName ?? 'Paid'} tier required</p>` : ''}
|
||||
${renderStars(prompt.averageRating)}
|
||||
</div>
|
||||
<div class="post-actions">
|
||||
<button class="action-btn share-btn" onclick="event.stopPropagation(); navigator.clipboard.writeText(location.origin+'/post-detail?id=${prompt.id}')"><i class="bi bi-share"></i></button>
|
||||
<button class="action-btn like-btn ${liked ? 'active' : ''}" onclick="toggleFeedState(event, 'liked', '${prompt.id}')"><i class="bi ${liked ? 'bi-heart-fill' : 'bi-heart'}"></i> <span>${liked ? 'Liked' : 'Like'}</span></button>
|
||||
<button class="action-btn comment-btn" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat"></i> <span>Review</span></button>
|
||||
<button class="action-btn share-btn" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share"></i> <span>Share</span></button>
|
||||
<button class="action-btn save-btn ${saved ? 'active' : ''}" onclick="toggleFeedState(event, 'saved', '${prompt.id}')"><i class="bi ${saved ? 'bi-bookmark-fill' : 'bi-bookmark'}"></i> <span>${saved ? 'Saved' : 'Save'}</span></button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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.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";
|
||||
|
||||
@ -93,24 +93,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div style="margin-bottom: 24px">
|
||||
<input
|
||||
id="search-input"
|
||||
type="text"
|
||||
placeholder="Search prompts..."
|
||||
style="
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 30px;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
background: #f8fafc;
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Prompts Grid -->
|
||||
<div class="prompts-grid" id="prompts-grid"></div>
|
||||
|
||||
@ -426,7 +408,7 @@
|
||||
|
||||
function renderCard(p) {
|
||||
const locked = !p.canAccess && p.tierLevel != null;
|
||||
const img = p._img || MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
|
||||
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">
|
||||
@ -456,7 +438,7 @@
|
||||
const grid = document.getElementById("prompts-grid");
|
||||
const emptyEl = document.getElementById("market-empty");
|
||||
const errorEl = document.getElementById("market-error");
|
||||
const search = document.getElementById("search-input").value.trim();
|
||||
const search = document.getElementById("topbarSearchInput")?.value.trim() || "";
|
||||
const [sortBy, ascending] = document
|
||||
.getElementById("sort-select")
|
||||
.value.split("|");
|
||||
@ -605,10 +587,20 @@
|
||||
document
|
||||
.getElementById("sort-select")
|
||||
.addEventListener("change", loadPrompts);
|
||||
document.getElementById("search-input").addEventListener("input", () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(loadPrompts, 400);
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
const topbarObserver = new MutationObserver(wireMarketplaceTopbarSearch);
|
||||
topbarObserver.observe(document.body, { childList: true, subtree: true });
|
||||
wireMarketplaceTopbarSearch();
|
||||
|
||||
// Make openPayment global
|
||||
window.openPayment = openPayment;
|
||||
|
||||
@ -156,6 +156,17 @@
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Example Output -->
|
||||
<div class="example-section" id="example-section" style="display: none">
|
||||
<h2>EXAMPLE OUTPUT</h2>
|
||||
<div class="example-content">
|
||||
<div id="example-output-text" class="example-output-text" style="white-space: pre-wrap"></div>
|
||||
<div id="example-image" class="example-image" style="display: none">
|
||||
<img id="example-image-img" src="" alt="Example output image">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locked section (shown instead of prompt if no access) -->
|
||||
<div
|
||||
id="locked-section"
|
||||
@ -274,15 +285,20 @@
|
||||
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;
|
||||
renderExamples(p);
|
||||
|
||||
// Tier badge
|
||||
const badge = document.getElementById("tier-badge");
|
||||
if (p.tierName) {
|
||||
if (p.price != null && Number(p.price) > 0) {
|
||||
badge.innerHTML = `<span style="background:#fef3c7;color:#92400e;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">$${Number(p.price).toFixed(2)}</span>`;
|
||||
} else if (p.tierName) {
|
||||
badge.innerHTML = `<span style="background:#f1f5f9;color:#475569;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;"><i class="bi bi-lock-fill"></i> ${p.tierName}</span>`;
|
||||
} else {
|
||||
badge.innerHTML = `<span style="background:#dcfce7;color:#166534;border-radius:20px;padding:4px 14px;font-size:0.8rem;font-weight:600;">Free</span>`;
|
||||
@ -321,6 +337,30 @@
|
||||
document.getElementById("detail-body").style.display = "block";
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user