Add prompt marketplace interactions and profile prompt tabs

This commit is contained in:
Isabelle Nachbaur 2026-05-30 21:13:25 +02:00
parent 691bc72681
commit c95319b1fe
20 changed files with 734 additions and 146 deletions

View File

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

View File

@ -1,4 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiCreatePromptRequest(string Title, string Description, string Content, string Category, int? SubscriptionTier, string? Slug, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
public record ApiUpdatePromptRequest(string Title, string Description, string Content, string Category, string? ExampleOutput, string? ExampleImageUrl, decimal? Price);
}

View File

@ -44,7 +44,9 @@ namespace OnlyPrompt.Backend.Controllers
query = sortBy switch {
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
FeedSortType.Rating => query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 2.5, ascending),
FeedSortType.Rating => ascending
? query.OrderBy(x => x.Likes.Count).ThenBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0)
: query.OrderByDescending(x => x.Likes.Count).ThenByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0),
_ => query
};
@ -60,6 +62,11 @@ namespace OnlyPrompt.Backend.Controllers
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),

View File

@ -55,7 +55,9 @@ namespace OnlyPrompt.Backend.Controllers
query = sortBy switch
{
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
FeedSortType.Rating => query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 2.5, ascending),
FeedSortType.Rating => ascending
? query.OrderBy(x => x.Likes.Count).ThenBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0)
: query.OrderByDescending(x => x.Likes.Count).ThenByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0),
_ => query
};
@ -71,10 +73,45 @@ namespace OnlyPrompt.Backend.Controllers
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
x.CreatorId == userId || ((x.Price == null || x.Price <= 0) && (x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)))
)).ToArrayAsync();
return prompts;
}
[HttpGet("mine")]
public async Task<ApiMinimalPrompt[]> GetOwnPromptsAsync()
{
var userId = User.GetUserId();
var prompts = await _db.Prompts
.Where(x => x.CreatorId == userId)
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.Reviews.Average(r => (double?)r.Rating),
true
)).ToArrayAsync();
return prompts;
@ -92,11 +129,140 @@ namespace OnlyPrompt.Backend.Controllers
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
if (prompt.CreatorId != userId && prompt.Price.HasValue && prompt.Price.Value > 0)
return TypedResults.NotFound("Prompt not found or requires payment");
var canAccess = await GetAccessiblePrompts(userId.Value).AnyAsync(p => p.Id == prompt.Id);
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = canAccess ? prompt.Prompt : null, CanAccess = canAccess };
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 = request.Price;
await _db.SaveChangesAsync();
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = prompt.Prompt, CanAccess = true };
return TypedResults.Ok(apiPrompt);
}
[HttpPut("{id}/likes")]
public async Task<Results<Ok<ApiLikeState>, NotFound<string>>> LikePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
var exists = await _db.PromptLikes.AnyAsync(l => l.PromptId == prompt.Id && l.UserId == userId);
if (exists == false)
{
_db.PromptLikes.Add(new PromptLikeModel
{
PromptId = prompt.Id,
UserId = userId!.Value
});
await _db.SaveChangesAsync();
}
var count = await _db.PromptLikes.CountAsync(l => l.PromptId == prompt.Id);
return TypedResults.Ok(new ApiLikeState(count, true));
}
[HttpDelete("{id}/likes")]
public async Task<Results<Ok<ApiLikeState>, NotFound<string>>> UnlikePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
await _db.PromptLikes
.Where(l => l.PromptId == prompt.Id && l.UserId == userId)
.ExecuteDeleteAsync();
var count = await _db.PromptLikes.CountAsync(l => l.PromptId == prompt.Id);
return TypedResults.Ok(new ApiLikeState(count, false));
}
[HttpPut("{id}/saves")]
public async Task<Results<Ok<ApiSaveState>, NotFound<string>>> SavePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
var exists = await _db.PromptSaves.AnyAsync(s => s.PromptId == prompt.Id && s.UserId == userId);
if (exists == false)
{
_db.PromptSaves.Add(new PromptSaveModel
{
PromptId = prompt.Id,
UserId = userId!.Value
});
await _db.SaveChangesAsync();
}
var count = await _db.PromptSaves.CountAsync(s => s.PromptId == prompt.Id);
return TypedResults.Ok(new ApiSaveState(count, true));
}
[HttpDelete("{id}/saves")]
public async Task<Results<Ok<ApiSaveState>, NotFound<string>>> UnsavePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
await _db.PromptSaves
.Where(s => s.PromptId == prompt.Id && s.UserId == userId)
.ExecuteDeleteAsync();
var count = await _db.PromptSaves.CountAsync(s => s.PromptId == prompt.Id);
return TypedResults.Ok(new ApiSaveState(count, false));
}
[HttpDelete("{id}")]
public async Task<Results<NoContent, NotFound<string>>> DeletePromptAsync(Identifier id)
{

View File

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

View File

@ -25,13 +25,11 @@ 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; }
[MaxLength(4000)]
public string? ExampleOutput { get; set; }
public string? ExampleImageUrl { get; set; }
@ -48,5 +46,7 @@ namespace OnlyPrompt.Backend.Database.Models
[DeleteBehavior(DeleteBehavior.SetNull)]
public virtual SubscriptionTierModel? SubscriptionTier { get; set; }
public virtual IList<ReviewModel> Reviews { get; set; } = new List<ReviewModel>();
public virtual IList<PromptLikeModel> Likes { get; set; } = new List<PromptLikeModel>();
public virtual IList<PromptSaveModel> Saves { get; set; } = new List<PromptSaveModel>();
}
}

