Compare commits

...

19 Commits

Author SHA1 Message Date
Aeolin Ferjünnoz
870630063a - fixed two way binding
- added support for list like attributes to toggle classes for example
2026-04-14 15:00:11 +02:00
Aeolin Ferjünnoz
0f2874e6df - seems to be working
- added some utility
2026-04-13 21:19:07 +02:00
Aeolin Ferjünnoz
11ebf22ec9 - virtual dom based approach working 2026-04-13 20:53:26 +02:00
Aeolin Ferjünnoz
62ac0a6bb7 - pwa kinda working 2026-04-13 18:02:14 +02:00
Aeolin Ferjünnoz
a2b898b54f experimental pwa implementation 2026-04-13 16:35:53 +02:00
Aeolin Ferjünnoz
4eaea29513 - added instructions on docker usage 2026-04-12 17:18:29 +02:00
Aeolin Ferjünnoz
806ad23315 - register and login working 2026-04-12 17:14:52 +02:00
Aeolin Ferjünnoz
6abc36bc9b - started implementing frontend to backend 2026-04-12 16:38:59 +02:00
Aeolin Ferjünnoz
3c9f7323ba - first version of backend done 2026-04-12 03:45:01 +02:00
Aeolin Ferjünnoz
d466365348 - implement most controllers 2026-04-12 02:23:26 +02:00
Aeolin Ferjünnoz
22aabc8f27 - started implementing backend 2026-04-11 21:36:05 +02:00
3da8813c41 filter for marketplace 2026-04-07 23:56:55 +02:00
02b8a75947 prompt creation & detail page 2026-04-07 23:46:17 +02:00
d2d5c0c66c new pages and linking 2026-04-06 20:58:30 +02:00
4105659b0b feed page 2026-04-06 20:18:25 +02:00
da52852fdf removed files 2026-04-06 16:32:00 +02:00
7b23b296d3 marketplace 2026-04-06 16:30:55 +02:00
a4a5d03f9f creators page 2026-04-06 16:21:33 +02:00
11e973ce61 login, signup, profile page setup 2026-04-05 18:04:51 +02:00
124 changed files with 11184 additions and 50 deletions

30
.dockerignore Normal file
View File

@ -0,0 +1,30 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**

428
.gitignore vendored Normal file
View File

@ -0,0 +1,428 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
[Dd]ebug/x64/
[Dd]ebugPublic/x64/
[Rr]elease/x64/
[Rr]eleases/x64/
bin/x64/
obj/x64/
[Dd]ebug/x86/
[Dd]ebugPublic/x86/
[Rr]elease/x86/
[Rr]eleases/x86/
bin/x86/
obj/x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
**/.paket/paket.exe
paket-files/
# FAKE - F# Make
**/.fake/
# CodeRush personal settings
**/.cr/personal
# Python Tools for Visual Studio (PTVS)
**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
#tools/**
#!tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp

View File

@ -0,0 +1,4 @@
namespace OnlyPrompt.Backend.ApiModels.Auth
{
public record ApiUser(Guid Id, string UserName, string Email, string[] Roles);
}

View File

@ -0,0 +1,8 @@
using OnlyPrompt.Backend.ApiModels.Validators;
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.ApiModels.Auth
{
public record ApiLoginRequest(string UserNameOrEmail, string Password);
public record ApiRegisterRequest([MaxLength(100)] string DisplayName, [MaxLength(100)][NoWhitespace] string? UserName, string Email, string Password);
}

View File

@ -0,0 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Category
{
public record ApiCategory(Guid Id, string Name, string Slug, string? Description);
public record ApiMinimalCategory(string Name, string Slug);
}

View File

@ -0,0 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Category
{
public record ApiCreateCategoryRequest(string Name, string? Slug, string? Description);
public record ApiUpdateCategoryRequest(string? Name, string? Slug, string? Description);
}

View File

@ -0,0 +1,8 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public enum FeedSortType
{
Date,
Rating
}
}

View File

@ -0,0 +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);
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);
}

View File

@ -0,0 +1,6 @@
using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public record ApiCreatePromptRequest(string Title, string Description, string Content, Identifier Category, int? SubscriptionTier, string Slug);
}

View File

@ -0,0 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Subscription
{
public record ApiSubscriptionTier(Guid Id, string Name, int Level, decimal MonthlyPrice, string? Description);
public record ApiSubscription(Guid SubscribedToId, string SubscribedToName, ApiSubscriptionTier? CurrentTier);
}

View File

@ -0,0 +1,5 @@
namespace OnlyPrompt.Backend.ApiModels.Subscription
{
public record ApiCreateSubscriptionTierRequest(string Name, decimal MonthlyPrice, int Level, string? Description);
public record ApiUpdateSubscriptionTierRequest(string? Name, decimal? MonthlyPrice, int? Level, string? Description);
}

View File

@ -0,0 +1,4 @@
namespace OnlyPrompt.Backend.ApiModels.UserProfile
{
public record ApiUserProfile(string DisplayName, string Slug, string? Bio, string AvatarUrl, string? Specialities, double AverageRating, int Subscribers);
}

View File

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

View File

@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.ApiModels.Validators
{
public class NoWhitespaceAttribute : ValidationAttribute
{
public override bool IsValid(object? value)
{
if (value is string strValue)
{
if (strValue.Any(c => char.IsWhiteSpace(c)))
return false;
return true;
}
return true; // If it's not a string, we consider it valid. Use [NoWhitespace] only on string properties.
}
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if(value is string strValue)
{
if(strValue.Any(c => char.IsWhiteSpace(c)))
return new ValidationResult($"{validationContext.DisplayName} should not contain any whitespace characters.");
return ValidationResult.Success;
}
return ValidationResult.Success; // If it's not a string, we consider it valid. Use [NoWhitespace] only on string properties.
}
}
}

View File

@ -0,0 +1,83 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/admin")]
[Authorize(Roles = ModelConstants.AdminRole)]
public class AdminController : BaseController
{
public AdminController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
private Task<UserModel?> GetNonAdminUserAsync(Guid id, bool isSysAdmin = false)
{
return _db.Users.FirstOrDefaultAsync(
u => u.Id == id
&& (isSysAdmin || u.Roles.Contains(ModelConstants.AdminRole) == false)
&& u.Roles.Contains(ModelConstants.SysAdminRole) == false
);
}
[HttpPost("users/{userId}/disable")]
public async Task<Results<Ok, NotFound<string>>> DisableUserAsync(Guid userId)
{
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
if (user is null)
return TypedResults.NotFound("User not found.");
user.IsLockoutEnabled = true;
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
[HttpPost("users/{userId}/enable")]
public async Task<Results<Ok, NotFound<string>>> EnableUserAsync(Guid userId)
{
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
if (user is null)
return TypedResults.NotFound("User not found.");
user.IsLockoutEnabled = false;
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
[HttpPut("users/{userId}/roles/{role}")]
public async Task<Results<Ok, NotFound<string>>> UpdateUserRolesAsync(Guid userId, string role)
{
if (ModelConstants.AllRoles.Contains(role) == false)
return TypedResults.NotFound($"No such role '{role}'");
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
if (user is null)
return TypedResults.NotFound("User not found.");
user.Roles.Add(role);
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
[HttpDelete("users/{userId}/roles/{role}")]
public async Task<Results<Ok, NotFound<string>>> RemoveUserRoleAsync(Guid userId, string role)
{
if (ModelConstants.AllRoles.Contains(role) == false)
return TypedResults.NotFound($"No such role '{role}'");
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
if (user is null)
return TypedResults.NotFound("User not found.");
user.Roles.Remove(role);
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
}
}

View File

@ -0,0 +1,109 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.Auth;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Services.Jwt;
using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/auth")]
public class AuthController : BaseController
{
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;
public AuthController(OnlyPromptContext db, IPasswordHasher<UserModel> passwordHasher, IMapper mapper, ILogger<AuthController> logger, ITokenService jwtService) : base(db, mapper)
{
_passwordHasher=passwordHasher;
_logger=logger;
_jwtService=jwtService;
}
[AllowAnonymous]
[HttpPost("login")]
public async Task<Results<Ok, RedirectHttpResult, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request, [FromQuery]string redirect = null)
{
var user = await FindUserAsync(request.UserNameOrEmail);
if (user is null)
return TypedResults.NotFound("User not found");
var verificationResult = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
if (verificationResult == PasswordVerificationResult.Failed)
return TypedResults.NotFound("User not found"); // Don't reveal that the user exists
if (user.IsLockoutEnabled)
return TypedResults.BadRequest("User is locked out"); // Don't reveal that the user exists
var token = _jwtService.BuildToken(user, out var validUntil);
this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil));
if (string.IsNullOrEmpty(redirect) == false)
return TypedResults.Redirect(redirect, false);
return TypedResults.Ok();
}
[AllowAnonymous]
[HttpPost("register")]
public async Task<Results<RedirectHttpResult, ValidationProblem, Ok<ApiUser>>> RegisterAsync([FromBody] ApiRegisterRequest request, [FromQuery] string redirect = null)
{
var existingUser = await FindUserAsync(request.UserName, request.Email);
if (existingUser is not null)
{
var errors = new Dictionary<string, string[]>();
if (existingUser.UserName == request.UserName)
errors.Add(nameof(request.UserName), ["Username is already taken"]);
if (existingUser.Email == request.Email)
errors.Add(nameof(request.Email), ["Email is already registered"]);
return TypedResults.ValidationProblem(errors);
}
var id = Guid.CreateVersion7();
var slug = await SlugHelper.GenerateUniqueSlugAsync(request.UserName, s => _db.UserProfiles.AnyAsync(up => up.Slug == s), 32);
var avatarUrl = $"https://api.dicebear.com/9.x/bottts/svg?seed={id}";
var newUser = new UserModel
{
Id = id,
Profile = new UserProfileModel
{
AvatarUrl = avatarUrl,
DisplayName = request.DisplayName,
Slug = slug,
},
Roles = [ModelConstants.UserRole],
PasswordHash = null,
UserName = request.UserName ?? request.Email,
Email = request.Email,
IsLockoutEnabled = false,
};
newUser.PasswordHash = _passwordHasher.HashPassword(newUser, request.Password);
_db.Users.Add(newUser);
await _db.SaveChangesAsync();
if(string.IsNullOrEmpty(redirect) == false)
return TypedResults.Redirect(redirect, false);
return TypedResults.Ok(_mapper.Map<ApiUser>(newUser));
}
[HttpPost("logout")]
public RedirectHttpResult Logout()
{
this.Response.Cookies.Delete("jwt", AuthCookieOptions);
return TypedResults.Redirect("login");
}
}
}

View File

@ -0,0 +1,45 @@
using AutoMapper;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.Controllers
{
public abstract class BaseController : Controller
{
protected OnlyPromptContext _db;
protected IMapper _mapper;
public BaseController(OnlyPromptContext db, IMapper mapper)
{
_db=db;
_mapper=mapper;
}
public Task<UserModel?> FindUserAsync(Guid id) => _db.Users.FirstOrDefaultAsync(x => x.Id == id);
public Task<UserModel?> FindUserAsync(string userName, string email) => _db.Users.FirstOrDefaultAsync(x => x.Email == email || x.UserName == userName);
public Task<UserModel?> FindUserAsync(string emailOrUsername) => _db.Users.FirstOrDefaultAsync(x => x.Email == emailOrUsername || x.UserName == emailOrUsername);
public async Task<UserProfileModel?> GetUserProfileAsync()
{
var id = User.GetUserId();
if (id.HasValue == false)
return null;
var profile = await _db.UserProfiles.FirstOrDefaultAsync(x => x.Id == id.Value);
return profile;
}
public async Task<UserModel?> GetUserAsync()
{
var id = User.GetUserId();
if (id.HasValue == false)
return null;
var user = await _db.Users.FindAsync(id.Value);
return user;
}
}
}

View File