View File

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

View File

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

View File

@ -19,8 +19,7 @@ namespace OnlyPrompt.Backend.Migrations
migrationBuilder.AddColumn<string>(
name: "ExampleOutput",
table: "Prompts",
type: "character varying(4000)",
maxLength: 4000,
type: "text",
nullable: true);
migrationBuilder.AddColumn<decimal>(

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Web;
@ -24,6 +25,8 @@ builder.Services.AddDbContext<OnlyPromptContext>(opts =>
{
opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
opts.UseLazyLoadingProxies();
opts.ConfigureWarnings(warnings =>
warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
});
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
@ -110,6 +113,8 @@ 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();
@ -123,13 +128,57 @@ static async Task EnsurePromptDetailColumnsAsync(OnlyPromptContext db)
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "ExampleOutput" character varying(4000);
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)

View File

@ -38,6 +38,10 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.ExampleOutput, x => x.ExampleOutput)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.Price, x => x.Price)
.MapCtorParamFrom(x => x.LikeCount, x => x.Likes.Count)
.MapCtorParamFrom(x => x.IsLiked, x => false)
.MapCtorParamFrom(x => x.SaveCount, x => x.Saves.Count)
.MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
@ -54,6 +58,11 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.Price, x => x.Price)
.MapCtorParamFrom(x => x.LikeCount, x => x.Likes.Count)
.MapCtorParamFrom(x => x.IsLiked, x => false)
.MapCtorParamFrom(x => x.SaveCount, x => x.Saves.Count)
.MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))

View File