@ -0,0 +1,123 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.Category;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/categories")]
[Authorize(Roles = ModelConstants.AdminRole, AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class CategoryController : BaseController
{
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(CategoryModel.Slug), new[] { "Slug already exists." } }
});
public CategoryController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[HttpGet("minimal")]
[Authorize(Roles = ModelConstants.UserRole)]
public async Task<ApiMinimalCategory[]> GetMinimalCategoriesAsync()
{
var categories = await _db.Categories
.ProjectTo<ApiMinimalCategory>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return categories;
}
[HttpGet]
[Authorize(Roles = ModelConstants.UserRole)]
public async Task<ApiCategory[]> GetCategoriesAsync([Range(0, double.MaxValue)][FromQuery] int offset = 0, [Range(1, 100)][FromQuery] int limit = 20)
{
var categories = await _db.Categories
.OrderBy(c => c.Id)
.Skip(offset)
.Take(limit)
.ProjectTo<ApiCategory>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return categories;
}
[HttpPost]
public async Task<Results<Ok<ApiCategory>, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request)
{
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug);
if (exists)
return SlugExistsProblem;
var model = _mapper.Map<CategoryModel>(request);
if (string.IsNullOrWhiteSpace(model.Slug))
model.Slug = await SlugHelper.GenerateUniqueSlugAsync(request.Name, slug => _db.Categories.AnyAsync(c => c.Slug == slug), ModelConstants.MaxSlugLength);
_db.Categories.Add(model);
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiCategory>(model));
}
[HttpPut("{id}")]
public async Task<Results<Ok<ApiCategory>, NotFound<string>, ValidationProblem>> UpdateCategoryAsync(Identifier id, [FromBody] ApiUpdateCategoryRequest request)
{
var category = await _db.Categories.FindByIdentifierAsync(id);
if (category is null)
return TypedResults.NotFound("Category not found");
if (string.IsNullOrWhiteSpace(request.Name) == false)
category.Name = request.Name;
if(string.IsNullOrWhiteSpace(request.Description) == false)
category.Description = request.Description;
if (string.IsNullOrWhiteSpace(request.Slug) == false && request.Slug != category.Slug)
{
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug && c.Id != category.Id);
if (exists)
return SlugExistsProblem;
category.Slug = request.Slug;
}
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiCategory>(category));
}
[HttpDelete("{id}")]
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);
if (hasPrompts)
{
if (replaceWith.HasValue == false)
return TypedResults.BadRequest("Category has associated prompts. Provide a replacement category to reassign them to.");
var replacement = await _db.Categories.FindByIdentifierAsync(replaceWith.Value);
if(replacement is null)
return TypedResults.NotFound("Replacement category not found.");
await _db.Prompts.Where(p => p.CategoryId == id.Id)
.ExecuteUpdateAsync(p => p.SetProperty(p => p.CategoryId, replacement.Id));
}
var count = await _db.Categories.OfIdentifer(id)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound("Category not found");
return TypedResults.NoContent();
}
}
}

View File

@ -0,0 +1,73 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.Prompt;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Utils;
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/feed")]
[Authorize(Roles = ModelConstants.UserRole)]
public class FeedController : BaseController
{
public FeedController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[HttpGet]
public async Task<ApiMinimalPrompt[]> GetFeedAsync(
[Range(0, double.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]DateTime? fromDate = null,
[FromQuery]DateTime? toDate = null
)
{
var userId = User.GetUserId();
var query = _db.Prompts
.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);
if (fromDate.HasValue)
query = query.Where(x => x.UpdatedAt >= fromDate.Value);
if (toDate.HasValue)
query = query.Where(x => x.UpdatedAt <= toDate.Value);
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),
_ => query
};
var prompts = await query
.Skip(offset)
.Take(limit)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.SubscriptionTier.Level,
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();
return prompts;
}
}
}

View File

@ -0,0 +1,78 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.UserProfile;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/profiles")]
[Authorize(Roles = ModelConstants.UserRole)]
public class ProfileController : BaseController
{
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(UserProfileModel.Slug), new[] { "Slug already exists." } }
});
public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[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();
if (profile is null)
return TypedResults.NotFound("Profile not found or is private.");
return TypedResults.Ok(profile);
}
[HttpPut]
public async Task<Results<ValidationProblem, NotFound<string>, Ok<ApiUserProfile>>> UpdateProfileAsync([FromBody] ApiUpdateProfileRequest request)
{
var self = await GetUserProfileAsync();
if (self is null)
return TypedResults.NotFound("Profile not found.");
if (string.IsNullOrEmpty(request.Slug) == false)
{
if (await _db.UserProfiles.AnyAsync(up => up.Slug == request.Slug && up.Id != self.Id))
return SlugExistsProblem;
self.Slug = request.Slug;
}
if(string.IsNullOrEmpty(request.AvatarUrl) == false)
self.AvatarUrl = request.AvatarUrl;
if(string.IsNullOrEmpty(request.Bio) == false)
self.Bio = request.Bio;
if(string.IsNullOrEmpty(request.Specialities) == false)
self.Specialities = request.Specialities;
if (string.IsNullOrEmpty(request.DisplayName) == false)
self.DisplayName = request.DisplayName;
self.IsPublic = request.IsPublic;
await _db.SaveChangesAsync();
var result = _mapper.Map<ApiUserProfile>(self);
return TypedResults.Ok(result);
}
}
}

View File

@ -0,0 +1,183 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.Prompt;
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
{
[ApiController]
[Route("api/v1/prompts")]
[Authorize(Roles = ModelConstants.UserRole)]
public class PromptController : BaseController
{
public PromptController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
private IQueryable<PromptModel> GetAccessiblePrompts(Guid userId)
{
return _db.Prompts.Where(
p => p.SubscriptionTier == null
|| p.Creator.Subscribers.Any(
sub => sub.SubscriberId == userId
&& p.SubscriptionTier!.Level <= sub.SubscriptionTier!.Level
)
);
}
[HttpGet("{id}")]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> GetPromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await GetAccessiblePrompts(userId.Value)
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found or no permission");
var apiPrompt = _mapper.Map<ApiPrompt>(prompt);
return TypedResults.Ok(apiPrompt);
}
[HttpDelete("{id}")]
public async Task<Results<NoContent, NotFound<string>>> DeletePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var isAdmin = User.IsInRole(ModelConstants.AdminRole);
var count = await _db.Prompts
.OfIdentifer(id)
.Where(p => p.CreatorId == userId || isAdmin)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound("Prompt not found or no permission");
return TypedResults.NoContent();
}
[HttpPost]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> CreatePromptAsync([FromBody] ApiCreatePromptRequest request)
{
var userId = User.GetUserId();
var category = await _db.Categories.FindByIdentifierAsync(request.Category);
if (category is null)
return TypedResults.NotFound("Category not found");
SubscriptionTierModel? subscriptionTier = null;
if (request.SubscriptionTier.HasValue)
{
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");
}
var slug = request.Slug;
if (string.IsNullOrEmpty(slug))
slug = await SlugHelper.GenerateUniqueSlugAsync(request.Title, slug => _db.Prompts.AnyAsync(p => p.Slug == slug), ModelConstants.MaxSlugLength);
var prompt = new PromptModel
{
Id = Guid.NewGuid(),
Title = request.Title,
Description = request.Description,
Prompt = request.Content,
CreatorId = userId.Value,
SubscriptionTier = subscriptionTier,
Category = category,
Slug = slug
};
_db.Prompts.Add(prompt);
await _db.SaveChangesAsync();
var apiPrompt = _mapper.Map<ApiPrompt>(prompt);
return TypedResults.Ok(apiPrompt);
}
[HttpGet("{id}/reviews")]
public async Task<Ok<ApiReview[]>> GetReviewsAsync(Identifier id, [FromQuery] int offset = 0, [Range(1, 200)][FromQuery] int limit = 20)
{
var userId = User.GetUserId();
var accessiblePrompts = GetAccessiblePrompts(userId!.Value);
var reviews = await accessiblePrompts.Select(x => x.Reviews)
.Skip(offset)
.Take(limit)
.ProjectTo<ApiReview>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return TypedResults.Ok(reviews);
}
[HttpPut("{id}/reviews")]
public async Task<Results<Ok<ApiReview>, BadRequest<string>, NotFound<string>>> AddReviewAsync(Identifier id, [FromBody] ApiCreateReviewRequest request)
{
var userId = User.GetUserId();
var prompt = await GetAccessiblePrompts(userId!.Value)
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found or no permission");
if(prompt.CreatorId == userId)
return TypedResults.BadRequest("Cannot review your own prompt");
var review = await _db.Reviews.FirstOrDefaultAsync(
r => r.PromptId == prompt.Id
&& r.ReviewerId == userId
);
if (review is null)
{
review = new ReviewModel
{
PromptId = prompt.Id,
ReviewerId = userId.Value,
Comment = request.Comment,
Rating = request.Rating
};
_db.Reviews.Add(review);
}
else
{
review.Comment = request.Comment;
review.Rating = request.Rating;
}
await _db.SaveChangesAsync();
var apiReview = _mapper.Map<ApiReview>(review);
return TypedResults.Ok(apiReview);
}
[HttpDelete("{promptId}/reviews/{reviewerId}")]
public async Task<Results<NoContent, NotFound<string>>> DeleteReviewAsync(Identifier promptId, Guid reviewerId)
{
var userId = User.GetUserId();
var isAdmin = User.IsInRole(ModelConstants.AdminRole);
var count = await _db.Reviews
.Where(
r => (promptId.Id.HasValue ? r.PromptId == promptId.Id : r.Prompt.Slug == promptId.Slug)
&& (r.ReviewerId == reviewerId || isAdmin)
)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound("Review not found or no permission");
return TypedResults.NoContent();
}
}
}

View File

@ -0,0 +1,180 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.Subscription;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
using System.ComponentModel.DataAnnotations;
using System.Formats.Asn1;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/subscriptions")]
[Authorize(Roles = ModelConstants.UserRole)]
public class SubscriptionController : BaseController
{
private static ValidationProblem TierLevelExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(SubscriptionTierModel.Level), new[] { "Tier with this level already exists." } }
});
public SubscriptionController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[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))
.FirstOrDefaultAsync(
user => subscribeToId.Id.HasValue ? user.Id == subscribeToId.Id.Value : user.Profile.Slug == subscribeToId.Slug
);
if (subscribeTo is null)
return TypedResults.NotFound($"No user found with identifier {subscribeToId}");
if (subscribeTo.Id == userId)
return TypedResults.BadRequest("Cannot subscribe to yourself");
SubscriptionTierModel? tier = subscribeTo.SubscriptionTiers.FirstOrDefault();
if (level.HasValue && tier is null)
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.SubscriberId == userId
);
if (existingSubscription is null)
{
existingSubscription = new SubscriptionModel
{
SubscribedTo = subscribeTo,
SubscriberId = userId.Value,
SubscriptionTier = tier
};
_db.Subscriptions.Add(existingSubscription);
}
else
{
existingSubscription.SubscriptionTier = tier;
}
await _db.SaveChangesAsync();
return TypedResults.Ok();
}
[HttpGet]
public async Task<ApiSubscription[]> GetSubscriptionsAsync([Range(0, double.MaxValue)]int offset = 0, [Range(1, 100)]int limit = 20)
{
var userId = User.GetUserId();
var subscriptions = await _db.Subscriptions
.Where(x => x.SubscriberId == userId)
.OrderBy(x => x.SubscribedToId)
.Skip(offset)
.Take(limit)
.ProjectTo<ApiSubscription>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return subscriptions;
}
[HttpGet("{userId}")]
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.SubscriberId == userId
)
.ProjectTo<ApiSubscription>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
return subscription;
}
[HttpDelete("{userId}")]
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.SubscriberId == userId
)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound($"No subscription found for user {subscribeToId}");
return TypedResults.Ok();
}
[HttpPost("tiers")]
public async Task<Results<Ok<ApiSubscriptionTier>, ValidationProblem>> CreateOrUpdateSubscriptionTierAsync([FromBody] ApiCreateSubscriptionTierRequest tier)
{
var userId = User.GetUserId();
var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level);
if (levelExists)
return TierLevelExistsProblem;
var model = _mapper.Map<SubscriptionTierModel>(tier);
model.UserId = userId!.Value;
_db.SubscriptionTiers.Add(model);
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiSubscriptionTier>(model));
}
[HttpPut("tiers/{id}")]
public async Task<Results<Ok<ApiSubscriptionTier>, NotFound<string>, ValidationProblem>> UpdateSubscriptionTierAsync(Guid id, [FromBody] ApiUpdateSubscriptionTierRequest tier)
{
var userId = User.GetUserId();
var existingTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(t => t.Id == id && t.UserId == userId);
if (existingTier is null)
return TypedResults.NotFound($"No subscription tier found with id {id}");
if (existingTier.Level != tier.Level)
{
var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level);
if (levelExists)
return TierLevelExistsProblem;
}
if (string.IsNullOrEmpty(tier.Name) == false)
existingTier.Name = tier.Name;
if (string.IsNullOrEmpty(tier.Description) == false)
existingTier.Description = tier.Description;
if (tier.Level.HasValue)
existingTier.Level = tier.Level.Value;
if (tier.MonthlyPrice.HasValue)
existingTier.MonthlyPrice = tier.MonthlyPrice.Value;
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiSubscriptionTier>(existingTier));
}
[HttpDelete("tiers/{id}")]
public async Task<Results<Ok, NotFound<string>>> DeleteSubscriptionTierAsync(Guid id)
{
var userId = User.GetUserId();
var count = await _db.SubscriptionTiers
.Where(t => t.Id == id && t.UserId == userId)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound($"No subscription tier found with id {id}");
return TypedResults.Ok();
}
}
}

View File

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Core
{
public class EntityBase : IEntity, ITrackableEntity
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
}

View File

@ -0,0 +1,7 @@
namespace OnlyPrompt.Backend.Database.Core
{
public interface IEntity
{
public Guid Id { get; }
}
}

View File

@ -0,0 +1,7 @@
namespace OnlyPrompt.Backend.Database.Core
{
public interface IHasSlug
{
public string Slug { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace OnlyPrompt.Backend.Database.Core
{
public interface ITrackableEntity
{
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
}

View File

@ -0,0 +1,14 @@
using System.Collections.Frozen;
namespace OnlyPrompt.Backend.Database
{
public static class ModelConstants
{
public const int MaxSlugLength = 100;
public const string UserRole = "user";
public const string AdminRole = "admin";
public const string SysAdminRole = "sys-admin";
public static readonly FrozenSet<string> AllRoles = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, UserRole, AdminRole, SysAdminRole);
}
}

View File

@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Slug), IsUnique = true)]
public class CategoryModel : EntityBase, IHasSlug
{
[MaxLength(ModelConstants.MaxSlugLength)]
[Column(TypeName = "citext")]
public required string Slug { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
}
}

View File

@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Slug), IsUnique = true)]
public class PromptModel : EntityBase, IHasSlug
{
[Required]
[ForeignKey(nameof(Creator))]
public Guid CreatorId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual UserModel Creator { get; set; }
[Required]
[ForeignKey(nameof(Category))]
public Guid CategoryId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual CategoryModel Category { get; set; }
[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(ModelConstants.MaxSlugLength)]
[Column(TypeName = "citext")]
public required string Slug { get; set; }
[ForeignKey(nameof(SubscriptionTier))]
public Guid? SubscriptionTierId { get; set; }
[DeleteBehavior(DeleteBehavior.SetNull)]
public virtual SubscriptionTierModel? SubscriptionTier { get; set; }
public virtual IList<ReviewModel> Reviews { get; set; } = new List<ReviewModel>();
}
}

View File

@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[PrimaryKey(nameof(ReviewerId), nameof(PromptId))]
public class ReviewModel : ITrackableEntity
{
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[Required]
[ForeignKey(nameof(Reviewer))]
public Guid ReviewerId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual UserModel Reviewer { get; set; }
[Required]
[ForeignKey(nameof(Prompt))]
public Guid PromptId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual PromptModel Prompt { get; set; }
[Range(1, 5)]
public int Rating { get; set; }
[MaxLength(200)]
public string? Comment { get; set; } = null;
}
}

View File

@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Table("UserSubscriptions")]
[PrimaryKey(nameof(SubscriberId), nameof(SubscribedToId))]
public class SubscriptionModel
{
[Required]
[ForeignKey(nameof(SubscribedTo))]
public Guid SubscribedToId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual UserModel SubscribedTo { get; set; }
[Required]
[ForeignKey(nameof(Subscriber))]
public Guid SubscriberId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual UserModel Subscriber { get; set; }
[ForeignKey(nameof(SubscriptionTier))]
public virtual Guid? SubscriptionTierId { get; set; }
[DeleteBehavior(DeleteBehavior.SetNull)]
public virtual SubscriptionTierModel? SubscriptionTier { get; set; }
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Level), nameof(UserId), IsUnique = true)]
public class SubscriptionTierModel : EntityBase
{
[Required]
public required string Name { get; set; }
[MaxLength(1000)]
public string? Description { get; set; }
[Required]
[ForeignKey(nameof(User))]
public Guid UserId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual UserModel User { get; set; }
public decimal MonthlyPrice { get; set; }
public int Level { get; set; }
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
public virtual IList<SubscriptionModel> Subscriptions { get; set; } = new List<SubscriptionModel>();
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Email), IsUnique = true)]
[Index(nameof(UserName), IsUnique = true)]
public class UserModel : EntityBase
{
[MaxLength(100)]
[Column(TypeName = "citext")]
public required string UserName { get; set; }
[Column(TypeName = "citext")]
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required List<string> Roles { get; set; } = new List<string>();
[Required]
public required virtual UserProfileModel Profile { get; set; }
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
public virtual IList<SubscriptionModel> Subscriptions { get; set; } = new List<SubscriptionModel>();
public virtual IList<SubscriptionModel> Subscribers { get; set; } = new List<SubscriptionModel>();
public virtual IList<SubscriptionTierModel> SubscriptionTiers { get; set; } = new List<SubscriptionTierModel>();
public bool IsLockoutEnabled { get; set; } = false;
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Slug), IsUnique = true)]
public class UserProfileModel : EntityBase, IHasSlug
{
[MaxLength(100)]
public required string DisplayName { get; set; }
[MaxLength(ModelConstants.MaxSlugLength)]
[Column(TypeName = "citext")]
public required string Slug { get; set; }
[MaxLength(2000)]
public string? Bio { get; set; }
public required string AvatarUrl { get; set; }
[MaxLength(200)]
public string? Specialities { get; set; }
public virtual UserModel User { get; set; }
public bool IsPublic { get; set; } = false;
}
}

View File