@ -28,8 +28,8 @@
<main class="create-main">
<div class="create-container">
<div class="create-header">
<h1>Create AI Prompt</h1>
<p>Design and save custom prompts for your AI workflows.</p>
<h1 id="create-title">Create AI Prompt</h1>
<p id="create-subtitle">Design and save custom prompts for your AI workflows.</p>
</div>
<form id="createPromptForm" class="create-form" enctype="multipart/form-data">
@ -96,7 +96,7 @@
<!-- Submit Button -->
<div class="form-actions">
<button type="submit" class="submit-btn">Publish Prompt</button>
<button type="submit" class="submit-btn" id="submitPromptBtn">Publish Prompt</button>
<button type="button" class="cancel-btn">Cancel</button>
</div>
<p id="create-status" style="text-align:center;color:#64748b;margin:0;"></p>
@ -112,6 +112,8 @@
const paidBtn = document.getElementById('paidBtn');
const priceField = document.getElementById('priceField');
const priceInput = document.getElementById('price');
const editPromptId = new URLSearchParams(location.search).get('id');
const submitPromptBtn = document.getElementById('submitPromptBtn');
freeBtn.addEventListener('click', () => {
freeBtn.classList.add('active');
@ -169,12 +171,56 @@
}
}
async function loadPromptForEdit() {
if (!editPromptId) return;
document.getElementById('create-title').textContent = 'Edit AI Prompt';
document.getElementById('create-subtitle').textContent = 'Update your published prompt.';
submitPromptBtn.textContent = 'Save Changes';
const status = document.getElementById('create-status');
status.textContent = 'Loading prompt...';
try {
const response = await fetch(`/api/v1/prompts/${editPromptId}`);
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) throw new Error('Prompt could not be loaded.');
const prompt = await response.json();
document.getElementById('title').value = prompt.title || '';
document.getElementById('description').value = prompt.description || '';
document.getElementById('category').value = prompt.categorySlug || document.getElementById('category').value;
document.getElementById('promptContent').value = prompt.content || '';
document.getElementById('exampleOutput').value = prompt.exampleOutput || '';
exampleImageUrl = prompt.exampleImageUrl || '';
if (exampleImageUrl) {
previewImg.src = exampleImageUrl;
imagePreview.style.display = 'block';
}
if (prompt.price != null && Number(prompt.price) > 0) {
paidBtn.click();
priceInput.value = Number(prompt.price);
} else {
freeBtn.click();
}
status.textContent = '';
} catch (error) {
status.textContent = error.message;
}
}
// Handle form submission
document.getElementById('createPromptForm').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('create-status');
const submitBtn = document.querySelector('.submit-btn');
status.textContent = 'Publishing...';
status.textContent = editPromptId ? 'Saving...' : 'Publishing...';
submitBtn.disabled = true;
try {
@ -192,8 +238,8 @@
slug: null
};
const response = await fetch('/api/v1/prompts', {
method: 'POST',
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)
@ -254,7 +300,7 @@
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
loadCategories();
loadCategories().then(loadPromptForEdit);
</script>
</body>
</html>

View File

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

View File

@ -153,8 +153,8 @@
}
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";
const liked = prompt.isLiked;
const saved = prompt.isSaved;
return `
<div class="post-card${locked ? " post-locked" : ""}" onclick="location.href='/post-detail?id=${prompt.id}'">
<div class="post-header">
@ -172,14 +172,33 @@
${renderStars(prompt.averageRating)}
</div>
<div class="post-actions">
<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 like-btn ${liked ? 'active' : ''}" onclick="toggleLike(event, '${prompt.id}', ${liked})"><i class="bi ${liked ? 'bi-heart-fill' : 'bi-heart'}"></i> <span>Like (${prompt.likeCount || 0})</span></button>
<button class="action-btn comment-btn" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat"></i> <span>Review</span></button>
<button class="action-btn share-btn" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share"></i> <span>Share</span></button>
<button class="action-btn save-btn ${saved ? 'active' : ''}" onclick="toggleFeedState(event, 'saved', '${prompt.id}')"><i class="bi ${saved ? 'bi-bookmark-fill' : 'bi-bookmark'}"></i> <span>${saved ? 'Saved' : 'Save'}</span></button>
<button class="action-btn save-btn ${saved ? 'active' : ''}" onclick="toggleSave(event, '${prompt.id}', ${saved})"><i class="bi ${saved ? 'bi-bookmark-fill' : 'bi-bookmark'}"></i> <span>Save (${prompt.saveCount || 0})</span></button>
</div>
</div>`;
}
window.toggleLike = async function(event, id, isLiked) {
event.stopPropagation();
const response = await fetch(`/api/v1/prompts/${id}/likes`, {
method: isLiked ? "DELETE" : "PUT",
credentials: "same-origin"
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) return;
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending === "true"
);
};
window.toggleFeedState = function(event, type, id) {
event.stopPropagation();
const key = `prompt-${type}-${id}`;
@ -191,6 +210,25 @@
);
};
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}`);

View File

@ -90,6 +90,9 @@
<option value="date|true">Oldest</option>
<option value="rating|false">Best Rating</option>
<option value="rating|true">Lowest Rating</option>
<option value="free|true">Free</option>
<option value="price|true">Lowest Price</option>
<option value="price|false">Highest Price</option>
</select>
</div>
@ -391,9 +394,36 @@
return `<span class="prompt-rating"><span style="color:#f59e0b">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> ${rating.toFixed(1)}</span>`;
}
function tierPrice(level) {
if (!level) return "Free";
return `$${(level * 4.99).toFixed(2)}/mo`;
function promptPrice(prompt) {
if (prompt.price != null && Number(prompt.price) > 0) {
return `$${Number(prompt.price).toFixed(2)}`;
}
if (prompt.tierLevel) return `$${(prompt.tierLevel * 4.99).toFixed(2)}/mo`;
if (prompt.canAccess === false) return "Paid";
return "Free";
}
function getNumericPrice(prompt) {
if (prompt.price != null && Number(prompt.price) > 0) {
return Number(prompt.price);
}
if (prompt.tierLevel) return prompt.tierLevel * 4.99;
return 0;
}
function applyMarketplaceSort(prompts, sortBy, ascending) {
if (sortBy === "free") {
return prompts.filter((prompt) => getNumericPrice(prompt) === 0);
}
if (sortBy === "price") {
const direction = ascending === "true" ? 1 : -1;
return prompts
.slice()
.sort((a, b) => (getNumericPrice(a) - getNumericPrice(b)) * direction);
}
return prompts;
}
const MARKET_IMAGES = [
@ -407,7 +437,8 @@
let cardIndex = 0;
function renderCard(p) {
const locked = !p.canAccess && p.tierLevel != null;
const paid = p.price != null && Number(p.price) > 0;
const locked = p.canAccess === false || paid || p.tierLevel != null;
const img = p.exampleImageUrl || p._img || MARKET_IMAGES[cardIndex++ % MARKET_IMAGES.length];
return `
<div class="prompt-card">
@ -419,16 +450,19 @@
<span style="margin-left:auto;font-size:0.75rem;color:#94a3b8;">${timeAgo(p.timeStamp)}</span>
</div>
<h3 class="prompt-title">${p.title}</h3>
${p.tierName ? `<span style="display:inline-block;background:#f1f5f9;color:#475569;border-radius:20px;padding:2px 10px;font-size:0.75rem;margin-bottom:8px;"><i class="bi bi-lock-fill"></i> ${p.tierName}</span>` : '<span style="display:inline-block;background:#dcfce7;color:#166534;border-radius:20px;padding:2px 10px;font-size:0.75rem;margin-bottom:8px;">Free</span>'}
<div style="margin-bottom:12px;">${renderStars(p.averageRating)}</div>
<div class="prompt-price">${tierPrice(p.tierLevel)}</div>
<div class="prompt-price">${promptPrice(p)}</div>
<div class="prompt-actions">
${
locked
? `<button class="buy-btn" onclick='openPayment(${JSON.stringify(p)})'>Subscribe</button>`
? `<button class="buy-btn" style="background:#ef4444;" onclick='openPayment(${JSON.stringify(p)})'><i class="bi bi-lock-fill"></i> Pay</button>`
: `<button class="buy-btn" style="background:#10b981;" onclick="location.href='/post-detail?id=${p.id}'">Access <i class="bi bi-unlock-fill"></i></button>`
}
<button class="details-btn" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>
${
locked
? `<button class="details-btn" disabled style="opacity:.45;cursor:not-allowed;"><i class="bi bi-lock-fill"></i> Details</button>`
: `<button class="details-btn" onclick="location.href='/post-detail?id=${p.id}'">View Details</button>`
}
</div>
</div>
</div>`;
@ -447,74 +481,11 @@
emptyEl.style.display = "none";
errorEl.style.display = "none";
cardIndex = 0;
const DEMO_PROMPTS = [
{
id: "demo-1",
title: "Creative Blog Post Generator",
creatorName: "JaneDoe",
timeStamp: new Date(Date.now() - 7200000).toISOString(),
tierLevel: null,
tierName: null,
averageRating: 4.8,
canAccess: true,
},
{
id: "demo-2",
title: "Python Code Assistant",
creatorName: "AlexDev",
timeStamp: new Date(Date.now() - 86400000).toISOString(),
tierLevel: 1,
tierName: "Basic",
averageRating: 4.7,
canAccess: false,
},
{
id: "demo-3",
title: "Digital Art Style Guide",
creatorName: "MiaArt",
timeStamp: new Date(Date.now() - 172800000).toISOString(),
tierLevel: 2,
tierName: "Pro",
averageRating: 4.9,
canAccess: false,
},
{
id: "demo-4",
title: "Marketing Copywriter",
creatorName: "TomCopy",
timeStamp: new Date(Date.now() - 259200000).toISOString(),
tierLevel: null,
tierName: null,
averageRating: 4.6,
canAccess: true,
},
{
id: "demo-5",
title: "Midjourney Image Prompts",
creatorName: "SarahViz",
timeStamp: new Date(Date.now() - 432000000).toISOString(),
tierLevel: 1,
tierName: "Basic",
averageRating: 4.7,
canAccess: false,
},
{
id: "demo-6",
title: "UI Design Assistant",
creatorName: "LilyUI",
timeStamp: new Date(Date.now() - 604800000).toISOString(),
tierLevel: 3,
tierName: "Premium",
averageRating: 4.9,
canAccess: false,
_img: "/images/content/market6.png",
},
];
// pre-assign images to demo prompts
DEMO_PROMPTS.forEach((p, i) => (p._img = MARKET_IMAGES[i]));
try {
let url = `/api/v1/prompts?sortBy=${sortBy}&ascending=${ascending}&limit=50`;
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)}`;
@ -525,15 +496,7 @@
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
let prompts = await res.json();
// If API is empty and no filters active, show demo content
if (prompts.length === 0 && !activeCategory && !search) {
grid.innerHTML =
`<div style="grid-column:1/-1;text-align:center;padding:8px 0 20px;color:#94a3b8;font-size:0.85rem;"><i class="bi bi-info-circle"></i> Showing example prompts — no real prompts yet.</div>` +
DEMO_PROMPTS.map(renderCard).join("");
return;
}
let prompts = applyMarketplaceSort(await res.json(), sortBy, ascending);
if (prompts.length === 0) {
emptyEl.style.display = "block";
@ -541,10 +504,9 @@
}
grid.innerHTML = prompts.map(renderCard).join("");
} catch (e) {
// On error, show demo content as fallback
grid.innerHTML =
`<div style="grid-column:1/-1;text-align:center;padding:8px 0 20px;color:#94a3b8;font-size:0.85rem;"><i class="bi bi-wifi-off"></i> Could not connect to server — showing demo content.</div>` +
DEMO_PROMPTS.map(renderCard).join("");
document.getElementById("market-error-msg").textContent =
e.message || "Please check if the backend is running.";
errorEl.style.display = "block";
}
}
@ -623,7 +585,9 @@
function openPayment(prompt) {
currentPrompt = prompt;
const usd = prompt.tierLevel ? prompt.tierLevel * 4.99 : 0;
const usd = prompt.price != null && Number(prompt.price) > 0
? Number(prompt.price)
: prompt.tierLevel ? prompt.tierLevel * 4.99 : 0;
document.getElementById("pay-prompt-title").textContent = prompt.title;
document.getElementById("price-btc").textContent =
`≈ ${(usd / 67000).toFixed(6)} BTC`;

View File

@ -118,6 +118,22 @@
</div>
<div style="margin-left: auto">
<span id="tier-badge"></span>
<button
id="edit-prompt-btn"
style="
display: none;
margin-left: 10px;
padding: 6px 14px;
border: none;
border-radius: 10px;
background: #f1f5f9;
color: #334155;
font-weight: 700;
cursor: pointer;
"
>
Edit
</button>
</div>
</div>
<h1 class="post-title" id="prompt-title"></h1>
@ -292,7 +308,11 @@
document.getElementById("prompt-description").textContent =
p.description;
document.getElementById("prompt-body").textContent = p.content;
document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes
<span style="margin-left:12px;"><i class="bi ${p.isSaved ? 'bi-bookmark-fill' : 'bi-bookmark'}" style="color:#f59e0b;"></i> ${p.saveCount || 0} saves</span>`;
renderExamples(p);
renderOwnerActions(p);
// Tier badge
const badge = document.getElementById("tier-badge");
@ -312,7 +332,9 @@
<span style="margin-left:8px;font-weight:600;">${p.averageRating.toFixed(1)}</span>
<span style="color:#94a3b8;font-size:0.85rem;margin-left:4px;">/ 5.0</span>`;
document.getElementById("prompt-rating-stat").innerHTML =
`<i class="bi bi-star-fill" style="color:#f59e0b;"></i> ${p.averageRating.toFixed(1)} rating`;
`<i class="bi ${p.isLiked ? 'bi-heart-fill' : 'bi-heart'}" style="color:#ef4444;"></i> ${p.likeCount || 0} likes
<span style="margin-left:12px;"><i class="bi ${p.isSaved ? 'bi-bookmark-fill' : 'bi-bookmark'}" style="color:#f59e0b;"></i> ${p.saveCount || 0} saves</span>
<span style="margin-left:12px;"><i class="bi bi-star-fill" style="color:#f59e0b;"></i> ${p.averageRating.toFixed(1)} rating</span>`;
} else {
document.getElementById("rating-display").innerHTML =
'<span style="color:#94a3b8;font-size:0.9rem;">No ratings yet</span>';
@ -337,6 +359,26 @@
document.getElementById("detail-body").style.display = "block";
}
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");