@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using OnlyPrompt.Backend.Database.Core;
using OnlyPrompt.Backend.Database.Models;
namespace OnlyPrompt.Backend.Database
{
public class OnlyPromptContext : DbContext
{
public DbSet<UserModel> Users { get; set; }
public DbSet<UserProfileModel> UserProfiles { get; set; }
public DbSet<CategoryModel> Categories { get; set; }
public DbSet<PromptModel> Prompts { get; set; }
public DbSet<SubscriptionTierModel> SubscriptionTiers { get; set; }
public DbSet<SubscriptionModel> Subscriptions { get; set; }
public DbSet<ReviewModel> Reviews { get; set; }
public OnlyPromptContext(DbContextOptions<OnlyPromptContext> options) : base(options)
{
}
private void HandleEntityTimestamps()
{
var entries = ChangeTracker.Entries<ITrackableEntity>();
var now = DateTime.UtcNow;
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
HandleEntityTimestamps();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
HandleEntityTimestamps();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
if(optionsBuilder.IsConfigured == false)
{
optionsBuilder.UseNpgsql();
optionsBuilder.UseLazyLoadingProxies();
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<UserModel>(entity =>
{
entity.HasOne(e => e.Profile)
.WithOne(p => p.User)
.HasForeignKey<UserProfileModel>(p => p.Id);
});
modelBuilder.Entity<SubscriptionModel>(entity =>
{
entity.HasOne(e => e.Subscriber)
.WithMany(s => s.Subscriptions)
.HasForeignKey(e => e.SubscriberId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.SubscribedTo)
.WithMany(c => c.Subscribers)
.HasForeignKey(e => e.SubscribedToId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.SubscriptionTier)
.WithMany(t => t.Subscriptions)
.HasForeignKey(e => e.SubscriptionTierId)
.OnDelete(DeleteBehavior.SetNull);
});
}
}
}

View File

@ -0,0 +1,31 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"]
ADD ["OnlyPrompt.Frontend", "OnlyPrompt.Backend/wwwroot"]
RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj"
COPY . .
WORKDIR "/src/OnlyPrompt.Backend"
RUN dotnet build "./OnlyPrompt.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./OnlyPrompt.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "OnlyPrompt.Backend.dll"]

View File

@ -0,0 +1,419 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using OnlyPrompt.Backend.Database;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
[DbContext(typeof(OnlyPromptContext))]
[Migration("20260411191205_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Categories");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("SubscriptionTierId");
b.ToTable("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("PromptId");
b.HasIndex("ReviewerId", "PromptId")
.IsUnique();
b.ToTable("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.Property<Guid>("SubscriberId")
.HasColumnType("uuid");
b.Property<Guid>("SubscribedToId")
.HasColumnType("uuid");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.HasKey("SubscriberId", "SubscribedToId");
b.HasIndex("SubscribedToId");
b.HasIndex("SubscriptionTierId");
b.ToTable("UserSubscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Level", "UserId")
.IsUnique();
b.ToTable("SubscriptionTiers");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("citext");
b.Property<bool>("IsLockoutEnabled")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<string[]>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Bio")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsPublic")
.HasColumnType("boolean");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<string>("Specialities")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("UserProfiles");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category")
.WithMany("Prompts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator")
.WithMany("Prompts")
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Prompts")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("Creator");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt")
.WithMany("Reviews")
.HasForeignKey("PromptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer")
.WithMany()
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Prompt");
b.Navigation("Reviewer");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo")
.WithMany("Subscribers")
.HasForeignKey("SubscribedToId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber")
.WithMany("Subscriptions")
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Subscriptions")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SubscribedTo");
b.Navigation("Subscriber");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithOne("Profile")
.HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Navigation("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Navigation("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Navigation("Prompts");
b.Navigation("Subscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Navigation("Profile")
.IsRequired();
b.Navigation("Prompts");
b.Navigation("Subscribers");
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,300 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:citext", ",,");
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Slug = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserName = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
Email = table.Column<string>(type: "citext", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: false),
Roles = table.Column<string[]>(type: "text[]", nullable: false),
IsLockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SubscriptionTiers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MonthlyPrice = table.Column<decimal>(type: "numeric", nullable: false),
Level = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SubscriptionTiers", x => x.Id);
table.ForeignKey(
name: "FK_SubscriptionTiers_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserProfiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DisplayName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Slug = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
Bio = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: false),
Specialities = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
IsPublic = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserProfiles", x => x.Id);
table.ForeignKey(
name: "FK_UserProfiles_Users_Id",
column: x => x.Id,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Prompts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
CategoryId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
Slug = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
SubscriptionTierId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Prompts", x => x.Id);
table.ForeignKey(
name: "FK_Prompts_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Prompts_SubscriptionTiers_SubscriptionTierId",
column: x => x.SubscriptionTierId,
principalTable: "SubscriptionTiers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Prompts_Users_CreatorId",
column: x => x.CreatorId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserSubscriptions",
columns: table => new
{
SubscribedToId = table.Column<Guid>(type: "uuid", nullable: false),
SubscriberId = table.Column<Guid>(type: "uuid", nullable: false),
SubscriptionTierId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserSubscriptions", x => new { x.SubscriberId, x.SubscribedToId });
table.ForeignKey(
name: "FK_UserSubscriptions_SubscriptionTiers_SubscriptionTierId",
column: x => x.SubscriptionTierId,
principalTable: "SubscriptionTiers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_UserSubscriptions_Users_SubscribedToId",
column: x => x.SubscribedToId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserSubscriptions_Users_SubscriberId",
column: x => x.SubscriberId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Reviews",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ReviewerId = table.Column<Guid>(type: "uuid", nullable: false),
PromptId = table.Column<Guid>(type: "uuid", nullable: false),
Rating = table.Column<int>(type: "integer", nullable: false),
Comment = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reviews", x => x.Id);
table.ForeignKey(
name: "FK_Reviews_Prompts_PromptId",
column: x => x.PromptId,
principalTable: "Prompts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Reviews_Users_ReviewerId",
column: x => x.ReviewerId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Categories_Slug",
table: "Categories",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Prompts_CategoryId",
table: "Prompts",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_Prompts_CreatorId",
table: "Prompts",
column: "CreatorId");
migrationBuilder.CreateIndex(
name: "IX_Prompts_Slug",
table: "Prompts",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Prompts_SubscriptionTierId",
table: "Prompts",
column: "SubscriptionTierId");
migrationBuilder.CreateIndex(
name: "IX_Reviews_PromptId",
table: "Reviews",
column: "PromptId");
migrationBuilder.CreateIndex(
name: "IX_Reviews_ReviewerId_PromptId",
table: "Reviews",
columns: new[] { "ReviewerId", "PromptId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SubscriptionTiers_Level_UserId",
table: "SubscriptionTiers",
columns: new[] { "Level", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SubscriptionTiers_UserId",
table: "SubscriptionTiers",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserProfiles_Slug",
table: "UserProfiles",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_Email",
table: "Users",
column: "Email",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_UserName",
table: "Users",
column: "UserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserSubscriptions_SubscribedToId",
table: "UserSubscriptions",
column: "SubscribedToId");
migrationBuilder.CreateIndex(
name: "IX_UserSubscriptions_SubscriptionTierId",
table: "UserSubscriptions",
column: "SubscriptionTierId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Reviews");
migrationBuilder.DropTable(
name: "UserProfiles");
migrationBuilder.DropTable(
name: "UserSubscriptions");
migrationBuilder.DropTable(
name: "Prompts");
migrationBuilder.DropTable(
name: "Categories");
migrationBuilder.DropTable(
name: "SubscriptionTiers");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,419 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using OnlyPrompt.Backend.Database;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
[DbContext(typeof(OnlyPromptContext))]
[Migration("20260412002927_ReviewManyToMany")]
partial class ReviewManyToMany
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Categories");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Prompt")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("SubscriptionTierId");
b.ToTable("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.Property<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ReviewerId", "PromptId");
b.HasIndex("PromptId");
b.ToTable("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.Property<Guid>("SubscriberId")
.HasColumnType("uuid");
b.Property<Guid>("SubscribedToId")
.HasColumnType("uuid");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.HasKey("SubscriberId", "SubscribedToId");
b.HasIndex("SubscribedToId");
b.HasIndex("SubscriptionTierId");
b.ToTable("UserSubscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Level", "UserId")
.IsUnique();
b.ToTable("SubscriptionTiers");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("citext");
b.Property<bool>("IsLockoutEnabled")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Bio")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsPublic")
.HasColumnType("boolean");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<string>("Specialities")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("UserProfiles");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category")
.WithMany("Prompts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator")
.WithMany("Prompts")
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Prompts")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("Creator");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt")
.WithMany("Reviews")
.HasForeignKey("PromptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer")
.WithMany()
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Prompt");
b.Navigation("Reviewer");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo")
.WithMany("Subscribers")
.HasForeignKey("SubscribedToId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber")
.WithMany("Subscriptions")
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Subscriptions")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SubscribedTo");
b.Navigation("Subscriber");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithOne("Profile")
.HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Navigation("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Navigation("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Navigation("Prompts");
b.Navigation("Subscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Navigation("Profile")
.IsRequired();
b.Navigation("Prompts");
b.Navigation("Subscribers");
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class ReviewManyToMany : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_Reviews",
table: "Reviews");
migrationBuilder.DropIndex(
name: "IX_Reviews_ReviewerId_PromptId",
table: "Reviews");
migrationBuilder.DropColumn(
name: "Id",
table: "Reviews");
migrationBuilder.DropColumn(
name: "Content",
table: "Prompts");
migrationBuilder.AddColumn<string>(
name: "Description",
table: "Prompts",
type: "character varying(1000)",
maxLength: 1000,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Prompt",
table: "Prompts",
type: "character varying(4000)",
maxLength: 4000,
nullable: false,
defaultValue: "");
migrationBuilder.AddPrimaryKey(
name: "PK_Reviews",
table: "Reviews",
columns: new[] { "ReviewerId", "PromptId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_Reviews",
table: "Reviews");
migrationBuilder.DropColumn(
name: "Description",
table: "Prompts");
migrationBuilder.DropColumn(
name: "Prompt",
table: "Prompts");
migrationBuilder.AddColumn<Guid>(
name: "Id",
table: "Reviews",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.AddColumn<string>(
name: "Content",
table: "Prompts",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddPrimaryKey(
name: "PK_Reviews",
table: "Reviews",
column: "Id");
migrationBuilder.CreateIndex(
name: "IX_Reviews_ReviewerId_PromptId",
table: "Reviews",
columns: new[] { "ReviewerId", "PromptId" },
unique: true);
}
}
}

View File

@ -0,0 +1,416 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using OnlyPrompt.Backend.Database;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
[DbContext(typeof(OnlyPromptContext))]
partial class OnlyPromptContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true)
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Categories");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Prompt")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("SubscriptionTierId");
b.ToTable("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.Property<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ReviewerId", "PromptId");
b.HasIndex("PromptId");
b.ToTable("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.Property<Guid>("SubscriberId")
.HasColumnType("uuid");
b.Property<Guid>("SubscribedToId")
.HasColumnType("uuid");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.HasKey("SubscriberId", "SubscribedToId");
b.HasIndex("SubscribedToId");
b.HasIndex("SubscriptionTierId");
b.ToTable("UserSubscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Level", "UserId")
.IsUnique();
b.ToTable("SubscriptionTiers");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("citext");
b.Property<bool>("IsLockoutEnabled")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Bio")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsPublic")
.HasColumnType("boolean");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<string>("Specialities")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("UserProfiles");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category")
.WithMany("Prompts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator")
.WithMany("Prompts")
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Prompts")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("Creator");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt")
.WithMany("Reviews")
.HasForeignKey("PromptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer")
.WithMany()
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Prompt");
b.Navigation("Reviewer");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo")
.WithMany("Subscribers")
.HasForeignKey("SubscribedToId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber")
.WithMany("Subscriptions")
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Subscriptions")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SubscribedTo");
b.Navigation("Subscriber");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithOne("Profile")
.HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Navigation("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Navigation("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Navigation("Prompts");
b.Navigation("Subscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Navigation("Profile")
.IsRequired();
b.Navigation("Prompts");
b.Navigation("Subscribers");
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" NoWarn="NU1605" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.5" NoWarn="NU1605" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Identity.Web" Version="3.14.1" />
<PackageReference Include="Microsoft.Identity.Web.DownstreamApi" Version="3.14.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.13.22" />
<PackageReference Include="Scalar.AspNetCore.Microsoft" Version="2.13.22" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
<Folder Include="wwwroot\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@OnlyPrompt.Backend_HostAddress = http://localhost:5093
GET {{OnlyPrompt.Backend_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Web;
using Microsoft.IdentityModel.Tokens;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Services.Jwt;
using OnlyPrompt.Backend.Utils;
using Scalar.AspNetCore;
using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;
// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme);
builder.Services.AddDbContext<OnlyPromptContext>(opts =>
{
opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
opts.UseLazyLoadingProxies();
});
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
builder.Services.AddAutoMapper(AutoMapperSetup.Setup);
builder.Services.AddValidation(opts =>
{
opts.MaxDepth = 10;
});
builder.Services.AddAuthorization();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts =>
{
opts.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = config["Jwt:Issuer"],
ValidAudience = config["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]))
};
opts.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (context.Request.Cookies.ContainsKey("jwt"))
context.Token = context.Request.Cookies["jwt"];
return Task.CompletedTask;
}
};
});
builder.Services.AddControllers().AddJsonOptions(jsonOpts =>
{
jsonOpts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
builder.Services.AddOpenApi(opts => opts.AddScalarTransformers());
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
var rewrite = new RewriteOptions()
.AddRewrite(@"^(?!scalar\/?|api\/?)([^.]+)$", "$1.html", skipRemainingRules: true);
app.UseRewriter(rewrite);
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
var dir = Path.GetFullPath("./../OnlyPrompt.Frontend");
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(dir),
RedirectToAppendTrailingSlash = true,
HttpsCompression = HttpsCompressionMode.Compress,
});
}
else
{
app.UseStaticFiles();
}
app.MapControllers();
app.MapFallbackToFile("/login.html");
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
await db.Database.MigrateAsync();
app.Run();

View File

@ -0,0 +1,25 @@
{
"profiles": {
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "https://localhost:7163/scalar",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7163;http://localhost:5093"
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}

View File

@ -0,0 +1,15 @@
using OnlyPrompt.Backend.Database.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace OnlyPrompt.Backend.Services.Jwt
{
public interface ITokenService
{
string BuildToken(UserModel user, out DateTime validUntil);
bool ValidateToken(string token, out ClaimsPrincipal claims);
}
}

View File

@ -0,0 +1,72 @@
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace OnlyPrompt.Backend.Services.Jwt
{
public class JwtTokenService : ITokenService
{
private string _key;
private string _issuer;
private string _audience;
private TimeSpan _valid;
public JwtTokenService(IConfiguration config)
{
config = config.GetSection("Jwt");
_key = config["Key"];
_issuer = config["Issuer"];
_audience = config["Audience"];
_valid = config.GetValue<TimeSpan>("Valid");
}
public string BuildToken(UserModel user, out DateTime validUntil)
{
var claims = user.GetClaims().ToList();
validUntil = DateTime.UtcNow.Add(_valid);
claims.Add(new Claim("exp", new DateTimeOffset(validUntil).ToUnixTimeSeconds().ToString()));
claims.Add(new Claim("amr", "pwd"));
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new JwtSecurityToken(_issuer, _audience, claims, expires: validUntil, signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
}
public bool ValidateToken(string token, out ClaimsPrincipal claims)
{
var mySecret = Encoding.UTF8.GetBytes(_key);
var mySecurityKey = new SymmetricSecurityKey(mySecret);
var tokenHandler = new JwtSecurityTokenHandler();
try
{
claims = tokenHandler.ValidateToken(token,
new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidIssuer = _issuer,
ValidAudience = _audience,
IssuerSigningKey = mySecurityKey,
}, out SecurityToken validatedToken);
}
catch
{
claims = null;
return false;
}
return true;
}
}
}

View File

@ -0,0 +1,93 @@
using AutoMapper;
using OnlyPrompt.Backend.ApiModels.Auth;
using OnlyPrompt.Backend.ApiModels.Category;
using OnlyPrompt.Backend.ApiModels.Prompt;
using OnlyPrompt.Backend.ApiModels.Subscription;
using OnlyPrompt.Backend.ApiModels.UserProfile;
using OnlyPrompt.Backend.Database.Models;
namespace OnlyPrompt.Backend.Utils
{
public static class AutoMapperSetup
{
public static void Setup(IMapperConfigurationExpression config)
{
config.CreateMap<UserModel, ApiUser>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.UserName, x => x.UserName)
.MapCtorParamFrom(x => x.Roles, x => x.Roles)
.MapCtorParamFrom(x => x.Email, x => x.Email);
config.CreateMap<UserProfileModel, ApiUserProfile>()
.MapCtorParamFrom(x => x.DisplayName, x => x.DisplayName)
.MapCtorParamFrom(x => x.Slug, x => x.Slug)
.MapCtorParamFrom(x => x.Bio, x => x.Bio)
.MapCtorParamFrom(x => x.AvatarUrl, x => x.AvatarUrl)
.MapCtorParamFrom(x => x.Specialities, x => x.Specialities)
.MapCtorParamFrom(x => x.AverageRating, x => x.User.Prompts.Average(p => p.Reviews.Average(r => r.Rating)))
.MapCtorParamFrom(x => x.Subscribers, x => x.User.Subscribers.Count());
config.CreateMap<PromptModel, ApiPrompt>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Title, x => x.Title)
.MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.Content, x => x.Prompt)
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating));
config.CreateMap<PromptModel, ApiMinimalPrompt>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Title, x => x.Title)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.CanAccess, x => true);
config.CreateMap<ReviewModel, ApiReview>()
.MapCtorParamFrom(x => x.CreatorId, x => x.ReviewerId)
.MapCtorParamFrom(x => x.CreatorName, x => x.Reviewer.Profile.DisplayName)
.MapCtorParamFrom(x => x.Comment, x => x.Comment)
.MapCtorParamFrom(x => x.Rating, x => x.Rating);
config.CreateMap<CategoryModel, ApiCategory>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.Slug, x => x.Slug);
config.CreateMap<CategoryModel, ApiMinimalCategory>()
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Slug, x => x.Slug);
config.CreateMap<ApiCreateCategoryRequest, CategoryModel>()
.MapMemberFrom(x => x.Description, x => x.Description)
.MapMemberFrom(x => x.Name, x => x.Name)
.MapMemberFrom(x => x.Slug, x => x.Slug);
config.CreateMap<SubscriptionTierModel, ApiSubscriptionTier>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Level, x => x.Level)
.MapCtorParamFrom(x => x.MonthlyPrice, x => x.MonthlyPrice)
.MapCtorParamFrom(x => x.Description, x => x.Description);
config.CreateMap<ApiCreateSubscriptionTierRequest, SubscriptionTierModel>()
.MapMemberFrom(x => x.Name, x => x.Name)
.MapMemberFrom(x => x.Level, x => x.Level)
.MapMemberFrom(x => x.MonthlyPrice, x => x.MonthlyPrice)
.MapMemberFrom(x => x.Description, x => x.Description);
config.CreateMap<SubscriptionModel, ApiSubscription>()
.MapCtorParamFrom(x => x.SubscribedToId, x => x.SubscribedToId)
.MapCtorParamFrom(x => x.SubscribedToName, x => x.SubscribedTo.Profile.DisplayName)
.MapCtorParamFrom(x => x.CurrentTier, x => x.SubscriptionTier);
}
}
}

View File

@ -0,0 +1,48 @@
using AutoMapper;
using AutoMapper.Configuration;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading.Tasks;
namespace OnlyPrompt.Backend.Utils
{
public static class AutomapperExtensions
{
public static IMappingExpression<TSource, TDestination> CreateUpdateMap<TSource, TDestination>(this IMapperConfigurationExpression cfg, MemberList memberList = MemberList.Source)
{
return cfg.CreateMap<TSource, TDestination>(memberList)
.IgnoreNullMembers();
}
public static IMappingExpression<TSource, TDestination> MapMemberFrom<TSource, TDestination, TMember, TSourceMember>(this IMappingExpression<TSource, TDestination> mapping, Expression<Func<TDestination, TMember>> destinationMember, Expression<Func<TSource, TSourceMember>> sourceMember)
{
mapping.ForMember(destinationMember, x => x.MapFrom(sourceMember));
return mapping;
}
public static IMappingExpression<TSource, TDestination> IgnoreNullMembers<TSource, TDestination>(this IMappingExpression<TSource, TDestination> mapping)
{
mapping.ForAllMembers(opts => opts.Condition((src, dest, member) => src != null));
return mapping;
}
public static IMappingExpression<TSource, TDestination> MapCtorParamFrom<TSource, TDestination, TMember, TSourceMember>(this IMappingExpression<TSource, TDestination> mapping, Expression<Func<TDestination, TMember>> destinationMember, Expression<Func<TSource, TSourceMember>> sourceMember)
{
mapping.ForCtorParam(destinationMember, x => x.MapFrom(sourceMember));
return mapping;
}
public static IMappingExpression<TSource, TDestination> ForCtorParam<TSource, TDestination, DValue>(this IMappingExpression<TSource, TDestination> mapping, Expression<Func<TDestination, DValue>> paramSelector, Action<ICtorParamConfigurationExpression<TSource>> configure)
{
var ctorParamName = ((MemberExpression)paramSelector.Body).Member.Name;
mapping.ForCtorParam(ctorParamName, configure);
return mapping;
}
}
}

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
namespace OnlyPrompt.Backend.Utils
{
public static class EntityExtensions
{
public static Task<T?> FindBySlugAsync<T>(this IQueryable<T> queryable, string slug) where T : class, IHasSlug
{
return queryable.FirstOrDefaultAsync(e => e.Slug == slug);
}
public static Task<T?> FindByIdentifierAsync<T>(this IQueryable<T> queryable, Identifier identifier) where T : class, IHasSlug, IEntity
{
if (identifier.Id.HasValue)
return queryable.FirstOrDefaultAsync(e => e.Id == identifier.Id.Value);
return queryable.FindBySlugAsync(identifier.Slug);
}
public static IQueryable<T> OfIdentifer<T>(this IQueryable<T> queryable, Identifier identifier) where T : class, IHasSlug, IEntity
{
if (identifier.Id.HasValue)
return queryable.Where(e => e.Id == identifier.Id.Value);
return queryable.Where(e => e.Slug == identifier.Slug);
}
}
}

View File

@ -0,0 +1,80 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using OnlyPrompt.Backend.Database.Models;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using System.Security.Claims;
namespace OnlyPrompt.Backend.Utils
{
public static class Extensions
{
public static string? GetIdentifier(this ClaimsPrincipal principal) => principal.FindFirstValue(ClaimTypes.NameIdentifier);
public static bool TryGetIdentifier(this ClaimsPrincipal principal, [NotNullWhen(true)]out string? identifier)
{
identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier);
return identifier != null;
}
public static Guid? GetUserId(this ClaimsPrincipal principal)
{
if (principal.TryGetIdentifier(out var identifier) && Guid.TryParse(identifier, out var userId))
return userId;
return null;
}
public static IEnumerable<Claim> GetClaims(this UserModel user)
{
yield return new Claim(ClaimTypes.NameIdentifier, user.Id.ToString());
yield return new Claim(ClaimTypes.Name, user.UserName);
yield return new Claim(ClaimTypes.Email, user.Email);
foreach (var role in user.Roles)
yield return new Claim(ClaimTypes.Role, role);
}
public static IQueryable<T> OrderBy<T, TKey>(this IQueryable<T> source, Expression<Func<T, TKey>> selecter, bool ascending)
{
if(ascending)
return source.OrderBy(selecter);
else
return source.OrderByDescending(selecter);
}
public static CookieOptions Copy(this CookieOptions options, Action<CookieOptions>? modify = null)
{
var newOptions = new CookieOptions
{
Domain = options.Domain,
Expires = options.Expires,
HttpOnly = options.HttpOnly,
IsEssential = options.IsEssential,
MaxAge = options.MaxAge,
Path = options.Path,
SameSite = options.SameSite,
Secure = options.Secure
};
modify?.Invoke(newOptions);
return newOptions;
}
public static string Limit(this string @string, int maxLength)
{
if (@string.Length <= maxLength)
return @string;
return @string.Substring(0, maxLength);
}
public const string LowerAlphabet = "abcdefghijklmnopqrstuvwxyz";
public static string GetString(this Random @random, int lenght, string alphabet = LowerAlphabet)
{
Span<char> chars = stackalloc char[lenght];
for (int i = 0; i < lenght; i++)
chars[i] = alphabet[@random.Next(alphabet.Length)];
return new string(chars);
}
}
}

View File

@ -0,0 +1,34 @@
using Scalar.AspNetCore;
namespace OnlyPrompt.Backend.Utils
{
public struct Identifier
{
public string Slug { get; init; }
public Guid? Id { get; init; }
public Identifier(string slug)
{
this.Slug = slug;
this.Id = null;
}
public Identifier(Guid guid)
{
this.Id = guid;
this.Slug = string.Empty;
}
public static implicit operator Identifier(string slug) => new Identifier(slug);
public static implicit operator Identifier(Guid guid) => new Identifier(guid);
public static bool TryParse(string input, out Identifier identifier)
{
identifier= new Identifier(input);
if (Guid.TryParse(input, out var guid))
identifier = new Identifier(guid);
return true;
}
}
}

View File