View File

@ -55,38 +55,14 @@
</section>
<nav style="display:flex;gap:24px;border-bottom:2px solid #e5e7eb;margin:32px 0 18px 0;">
<a href="#" style="padding:10px 0;color:#3b82f6;font-weight:600;border-bottom:2px solid #3b82f6;">My Prompts (2)</a>
<a href="#" style="padding:10px 0;color:#64748b;">Favorites (15)</a>
<a href="#" style="padding:10px 0;color:#64748b;">Saved (7)</a>
<nav class="profile-tabs" style="display:flex;gap:24px;border-bottom:2px solid #e5e7eb;margin:32px 0 18px 0;flex-wrap:wrap;">
<button type="button" class="profile-tab active" data-tab="mine" id="myPromptsTab">My Prompts</button>
<button type="button" class="profile-tab" data-tab="favorites" id="favoritesTab">Favorites</button>
<button type="button" class="profile-tab" data-tab="saved" id="savedTab">Saved</button>
</nav>
<section style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px;">
<div style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:18px;display:flex;gap:16px;">
<img src="../images/content/post1.png" style="width:60px;height:60px;border-radius:12px;">
<div>
<div style="font-weight:700;">Galactic Catventure</div>
<div style="color:#64748b;margin-bottom:6px;">A cosmic journey of a curious cat exploring the stars.</div>
<div style="display:flex;gap:16px;color:#64748b;">
<span><i class="bi bi-heart"></i> 128</span>
<span><i class="bi bi-chat"></i> 15</span>
</div>
</div>
</div>
<div style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:18px;display:flex;gap:16px;">
<img src="../images/content/post2.png" style="width:60px;height:60px;border-radius:12px;">
<div>
<div style="font-weight:700;">Minimalist Cat Logo</div>
<div style="color:#64748b;margin-bottom:6px;">Sleek logo design for feline fans.</div>
<div style="display:flex;gap:16px;color:#64748b;">
<span><i class="bi bi-heart"></i> 54</span>
<span><i class="bi bi-chat"></i> 6</span>
</div>
</div>
</div>
<section id="profile-prompts-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px;">
<div style="grid-column:1/-1;color:#64748b;text-align:center;padding:28px;">Loading prompts...</div>
</section>
</main>
@ -118,6 +94,13 @@
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');
let ownPrompts = [];
let allPrompts = [];
let activeProfileTab = 'mine';
async function loadOwnProfile() {
try {
@ -138,7 +121,106 @@
}
}
function isPromptMarked(type, id) {
if (type === 'liked') {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isLiked === true;
}
if (type === 'saved') {
const prompt = allPrompts.find((item) => item.id === id);
return prompt?.isSaved === true;
}
return false;
}
function renderProfilePrompt(prompt, options = {}) {
const image = prompt.exampleImageUrl || '../images/content/post1.png';
const showEdit = options.showEdit === true;
const rating = prompt.averageRating == null ? 'No ratings' : prompt.averageRating.toFixed(1);
return `
<div onclick="location.href='/post-detail?id=${prompt.id}'" style="background:#fff;border-radius:18px;box-shadow:0 2px 8px rgba(59,130,246,0.06);padding:18px;display:flex;gap:16px;cursor:pointer;">
<img src="${image}" alt="${prompt.title}" style="width:72px;height:72px;border-radius:12px;object-fit:cover;">
<div style="flex:1;min-width:0;">
<div style="font-weight:700;">${prompt.title}</div>
<div style="color:#64748b;margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${prompt.description || 'No description yet.'}</div>
<div style="display:flex;gap:16px;color:#64748b;align-items:center;flex-wrap:wrap;">
<span><i class="bi bi-star"></i> ${rating}</span>
${prompt.creatorName ? `<span>@${prompt.creatorName}</span>` : ''}
${showEdit ? `<button onclick="event.stopPropagation(); location.href='/create?id=${prompt.id}'" style="border:none;background:#f1f5f9;color:#334155;border-radius:10px;padding:6px 10px;font-weight:700;cursor:pointer;">Edit</button>` : ''}
</div>
</div>
</div>`;
}
function renderPromptList(prompts, emptyText, options = {}) {
if (!prompts.length) {
profilePromptsGrid.innerHTML = `<div style="grid-column:1/-1;color:#64748b;text-align:center;padding:28px;">${emptyText}</div>`;
return;
}
profilePromptsGrid.innerHTML = prompts.map((prompt) => renderProfilePrompt(prompt, options)).join('');
}
function updateTabs() {
document.querySelectorAll('.profile-tab').forEach((tab) => {
tab.classList.toggle('active', tab.dataset.tab === activeProfileTab);
});
const liked = allPrompts.filter((prompt) => isPromptMarked('liked', prompt.id));
const saved = allPrompts.filter((prompt) => isPromptMarked('saved', prompt.id));
myPromptsTab.textContent = `My Prompts (${ownPrompts.length})`;
favoritesTab.textContent = `Favorites (${liked.length})`;
savedTab.textContent = `Saved (${saved.length})`;
if (activeProfileTab === 'favorites') {
renderPromptList(liked, 'No liked prompts yet.');
} else if (activeProfileTab === 'saved') {
renderPromptList(saved, 'No saved prompts yet.');
} else {
renderPromptList(ownPrompts, 'No prompts yet.', { showEdit: true });
}
}
async function loadOwnPrompts() {
try {
const response = await fetch('/api/v1/prompts/mine');
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) throw new Error('Prompts could not be loaded.');
ownPrompts = await response.json();
updateTabs();
} catch (error) {
profilePromptsGrid.innerHTML = `<div style="grid-column:1/-1;color:#ef4444;text-align:center;padding:28px;">${error.message}</div>`;
}
}
async function loadAllPromptReferences() {
try {
const response = await fetch('/api/v1/prompts?limit=100');
if (!response.ok) return;
allPrompts = await response.json();
updateTabs();
} catch {
// Favorites and saved stay empty if prompts cannot be loaded.
}
}
document.querySelectorAll('.profile-tab').forEach((tab) => {
tab.addEventListener('click', () => {
activeProfileTab = tab.dataset.tab;
updateTabs();
});
});
loadOwnProfile();
loadOwnPrompts();
loadAllPromptReferences();
</script>
</body>
</html>