@ -0,0 +1,42 @@
using System.Text.RegularExpressions;
namespace OnlyPrompt.Backend.Utils
{
public static class SlugHelper
{
private static readonly Regex InvalidCharacters = new(@"[^a-z0-9\-]", RegexOptions.Compiled);
private static readonly Regex MultipleDashes = new(@"-+", RegexOptions.Compiled);
public static string GenerateSlug(string input, int? maxLength = null)
{
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
var slug = input.ToLowerInvariant().Replace(" ", "-").Replace("_", "-");
slug = InvalidCharacters.Replace(slug, string.Empty);
slug = MultipleDashes.Replace(slug, "-");
slug = slug.Trim('-');
if (maxLength.HasValue)
slug = slug.Limit(maxLength.Value);
return slug;
}
private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789";
public static async Task<string> GenerateUniqueSlugAsync(string input, Func<string, Task<bool>> existsFunc, int? maxLenght)
{
var slug = GenerateSlug(input);
var exists = await existsFunc(slug);
if (exists)
{
var suffix = Random.Shared.GetString(8, SuffixChars);
if (maxLenght.HasValue)
slug = slug.Limit(maxLenght.Value - 9);
slug = $"{slug}-{suffix}";
}
return slug;
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,18 @@
{
"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;"
},
"Jwt": {
"Issuer": "https://onlyprompts.com",
"Audience": "https://onlyprompts.com",
"Key": "TfZi@!CC!b5UoD81gs&%tvY4J0M$p3cI",
"Valid": "3.0:0:0"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

16
OnlyPrompt.Frontend/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "msedge",
"request": "launch",
"name": "Open current page in Edge",
"cwd": "${workspaceRoot}",
"url": "https://localhost:7163/${fileBasename}"
}
]
}

View File

@ -0,0 +1,4 @@
{
"editor.tabSize": 2,
"editor.indentSize": 2
}

View File

@ -0,0 +1,129 @@
<!-- OnlyPrompt - Chats page:
- Direct messaging interface with conversation list and active chat window -->
<!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">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div style="flex:1; display: flex; flex-direction: column;">
<div id="topbar-container"></div>
<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>
<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>
<!-- 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>
</div>
</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>
</div>
<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>
</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 Chats link (4th link, index 3)
const chatsLink = document.querySelectorAll('#sidebar-container .sidebar li a')[3];
if (chatsLink) chatsLink.classList.add('active');
});
fetch('../html/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>

View File

@ -0,0 +1,161 @@
<!-- OnlyPrompt - Marketplace page:
- Browse and filter AI prompts with buy/view details buttons and pricing -->
<!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">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<div id="topbar-container"></div>
<main class="creators-main">
<!-- Header / Titel -->
<div class="creators-header">
<h1>Discover Creators</h1>
<p>Follow your favorite prompt artists and get inspired.</p>
</div>
<!-- Filter Buttons -->
<div class="filter-buttons">
<button class="filter-btn active">Popular</button>
<button class="filter-btn">Rising</button>
<button class="filter-btn">New</button>
<button class="filter-btn">Top Rated</button>
</div>
<!-- Creators Grid -->
<div class="creators-grid">
<!-- Creator Card 1 -->
<div class="creator-card">
<img src="../images/content/creator1.png" alt="Sarah Jenkins" class="creator-avatar">
<div class="creator-info">
<h3 class="creator-name">Sarah Jenkins</h3>
<div class="creator-handle">@sarahj</div>
<p class="creator-bio">AI Explorer | Prompt Curator | Exploring creativity through generative AI.</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle"></i> 42 prompts</span>
<span><i class="bi bi-star-fill"></i> 4.9</span>
</div>
<button class="follow-btn">Follow</button>
</div>
</div>
<!-- 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');
});
fetch('../html/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>

View File

@ -0,0 +1,181 @@
<!-- OnlyPrompt - Create Prompt page:
- Form to publish new AI prompts with title, description, category, content, example output, image upload, and pricing toggle -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Create New Prompt</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/create.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 style="flex:1; display: flex; flex-direction: column;">
<div id="topbar-container"></div>
<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>
</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" required>
</div>
<!-- Description -->
<div class="form-group">
<label for="description">Description *</label>
<textarea id="description" name="description" rows="2" placeholder="Draft a narrative about a small team overcoming challenges to launch a groundbreaking app" required></textarea>
</div>
<!-- Category -->
<div class="form-group">
<label for="category">Category *</label>
<select id="category" name="category" required>
<option value="creative-writing">Creative Writing</option>
<option value="coding">Coding</option>
<option value="art">Art</option>
<option value="marketing">Marketing</option>
<option value="video">Video</option>
<option value="data">Data</option>
</select>
</div>
<!-- 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..." required></textarea>
<small class="form-hint">Use clear, step-by-step instructions for the AI.</small>
</div>
<!-- Example Output (Text) -->
<div class="form-group">
<label for="exampleOutput">Example Output (optional)</label>
<textarea id="exampleOutput" name="exampleOutput" rows="4" placeholder="Show an example of what the AI might generate..."></textarea>
</div>
<!-- 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">
<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">
<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="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">You can set a price later or keep it free.</small>
</div>
<!-- Submit Button -->
<div class="form-actions">
<button type="submit" class="submit-btn">Publish Prompt</button>
<button type="button" class="cancel-btn">Cancel</button>
</div>
</form>
</div>
</main>
</div>
</div>
<script>
// Toggle between free and paid
const freeBtn = document.getElementById('freeBtn');
const paidBtn = document.getElementById('paidBtn');
const priceField = document.getElementById('priceField');
const priceInput = document.getElementById('price');
freeBtn.addEventListener('click', () => {
freeBtn.classList.add('active');
paidBtn.classList.remove('active');
priceField.style.display = 'none';
priceInput.removeAttribute('required');
});
paidBtn.addEventListener('click', () => {
paidBtn.classList.add('active');
freeBtn.classList.remove('active');
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');
if (imageInput) {
imageInput.addEventListener('change', function(event) {
const file = event.target.files[0];
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;
imagePreview.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
imagePreview.style.display = 'none';
previewImg.src = '#';
if (file) alert('Please upload a PNG or JPG image.');
}
});
}
// Handle form submission (demo only)
document.getElementById('createPromptForm').addEventListener('submit', (e) => {
e.preventDefault();
alert('Prompt published! (Demo)');
// Here you would normally send data to a backend (including the image file)
});
// Cancel button (go back)
document.querySelector('.cancel-btn').addEventListener('click', () => {
window.history.back();
});
// 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');
});
// 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');
});
fetch('../html/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>

View File

@ -0,0 +1,41 @@
/*
This file contains global base styles and resets
--> ensures consistent spacing, font usage, and box sizing
across all elements in the application
*/
/* Reset default browser styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: system-ui, sans-serif;
}
/* Global body styling */
body {
background: var(--bg);
color: var(--text);
}
/* Form errors */
.form-error {
color: red;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-error ul {
list-style: none;
padding-left: 0;
list-style: '*';
}
.form-error li {
margin-bottom: 0.25rem;
}
.form-error li .error {
color: red;
font-style: italic;
}

View File

@ -0,0 +1,247 @@
/* 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;
background: transparent;
}
/* Chat container (flex, two columns) */
.chat-container {
display: flex;
gap: 24px;
background: #fff;
border-radius: 18px;
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;
}
/* LEFT COLUMN: Chat list */
.chat-list {
width: 320px;
border-right: 1px solid #eef2f7;
display: flex;
flex-direction: column;
background: #fff;
}
.chat-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #eef2f7;
}
.chat-list-header h2 {
font-size: 1.2rem;
font-weight: 700;
margin: 0;
}
.new-chat-btn {
background: none;
border: none;
font-size: 1.2rem;
color: #3b82f6;
cursor: pointer;
}
.chat-list-items {
flex: 1;
overflow-y: auto;
}
.chat-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid #f0f2f5;
}
.chat-item:hover {
background: #f8fafc;
}
.chat-item.active {
background: #eef2ff;
border-left: 3px solid #3b82f6;
}
.chat-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.chat-item-info {
flex: 1;
min-width: 0;
}
.chat-name {
font-weight: 700;
font-size: 0.95rem;
margin-bottom: 4px;
}
.chat-last-msg {
font-size: 0.8rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-time {
font-size: 0.7rem;
color: #94a3b8;
}
/* RIGHT COLUMN: Active chat */
.chat-active {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
}
.chat-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid #eef2f7;
}
.chat-avatar-large {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.chat-header-info {
flex: 1;
}
.chat-header-name {
font-weight: 700;
font-size: 1rem;
}
.chat-header-status {
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 6px;
}
.online-dot {
display: inline-block;
width: 8px;
height: 8px;
background-color: #10b981;
border-radius: 50%;
}
/* Chat messages area */
.chat-messages {
flex: 1;
padding: 20px 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
background: #fafcff;
}
.message {
display: flex;
flex-direction: column;
max-width: 70%;
}
.message.received {
align-items: flex-start;
}
.message.sent {
align-items: flex-end;
margin-left: auto;
}
.message-bubble {
padding: 10px 14px;
border-radius: 18px;
font-size: 0.9rem;
line-height: 1.4;
background: #f1f5f9;
color: #1e293b;
}
.message.sent .message-bubble {
background: var(--gradient);
color: white;
}
.message-time {
font-size: 0.7rem;
color: #94a3b8;
margin-top: 4px;
padding: 0 8px;
}
/* Input area */
.chat-input-area {
display: flex;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #eef2f7;
background: #fff;
}
.chat-input-area input {
flex: 1;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 30px;
font-size: 0.9rem;
outline: none;
}
.chat-input-area input:focus {
border-color: #3b82f6;
}
.send-btn {
background: var(--gradient);
border: none;
padding: 0 24px;
border-radius: 30px;
color: white;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.send-btn:hover {
opacity: 0.85;
}
/* Responsive */
@media (max-width: 768px) {
.chats-main {
padding: 16px;
}
.chat-list {
width: 280px;
}
}
@media (max-width: 640px) {
.chat-container {
flex-direction: column;
height: auto;
}
.chat-list {
width: 100%;
border-right: none;
border-bottom: 1px solid #eef2f7;
}
.chat-active {
height: 500px;
}
}

View File

@ -0,0 +1,170 @@
/* 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;
margin: 0 auto !important;
width: 100%;
max-width: 1400px;
}
/* Header */
.creators-header {
text-align: center;
margin-bottom: 24px;
}
.creators-header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
.creators-header p {
color: #64748b;
font-size: 1rem;
}
/* Filter buttons */
.filter-buttons {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 32px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 16px;
}
.filter-btn {
background: transparent;
border: none;
padding: 8px 20px;
font-size: 0.9rem;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-radius: 30px;
transition: all 0.2s;
}
.filter-btn.active {
background: var(--gradient);
color: white;
}
.filter-btn:hover:not(.active) {
background: #f1f5f9;
}
/* Creators grid */
.creators-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
/* Creator card */
.creator-card {
background: #fff;
border-radius: 18px;
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;
}
.creator-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
}
.creator-avatar {
width: 70px;
height: 70px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.creator-info {
flex: 1;
}
.creator-name {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 4px;
}
.creator-handle {
color: #64748b;
font-size: 0.85rem;
margin-bottom: 8px;
}
.creator-bio {
color: #334155;
font-size: 0.85rem;
line-height: 1.4;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.creator-stats {
display: flex;
gap: 16px;
margin-bottom: 12px;
font-size: 0.8rem;
color: #64748b;
}
.creator-stats i {
margin-right: 4px;
}
.follow-btn {
background: var(--gradient);
color: white;
border: none;
border-radius: 14px;
padding: 6px 16px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.follow-btn:hover {
opacity: 0.85;
}
/* Responsive */
@media (max-width: 768px) {
.creators-main {
padding: 16px !important;
}
.filter-buttons {
gap: 8px;
}
.filter-btn {
padding: 6px 14px;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.creators-main {
padding: 12px !important;
}
.creator-card {
flex-direction: column;
align-items: center;
text-align: center;
}
.creator-stats {
justify-content: center;
}
.follow-btn {
width: 100%;
}
}

View File

@ -0,0 +1,155 @@
/* 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;
justify-content: center;
align-items: center;
padding: 20px 32px;
background: transparent;
}
.create-container {
max-width: 800px;
width: 100%;
background: #fff;
border-radius: 18px;
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);
}
/* Header */
.create-header {
text-align: center;
margin-bottom: 32px;
}
.create-header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
.create-header p {
color: #64748b;
font-size: 1rem;
}
/* Form */
.create-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
font-size: 0.95rem;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 12px 14px;
border: 1px solid #dbe2ea;
border-radius: 12px;
font-size: 0.95rem;
font-family: inherit;
background: #ffffff;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124,58,237,0.1);
}
.form-hint {
font-size: 0.75rem;
color: #64748b;
}
/* Pricing toggle */
.pricing-group .pricing-toggle {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.price-option {
background: #f1f5f9;
border: none;
padding: 8px 20px;
border-radius: 30px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: #475569;
}
.price-option.active {
background: var(--gradient);
color: white;
}
#priceField {
margin-top: 8px;
}
/* Buttons */
.form-actions {
display: flex;
gap: 16px;
margin-top: 8px;
}
.submit-btn, .cancel-btn {
flex: 1;
border: none;
padding: 12px;
border-radius: 12px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn {
background: var(--gradient);
color: white;
}
.cancel-btn {
background: #f1f5f9;
color: #475569;
}
.submit-btn:hover, .cancel-btn:hover {
opacity: 0.85;
}
/* Responsive */
@media (max-width: 768px) {
.create-container {
padding: 24px;
}
.create-header h1 {
font-size: 1.6rem;
}
}
@media (max-width: 480px) {
.create-container {
padding: 20px;
}
.form-actions {
flex-direction: column;
}
}

View File

@ -0,0 +1,217 @@
/* 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;
margin: 0 auto !important;
width: 100%;
max-width: 1400px;
}
/* Feed Header (centered) */
.feed-header {
text-align: center;
margin-bottom: 24px;
}
.feed-header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
.feed-header p {
color: #64748b;
font-size: 1rem;
}
/* Filter Buttons (centered) */
.filter-buttons {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 32px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 16px;
}
.filter-btn {
background: transparent;
border: none;
padding: 8px 20px;
font-size: 0.9rem;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-radius: 30px;
transition: all 0.2s;
}
.filter-btn.active {
background: var(--gradient);
color: white;
}
.filter-btn:hover:not(.active) {
background: #f1f5f9;
}
/* Posts Grid - multicolumn like marketplace */
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
/* Post Card */
.post-card {
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
}
/* Post Header */
.post-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #f0f2f5;
}
.post-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.post-author {
flex: 1;
display: flex;
flex-direction: column;
}
.post-name {
font-weight: 700;
font-size: 0.9rem;
}
.post-handle {
font-size: 0.7rem;
color: #64748b;
}
.post-date {
font-size: 0.7rem;
color: #94a3b8;
}
/* Post Content */
.post-content {
padding: 12px;
flex: 1;
}
.post-title {
font-size: 1.1rem;
font-weight: 700;
margin: 0 0 6px 0;
}
.post-description {
color: #334155;
font-size: 0.85rem;
line-height: 1.4;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Square image */
.post-image {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 12px;
margin-top: 8px;
}
/* Post Actions */
.post-actions {
display: flex;
gap: 20px;
padding: 10px 12px 12px 12px;
border-top: 1px solid #f0f2f5;
}
.action-btn {
background: none;
border: none;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: #64748b;
cursor: pointer;
transition: color 0.2s;
}
.action-btn i {
font-size: 1.1rem;
}
.like-btn:hover {
color: #ef4444;
}
.comment-btn:hover {
color: #3b82f6;
}
.share-btn:hover {
color: #10b981;
}
.save-btn:hover {
color: #f59e0b;
}
/* Responsive: single column on small screens */
@media (max-width: 700px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.feed-main {
padding: 16px !important;
}
.filter-buttons {
gap: 8px;
}
.filter-btn {
padding: 6px 14px;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.feed-main {
padding: 12px !important;
}
.post-header {
padding: 10px;
}
.post-content {
padding: 10px;
}
.post-title {
font-size: 1rem;
}
.post-actions {
padding: 8px 10px 10px 10px;
}
}

View File

@ -0,0 +1,161 @@
/*
File contains the styles for the login page
--> defines the layout of the login screen
*/
.login-page {
min-height: 100vh;
display: flex; /* enables flexbox layout for centering */
justify-content: center; /* horizontally centers the card */
align-items: center; /* vertically centers the card */
padding: 24px; /* space inside the page edges */
/* Layered background: two soft radial gradients for color accents, then the main background color */
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 35%),
radial-gradient(circle at bottom right, rgba(236, 72, 153, 0.10), transparent 30%),
var(--bg);
}
/* Main login card container */
.login-card {
width: 100%;
max-width: 430px; /* prevents the card from getting too wide on large screens */
background: var(--card); /* uses card color from variables.css */
border-radius: 24px; /* rounded corners */
box-shadow: var(--shadow); /* soft shadow for card elevation */
padding: 40px 32px; /* inner spacing for content */
}
/* Logo area above the form */
.login-logo-wrapper {
display: flex; /* centers logo horizontally */
justify-content: center;
margin-bottom: 20px;
width: 100%;
overflow: hidden; /* hides any part of the logo that overflows the container */
}
/* Full logo styling */
.login-logo {
width: 100%;
max-width: 220px; /* logo never exceeds this width */
height: auto;
display: block;
object-fit: contain; /* keeps logo aspect ratio, prevents stretching */
}
.login-title {
font-size: 2rem;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.login-subtitle {
text-align: center;
color: var(--text-muted);
margin-bottom: 28px;
}
/* Form layout */
.login-form {
display: flex;
flex-direction: column; /* stack form fields vertically */
gap: 18px; /* vertical space between form groups */
}
.form-group {
display: flex;
flex-direction: column; /* label above input */
gap: 8px; /* space between label and input */
}
.form-group label {
font-weight: 600;
font-size: 0.95rem;
}
.form-group input {
width: 100%;
padding: 14px 16px; /* vertical and horizontal padding for input */
border: 1px solid #dbe2ea; /* subtle border */
border-radius: 14px; /* rounded input corners */
background: #ffffff;
font-size: 1rem;
color: var(--text);
}
/* Highlight input when focused */
.form-group input:focus {
outline: none; /* removes default browser outline */
border-color: #7c3aed; /* purple border on focus */
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.10); /* soft glow for focus state */
}
/* Password field with button inside the same row */
.password-wrapper {
display: flex; /* input and button in one row */
align-items: center; /* vertically center input and button */
gap: 10px; /* space between input and show/hide button */
}
.password-wrapper input {
flex: 1; /* input takes all available space, button stays compact */
}
.toggle-password {
border: none;
background: transparent; /* no background for button */
color: #64748b;
font-weight: 600;
cursor: pointer; /* pointer cursor for better UX */
}
/* Main login action button */
.login-button {
margin-top: 4px;
border: none;
border-radius: 14px;
padding: 14px 18px;
background: var(--gradient); /* uses a gradient background from variables */
color: white;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.22); /* blue shadow for button depth */
}
.login-button:hover {
opacity: 0.95; /* slight fade on hover for feedback */
}
.signup-text {
margin-top: 24px;
text-align: center;
color: var(--text-muted);
/* used for the 'Don't have an account?' and link below the form */
}
.signup-text a {
color: #2563eb;
text-decoration: none;
font-weight: 600;
/* blue link for sign up/login */
}
/* Smaller spacing and sizing for narrow screens */
@media (max-width: 480px) {
/* Responsive adjustments for small screens (mobile) */
.login-card {
padding: 28px 20px; /* less padding on mobile */
border-radius: 20px; /* slightly less rounded */
}
.login-title {
font-size: 1.7rem; /* smaller title on mobile */
}
.login-logo {
max-width: 170px; /* smaller logo on mobile */
}
}

View File

@ -0,0 +1,225 @@
/* 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;
margin: 0 auto !important;
width: 100%;
max-width: 1400px;
}
/* Header centering */
.marketplace-header {
text-align: center;
margin-bottom: 24px;
}
.marketplace-header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
.marketplace-header p {
color: #64748b;
font-size: 1rem;
}
/* Filter + Sort Row */
.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 - centered */
.filter-buttons {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
flex: 1;
margin: 0;
border-bottom: none;
padding-bottom: 0;
}
.filter-btn {
background: transparent;
border: none;
padding: 8px 20px;
font-size: 0.9rem;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-radius: 30px;
transition: all 0.2s;
}
.filter-btn.active {
background: var(--gradient);
color: white;
}
.filter-btn:hover:not(.active) {
background: #f1f5f9;
}
/* Sort Dropdown - right aligned */
.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;
margin-left: auto;
}
/* Prompts grid */
.prompts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
/* Prompt card */
.prompt-card {
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
overflow: hidden;
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);
}
.prompt-img {
width: 100%;
height: 160px;
object-fit: cover;
}
.prompt-info {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.prompt-title {
font-size: 1.2rem;
font-weight: 700;
margin: 0;
}
.prompt-author {
color: #64748b;
font-size: 0.85rem;
}
.prompt-description {
color: #334155;
font-size: 0.85rem;
line-height: 1.4;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.prompt-rating {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: #f59e0b;
}
.prompt-rating span:first-child i {
color: #f59e0b;
}
.prompt-rating span:last-child {
color: #64748b;
}
.prompt-price {
font-size: 1.3rem;
font-weight: 700;
color: #3b82f6;
margin: 8px 0 4px;
}
.prompt-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.buy-btn, .details-btn {
flex: 1;
border: none;
border-radius: 14px;
padding: 8px 12px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.buy-btn {
background: var(--gradient);
color: white;
}
.details-btn {
background: #f1f5f9;
color: #334155;
}
.buy-btn:hover, .details-btn:hover {
opacity: 0.85;
}
/* Responsive */
@media (max-width: 700px) {
.filter-sort-row {
flex-direction: column;
align-items: stretch;
}
.sort-dropdown {
align-self: flex-end;
margin-left: 0;
}
}
@media (max-width: 768px) {
.marketplace-main {
padding: 16px !important;
}
.filter-btn {
padding: 6px 14px;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.marketplace-main {
padding: 12px !important;
}
.prompt-actions {
flex-direction: column;
}
}

View File

@ -0,0 +1,217 @@
/* 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;
justify-content: center;
padding: 20px 32px;
background: transparent;
}
.post-detail-container {
max-width: 900px;
width: 100%;
background: #fff;
border-radius: 18px;
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);
}
/* Header */
.post-header {
margin-bottom: 28px;
border-bottom: 1px solid #eef2f7;
padding-bottom: 20px;
}
.post-title {
font-size: 2rem;
font-weight: 800;
margin-bottom: 12px;
}
.post-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.category {
background: #f1f5f9;
padding: 4px 12px;
border-radius: 30px;
font-size: 0.8rem;
font-weight: 600;
color: #3b82f6;
}
.updated {
font-size: 0.85rem;
color: #64748b;
}
.post-stats {
display: flex;
gap: 20px;
font-size: 0.9rem;
color: #475569;
}
.post-stats i {
margin-right: 6px;
}
/* Prompt Section */
.prompt-section {
margin-bottom: 28px;
}
.prompt-section h2 {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 16px;
letter-spacing: -0.3px;
}
.prompt-content {
background: #f8fafc;
padding: 20px;
border-radius: 16px;
font-size: 1rem;
line-height: 1.5;
color: #1e293b;
}
.prompt-content ul {
margin-top: 12px;
padding-left: 20px;
}
.prompt-content li {
margin-bottom: 6px;
}
/* Rating & Like */
.rating-section {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid #eef2f7;
}
.rating-stars {
display: flex;
gap: 20px;
font-size: 1.1rem;
font-weight: 700;
}
.rating-stars i {
color: #f59e0b;
margin-right: 6px;
}
.like-btn {
background: none;
border: 1px solid #e2e8f0;
padding: 8px 18px;
border-radius: 30px;
font-size: 0.9rem;
font-weight: 600;
color: #475569;
cursor: pointer;
transition: all 0.2s;
}
.like-btn:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.like-btn i {
margin-right: 6px;
}
/* Example Output Section */
.example-section {
margin-bottom: 32px;
}
.example-section h2 {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 16px;
}
.example-content {
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 16px;
padding: 20px;
}
.example-content h3 {
font-size: 1rem;
font-weight: 700;
margin-bottom: 12px;
color: #3b82f6;
}
.example-output-text {
font-size: 0.95rem;
line-height: 1.5;
color: #334155;
}
.example-output-text p {
margin-bottom: 12px;
}
.example-image {
margin-top: 16px;
}
.example-image img {
max-width: 100%;
border-radius: 12px;
}
/* Unlock Section */
.unlock-section {
text-align: center;
margin-top: 16px;
}
.unlock-btn {
background: var(--gradient);
border: none;
padding: 14px 32px;
border-radius: 40px;
font-size: 1.1rem;
font-weight: 700;
color: white;
cursor: pointer;
transition: opacity 0.2s;
}
.unlock-btn:hover {
opacity: 0.9;
}
/* Responsive */
@media (max-width: 768px) {
.post-detail-main {
padding: 16px;
}
.post-detail-container {
padding: 24px;
}
.post-title {
font-size: 1.6rem;
}
}
@media (max-width: 480px) {
.post-detail-container {
padding: 20px;
}
.rating-section {
flex-direction: column;
align-items: flex-start;
}
.prompt-content {
padding: 16px;
}
}

View File

@ -0,0 +1,94 @@
/* Profile Page - Full width layout, darker share button, responsive grid */
/* Force main content container to full width, remove centering and max-width */
.layout > div[style*="flex:1"] {
margin: 0 !important;
max-width: 100% !important;
padding: 0 !important;
width: 100% !important;
}
/* Inner spacing for the profile card */
.profile-main {
background: transparent !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 20px 32px !important;
margin: 0 auto !important;
width: 100%;
max-width: 1600px; /* Limits content on very large screens, but still wide */
}
/* Make prompts grid use more columns on large screens */
.profile-main section:last-child {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)) !important;
gap: 24px !important;
width: 100% !important;
}
/* Share button: darker background and text */
.profile-header button:last-child {
background: #cbd5e1 !important; /* darker gray */
color: #1e293b !important;
box-shadow: none !important;
border: none !important;
}
/* Buttons keep rounded corners */
.login-button {
border-radius: 14px !important;
}
/* Prompt cards: rounded corners */
.profile-main section > div {
border-radius: 18px !important;
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
}
/* Prompt images: rounded corners */
.profile-main section img {
border-radius: 12px !important;
}
/* Avatar remains round */
.profile-avatar {
border-radius: 50% !important;
}
/* All outer containers stay square */
.layout,
.profile-main,
.profile-header,
.profile-tabs,
nav {
border-radius: 0 !important;
}
/* Responsive: tablets */
@media (max-width: 768px) {
.profile-main {
padding: 16px !important;
}
.profile-header {
flex-direction: column;
align-items: flex-start !important;
}
.profile-header > div:last-child {
flex-direction: row;
width: 100%;
}
}
/* Responsive: mobile */
@media (max-width: 480px) {
.profile-main {
padding: 12px !important;
}
.profile-header > div:last-child {
flex-direction: column;
}
.profile-main section:last-child {
grid-template-columns: 1fr !important;
}
}

View File

@ -0,0 +1,184 @@
/* 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;
justify-content: center;
align-items: center;
padding: 20px 32px;
background: transparent;
}
.settings-container {
max-width: 700px;
width: 100%;
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
padding: 32px;
}
.settings-header {
text-align: center;
margin-bottom: 28px;
}
.settings-header h1 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 8px;
}
.settings-header p {
color: #64748b;
font-size: 0.95rem;
}
/* Tabs */
.settings-tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid #eef2f7;
margin-bottom: 28px;
}
.tab-btn {
background: transparent;
border: none;
padding: 10px 20px;
font-size: 1rem;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-btn.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-btn:hover:not(.active) {
color: #334155;
}
/* Tab content */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Form elements */
.settings-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
font-size: 0.95rem;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid #dbe2ea;
border-radius: 12px;
font-size: 0.95rem;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124,58,237,0.1);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
font-weight: normal;
cursor: pointer;
}
.checkbox-label input {
width: auto;
margin: 0;
}
/* Avatar upload */
.avatar-upload {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.settings-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.upload-btn {
background: #f1f5f9;
border: none;
padding: 8px 16px;
border-radius: 30px;
font-weight: 600;
cursor: pointer;
}
.upload-btn:hover {
background: #e2e8f0;
}
/* Save button */
.save-btn {
background: var(--gradient);
color: white;
border: none;
padding: 12px;
border-radius: 12px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
width: 100%;
transition: opacity 0.2s;
}
.save-btn:hover {
opacity: 0.85;
}
/* Responsive */
@media (max-width: 768px) {
.settings-container {
padding: 24px;
}
.settings-header h1 {
font-size: 1.5rem;
}
}
@media (max-width: 480px) {
.settings-container {
padding: 20px;
}
.settings-tabs {
gap: 4px;
}
.tab-btn {
padding: 8px 12px;
font-size: 0.9rem;
}
.avatar-upload {
flex-direction: column;
align-items: flex-start;
}
}

View File

@ -0,0 +1,170 @@
/*
Sidebar styles for OnlyPrompt
- modern soft card look
- responsive: full sidebar on desktop, icon-only on smaller screens
- logout button appears directly after the last menu item with separator line
*/
.sidebar-shell {
width: 100%;
height: 100%;
background: #ffffff;
border-right: 1px solid #eef2f7;
padding: 24px 16px;
display: flex;
flex-direction: column;
}
/* Logo */
.sidebar-logo {
display: flex;
align-items: center;
justify-content: center;
min-height: 72px;
margin-bottom: 32px;
padding: 6px 8px;
}
.sidebar-logo-full {
max-width: 170px;
width: 100%;
height: auto;
display: block;
}
.sidebar-logo-icon {
width: 36px;
height: 36px;
display: none;
}
/* Navigation normal block layout, no flex grow */
.sidebar {
/* No flex:1 keeps navigation at its natural height */
}
.sidebar ul {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar li {
margin-bottom: 8px;
}
.sidebar a {
display: flex;
align-items: center;
gap: 14px;
text-decoration: none;
color: #475569;
font-size: 1rem;
font-weight: 600;
padding: 12px 16px;
transition: background 0.2s ease, color 0.2s ease;
}
.sidebar a:hover {
background: #f1f5f9;
}
.sidebar i {
font-size: 1.3rem;
flex-shrink: 0;
}
.icon-blue {
color: #3b82f6 !important;
}
.icon-purple {
color: #8b5cf6 !important;
}
.icon-pink {
color: #ec4899 !important;
}
/* Active item */
.sidebar a.active {
background: #eef2ff;
color: #2563eb;
border-left: 3px solid #3b82f6;
}
/* Bottom logout area directly after the menu, with separator line */
.sidebar-bottom {
margin-top: 16px; /* Small gap above the separator */
border-top: 1px solid #eef2f7;
padding-top: 16px;
}
.sidebar-logout {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
text-decoration: none;
color: #64748b;
font-weight: 600;
transition: background 0.2s ease;
}
.sidebar-logout:hover {
background: #f1f5f9;
}
.logout-left {
display: flex;
align-items: center;
gap: 12px;
}
.sidebar-logout i {
font-size: 1.2rem;
}
.logout-arrow {
color: #cbd5e1;
}
/* Responsive: icon-only sidebar */
@media (max-width: 900px) {
.sidebar-shell {
padding-left: 12px;
padding-right: 12px;
}
.sidebar-logo-full {
display: none;
}
.sidebar-logo-icon {
display: block;
}
.sidebar .nav-text,
.sidebar-logout .nav-text,
.logout-arrow {
display: none;
}
.sidebar a,
.sidebar-logout {
justify-content: center;
padding: 12px;
}
.sidebar a.active {
border-left: none;
border-right: 3px solid #3b82f6;
}
}
@media (max-width: 700px) {
.sidebar a,
.sidebar-logout {
padding: 10px;
}
}

View File

@ -0,0 +1,169 @@
/*
File contains the styles for the signup page
--> defines the layout of the signup screen
*/
.login-page {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 35%),
radial-gradient(circle at bottom right, rgba(236, 72, 153, 0.10), transparent 30%),
var(--bg);
}
/* Main signup card container */
.login-card {
width: 100%;
max-width: 430px;
background: var(--card); /*variable.css*/
border-radius: 24px;
box-shadow: var(--shadow);
padding: 40px 32px;
}
/* Logo area above the form */
.login-logo-wrapper {
display: flex;
justify-content: center;
margin-bottom: 20px;
width: 100%;
overflow: hidden;
}
/* Full logo styling */
.login-logo {
width: 100%;
max-width: 220px;
height: auto;
display: block;
object-fit: contain;
}
.login-title {
font-size: 2rem;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.login-subtitle {
text-align: center;
color: var(--text-muted);
margin-bottom: 28px;
}
/* Form layout */
.login-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
font-size: 0.95rem;
}
.form-group input {
width: 100%;
padding: 14px 16px;
border: 1px solid #dbe2ea;
border-radius: 14px;
background: #ffffff;
font-size: 1rem;
color: var(--text);
}
/* Highlight input when focused */
.form-group input:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.10);
}
/* Password field with button inside the same row */
.password-wrapper {
display: flex;
align-items: center;
gap: 10px;
}
.password-wrapper input {
flex: 1;
}
.toggle-password {
border: none;
background: transparent;
color: #64748b;
font-weight: 600;
cursor: pointer;
}
.signup-terms {
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
margin: 18px 0 0 0;
}
.signup-terms a {
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
/* Main login action button */
.login-button {
margin-top: 4px;
border: none;
border-radius: 14px;
padding: 14px 18px;
background: var(--gradient);
color: white;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.22);
}
.login-button:hover {
opacity: 0.95;
}
.signup-text {
margin-top: 24px;
text-align: center;
color: var(--text-muted);
}
.signup-text a {
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
/* Smaller spacing and sizing for narrow screens */
@media (max-width: 480px) {
.login-card {
padding: 28px 20px;
border-radius: 20px;
}
.login-title {
font-size: 1.7rem;
}
.login-logo {
max-width: 170px;
}
}

View File

@ -0,0 +1,125 @@
/*
Topbar styles for OnlyPrompt
- clean, modern, full-width
- search bar centered (expands on full screen), profile avatar always on the right
- ONLY search bar and avatar have rounded corners
*/
.topbar-shell {
width: 100%;
background: #ffffff;
border-bottom: 1px solid #eef2f7;
padding: 16px 32px;
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;
padding: 10px 20px;
border-radius: 14px; /* Rounded like login inputs */
}
.topbar-search i {
color: #94a3b8;
font-size: 1.3rem;
}
.topbar-search input {
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: 0.95rem;
color: #334155;
}
.topbar-search input::placeholder {
color: #94a3b8;
font-weight: 400;
}
/* Icons and avatar container */
.topbar-actions {
display: flex;
align-items: center;
gap: 16px;
}
.topbar-icon-btn {
background: transparent;
border: none;
font-size: 1.4rem;
color: #475569;
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s, color 0.2s;
}
.topbar-icon-btn:hover {
background: #f1f5f9;
color: #3b82f6;
}
.topbar-avatar-btn {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.topbar-avatar {
width: 48px;
height: 48px;
object-fit: cover;
border: 1px solid #e2e8f0;
border-radius: 50%; /* Avatar round */
}
/* Responsive adjustments */
@media (max-width: 768px) {
.topbar-shell {
padding: 12px 20px;
}
.topbar-search {
padding: 8px 16px;
}
.topbar-search i {
font-size: 1.1rem;
}
.topbar-avatar {
width: 40px;
height: 40px;
}
.topbar-icon-btn {
font-size: 1.2rem;
padding: 6px;
}
.topbar-actions {
gap: 8px;
}
}
@media (max-width: 480px) {
.topbar-shell {
padding: 10px 16px;
}
.topbar-search {
padding: 6px 12px;
}
}

View File

@ -0,0 +1,27 @@
/*
This file contains global design variables such as colors,
gradients, spacing, and shadows
--> these variables ensure a consistent design accross the whole application
*/
:root {
/* Main gradient used for buttons and highlights */
--gradient: linear-gradient(90deg, #7c3aed, #3b82f6, #ec4899);
/* Background color of the application */
--bg: #f8fafc;
/* Default text color */
--text: #0f172a;
/* Container background */
--card: #ffffff;
/* Border radius for rounded elements */
--radius: 16px;
/* Standard shadow for cards and components */
--shadow: 0 10px 30px rgba(0,0,0,0.08);
}

View File

@ -0,0 +1,166 @@
<!-- OnlyPrompt - Feed page:
- Social media style post feed with likes, comments, saves, and share actions (following/foryou tabs) -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Feed</title>
<link rel="stylesheet" href="../css/variables.css">
<link rel="stylesheet" href="../css/base.css">
<link rel="stylesheet" href="../css/sidebar.css">
<link rel="stylesheet" href="../css/login.css">
<link rel="stylesheet" href="../css/topbar.css">
<link rel="stylesheet" href="../css/dashboard.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
<div id="sidebar-container"></div>
<div style="flex:1; margin:40px auto; max-width:950px;">
<div id="topbar-container"></div>
<main class="feed-main">
<!-- Optional: Feed Header -->
<div class="feed-header">
<h1>Feed</h1>
<p>Latest prompts and inspiration from creators you follow</p>
</div>
<!-- Filter Buttons (optional) -->
<div class="filter-buttons">
<button class="filter-btn active">For You</button>
<button class="filter-btn">Following</button>
<button class="filter-btn">Trending</button>
<button class="filter-btn">Recent</button>
</div>
<!-- Posts Grid (einfach als Liste / Grid hier als Grid wie Marketplace) -->
<div class="posts-grid">
<!-- Post 1 -->
<div class="post-card" onclick="location.href='post-detail.html?id=1'">
<div class="post-header">
<img src="../images/content/creator1.png" alt="Sarah Jenkins" class="post-avatar">
<div class="post-author">
<span class="post-name">Sarah Jenkins</span>
<span class="post-handle">@sarahj</span>
</div>
<span class="post-date">2 hours ago</span>
</div>
<div class="post-content">
<h3 class="post-title">Conceptual Landscape Art</h3>
<p class="post-description">Enchanting, vintage, antique vibes. A journey through surreal landscapes.</p>
<img src="../images/content/feed1.png" alt="Conceptual Landscape" class="post-image">
</div>
<!-- Like, Comment, Share, Save Buttons -->
<div class="post-actions">
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
</div>
</div>
<!-- Post 2 -->
<div class="post-card" onclick="location.href='post-detail.html?id=2'">
<div class="post-header">
<img src="../images/content/creator2.png" alt="Alex Chen" class="post-avatar">
<div class="post-author">
<span class="post-name">Alex Chen</span>
<span class="post-handle">@alexchen</span>
</div>
<span class="post-date">Yesterday</span>
</div>
<div class="post-content">
<h3 class="post-title">Minimalist Logo Design</h3>
<p class="post-description">Clean, modern, minimalist logo for tech startups.</p>
<img src="../images/content/feed2.png" alt="Minimalist Logo" class="post-image">
</div>
<!-- Like, Comment, Share, Save Buttons -->
<div class="post-actions">
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
</div>
</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');
});
fetch('../html/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -0,0 +1,150 @@
// 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);
}
}
}
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;
}
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;
});
};

View File

@ -0,0 +1,21 @@
import { sendFormAsync } from "./shared.js";
function togglePassword() {
const passwordInput = document.getElementById('password');
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
passwordInput.type = newInputType;
}
async function submitLoginForm(){
const form = document.getElementById('loginForm');
await sendFormAsync(form);
}
const togglePasswordButton = document.getElementById('togglePassword');
togglePasswordButton.addEventListener('click', togglePassword);
const loginForm = document.getElementById('loginForm');
loginForm.addEventListener('submit', async (event) => {
event.preventDefault(); // Prevent the default form submission
await submitLoginForm();
});

View File

@ -0,0 +1,556 @@
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>`,
);
}
}
}

Some files were not shown because too many files have changed in this diff Show More