commit 63ee71a19b5bdfcbf7995abb16ea2c4aaa8b3f24 Author: GeNii96 Date: Wed May 27 12:47:16 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/.dockerignore @@ -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/** \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba7a514 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/OnlyPrompt.Backend/ApiModels/Auth/Models.cs b/OnlyPrompt.Backend/ApiModels/Auth/Models.cs new file mode 100644 index 0000000..90dc0a6 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Auth/Models.cs @@ -0,0 +1,4 @@ +namespace OnlyPrompt.Backend.ApiModels.Auth +{ + public record ApiUser(Guid Id, string UserName, string Email, string[] Roles); +} diff --git a/OnlyPrompt.Backend/ApiModels/Auth/Requests.cs b/OnlyPrompt.Backend/ApiModels/Auth/Requests.cs new file mode 100644 index 0000000..929cfef --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Auth/Requests.cs @@ -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); +} diff --git a/OnlyPrompt.Backend/ApiModels/Category/Models.cs b/OnlyPrompt.Backend/ApiModels/Category/Models.cs new file mode 100644 index 0000000..31311b2 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Category/Models.cs @@ -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); +} diff --git a/OnlyPrompt.Backend/ApiModels/Category/Requests.cs b/OnlyPrompt.Backend/ApiModels/Category/Requests.cs new file mode 100644 index 0000000..647afe7 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Category/Requests.cs @@ -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); +} diff --git a/OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs b/OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs new file mode 100644 index 0000000..7f60f48 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs @@ -0,0 +1,8 @@ +namespace OnlyPrompt.Backend.ApiModels.Prompt +{ + public enum FeedSortType + { + Date, + Rating + } +} diff --git a/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs b/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs new file mode 100644 index 0000000..338ff18 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs @@ -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); +} diff --git a/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs b/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs new file mode 100644 index 0000000..d9b1fb6 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs @@ -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); +} diff --git a/OnlyPrompt.Backend/ApiModels/Subscription/Models.cs b/OnlyPrompt.Backend/ApiModels/Subscription/Models.cs new file mode 100644 index 0000000..573adbd --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Subscription/Models.cs @@ -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); +} diff --git a/OnlyPrompt.Backend/ApiModels/Subscription/Requests.cs b/OnlyPrompt.Backend/ApiModels/Subscription/Requests.cs new file mode 100644 index 0000000..aa9fcf0 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Subscription/Requests.cs @@ -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); +} diff --git a/OnlyPrompt.Backend/ApiModels/UserProfile/Models.cs b/OnlyPrompt.Backend/ApiModels/UserProfile/Models.cs new file mode 100644 index 0000000..3894287 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/UserProfile/Models.cs @@ -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); +} diff --git a/OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs b/OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs new file mode 100644 index 0000000..b8538d1 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs @@ -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); +} diff --git a/OnlyPrompt.Backend/ApiModels/Validators/NoWhitespaceAttribute.cs b/OnlyPrompt.Backend/ApiModels/Validators/NoWhitespaceAttribute.cs new file mode 100644 index 0000000..a7f0367 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Validators/NoWhitespaceAttribute.cs @@ -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. + } + } + +} diff --git a/OnlyPrompt.Backend/Controllers/AdminController.cs b/OnlyPrompt.Backend/Controllers/AdminController.cs new file mode 100644 index 0000000..475365e --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/AdminController.cs @@ -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 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>> 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>> 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>> 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>> 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(); + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/AuthController.cs b/OnlyPrompt.Backend/Controllers/AuthController.cs new file mode 100644 index 0000000..e02d2aa --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/AuthController.cs @@ -0,0 +1,120 @@ +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 = false, HttpOnly = true, IsEssential = true, SameSite = SameSiteMode.Lax }; + private readonly IPasswordHasher _passwordHasher; + private readonly ITokenService _jwtService; + private readonly ILogger _logger; + + public AuthController(OnlyPromptContext db, IPasswordHasher passwordHasher, IMapper mapper, ILogger logger, ITokenService jwtService) : base(db, mapper) + { + _passwordHasher=passwordHasher; + _logger=logger; + _jwtService=jwtService; + } + + + [AllowAnonymous] + [HttpPost("login")] + public async Task, NotFound>> 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>> 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(); + + 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(newUser)); + } + + + [HttpPost("logout")] + public RedirectHttpResult Logout() + { + this.Response.Cookies.Delete("jwt", AuthCookieOptions); + return TypedResults.Redirect("/login"); + } + + [Authorize] + [HttpGet("me")] + public async Task, NotFound>> GetCurrentUserAsync() + { + var user = await GetUserAsync(); + if (user is null) + return TypedResults.NotFound("User not found"); + + return TypedResults.Ok(_mapper.Map(user)); + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/BaseController.cs b/OnlyPrompt.Backend/Controllers/BaseController.cs new file mode 100644 index 0000000..1e9b378 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/BaseController.cs @@ -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 FindUserAsync(Guid id) => _db.Users.FirstOrDefaultAsync(x => x.Id == id); + public Task FindUserAsync(string userName, string email) => _db.Users.FirstOrDefaultAsync(x => x.Email == email || x.UserName == userName); + public Task FindUserAsync(string emailOrUsername) => _db.Users.FirstOrDefaultAsync(x => x.Email == emailOrUsername || x.UserName == emailOrUsername); + + public async Task 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 GetUserAsync() + { + var id = User.GetUserId(); + if (id.HasValue == false) + return null; + + var user = await _db.Users.FindAsync(id.Value); + return user; + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/CategoryController.cs b/OnlyPrompt.Backend/Controllers/CategoryController.cs new file mode 100644 index 0000000..0a7cc66 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/CategoryController.cs @@ -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 + { + { 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 GetMinimalCategoriesAsync() + { + var categories = await _db.Categories + .ProjectTo(_mapper.ConfigurationProvider) + .ToArrayAsync(); + + return categories; + } + + [HttpGet] + [Authorize(Roles = ModelConstants.UserRole)] + public async Task 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(_mapper.ConfigurationProvider) + .ToArrayAsync(); + + return categories; + } + + [HttpPost] + public async Task, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request) + { + var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug); + if (exists) + return SlugExistsProblem; + + var model = _mapper.Map(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(model)); + } + + [HttpPut("{id}")] + public async Task, NotFound, 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(category)); + } + + [HttpDelete("{id}")] + public async Task, NotFound>> 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(); + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/FeedController.cs b/OnlyPrompt.Backend/Controllers/FeedController.cs new file mode 100644 index 0000000..f6d0c00 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/FeedController.cs @@ -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 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; + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/ProfileController.cs b/OnlyPrompt.Backend/Controllers/ProfileController.cs new file mode 100644 index 0000000..330fdf3 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/ProfileController.cs @@ -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 + { + { nameof(UserProfileModel.Slug), new[] { "Slug already exists." } } + }); + + public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper) + { + } + + [HttpGet("{id}")] + public async Task, Ok>> GetProfileAsync(Identifier id) + { + var userId = User.GetUserId(); + var profile = await _db.UserProfiles.OfIdentifer(id) + .Where(up => up.IsPublic || up.Id == userId) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + + if (profile is null) + return TypedResults.NotFound("Profile not found or is private."); + + return TypedResults.Ok(profile); + } + + [HttpPut] + public async Task, Ok>> 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(self); + return TypedResults.Ok(result); + } + + } +} diff --git a/OnlyPrompt.Backend/Controllers/PromptController.cs b/OnlyPrompt.Backend/Controllers/PromptController.cs new file mode 100644 index 0000000..82aef98 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/PromptController.cs @@ -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 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, NotFound>> 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(prompt); + return TypedResults.Ok(apiPrompt); + } + + [HttpDelete("{id}")] + public async Task>> 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, NotFound>> 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(prompt); + return TypedResults.Ok(apiPrompt); + } + + [HttpGet("{id}/reviews")] + public async Task> 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(_mapper.ConfigurationProvider) + .ToArrayAsync(); + + return TypedResults.Ok(reviews); + } + + [HttpPut("{id}/reviews")] + public async Task, BadRequest, NotFound>> 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(review); + return TypedResults.Ok(apiReview); + } + + [HttpDelete("{promptId}/reviews/{reviewerId}")] + public async Task>> 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(); + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/SubscriptionController.cs b/OnlyPrompt.Backend/Controllers/SubscriptionController.cs new file mode 100644 index 0000000..dee4b31 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/SubscriptionController.cs @@ -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 + { + { 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, NotFound>> 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 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(_mapper.ConfigurationProvider) + .ToArrayAsync(); + + return subscriptions; + } + + [HttpGet("{userId}")] + public async Task 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(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + + return subscription; + } + + [HttpDelete("{userId}")] + public async Task>> 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, 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(tier); + model.UserId = userId!.Value; + _db.SubscriptionTiers.Add(model); + await _db.SaveChangesAsync(); + return TypedResults.Ok(_mapper.Map(model)); + } + + [HttpPut("tiers/{id}")] + public async Task, NotFound, 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(existingTier)); + } + + [HttpDelete("tiers/{id}")] + public async Task>> 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(); + } + } +} diff --git a/OnlyPrompt.Backend/Database/Core/EntityBase.cs b/OnlyPrompt.Backend/Database/Core/EntityBase.cs new file mode 100644 index 0000000..6f30c4e --- /dev/null +++ b/OnlyPrompt.Backend/Database/Core/EntityBase.cs @@ -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; + } +} diff --git a/OnlyPrompt.Backend/Database/Core/IEntity.cs b/OnlyPrompt.Backend/Database/Core/IEntity.cs new file mode 100644 index 0000000..762070c --- /dev/null +++ b/OnlyPrompt.Backend/Database/Core/IEntity.cs @@ -0,0 +1,7 @@ +namespace OnlyPrompt.Backend.Database.Core +{ + public interface IEntity + { + public Guid Id { get; } + } +} diff --git a/OnlyPrompt.Backend/Database/Core/IHasSlug.cs b/OnlyPrompt.Backend/Database/Core/IHasSlug.cs new file mode 100644 index 0000000..94628ae --- /dev/null +++ b/OnlyPrompt.Backend/Database/Core/IHasSlug.cs @@ -0,0 +1,7 @@ +namespace OnlyPrompt.Backend.Database.Core +{ + public interface IHasSlug + { + public string Slug { get; set; } + } +} diff --git a/OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs b/OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs new file mode 100644 index 0000000..49277ba --- /dev/null +++ b/OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs @@ -0,0 +1,8 @@ +namespace OnlyPrompt.Backend.Database.Core +{ + public interface ITrackableEntity + { + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/OnlyPrompt.Backend/Database/ModelConstants.cs b/OnlyPrompt.Backend/Database/ModelConstants.cs new file mode 100644 index 0000000..d5230c7 --- /dev/null +++ b/OnlyPrompt.Backend/Database/ModelConstants.cs @@ -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 AllRoles = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, UserRole, AdminRole, SysAdminRole); + } +} diff --git a/OnlyPrompt.Backend/Database/Models/CategoryModel.cs b/OnlyPrompt.Backend/Database/Models/CategoryModel.cs new file mode 100644 index 0000000..3c56dfc --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/CategoryModel.cs @@ -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 Prompts { get; set; } = new List(); + } +} diff --git a/OnlyPrompt.Backend/Database/Models/PromptModel.cs b/OnlyPrompt.Backend/Database/Models/PromptModel.cs new file mode 100644 index 0000000..3cc889e --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/PromptModel.cs @@ -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 Reviews { get; set; } = new List(); + } +} diff --git a/OnlyPrompt.Backend/Database/Models/ReviewModel.cs b/OnlyPrompt.Backend/Database/Models/ReviewModel.cs new file mode 100644 index 0000000..36ef545 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/ReviewModel.cs @@ -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; + } +} diff --git a/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs b/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs new file mode 100644 index 0000000..499df7b --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs @@ -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; } + } +} diff --git a/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs b/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs new file mode 100644 index 0000000..069dc88 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs @@ -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 Prompts { get; set; } = new List(); + public virtual IList Subscriptions { get; set; } = new List(); + } +} diff --git a/OnlyPrompt.Backend/Database/Models/UserModel.cs b/OnlyPrompt.Backend/Database/Models/UserModel.cs new file mode 100644 index 0000000..c0053b8 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/UserModel.cs @@ -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 Roles { get; set; } = new List(); + + [Required] + public required virtual UserProfileModel Profile { get; set; } + + public virtual IList Prompts { get; set; } = new List(); + public virtual IList Subscriptions { get; set; } = new List(); + public virtual IList Subscribers { get; set; } = new List(); + public virtual IList SubscriptionTiers { get; set; } = new List(); + + public bool IsLockoutEnabled { get; set; } = false; + } +} diff --git a/OnlyPrompt.Backend/Database/Models/UserProfileModel.cs b/OnlyPrompt.Backend/Database/Models/UserProfileModel.cs new file mode 100644 index 0000000..abe7984 --- /dev/null +++ b/OnlyPrompt.Backend/Database/Models/UserProfileModel.cs @@ -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; + } +} diff --git a/OnlyPrompt.Backend/Database/OnlyPromptContext.cs b/OnlyPrompt.Backend/Database/OnlyPromptContext.cs new file mode 100644 index 0000000..61ad105 --- /dev/null +++ b/OnlyPrompt.Backend/Database/OnlyPromptContext.cs @@ -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 Users { get; set; } + public DbSet UserProfiles { get; set; } + public DbSet Categories { get; set; } + public DbSet Prompts { get; set; } + public DbSet SubscriptionTiers { get; set; } + public DbSet Subscriptions { get; set; } + public DbSet Reviews { get; set; } + + public OnlyPromptContext(DbContextOptions options) : base(options) + { + } + + private void HandleEntityTimestamps() + { + var entries = ChangeTracker.Entries(); + 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 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(entity => + { + entity.HasOne(e => e.Profile) + .WithOne(p => p.User) + .HasForeignKey(p => p.Id); + }); + + modelBuilder.Entity(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); + }); + } + } +} diff --git a/OnlyPrompt.Backend/Dockerfile b/OnlyPrompt.Backend/Dockerfile new file mode 100644 index 0000000..a730896 --- /dev/null +++ b/OnlyPrompt.Backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/OnlyPrompt.Backend/Migrations/20260411191205_Initial.Designer.cs b/OnlyPrompt.Backend/Migrations/20260411191205_Initial.Designer.cs new file mode 100644 index 0000000..ae7e076 --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/20260411191205_Initial.Designer.cs @@ -0,0 +1,419 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("SubscriptionTierId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PromptId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("ReviewerId") + .HasColumnType("uuid"); + + b.Property("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("SubscriberId") + .HasColumnType("uuid"); + + b.Property("SubscribedToId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("citext"); + + b.Property("IsLockoutEnabled") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection("Roles") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("Specialities") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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 + } + } +} diff --git a/OnlyPrompt.Backend/Migrations/20260411191205_Initial.cs b/OnlyPrompt.Backend/Migrations/20260411191205_Initial.cs new file mode 100644 index 0000000..fe09980 --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/20260411191205_Initial.cs @@ -0,0 +1,300 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OnlyPrompt.Backend.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:citext", ",,"); + + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Slug = table.Column(type: "citext", maxLength: 100, nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + UserName = table.Column(type: "citext", maxLength: 100, nullable: false), + Email = table.Column(type: "citext", nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + Roles = table.Column(type: "text[]", nullable: false), + IsLockoutEnabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + UserId = table.Column(type: "uuid", nullable: false), + MonthlyPrice = table.Column(type: "numeric", nullable: false), + Level = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + DisplayName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Slug = table.Column(type: "citext", maxLength: 100, nullable: false), + Bio = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + AvatarUrl = table.Column(type: "text", nullable: false), + Specialities = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + IsPublic = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: false), + CategoryId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Content = table.Column(type: "text", nullable: false), + Slug = table.Column(type: "citext", maxLength: 100, nullable: false), + SubscriptionTierId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + SubscriberId = table.Column(type: "uuid", nullable: false), + SubscriptionTierId = table.Column(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(type: "uuid", nullable: false), + ReviewerId = table.Column(type: "uuid", nullable: false), + PromptId = table.Column(type: "uuid", nullable: false), + Rating = table.Column(type: "integer", nullable: false), + Comment = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.Designer.cs b/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.Designer.cs new file mode 100644 index 0000000..54cbe23 --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.Designer.cs @@ -0,0 +1,419 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("SubscriptionTierId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("ReviewerId") + .HasColumnType("uuid"); + + b.Property("PromptId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("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("SubscriberId") + .HasColumnType("uuid"); + + b.Property("SubscribedToId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("citext"); + + b.Property("IsLockoutEnabled") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("Specialities") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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 + } + } +} diff --git a/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.cs b/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.cs new file mode 100644 index 0000000..5ed18a9 --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OnlyPrompt.Backend.Migrations +{ + /// + public partial class ReviewManyToMany : Migration + { + /// + 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( + name: "Description", + table: "Prompts", + type: "character varying(1000)", + maxLength: 1000, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Prompt", + table: "Prompts", + type: "character varying(4000)", + maxLength: 4000, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddPrimaryKey( + name: "PK_Reviews", + table: "Reviews", + columns: new[] { "ReviewerId", "PromptId" }); + } + + /// + 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( + name: "Id", + table: "Reviews", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + 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); + } + } +} diff --git a/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs b/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs new file mode 100644 index 0000000..4dd139c --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs @@ -0,0 +1,416 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("SubscriptionTierId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("ReviewerId") + .HasColumnType("uuid"); + + b.Property("PromptId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("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("SubscriberId") + .HasColumnType("uuid"); + + b.Property("SubscribedToId") + .HasColumnType("uuid"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("citext"); + + b.Property("IsLockoutEnabled") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("Specialities") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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 + } + } +} diff --git a/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj b/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj new file mode 100644 index 0000000..c0ff109 --- /dev/null +++ b/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + Linux + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/OnlyPrompt.Backend/OnlyPrompt.Backend.http b/OnlyPrompt.Backend/OnlyPrompt.Backend.http new file mode 100644 index 0000000..d526ecb --- /dev/null +++ b/OnlyPrompt.Backend/OnlyPrompt.Backend.http @@ -0,0 +1,6 @@ +@OnlyPrompt.Backend_HostAddress = http://localhost:5093 + +GET {{OnlyPrompt.Backend_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/OnlyPrompt.Backend/Program.cs b/OnlyPrompt.Backend/Program.cs new file mode 100644 index 0000000..7d9dab6 --- /dev/null +++ b/OnlyPrompt.Backend/Program.cs @@ -0,0 +1,113 @@ +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(opts => +{ + opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")); + opts.UseLazyLoadingProxies(); +}); + +builder.Services.AddSingleton, PasswordHasher>(); +builder.Services.AddSingleton(); +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.UseAuthentication(); +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.MapGet("/", async (HttpContext context) => +{ + var authResult = await context.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme); + return authResult.Succeeded + ? Results.Redirect("/dashboard") + : Results.Redirect("/login"); +}); +app.MapFallbackToFile("/login.html"); +using var scope = app.Services.CreateScope(); +var db = scope.ServiceProvider.GetRequiredService(); +await db.Database.MigrateAsync(); + +app.Run(); diff --git a/OnlyPrompt.Backend/Properties/launchSettings.json b/OnlyPrompt.Backend/Properties/launchSettings.json new file mode 100644 index 0000000..4d4a6f1 --- /dev/null +++ b/OnlyPrompt.Backend/Properties/launchSettings.json @@ -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" +} \ No newline at end of file diff --git a/OnlyPrompt.Backend/Services/Jwt/ITokenService.cs b/OnlyPrompt.Backend/Services/Jwt/ITokenService.cs new file mode 100644 index 0000000..3c5d961 --- /dev/null +++ b/OnlyPrompt.Backend/Services/Jwt/ITokenService.cs @@ -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); + } +} diff --git a/OnlyPrompt.Backend/Services/Jwt/JwtTokenService.cs b/OnlyPrompt.Backend/Services/Jwt/JwtTokenService.cs new file mode 100644 index 0000000..22a7eb8 --- /dev/null +++ b/OnlyPrompt.Backend/Services/Jwt/JwtTokenService.cs @@ -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("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; + } + } + +} diff --git a/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs new file mode 100644 index 0000000..16bb02d --- /dev/null +++ b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs @@ -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() + .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() + .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() + .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() + .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() + .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() + .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() + .MapCtorParamFrom(x => x.Name, x => x.Name) + .MapCtorParamFrom(x => x.Slug, x => x.Slug); + + config.CreateMap() + .MapMemberFrom(x => x.Description, x => x.Description) + .MapMemberFrom(x => x.Name, x => x.Name) + .MapMemberFrom(x => x.Slug, x => x.Slug); + + config.CreateMap() + .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() + .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() + .MapCtorParamFrom(x => x.SubscribedToId, x => x.SubscribedToId) + .MapCtorParamFrom(x => x.SubscribedToName, x => x.SubscribedTo.Profile.DisplayName) + .MapCtorParamFrom(x => x.CurrentTier, x => x.SubscriptionTier); + } + } +} diff --git a/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs b/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs new file mode 100644 index 0000000..5546ced --- /dev/null +++ b/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs @@ -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 CreateUpdateMap(this IMapperConfigurationExpression cfg, MemberList memberList = MemberList.Source) + { + return cfg.CreateMap(memberList) + .IgnoreNullMembers(); + } + + public static IMappingExpression MapMemberFrom(this IMappingExpression mapping, Expression> destinationMember, Expression> sourceMember) + { + mapping.ForMember(destinationMember, x => x.MapFrom(sourceMember)); + return mapping; + } + + public static IMappingExpression IgnoreNullMembers(this IMappingExpression mapping) + { + mapping.ForAllMembers(opts => opts.Condition((src, dest, member) => src != null)); + return mapping; + } + + public static IMappingExpression MapCtorParamFrom(this IMappingExpression mapping, Expression> destinationMember, Expression> sourceMember) + { + mapping.ForCtorParam(destinationMember, x => x.MapFrom(sourceMember)); + return mapping; + } + + public static IMappingExpression ForCtorParam(this IMappingExpression mapping, Expression> paramSelector, Action> configure) + { + var ctorParamName = ((MemberExpression)paramSelector.Body).Member.Name; + mapping.ForCtorParam(ctorParamName, configure); + return mapping; + } + + + } +} diff --git a/OnlyPrompt.Backend/Utils/EntityExtensions.cs b/OnlyPrompt.Backend/Utils/EntityExtensions.cs new file mode 100644 index 0000000..b109c30 --- /dev/null +++ b/OnlyPrompt.Backend/Utils/EntityExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database.Core; + +namespace OnlyPrompt.Backend.Utils +{ + public static class EntityExtensions + { + public static Task FindBySlugAsync(this IQueryable queryable, string slug) where T : class, IHasSlug + { + return queryable.FirstOrDefaultAsync(e => e.Slug == slug); + } + + public static Task FindByIdentifierAsync(this IQueryable 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 OfIdentifer(this IQueryable 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); + } + } +} diff --git a/OnlyPrompt.Backend/Utils/Extensions.cs b/OnlyPrompt.Backend/Utils/Extensions.cs new file mode 100644 index 0000000..806ecad --- /dev/null +++ b/OnlyPrompt.Backend/Utils/Extensions.cs @@ -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 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 OrderBy(this IQueryable source, Expression> selecter, bool ascending) + { + if(ascending) + return source.OrderBy(selecter); + else + return source.OrderByDescending(selecter); + } + + public static CookieOptions Copy(this CookieOptions options, Action? 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 chars = stackalloc char[lenght]; + for (int i = 0; i < lenght; i++) + chars[i] = alphabet[@random.Next(alphabet.Length)]; + + return new string(chars); + } + } +} diff --git a/OnlyPrompt.Backend/Utils/Identifier.cs b/OnlyPrompt.Backend/Utils/Identifier.cs new file mode 100644 index 0000000..a0b70dc --- /dev/null +++ b/OnlyPrompt.Backend/Utils/Identifier.cs @@ -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; + } + } +} diff --git a/OnlyPrompt.Backend/Utils/SlugHelper.cs b/OnlyPrompt.Backend/Utils/SlugHelper.cs new file mode 100644 index 0000000..1906359 --- /dev/null +++ b/OnlyPrompt.Backend/Utils/SlugHelper.cs @@ -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 GenerateUniqueSlugAsync(string input, Func> 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; + } + } +} diff --git a/OnlyPrompt.Backend/appsettings.Development.json b/OnlyPrompt.Backend/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/OnlyPrompt.Backend/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/OnlyPrompt.Backend/appsettings.json b/OnlyPrompt.Backend/appsettings.json new file mode 100644 index 0000000..5680f33 --- /dev/null +++ b/OnlyPrompt.Backend/appsettings.json @@ -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": "*" +} diff --git a/OnlyPrompt.Frontend/.vscode/launch.json b/OnlyPrompt.Frontend/.vscode/launch.json new file mode 100644 index 0000000..5798a26 --- /dev/null +++ b/OnlyPrompt.Frontend/.vscode/launch.json @@ -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}" + } + ] +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/.vscode/settings.json b/OnlyPrompt.Frontend/.vscode/settings.json new file mode 100644 index 0000000..d3e01d5 --- /dev/null +++ b/OnlyPrompt.Frontend/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.tabSize": 2, + "editor.indentSize": 2 +} diff --git a/OnlyPrompt.Frontend/chats.html b/OnlyPrompt.Frontend/chats.html new file mode 100644 index 0000000..fc253cf --- /dev/null +++ b/OnlyPrompt.Frontend/chats.html @@ -0,0 +1,129 @@ + + + + + + + + OnlyPrompt - Chats + + + + + + + + + +
+ + + +
+ +
+ +
+ +
+ + +
+
+

Messages

+ +
+
+ +
+ Alex Chen +
+
Alex Chen
+
Hey Sarah! Really loved your last video on minimalism...
+
+
10:17 AM
+
+ +
+ Mia Wong +
+
Mia Wong
+
Thanks for the prompt tips! They worked perfectly.
+
+
Yesterday
+
+ +
+ Tom Rivera +
+
Tom Rivera
+
Let's schedule a call for the collab?
+
+
Yesterday
+
+
+
+ + +
+
+ Alex Chen +
+
Alex Chen
+
Online
+
+
+
+ +
+
Hey Sarah! Really loved your last video on minimalism. Quick question about your workspace layout?
+
10:15 AM
+
+ +
+
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!
+
10:16 AM
+
+ +
+
Thanks so much! Your aesthetic is exactly what I'm aiming for. Can't wait for your next piece!
+
10:17 AM
+
+ +
+
Awesome! Let me know if you need more tips. Enjoy the process! 😊
+
10:18 AM
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/community.html b/OnlyPrompt.Frontend/community.html new file mode 100644 index 0000000..ab0a0b3 --- /dev/null +++ b/OnlyPrompt.Frontend/community.html @@ -0,0 +1,161 @@ + + + + + + + + OnlyPrompt - Discover Creators + + + + + + + + + +
+ + + +
+ +
+ +
+ + +
+

Discover Creators

+

Follow your favorite prompt artists and get inspired.

+
+ + +
+ + + + +
+ + +
+ + +
+ Sarah Jenkins +
+

Sarah Jenkins

+
@sarahj
+

AI Explorer | Prompt Curator | Exploring creativity through generative AI.

+
+ 42 prompts + 4.9 +
+ +
+
+ + +
+ Alex Chen +
+

Alex Chen

+
@alexchen
+

Digital artist & prompt engineer. Creating surreal landscapes.

+
+ 87 prompts + 4.8 +
+ +
+
+ + +
+ Mia Wong +
+

Mia Wong

+
@miawong
+

Midjourney master | UI/UX prompts | Design systems.

+
+ 124 prompts + 5.0 +
+ +
+
+ + +
+ Tom Rivera +
+

Tom Rivera

+
@tomrivera
+

3D artist | Character design | Sci-fi & fantasy prompts.

+
+ 33 prompts + 4.7 +
+ +
+
+ + +
+ Emma Watson +
+

Emma Watson

+
@emmawatson
+

Watercolor & pet portraits | Whimsical art prompts.

+
+ 56 prompts + 4.9 +
+ +
+
+ + +
+ Liam O'Brien +
+

Liam O'Brien

+
@liamob
+

Minimalist logo designer | Brand identity prompts.

+
+ 28 prompts + 4.6 +
+ +
+
+ +
+ +
+
+
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/create.html b/OnlyPrompt.Frontend/create.html new file mode 100644 index 0000000..d885356 --- /dev/null +++ b/OnlyPrompt.Frontend/create.html @@ -0,0 +1,181 @@ + + + + + + + + OnlyPrompt - Create New Prompt + + + + + + + + + +
+ + + +
+ +
+ +
+
+
+

Create AI Prompt

+

Design and save custom prompts for your AI workflows.

+
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + Use clear, step-by-step instructions for the AI. +
+ + +
+ + +
+ + +
+ + + Upload a PNG or JPG – preview will appear below. + +
+ + +
+ +
+ + +
+ + You can set a price later or keep it free. +
+ + +
+ + +
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/base.css b/OnlyPrompt.Frontend/css/base.css new file mode 100644 index 0000000..493f50e --- /dev/null +++ b/OnlyPrompt.Frontend/css/base.css @@ -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; +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/chats.css b/OnlyPrompt.Frontend/css/chats.css new file mode 100644 index 0000000..e5205ca --- /dev/null +++ b/OnlyPrompt.Frontend/css/chats.css @@ -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; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/community.css b/OnlyPrompt.Frontend/css/community.css new file mode 100644 index 0000000..0d65aef --- /dev/null +++ b/OnlyPrompt.Frontend/css/community.css @@ -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%; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/create.css b/OnlyPrompt.Frontend/css/create.css new file mode 100644 index 0000000..516c29a --- /dev/null +++ b/OnlyPrompt.Frontend/css/create.css @@ -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; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/dashboard.css b/OnlyPrompt.Frontend/css/dashboard.css new file mode 100644 index 0000000..4311ca8 --- /dev/null +++ b/OnlyPrompt.Frontend/css/dashboard.css @@ -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 - multi‑column 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; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/login.css b/OnlyPrompt.Frontend/css/login.css new file mode 100644 index 0000000..5123766 --- /dev/null +++ b/OnlyPrompt.Frontend/css/login.css @@ -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 */ + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/marketplace.css b/OnlyPrompt.Frontend/css/marketplace.css new file mode 100644 index 0000000..a2ad116 --- /dev/null +++ b/OnlyPrompt.Frontend/css/marketplace.css @@ -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; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/post-detail.css b/OnlyPrompt.Frontend/css/post-detail.css new file mode 100644 index 0000000..2f8f9e8 --- /dev/null +++ b/OnlyPrompt.Frontend/css/post-detail.css @@ -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; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/profile.css b/OnlyPrompt.Frontend/css/profile.css new file mode 100644 index 0000000..08c2770 --- /dev/null +++ b/OnlyPrompt.Frontend/css/profile.css @@ -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; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/settings.css b/OnlyPrompt.Frontend/css/settings.css new file mode 100644 index 0000000..8ef259c --- /dev/null +++ b/OnlyPrompt.Frontend/css/settings.css @@ -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; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/sidebar.css b/OnlyPrompt.Frontend/css/sidebar.css new file mode 100644 index 0000000..75bbd9e --- /dev/null +++ b/OnlyPrompt.Frontend/css/sidebar.css @@ -0,0 +1,179 @@ +/* + 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-bottom form { + margin: 0; +} + +.sidebar-logout { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 16px; + border: 0; + background: transparent; + cursor: pointer; + text-decoration: none; + color: #64748b; + font: inherit; + font-weight: 600; + transition: background 0.2s ease; +} + +.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; + } +} diff --git a/OnlyPrompt.Frontend/css/signup.css b/OnlyPrompt.Frontend/css/signup.css new file mode 100644 index 0000000..c584354 --- /dev/null +++ b/OnlyPrompt.Frontend/css/signup.css @@ -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; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/topbar.css b/OnlyPrompt.Frontend/css/topbar.css new file mode 100644 index 0000000..7f9d710 --- /dev/null +++ b/OnlyPrompt.Frontend/css/topbar.css @@ -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; + } +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/css/variables.css b/OnlyPrompt.Frontend/css/variables.css new file mode 100644 index 0000000..ec138d9 --- /dev/null +++ b/OnlyPrompt.Frontend/css/variables.css @@ -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); +} + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/dashboard.html b/OnlyPrompt.Frontend/dashboard.html new file mode 100644 index 0000000..6d8519b --- /dev/null +++ b/OnlyPrompt.Frontend/dashboard.html @@ -0,0 +1,166 @@ + + + + + + + + OnlyPrompt - Feed + + + + + + + + + +
+ + + +
+ +
+ +
+ + +
+

Feed

+

Latest prompts and inspiration from creators you follow

+
+ + +
+ + + + +
+ + +
+ + +
+
+ Sarah Jenkins + + +
+
+

Conceptual Landscape Art

+

Enchanting, vintage, antique vibes. A journey through surreal landscapes.

+ Conceptual Landscape +
+ +
+ + + + +
+
+ + +
+
+ Alex Chen + + +
+
+

Minimalist Logo Design

+

Clean, modern, minimalist logo for tech startups.

+ Minimalist Logo +
+ +
+ + + + +
+
+ + +
+
+ Mia Wong + + +
+
+

Futuristic Cityscape

+

Cyberpunk neon city with flying cars and rain.

+ Cityscape +
+ +
+ + + + +
+
+ + +
+
+ Tom Rivera + + +
+
+

Watercolor Pet Portrait

+

Soft watercolor style, cute pet portrait.

+ Watercolor Pet +
+ +
+ + + + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/images/content/cat.png b/OnlyPrompt.Frontend/images/content/cat.png new file mode 100644 index 0000000..e3fc1c5 Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/cat.png differ diff --git a/OnlyPrompt.Frontend/images/content/creator1.png b/OnlyPrompt.Frontend/images/content/creator1.png new file mode 100644 index 0000000..131423c Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/creator1.png differ diff --git a/OnlyPrompt.Frontend/images/content/creator2.png b/OnlyPrompt.Frontend/images/content/creator2.png new file mode 100644 index 0000000..f4b0571 Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/creator2.png differ diff --git a/OnlyPrompt.Frontend/images/content/creator3.png b/OnlyPrompt.Frontend/images/content/creator3.png new file mode 100644 index 0000000..373a02c Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/creator3.png differ diff --git a/OnlyPrompt.Frontend/images/content/creator4.png b/OnlyPrompt.Frontend/images/content/creator4.png new file mode 100644 index 0000000..b002bda Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/creator4.png differ diff --git a/OnlyPrompt.Frontend/images/content/creator5.png b/OnlyPrompt.Frontend/images/content/creator5.png new file mode 100644 index 0000000..64dc15e Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/creator5.png differ diff --git a/OnlyPrompt.Frontend/images/content/creator6.png b/OnlyPrompt.Frontend/images/content/creator6.png new file mode 100644 index 0000000..794a0e6 Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/creator6.png differ diff --git a/OnlyPrompt.Frontend/images/content/feed1.png b/OnlyPrompt.Frontend/images/content/feed1.png new file mode 100644 index 0000000..32bc13b Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/feed1.png differ diff --git a/OnlyPrompt.Frontend/images/content/feed2.png b/OnlyPrompt.Frontend/images/content/feed2.png new file mode 100644 index 0000000..6ae5bcd Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/feed2.png differ diff --git a/OnlyPrompt.Frontend/images/content/feed3.png b/OnlyPrompt.Frontend/images/content/feed3.png new file mode 100644 index 0000000..85b7407 Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/feed3.png differ diff --git a/OnlyPrompt.Frontend/images/content/feed4.png b/OnlyPrompt.Frontend/images/content/feed4.png new file mode 100644 index 0000000..904392f Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/feed4.png differ diff --git a/OnlyPrompt.Frontend/images/content/market1.png b/OnlyPrompt.Frontend/images/content/market1.png new file mode 100644 index 0000000..6fbde38 Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/market1.png differ diff --git a/OnlyPrompt.Frontend/images/content/market2.png b/OnlyPrompt.Frontend/images/content/market2.png new file mode 100644 index 0000000..146abbc Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/market2.png differ diff --git a/OnlyPrompt.Frontend/images/content/market3.png b/OnlyPrompt.Frontend/images/content/market3.png new file mode 100644 index 0000000..a730bff Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/market3.png differ diff --git a/OnlyPrompt.Frontend/images/content/market4.png b/OnlyPrompt.Frontend/images/content/market4.png new file mode 100644 index 0000000..de3ba0f Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/market4.png differ diff --git a/OnlyPrompt.Frontend/images/content/market5.png b/OnlyPrompt.Frontend/images/content/market5.png new file mode 100644 index 0000000..dd56dc8 Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/market5.png differ diff --git a/OnlyPrompt.Frontend/images/content/market6.png b/OnlyPrompt.Frontend/images/content/market6.png new file mode 100644 index 0000000..c57f9f4 Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/market6.png differ diff --git a/OnlyPrompt.Frontend/images/content/post1.png b/OnlyPrompt.Frontend/images/content/post1.png new file mode 100644 index 0000000..35661e4 Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/post1.png differ diff --git a/OnlyPrompt.Frontend/images/content/post2.png b/OnlyPrompt.Frontend/images/content/post2.png new file mode 100644 index 0000000..0af377b Binary files /dev/null and b/OnlyPrompt.Frontend/images/content/post2.png differ diff --git a/OnlyPrompt.Frontend/images/logo_full.png b/OnlyPrompt.Frontend/images/logo_full.png new file mode 100644 index 0000000..cd0ecd6 Binary files /dev/null and b/OnlyPrompt.Frontend/images/logo_full.png differ diff --git a/OnlyPrompt.Frontend/images/logo_icon.png b/OnlyPrompt.Frontend/images/logo_icon.png new file mode 100644 index 0000000..4245702 Binary files /dev/null and b/OnlyPrompt.Frontend/images/logo_icon.png differ diff --git a/OnlyPrompt.Frontend/images/logo_text.png b/OnlyPrompt.Frontend/images/logo_text.png new file mode 100644 index 0000000..184462e Binary files /dev/null and b/OnlyPrompt.Frontend/images/logo_text.png differ diff --git a/OnlyPrompt.Frontend/js/linq.js b/OnlyPrompt.Frontend/js/linq.js new file mode 100644 index 0000000..5823ab9 --- /dev/null +++ b/OnlyPrompt.Frontend/js/linq.js @@ -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; + }); +}; diff --git a/OnlyPrompt.Frontend/js/login.js b/OnlyPrompt.Frontend/js/login.js new file mode 100644 index 0000000..9bd5223 --- /dev/null +++ b/OnlyPrompt.Frontend/js/login.js @@ -0,0 +1,34 @@ +import { sendFormAsync } from "./shared.js"; + +async function redirectIfAlreadySignedIn() { + const response = await fetch('/api/v1/auth/me', { + method: 'GET', + credentials: 'same-origin' + }); + + if (response.ok) { + window.location.replace('/dashboard'); + } +} + +function togglePassword() { + const passwordInput = document.getElementById('password'); + const 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(); +}); + +await redirectIfAlreadySignedIn(); diff --git a/OnlyPrompt.Frontend/js/node-pwa.js b/OnlyPrompt.Frontend/js/node-pwa.js new file mode 100644 index 0000000..d77f125 --- /dev/null +++ b/OnlyPrompt.Frontend/js/node-pwa.js @@ -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( + `

Failed to load template

${response.status} ${response.statusText}

`, + ); + } + + templateContent = await response.text(); + } + + try { + const template = new NodeTemplate(templateContent); + this.templateCache.set(route.id, template); + return template; + } catch (error) { + return new NodeTemplate( + `

Failed to compile template

${error.message}

`, + ); + } + } +} diff --git a/OnlyPrompt.Frontend/js/node-template.js b/OnlyPrompt.Frontend/js/node-template.js new file mode 100644 index 0000000..9fe4236 --- /dev/null +++ b/OnlyPrompt.Frontend/js/node-template.js @@ -0,0 +1,1033 @@ +import "./linq.js"; +import { formatDate } from "./utils.js"; + +// ─── Expression evaluation ─── + +function evaluate(data, expression) { + if (!expression) return data; + try { + return new Function("$data", `with($data) { return (${expression}); }`)( + data, + ); + } catch { + return undefined; + } +} + +// ─── Scope ─── + +class ScopeDict { + constructor(initial = {}) { + this._entries = { ...initial }; + } + set(name, value) { + if (name.startsWith("$")) + throw new Error(`'${name}' reserved — cannot use @let`); + this._entries[name] = value; + } + setBuiltin(name, value) { + this._entries[name] = value; + } + has(name) { + return name in this._entries; + } + get(name) { + return this._entries[name]; + } + clone() { + return new ScopeDict(this._entries); + } +} + +function scopeProxy(data, scope) { + if (data === null || data === undefined || typeof data !== "object") { + scope.setBuiltin("$this", data); + return new Proxy( + {}, + { + get(_, p) { + return p === "$this" ? data : scope.has(p) ? scope.get(p) : undefined; + }, + has(_, p) { + return p === "$this" || scope.has(p); + }, + }, + ); + } + scope.setBuiltin("$this", data); + return new Proxy(data, { + get(t, p) { + return p === "$this" ? data : scope.has(p) ? scope.get(p) : t[p]; + }, + has(t, p) { + return p === "$this" || scope.has(p) || p in t; + }, + }); +} + +// ─── Formatting ─── + +function formatNumber(value, spec) { + const hasThousands = spec.includes(","); + const dotIdx = spec.indexOf("."); + let decimals = dotIdx !== -1 ? spec.length - dotIdx - 1 : 0; + let result = value.toFixed(decimals); + if (hasThousands) { + const parts = result.split("."); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + result = parts.join("."); + } + if (!spec.includes(".") && !hasThousands) { + const padLen = spec.replace(/[^0]/g, "").length; + if (padLen > 0) { + const neg = value < 0; + let abs = Math.abs(Math.round(value)).toString().padStart(padLen, "0"); + result = neg ? "-" + abs : abs; + } + } + return result; +} + + + +function createFormatter(spec) { + return (v) => { + if (v == null) return ""; + if (v instanceof Date) return formatDate(v, spec); + if (typeof v === "number") return formatNumber(v, spec); + return String(v); + }; +} + +// ─── Dependency extraction ─── + +const RESERVED = new Set([ + "true", + "false", + "null", + "undefined", + "typeof", + "instanceof", + "new", + "delete", + "void", + "in", + "of", + "if", + "else", + "return", + "this", + "event", + "Math", + "Date", + "Array", + "Object", + "String", + "Number", + "Boolean", + "JSON", + "console", + "window", + "document", + "parseInt", + "parseFloat", + "NaN", + "Infinity", +]); + +function extractDeps(expression) { + const cleaned = expression.replace( + /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g, + "", + ); + const deps = new Set(); + for (const m of cleaned.matchAll(/\b([a-zA-Z_$][\w$]*)\b/g)) { + if (!RESERVED.has(m[1]) && !m[1].startsWith("$")) deps.add(m[1]); + } + return deps; +} + +// ─── Binding graph ─── + +class BindingGraph { + constructor() { + this._map = new Map(); + } + register(binding, deps) { + for (const d of deps) { + if (!this._map.has(d)) this._map.set(d, new Set()); + this._map.get(d).add(binding); + } + } + getAffected(props) { + const out = new Set(); + for (const p of props) { + const bs = this._map.get(p); + if (bs) for (const b of bs) out.add(b); + } + return out; + } +} + +// ─── String helpers ─── + +function skipWhitespace(str, pos) { + while (pos < str.length && /\s/.test(str[pos])) pos++; + return pos; +} + +function skipString(str, i) { + const q = str[i]; + i++; + while (i < str.length && str[i] !== q) { + if (str[i] === "\\") i++; + i++; + } + return i; +} + +function findClosingParen(str, open) { + let depth = 1, + i = open + 1; + while (i < str.length && depth > 0) { + if (str[i] === "'" || str[i] === '"' || str[i] === "`") { + i = skipString(str, i) + 1; + continue; + } + if (str[i] === "(") depth++; + else if (str[i] === ")") depth--; + if (depth > 0) i++; + } + if (depth !== 0) throw new Error("Unclosed ("); + return i; +} + +function findClosingBrace(str, open) { + let depth = 1, + i = open + 1; + while (i < str.length && depth > 0) { + if (str[i] === "'" || str[i] === '"' || str[i] === "`") { + i = skipString(str, i) + 1; + continue; + } + if (str[i] === "{" && str[i + 1] === "{") { + const end = str.indexOf("}}", i + 2); + if (end === -1) throw new Error("Unclosed {{ }}"); + i = end + 2; + continue; + } + if (str[i] === "{") depth++; + else if (str[i] === "}") depth--; + if (depth > 0) i++; + } + if (depth !== 0) throw new Error("Unclosed {"); + return i; +} + +// ─── Parse expression with optional format spec ─── + +function parseExpression(content) { + const colonIdx = content.lastIndexOf(":"); + if (colonIdx > 0) { + const fmt = content.substring(colonIdx + 1).trim(); + if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(fmt)) { + return { + expression: content.substring(0, colonIdx).trim(), + formatter: createFormatter(fmt), + }; + } + } + return { expression: content, formatter: null }; +} + +// ───────────────────────────────────────────────── +// Instruction classes — parser output +// ───────────────────────────────────────────────── + +class TextInstr { + constructor(value) { + this.value = value; + } +} + +class ExprInstr { + constructor(expression, formatter) { + this.expression = expression; + this.formatter = formatter; + } +} + +class ElementInstr { + constructor(tag, attrs, children) { + this.tag = tag; + this.attrs = attrs; + this.children = children; + } +} + +class AttrInstr { + constructor(name, parts, bracket = null) { + this.name = name; + this.parts = parts; + this.bracket = bracket; // null | "[]" | "[()]" + } +} + +class ForInstr { + constructor(varNames, expression, trackExpr, children, empty) { + this.varNames = varNames; + this.expression = expression; + this.trackExpr = trackExpr; + this.children = children; + this.empty = empty; + } +} + +class IfInstr { + constructor(expression, trueChildren, falseChildren) { + this.expression = expression; + this.trueChildren = trueChildren; + this.falseChildren = falseChildren; + } +} + +class LetInstr { + constructor(name, expression) { + this.name = name; + this.expression = expression; + } +} + +// ───────────────────────────────────────────────── +// Parser — template string → instruction tree +// ───────────────────────────────────────────────── + +const VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +function parse(str) { + return parseChildren(str, 0).children; +} + +function parseChildren(str, pos) { + const children = []; + + while (pos < str.length) { + // Closing tag → return to parent + if (str[pos] === "<" && str[pos + 1] === "/") break; + + // HTML comment (skip) + if (str.startsWith("", pos + 4); + if (end === -1) throw new Error("Unclosed HTML comment"); + pos = end + 3; + continue; + } + + // Opening tag + if (str[pos] === "<" && /[a-zA-Z]/.test(str[pos + 1])) { + const r = parseElement(str, pos); + children.push(r.node); + pos = r.pos; + continue; + } + + // @for + if (str.startsWith("@for(", pos)) { + const r = parseFor(str, pos); + children.push(r.node); + pos = r.pos; + continue; + } + + // @if + if (str.startsWith("@if(", pos)) { + const r = parseIf(str, pos); + children.push(r.node); + pos = r.pos; + continue; + } + + // @let + if (str.startsWith("@let ", pos)) { + const r = parseLet(str, pos); + children.push(r.node); + pos = r.pos; + continue; + } + + // Text / expressions + const r = parseText(str, pos); + children.push(...r.nodes); + pos = r.pos; + } + + return { children, pos }; +} + +function parseText(str, pos) { + const nodes = []; + let start = pos; + + while (pos < str.length) { + if (str[pos] === "<") break; + if ( + str[pos] === "@" && + (str.startsWith("@for(", pos) || + str.startsWith("@if(", pos) || + str.startsWith("@let ", pos)) + ) + break; + + if (str[pos] === "{" && str[pos + 1] === "{") { + if (pos > start) + nodes.push(new TextInstr(str.substring(start, pos))); + const end = str.indexOf("}}", pos + 2); + if (end === -1) throw new Error("Unclosed {{ }}"); + const { expression, formatter } = parseExpression( + str.substring(pos + 2, end).trim(), + ); + nodes.push(new ExprInstr(expression, formatter)); + pos = end + 2; + start = pos; + continue; + } + + pos++; + } + + if (pos > start) + nodes.push(new TextInstr(str.substring(start, pos))); + + return { nodes, pos }; +} + +function parseAttrValue(str, pos) { + const quote = str[pos]; + if (quote !== '"' && quote !== "'") { + let end = pos; + while (end < str.length && !/[\s\/>]/.test(str[end])) end++; + return { + parts: [new TextInstr(str.substring(pos, end))], + pos: end, + }; + } + + pos++; + const parts = []; + let start = pos; + + while (pos < str.length && str[pos] !== quote) { + if (str[pos] === "{" && str[pos + 1] === "{") { + if (pos > start) + parts.push(new TextInstr(str.substring(start, pos))); + const end = str.indexOf("}}", pos + 2); + if (end === -1) throw new Error("Unclosed {{ }} in attribute"); + const { expression, formatter } = parseExpression( + str.substring(pos + 2, end).trim(), + ); + parts.push(new ExprInstr(expression, formatter)); + pos = end + 2; + start = pos; + continue; + } + pos++; + } + + if (pos > start) + parts.push(new TextInstr(str.substring(start, pos))); + + pos++; // closing quote + return { parts, pos }; +} + +function parseElement(str, pos) { + pos++; // skip < + + let tagEnd = pos; + while (tagEnd < str.length && /[\w\-]/.test(str[tagEnd])) tagEnd++; + const tag = str.substring(pos, tagEnd); + pos = tagEnd; + + const attrs = []; + while (pos < str.length) { + pos = skipWhitespace(str, pos); + + if (str[pos] === ">") { + pos++; + break; + } + if (str[pos] === "/" && str[pos + 1] === ">") { + pos += 2; + return { node: new ElementInstr(tag, attrs, []), pos }; + } + + // Attribute name — supports (event)="...", [attr]="...", [(attr)]="..." syntax + let nameEnd = pos; + while (nameEnd < str.length && !/[\s=\/>]/.test(str[nameEnd])) nameEnd++; + const rawAttrName = str.substring(pos, nameEnd); + pos = nameEnd; + + // [attr]="expr" or [(attr)]="expr" — entire value is a single expression + const bracketMatch = rawAttrName.match(/^\[(\(?\w[\w.\-]*\)?)]/); + const isBracketExpr = bracketMatch !== null; + const attrName = isBracketExpr ? bracketMatch[1] : rawAttrName; + const bracket = isBracketExpr + ? (attrName.startsWith("(") && attrName.endsWith(")") ? "[()]" : "[]") + : null; + + pos = skipWhitespace(str, pos); + + if (str[pos] !== "=") { + attrs.push(new AttrInstr(attrName, [])); // boolean attr + continue; + } + + pos++; // skip = + pos = skipWhitespace(str, pos); + + if (isBracketExpr) { + const quote = str[pos]; + if (quote !== '"' && quote !== "'") throw new Error(`[${attrName}] requires a quoted value`); + pos++; // skip opening quote + const closeQuote = str.indexOf(quote, pos); + if (closeQuote === -1) throw new Error(`Unclosed quote for [${attrName}]`); + const { expression, formatter } = parseExpression(str.substring(pos, closeQuote).trim()); + attrs.push(new AttrInstr(attrName, [new ExprInstr(expression, formatter)], bracket)); + pos = closeQuote + 1; + } else { + const r = parseAttrValue(str, pos); + attrs.push(new AttrInstr(attrName, r.parts)); + pos = r.pos; + } + } + + if (VOID_ELEMENTS.has(tag.toLowerCase())) { + return { node: new ElementInstr(tag, attrs, []), pos }; + } + + const { children, pos: childEnd } = parseChildren(str, pos); + pos = childEnd; + + // Consume closing tag + if (str[pos] === "<" && str[pos + 1] === "/") { + const closeEnd = str.indexOf(">", pos + 2); + if (closeEnd !== -1) pos = closeEnd + 1; + } + + return { node: new ElementInstr(tag, attrs, children), pos }; +} + +function parseFor(str, pos) { + const parenOpen = pos + 4; + const parenClose = findClosingParen(str, parenOpen); + const clause = str.substring(parenOpen + 1, parenClose).trim(); + + const ofIdx = clause.indexOf(" of "); + if (ofIdx === -1) throw new Error('@for missing "of"'); + const varNames = clause + .substring(0, ofIdx) + .split(",") + .map((v) => v.trim()); + + let rest = clause.substring(ofIdx + 4).trim(); + let trackExpr = null; + const trackIdx = rest.indexOf("; track "); + if (trackIdx !== -1) { + trackExpr = rest.substring(trackIdx + 8).trim(); + rest = rest.substring(0, trackIdx).trim(); + } + + const braceOpen = str.indexOf("{", parenClose + 1); + if (braceOpen === -1) throw new Error("@for missing block"); + const braceClose = findClosingBrace(str, braceOpen); + const children = parse(str.substring(braceOpen + 1, braceClose)); + + let empty = null; + let afterBlock = braceClose + 1; + const remaining = str.substring(afterBlock); + const emptyMatch = remaining.match(/^\s*@empty\s*\{/); + if (emptyMatch) { + const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1; + const emptyBraceClose = findClosingBrace(str, emptyBraceOpen); + empty = parse(str.substring(emptyBraceOpen + 1, emptyBraceClose)); + afterBlock = emptyBraceClose + 1; + } + + return { + node: new ForInstr(varNames, rest, trackExpr, children, empty), + pos: afterBlock, + }; +} + +function parseIf(str, pos) { + const parenOpen = pos + 3; + const parenClose = findClosingParen(str, parenOpen); + const expression = str.substring(parenOpen + 1, parenClose).trim(); + + const trueBraceOpen = str.indexOf("{", parenClose + 1); + if (trueBraceOpen === -1) throw new Error("@if missing block"); + const trueBraceClose = findClosingBrace(str, trueBraceOpen); + const trueChildren = parse(str.substring(trueBraceOpen + 1, trueBraceClose)); + + let falseChildren = null; + let afterBlock = trueBraceClose + 1; + const remaining = str.substring(afterBlock); + const elseMatch = remaining.match(/^\s*@else\s*\{/); + if (elseMatch) { + const falseBraceOpen = afterBlock + elseMatch[0].length - 1; + const falseBraceClose = findClosingBrace(str, falseBraceOpen); + falseChildren = parse(str.substring(falseBraceOpen + 1, falseBraceClose)); + afterBlock = falseBraceClose + 1; + } + + return { + node: new IfInstr(expression, trueChildren, falseChildren), + pos: afterBlock, + }; +} + +function parseLet(str, pos) { + const lineEnd = str.indexOf("\n", pos); + const end = lineEnd === -1 ? str.length : lineEnd; + const decl = str.substring(pos + 5, end).trim(); + const eqIdx = decl.indexOf("="); + if (eqIdx === -1) throw new Error("@let missing ="); + return { + node: new LetInstr( + decl.substring(0, eqIdx).trim(), + decl.substring(eqIdx + 1).trim(), + ), + pos: end + 1, + }; +} + +// ───────────────────────────────────────────────── +// Bindings — live references to DOM nodes +// ───────────────────────────────────────────────── + +class TextBinding { + constructor(node, expression, formatter) { + this.node = node; + this.expression = expression; + this.formatter = formatter; + } + update(ctx) { + const raw = evaluate(ctx, this.expression); + const val = this.formatter ? this.formatter(raw) : raw; + const text = val == null ? "" : String(val); + if (this.node.textContent !== text) this.node.textContent = text; + } +} + +class AttrBinding { + constructor(element, attrName, parts) { + this.element = element; + this.attrName = attrName; + this.parts = parts; + } + update(ctx) { + let val = ""; + for (const p of this.parts) { + if (p instanceof TextInstr) { + val += p.value; + } else { + const raw = evaluate(ctx, p.expression); + const v = p.formatter ? p.formatter(raw) : raw; + val += v == null ? "" : String(v); + } + } + if (this.element.getAttribute(this.attrName) !== val) { + this.element.setAttribute(this.attrName, val); + } + } +} + +class AttrListBinding { + constructor(element, attrName, attrListPart, expression) { + this.element = element; + this.attrName = attrName; + this.attrListPart = attrListPart.trim(); + this.expression = expression; + this.pattern = new RegExp(`\\b${attrListPart}\\b`); + } + + update(ctx) { + const val = evaluate(ctx, this.expression); + const attrList = (this.element.getAttribute(this.attrName) ?? "").trim(); + const match = this.pattern.exec(attrList); + if (val && !match) { + // works because upper trim() removes all whitespace, so empty string means no attributes + this.element.setAttribute(this.attrName, attrList.length === 0 ? this.attrListPart : `${attrList} ${this.attrListPart}`); + } else if (!val && match) { + const start = match.index; + const end = match.index + this.attrListPart.length; + const newAttrList = attrList.slice(0, start) + attrList.slice(end); + this.element.setAttribute(this.attrName, newAttrList.trim()); + } + } +} + +class LetBinding { + constructor(name, expression) { + this.name = name; + this.expression = expression; + } + declare(scope, ctx) { + scope.set(this.name, evaluate(ctx, this.expression)); + } + update() { } +} + +class ForBinding { + constructor(startMarker, endMarker, instr) { + this.startMarker = startMarker; + this.endMarker = endMarker; + this.varNames = instr.varNames; + this.expression = instr.expression; + this.trackExpr = instr.trackExpr; + this.childInstructions = instr.children; + this.emptyInstructions = instr.empty; + this.entries = new Map(); // key → { nodes, result } + this.emptyNodes = []; + this.emptyResult = null; + } + + update(ctx) { + const list = evaluate(ctx, this.expression); + const arr = Array.isArray(list) ? list : list ? Array.from(list) : []; + const count = arr.length; + + if (count === 0) { + this._clearItems(); + if (this.emptyInstructions && this.emptyNodes.length === 0) { + const r = instantiate(this.emptyInstructions, ctx); + this.emptyNodes = Array.from(r.fragment.childNodes); + this.emptyResult = r; + this.endMarker.parentNode.insertBefore(r.fragment, this.endMarker); + } + return; + } + + this._clearEmpty(); + + const newKeys = arr.map((item, i) => this._trackKey(item, i, ctx)); + const newKeySet = new Set(newKeys); + + // Remove stale entries + for (const [key, entry] of this.entries) { + if (!newKeySet.has(key)) { + for (const n of entry.nodes) n.remove(); + entry.result.destroy(); + this.entries.delete(key); + } + } + + // Rebuild in correct order + const newEntries = new Map(); + for (let i = 0; i < count; i++) { + const item = arr[i]; + const key = newKeys[i]; + const scope = this._makeScope(item, i, count); + const proxy = scopeProxy(ctx, scope); + + let entry; + if (this.entries.has(key)) { + entry = this.entries.get(key); + entry.result.update(proxy); + } else { + const result = instantiate(this.childInstructions, proxy); + entry = { nodes: Array.from(result.fragment.childNodes), result }; + } + + for (const n of entry.nodes) { + this.endMarker.parentNode.insertBefore(n, this.endMarker); + } + newEntries.set(key, entry); + } + + this.entries = newEntries; + } + + _trackKey(item, index, ctx) { + if (!this.trackExpr) return item; + const scope = new ScopeDict(); + if (this.varNames.length === 1) { + scope.setBuiltin(this.varNames[0], item); + } else { + for (let v = 0; v < this.varNames.length; v++) + scope.setBuiltin(this.varNames[v], item[v]); + } + scope.setBuiltin("$index", index); + return evaluate(scopeProxy(ctx, scope), this.trackExpr); + } + + _makeScope(item, index, count) { + const scope = new ScopeDict(); + if (this.varNames.length === 1) { + scope.setBuiltin(this.varNames[0], item); + } else { + for (let v = 0; v < this.varNames.length; v++) + scope.setBuiltin(this.varNames[v], item[v]); + } + scope.setBuiltin("$index", index); + scope.setBuiltin("$first", index === 0); + scope.setBuiltin("$last", index === count - 1); + scope.setBuiltin("$even", index % 2 === 0); + scope.setBuiltin("$odd", index % 2 === 1); + scope.setBuiltin("$count", count); + return scope; + } + + _clearItems() { + for (const [, entry] of this.entries) { + for (const n of entry.nodes) n.remove(); + entry.result.destroy(); + } + this.entries.clear(); + } + + _clearEmpty() { + for (const n of this.emptyNodes) n.remove(); + this.emptyNodes = []; + if (this.emptyResult) { + this.emptyResult.destroy(); + this.emptyResult = null; + } + } + + destroy() { + this._clearItems(); + this._clearEmpty(); + } +} + +class IfBinding { + constructor(startMarker, endMarker, instr) { + this.startMarker = startMarker; + this.endMarker = endMarker; + this.expression = instr.expression; + this.trueInstructions = instr.trueChildren; + this.falseInstructions = instr.falseChildren; + this.currentBranch = null; + this.currentResult = null; + this.currentNodes = []; + } + + update(ctx) { + const branch = evaluate(ctx, this.expression) ? "true" : "false"; + + if (branch === this.currentBranch) { + if (this.currentResult) this.currentResult.update(ctx); + return; + } + + this._clear(); + this.currentBranch = branch; + + const instructions = + branch === "true" ? this.trueInstructions : this.falseInstructions; + if (!instructions) return; + + this.currentResult = instantiate(instructions, ctx); + this.currentNodes = Array.from(this.currentResult.fragment.childNodes); + this.endMarker.parentNode.insertBefore( + this.currentResult.fragment, + this.endMarker, + ); + } + + _clear() { + for (const n of this.currentNodes) n.remove(); + if (this.currentResult) this.currentResult.destroy(); + this.currentNodes = []; + this.currentResult = null; + } + + destroy() { + this._clear(); + } +} + +// ───────────────────────────────────────────────── +// Instantiate — instruction tree → DOM + bindings +// ───────────────────────────────────────────────── +// Walks instruction tree once, calls createElement / createTextNode, +// stores live node references in Binding objects. + +function instantiate(instructions, ctx, parentScope) { + const fragment = document.createDocumentFragment(); + const graph = new BindingGraph(); + const bindings = []; + const scope = parentScope ? parentScope.clone() : new ScopeDict(); + const proxy = scopeProxy(ctx, scope); + + buildNodes(instructions, fragment, proxy, bindings, graph); + + // Declare @let, then evaluate all bindings once + for (const b of bindings) { + if (b instanceof LetBinding) b.declare(scope, proxy); + } + for (const b of bindings) { + if (!(b instanceof LetBinding)) b.update(proxy); + } + + return new RenderResult(fragment, bindings, graph, scope); +} + +function buildNodes(instructions, parent, ctx, bindings, graph) { + for (const instr of instructions) { + if (instr instanceof TextInstr) { + parent.appendChild(document.createTextNode(instr.value)); + } else if (instr instanceof ExprInstr) { + const node = document.createTextNode(""); + parent.appendChild(node); + const b = new TextBinding(node, instr.expression, instr.formatter); + bindings.push(b); + graph.register(b, extractDeps(instr.expression)); + } else if (instr instanceof ElementInstr) { + const el = document.createElement(instr.tag); + + for (const attr of instr.attrs) { + // Bracket attrs ([]/[()]) → stash as metadata, not real DOM attrs + if (attr.bracket) { + if (!el.__templateMeta) el.__templateMeta = []; + const expr = attr.parts.length === 1 && attr.parts[0] instanceof ExprInstr + ? attr.parts[0].expression : null; + el.__templateMeta.push({ name: attr.name, bracket: attr.bracket, expression: expr }); + + // Still create bindings for [] attrs that affect rendering + if (attr.bracket === "[]") { + const deps = new Set(); + for (const p of attr.parts) { + if (p instanceof ExprInstr) { + for (const d of extractDeps(p.expression)) deps.add(d); + } + } + + if (attr.name.includes(".") && attr.parts.length === 1 && attr.parts[0] instanceof ExprInstr) { + const [attrName, attrListPart] = attr.name.split("."); + const b = new AttrListBinding(el, attrName, attrListPart, attr.parts[0].expression); + bindings.push(b); + graph.register(b, deps); + } else { + const b = new AttrBinding(el, attr.name, attr.parts); + bindings.push(b); + graph.register(b, deps); + } + } + continue; + } + + const hasDynamic = attr.parts.some((p) => p instanceof ExprInstr); + if (!hasDynamic) { + const val = attr.parts.length === 0 ? "" : attr.parts.map((p) => p.value).join(""); + el.setAttribute(attr.name, val); + } else { + const deps = new Set(); + for (const p of attr.parts) { + if (p instanceof ExprInstr) { + for (const d of extractDeps(p.expression)) deps.add(d); + } + } + const b = new AttrBinding(el, attr.name, attr.parts); + bindings.push(b); + graph.register(b, deps); + } + } + + buildNodes(instr.children, el, ctx, bindings, graph); + parent.appendChild(el); + } else if (instr instanceof ForInstr) { + const start = document.createComment("for"); + const end = document.createComment("/for"); + parent.appendChild(start); + parent.appendChild(end); + const b = new ForBinding(start, end, instr); + bindings.push(b); + graph.register(b, extractDeps(instr.expression)); + } else if (instr instanceof IfInstr) { + const start = document.createComment("if"); + const end = document.createComment("/if"); + parent.appendChild(start); + parent.appendChild(end); + const b = new IfBinding(start, end, instr); + bindings.push(b); + graph.register(b, extractDeps(instr.expression)); + } else if (instr instanceof LetInstr) { + bindings.push(new LetBinding(instr.name, instr.expression)); + } + } +} + +// ─── RenderResult ─── + +export class RenderResult { + constructor(fragment, bindings, graph, scope) { + this.fragment = fragment; + this.bindings = bindings; + this.graph = graph; + this._scope = scope; + } + + /** + * Re-evaluate bindings with new data. + * @param {object} data + * @param {string[]} [changedProps] — only update bindings for these props + */ + update(data, changedProps) { + const scope = this._scope.clone(); + const proxy = scopeProxy(data, scope); + + for (const b of this.bindings) { + if (b instanceof LetBinding) b.declare(scope, proxy); + } + + if (changedProps) { + for (const b of this.graph.getAffected(changedProps)) { + if (!(b instanceof LetBinding)) b.update(proxy); + } + } else { + for (const b of this.bindings) { + if (!(b instanceof LetBinding)) b.update(proxy); + } + } + } + + destroy() { + for (const b of this.bindings) { + if (b.destroy) b.destroy(); + } + } +} + +// ─── Public API ─── + +export class NodeTemplate { + constructor(templateString) { + this._instructions = templateString != null ? parse(templateString) : null; + } + + /** + * Build DOM once from template. + * Returns RenderResult with .fragment, .update(), .destroy(). + */ + render(data, parentScope) { + if (!this._instructions) throw new Error("No template"); + return instantiate(this._instructions, data, parentScope); + } +} diff --git a/OnlyPrompt.Frontend/js/node-test.js b/OnlyPrompt.Frontend/js/node-test.js new file mode 100644 index 0000000..e18c50e --- /dev/null +++ b/OnlyPrompt.Frontend/js/node-test.js @@ -0,0 +1,65 @@ +import { initRouter, Route, ViewChild, Component, ComponentInput, ComponentDefinition } from "./node-pwa.js"; +import { IntInput } from "./utils.js"; + +class TestComponent extends Component { + static get definition() { + return new ComponentDefinition({ + name: "test-component", + template: `
+

Test Component

+

This is a test component.

+ + +

Hello, {{name()}}! Counter is {{count()}}

+
`, + style: ` + .red-button { + background-color: red; + color: white; + border: none; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + } + + .green-button { + background-color: green; + color: white; + border: none; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + } + + .counter-button { + border-radius: 10px; + margin: 15px 30px; + font-size: 18px; + transition: opacity 0.3s ease; + } + + .counter-button:hover { + opacity: 0.8; + } + ` + }); + } + + count = new ComponentInput(0, IntInput); + name = new ComponentInput("World"); + + incrementCount() { + const currentCount = this.count(); + this.count.set(currentCount + 1); + } +} + +const route1 = new Route("/counter", TestComponent); +const route2 = new Route("/counter/:count", TestComponent); +initRouter("routerOutlet", [route1, route2]); diff --git a/OnlyPrompt.Frontend/js/pwa.js b/OnlyPrompt.Frontend/js/pwa.js new file mode 100644 index 0000000..7e57911 --- /dev/null +++ b/OnlyPrompt.Frontend/js/pwa.js @@ -0,0 +1,432 @@ +import "./linq.js"; +import { Template } from "./template.js"; + +class RouteMatch { + constructor(route, path, params) { + this.route = route; + this.path = path; + this.params = params; + this.segmentCount = route.fragments.length; + } +} + +export class Route { + constructor(pattern, componentClass) { + this.id = crypto.randomUUID(); + this.componentClass = componentClass; + this.ComponentDefinition = componentClass.definition; + this.fragments = pattern.split("/"); + } + + match(path) { + const parsedUrl = new URL(path, window.location.origin); + const params = {}; + parsedUrl.searchParams.forEach((value, key) => { + params[key] = value; + }); + + const pathFragments = parsedUrl.pathname + .split("/") + .filter((f) => f.length > 0); + + for (let i = 0; i < this.fragments.length; i++) { + const fragment = this.fragments[i]; + const pathFragment = pathFragments[i]; + if (fragment.startsWith(":")) { + params[fragment.substring(1)] = pathFragment; + continue; + } else if (fragment === "*") { + continue; + } else if (fragment === "**") { + return new RouteMatch(this, path, params); + } else if (fragment !== pathFragment) { + return null; + } + } + + return new RouteMatch(this, path, params); + } +} + +export class ComponentInput { + constructor(defaultValue, options = {}) { + this._value = defaultValue; + this.transform = options.transform || ((v) => v); + this.validate = options.validate || (() => true); + this.alias = options.alias || null; + this._isComponentInput = true; + this._owner = null; + const self = this; + const fn = function () { + return self._value; + }; + return new Proxy(fn, { + get(target, prop) { + if (prop === "set") return (v) => self._set(v); + if (prop === "_isComponentInput") return true; + if (prop === "_self") return self; + if (prop === "alias") return self.alias; + if (prop === Symbol.toPrimitive) return () => self._value; + if (prop === "valueOf") return () => self._value; + if (prop === "toString") return () => String(self._value); + const inner = self._value; + if (inner == null) return undefined; + const val = inner[prop]; + return typeof val === "function" ? val.bind(inner) : val; + }, + set(target, prop, value) { + if (self._value != null) { + self._value[prop] = value; + if (self._owner) self._owner.requestUpdate(); + } + return true; + }, + apply(target, thisArg, args) { + return self._value; + }, + }); + } + + _set(newValue) { + const transformed = this.transform(newValue); + if (!this.validate(transformed)) { + throw new Error("Invalid value"); + } + this._value = transformed; + if (this._owner) this._owner.requestUpdate(); + } + + static [Symbol.hasInstance](instance) { + return instance?._isComponentInput === true; + } +} + +export class ViewChild { + constructor(options = { selector: null, id: null, multiple: false }) { + this._selector = options.selector; + this._id = options.id; + this._multiple = options.multiple; + this._element = null; + this._isViewChild = true; + return new Proxy(this, { + get(target, prop) { + if (prop in target) return target[prop]; + if (target._element) { + const value = target._element[prop]; + return typeof value === "function" + ? value.bind(target._element) + : value; + } + }, + }); + } + + _setValue(element) { + this._element = element; + } + + static [Symbol.hasInstance](instance) { + return instance?._isViewChild === true; + } +} + +export class ComponentDefinition { + constructor( + options = { + templatesPath: null, + template: null, + stylesPath: null, + style: null, + scriptsPath: null, + }, + ) { + this.templatesPath = options.templatesPath; + this.template = options.template; + this.stylesPath = options.stylesPath; + this.scriptsPath = options.scriptsPath; + } +} + +class EventListener { + constructor(event, element, handler) { + this.event = event; + this.element = element; + this.handler = handler; + } + + attach() { + this.element.addEventListener(this.event, this.handler); + } + + detach() { + this.element.removeEventListener(this.event, this.handler); + } +} + +export class Component extends EventTarget { + constructor() { + super(); + this._eventListeners = []; + return new Proxy(this, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, target); + if (typeof value === "function" && typeof value.bind === "function") { + return prop in EventTarget.prototype + ? value.bind(target) + : value.bind(receiver); + } + return value; + }, + set(target, prop, value) { + if (target[prop] instanceof ComponentInput) { + target[prop].set(value); + return true; + } else if (target[prop] instanceof ViewChild) { + target[prop]._setValue(value); + } else { + target[prop] = value; + } + target.requestUpdate(); + return true; + }, + }); + } + + requestUpdate() { + this.dispatchEvent(new Event("requestUpdate", { component: this })); + } + + static get definition() { + throw new Error("Component definition is not defined"); + } + + onInit() { } + onBeforeRender() { } + onAfterRender() { } + onDestroy() { + this._eventListeners.forEach((listener) => listener.detach()); + } +} + +export function initRouter(outletId, includeId, routes) { + const outletRef = document.getElementById(outletId); + const includeRef = document.getElementById(includeId); + if (!outletRef) { + console.error(`Outlet element with id '${outletId}' not found`); + return null; + } + if (!includeRef) { + console.error(`Include element with id '${includeId}' not found`); + return null; + } + + return new Router(outletRef, includeRef, routes); +} + +export class Router { + constructor(outletRef, includeRef, routes) { + this.outletRef = outletRef; + this.includeRef = includeRef; + this.routes = routes; + this.templateCache = new Map(); + outletRef.innerHTML = "Loading..."; + includeRef.innerHTML = ""; + navigation.addEventListener("navigate", this.handleNavigate.bind(this)); + } + + findMatchingRoute(path) { + const routeMatch = this.routes + .asEnumerable() + .select((route) => route.match(path)) + .where((match) => match !== null) + .orderByDescending((match) => match.segmentCount) + .firstOrDefault(); + + return routeMatch; + } + + async handleNavigate(event) { + const routeMatch = this.findMatchingRoute(event.destination.url); + if (routeMatch) { + event.preventDefault(); + const template = await this.getCachedTemplate(routeMatch.route); + const controller = this.createComponentInstance( + routeMatch.route.componentClass, + routeMatch.params, + ); + this.destroyCurrent(); + this.activeController = controller; + this.activeTemplate = template; + this.insertIncludes(routeMatch.route); + this._boundUpdateHandler = this.handleUpdateRequest.bind(this); + this.activeController.addEventListener( + "requestUpdate", + this._boundUpdateHandler, + ); + this.renderActiveComponent(); + history.pushState(null, "", routeMatch.path); + } + } + + destroyCurrent() { + if (this.activeController) { + this.activeController.removeEventListener( + "requestUpdate", + this._boundUpdateHandler, + ); + this.activeController.onDestroy(); + this.activeController = null; + } + } + + handleUpdateRequest(args) { + if (this._rendering) { + this._pendingUpdate = true; + return; + } else { + this.renderActiveComponent(); + } + } + + insertIncludes(route) { + if (Array.isArray(route.ComponentDefinition.stylesPath)) { + route.ComponentDefinition.stylesPath.forEach((path) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = path; + this.includeRef.appendChild(link); + }); + } else if (route.ComponentDefinition.stylesPath) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = route.ComponentDefinition.stylesPath; + this.includeRef.appendChild(link); + } + } + + renderActiveComponent() { + this._rendering = true; + this._pendingUpdate = false; + this.activeController.onBeforeRender(); + const renderedTemplate = this.activeTemplate.render(this.activeController); + this.outletRef.innerHTML = renderedTemplate; + this.wireEvents(this.activeController); + this.wireViewChildren(this.activeController); + this.activeController.onAfterRender(); + this._rendering = false; + if (this._pendingUpdate) { + this.renderActiveComponent(); + } + } + + wireViewChildren(controller) { + const viewChildProps = Object.entries(controller) + .filter(([_, value]) => value instanceof ViewChild) + .forEach(([key, viewChild]) => { + if (viewChild._id) { + const element = this.outletRef.querySelector(`#${viewChild._id}`); + if (element) viewChild._setValue(element); + } else if (viewChild._selector) { + const element = this.outletRef.querySelector(viewChild._selector); + if (element) viewChild._setValue(element); + } else { + console.warn(`ViewChild ${key} has no selector or id defined`); + } + }); + } + + wireEvents(controller) { + const allElements = this.outletRef.querySelectorAll("*"); + allElements.forEach((element) => { + element.getAttributeNames().forEach((attr) => { + const match = attr.match(/^\((\w+)\)$/); + if (!match) return; + + const eventName = match[1]; + const attrValue = element.getAttribute(attr).trim(); + const methodName = attrValue.endsWith("()") + ? attrValue.slice(0, -2) + : attrValue; + + let listener; + if (typeof controller[methodName] === "function") { + // Named method — wire directly + const handler = controller[methodName].bind(controller); + listener = new EventListener(eventName, element, handler); + } else { + const fn = new Function("event", attrValue).bind(controller); + listener = new EventListener(eventName, element, fn); + } + listener.attach(); + + controller._eventListeners.push(listener); + }); + }); + } + + createComponentInstance(component, params) { + const instance = new component(); + + for (const key of Object.keys(instance)) { + const val = instance[key]; + if (val instanceof ComponentInput) { + val._self._owner = instance; + } + } + + instance.onInit(); + + for (const [paramKey, paramValue] of Object.entries(params)) { + if (instance[paramKey] instanceof ComponentInput) { + instance[paramKey].set(paramValue); + continue; + } + for (const key of Object.keys(instance)) { + const val = instance[key]; + if (val instanceof ComponentInput && val.alias === paramKey) { + val.set(paramValue); + break; + } + } + } + + return instance; + } + + async getCachedTemplate(route) { + if (this.templateCache.has(route.id)) { + return this.templateCache.get(route.id); + } + + let templateContent = route.componentClass.definition.template; + if (!templateContent && route.componentClass.definition.templatesPath) { + const response = await fetch( + route.componentClass.definition.templatesPath, + ); + + if (response.ok === false) { + return new Template(` +
+

Failed to load template

+

${response.status} ${response.statusText}

+
+ `); + } + + templateContent = await response.text(); + } + + try { + const template = new Template(templateContent); + this.templateCache.set(route.id, template); + return template; + } catch (error) { + return new Template(` +
+

Failed to compile template

+

${error.message}

+
+ `); + } + } +} diff --git a/OnlyPrompt.Frontend/js/shared.js b/OnlyPrompt.Frontend/js/shared.js new file mode 100644 index 0000000..13d0c7b --- /dev/null +++ b/OnlyPrompt.Frontend/js/shared.js @@ -0,0 +1,160 @@ +import './linq.js' +import { Template } from './template.js'; + +export function formToObject(form) { + const data = new FormData(form); + const object = {}; + data.forEach((value, key) => { + setNestedValue(object, key, value); + }); + return object; +} + +function setNestedValue(obj, path, value) { + path.split('.').asEnumerable() + .isLast() + .forEach((key, isLast) => { + if (isLast) { + obj[key] = value; + } + else { + if (!obj[key]) { + obj[key] = {}; + } + + obj = obj[key]; + } + }); +} + +export async function sendFormAsync(form, url, method) { + url = url || form.action; + method = method || form.method || 'post'; + const data = formToObject(form); + const response = await sendJsonAsync(url, data, method); + if (response.ok && response.redirected) { + window.location.href = response.url; + return null; + } + + const responseText = await response.text(); + if (response.ok == false && handleValidationError(response, responseText, form)) { + return null; + } + + if (response.ok == false) { + handleGenericFormError(response, responseText, form); + return null; + } else { + return responseText.length == 0 ? null : JSON.parse(responseText); + } +} + +export async function sendJsonAsync(url, data, method = 'post') { + const response = await fetch(url, { + method: method.toUpperCase(), + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + return response; +} + +export async function postAndRenderAsync(url, data, template, targetElement) { + const response = await sendJsonAsync(url, data); + if (response.ok) { + const responseText = await response.text(); + targetElement.innerHTML = template.render(responseText.length == 0 ? undefined : JSON.parse(responseText)); + } +} + +export async function postFormAndRenderAsync(url, form, template, targetElement) { + const object = formToObject(form); + const data = await postFormAsync(url, object, template, targetElement); + if (data) { + targetElement.innerHTML = template.render(data); + } +} + +const genericFormErrorTemplate = new Template(` +
+ An error occurred while submitting the form. Please try again later. + {{ $this }} +
+`); + +function handleGenericFormError(response, responseText, form) { + if (!response.ok) { + const html = genericFormErrorTemplate.render(responseText); + form.insertAdjacentHTML('beforeend', html); + } +} + +const validationErrorTemplate = new Template(` +
+
    + @for(error of $this) { +
  • {{error}}
  • + } +
+
+`); + +const unknownInputErrorTemplate = new Template(` +
+

An error occurred with the following fields:

+ @for(field, errors of Object.entries($this)) { +
    + @for(error of errors) { +
  • {{field}}: {{error}}
  • + } +
+ } +
+`); + +function toCamelCase(str) { + str = str.replace(/([-_][a-z])/gi, (match) => { + return match.toUpperCase() + .replace('-', '') + .replace('_', ''); + }); + + str = str[0].toLowerCase() + str.substring(1); + return str; +} + + +function handleValidationError(response, responseText, form) { + if (response.status !== 400) return false; + const responseObject = JSON.parse(responseText); + const unknownInputErrors = {}; + if (responseObject.type === 'https://tools.ietf.org/html/rfc9110#section-15.5.1' && responseObject.errors) { + for (const [field, messages] of Object.entries(responseObject.errors)) { + const input = form.querySelector(`[name="${toCamelCase(field)}"]`); + if (input) { + const parent = input.parentElement; + const errorHtml = validationErrorTemplate.render(messages); + let errorContainer = parent.querySelector('.form-error'); // Check if an error container already exists + if (errorContainer) { + errorContainer.outerHTML = errorHtml; // Replace existing error container + } else { + parent.insertAdjacentHTML('beforeend', errorHtml); + } + } else { + unknownInputErrors[field] = messages; + } + } + + if (Object.keys(unknownInputErrors).length > 0) { + const html = unknownInputErrorTemplate.render(unknownInputErrors); + form.insertAdjacentHTML('beforeend', html); + } + + return true; + } + + return false; +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/js/signup.js b/OnlyPrompt.Frontend/js/signup.js new file mode 100644 index 0000000..2ba88be --- /dev/null +++ b/OnlyPrompt.Frontend/js/signup.js @@ -0,0 +1,12 @@ +import { sendFormAsync } from "./shared.js"; + +async function signupAsync(params) { + const form = document.getElementById('signupForm'); + await sendFormAsync(form); +} + +const signupForm = document.getElementById('signupForm'); +signupForm.addEventListener('submit', async (event) => { + event.preventDefault(); // Prevent the default form submission + await signupAsync(); +}); \ No newline at end of file diff --git a/OnlyPrompt.Frontend/js/template.js b/OnlyPrompt.Frontend/js/template.js new file mode 100644 index 0000000..aed37a4 --- /dev/null +++ b/OnlyPrompt.Frontend/js/template.js @@ -0,0 +1,476 @@ +import "./linq.js"; + +function selectPath(data, expression) { + if (!expression) return data; + try { + return new Function("$data", `with($data) { return (${expression}); }`)( + data, + ); + } catch { + console.error(`Error evaluating Template expression: ${expression}`); + return undefined; + } +} + +class ScopeDict { + constructor(initial = {}) { + this._entries = { ...initial }; + } + + set(name, value) { + if (name.startsWith("$")) { + throw new Error( + `Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`, + ); + } + this._entries[name] = value; + } + + setBuiltin(name, value) { + this._entries[name] = value; + } + + has(name) { + return name in this._entries; + } + + get(name) { + return this._entries[name]; + } + + clone() { + return new ScopeDict(this._entries); + } +} + +function createScopeProxy(data, scope) { + if (data === null || data === undefined || typeof data !== "object") { + scope.setBuiltin("$this", data); + return new Proxy( + {}, + { + get(_target, prop) { + if (prop === "$this") return data; + if (scope.has(prop)) return scope.get(prop); + return undefined; + }, + has(_target, prop) { + return prop === "$this" || scope.has(prop); + }, + }, + ); + } + scope.setBuiltin("$this", data); + return new Proxy(data, { + get(target, prop) { + if (prop === "$this") return data; + if (scope.has(prop)) return scope.get(prop); + return target[prop]; + }, + has(target, prop) { + return prop === "$this" || scope.has(prop) || prop in target; + }, + }); +} + +function skipString(str, i) { + const quote = str[i]; + i++; + while (i < str.length && str[i] !== quote) { + if (str[i] === "\\") i++; + i++; + } + return i; +} + +function findClosingParen(str, openPos) { + let depth = 1; + let i = openPos + 1; + while (i < str.length && depth > 0) { + if (str[i] === "'" || str[i] === '"' || str[i] === "`") { + i = skipString(str, i) + 1; + continue; + } + if (str[i] === "(") depth++; + else if (str[i] === ")") depth--; + if (depth > 0) i++; + } + if (depth !== 0) throw new Error("Unclosed ( in template"); + return i; +} + +function findClosingBrace(str, openPos) { + let depth = 1; + let i = openPos + 1; + while (i < str.length && depth > 0) { + if (str[i] === "'" || str[i] === '"' || str[i] === "`") { + i = skipString(str, i) + 1; + continue; + } + if (str[i] === "{" && str[i + 1] === "{") { + const end = str.indexOf("}}", i + 2); + if (end === -1) throw new Error("Unclosed {{ }} in template"); + i = end + 2; + continue; + } + if (str[i] === "{") depth++; + else if (str[i] === "}") depth--; + if (depth > 0) i++; + } + if (depth !== 0) throw new Error("Unclosed { in template"); + return i; +} + +function formatNumber(value, spec) { + const hasThousands = spec.includes(","); + const dotIdx = spec.indexOf("."); + + let decimals = 0; + if (dotIdx !== -1) { + decimals = spec.length - dotIdx - 1; + } + + let result = value.toFixed(decimals); + + if (hasThousands) { + const parts = result.split("."); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + result = parts.join("."); + } + + if (!spec.includes(".") && !hasThousands) { + const padLength = spec.replace(/[^0]/g, "").length; + if (padLength > 0) { + const isNeg = value < 0; + let abs = Math.abs(Math.round(value)).toString(); + abs = abs.padStart(padLength, "0"); + result = isNeg ? "-" + abs : abs; + } + } + + return result; +} + +function formatDate(date, spec) { + const pad = (n, len = 2) => String(n).padStart(len, "0"); + + const tokens = { + yyyy: date.getFullYear(), + yy: String(date.getFullYear()).slice(-2), + MM: pad(date.getMonth() + 1), + M: date.getMonth() + 1, + dd: pad(date.getDate()), + d: date.getDate(), + HH: pad(date.getHours()), + H: date.getHours(), + hh: pad(date.getHours() % 12 || 12), + h: date.getHours() % 12 || 12, + mm: pad(date.getMinutes()), + m: date.getMinutes(), + ss: pad(date.getSeconds()), + s: date.getSeconds(), + fff: pad(date.getMilliseconds(), 3), + }; + + let result = spec; + for (const [token, value] of Object.entries(tokens).sort( + (a, b) => b[0].length - a[0].length, + )) { + result = result.replaceAll(token, String(value)); + } + + return result; +} + +function createFormatter(formatSpec) { + return (value) => { + if (value == null) return ""; + if (value instanceof Date) return formatDate(value, formatSpec); + if (typeof value === "number") return formatNumber(value, formatSpec); + return String(value); + }; +} + +class StaticTemplatePart { + constructor(text) { + this.text = text; + } + + render(_data) { + return this.text; + } +} + +class VariableTemplatePart { + constructor(contextPath, formatter) { + this.contextPath = contextPath; + this.formatter = formatter || ((x) => x); + } + + render(data) { + return this.formatter(selectPath(data, this.contextPath)); + } +} + +class LetTemplatePart { + constructor(name, expression) { + this.name = name; + this.expression = expression; + } + + declareScoped(scope, data) { + scope.set(this.name, selectPath(data, this.expression)); + } + + render(_data) { + return ""; + } +} + +class ListTemplatePart { + constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) { + this.varNames = varNames; + this.contextPath = contextPath; + this.formatter = formatter || ((x) => x); + this.innerTemplate = innerTemplate; + this.emptyTemplate = emptyTemplate; + } + + render(data) { + const list = this.formatter(selectPath(data, this.contextPath)); + const arr = Array.isArray(list) ? list : Array.from(list); + + if (arr.length === 0) { + return this.emptyTemplate ? this.emptyTemplate.render(data) : ""; + } + + let result = ""; + const count = arr.length; + for (let index = 0; index < count; index++) { + const scope = new ScopeDict(); + const item = arr[index]; + if (this.varNames.length === 1) { + scope.setBuiltin(this.varNames[0], item); + } else { + for (let v = 0; v < this.varNames.length; v++) { + scope.setBuiltin(this.varNames[v], item[v]); + } + } + scope.setBuiltin("$index", index); + scope.setBuiltin("$first", index === 0); + scope.setBuiltin("$last", index === count - 1); + scope.setBuiltin("$even", index % 2 === 0); + scope.setBuiltin("$odd", index % 2 === 1); + scope.setBuiltin("$count", count); + result += this.innerTemplate.render(data, scope); + } + + return result; + } +} + +class ConditionalTemplatePart { + constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) { + this.contextPath = contextPath; + this.formatter = formatter || ((x) => x); + this.condition = condition; + this.trueTemplate = trueTemplate; + this.falseTemplate = falseTemplate; + } + + render(data) { + const selectedData = this.formatter(selectPath(data, this.contextPath)); + if (this.condition(selectedData)) { + return this.trueTemplate ? this.trueTemplate.render(data) : ""; + } else { + return this.falseTemplate ? this.falseTemplate.render(data) : ""; + } + } +} + +export class Template { + constructor(templateString) { + if (templateString != null) { + this.parts = this._parseBlock(templateString); + } + } + + _parseBlock(str) { + const parts = []; + let i = 0; + + while (i < str.length) { + // {{ expression }} or {{ expression:format }} + if (str[i] === "{" && str[i + 1] === "{") { + const end = str.indexOf("}}", i + 2); + if (end === -1) throw new Error("Unclosed {{ }}"); + const content = str.substring(i + 2, end).trim(); + + let expression, + formatter = null; + const colonIdx = content.lastIndexOf(":"); + if (colonIdx > 0) { + const possibleFormat = content.substring(colonIdx + 1).trim(); + if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) { + expression = content.substring(0, colonIdx).trim(); + formatter = createFormatter(possibleFormat); + } else { + expression = content; + } + } else { + expression = content; + } + + parts.push(new VariableTemplatePart(expression, formatter)); + i = end + 2; + continue; + } + + // @for(item of expression){...} @empty{...} + if (str.startsWith("@for(", i)) { + const parenOpen = i + 4; + const parenClose = findClosingParen(str, parenOpen); + const forClause = str.substring(parenOpen + 1, parenClose).trim(); + + const ofIdx = forClause.indexOf(" of "); + if (ofIdx === -1) + throw new Error('@for missing "of": @for(item of expression)'); + const varNames = forClause + .substring(0, ofIdx) + .split(",") + .map((v) => v.trim()); + const expression = forClause.substring(ofIdx + 4).trim(); + + const braceOpen = str.indexOf("{", parenClose + 1); + if (braceOpen === -1) throw new Error("@for missing block"); + const braceClose = findClosingBrace(str, braceOpen); + const blockContent = str.substring(braceOpen + 1, braceClose); + + const innerTemplate = new Template(null); + innerTemplate.parts = this._parseBlock(blockContent); + + let emptyTemplate = null; + let afterBlock = braceClose + 1; + + const remaining = str.substring(afterBlock); + const emptyMatch = remaining.match(/^\s*@empty\s*\{/); + if (emptyMatch) { + const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1; + const emptyBraceClose = findClosingBrace(str, emptyBraceOpen); + const emptyContent = str.substring( + emptyBraceOpen + 1, + emptyBraceClose, + ); + + emptyTemplate = new Template(null); + emptyTemplate.parts = this._parseBlock(emptyContent); + afterBlock = emptyBraceClose + 1; + } + + parts.push( + new ListTemplatePart( + varNames, + expression, + innerTemplate, + emptyTemplate, + ), + ); + i = afterBlock; + continue; + } + + // @if(expression){...}@else{...} + if (str.startsWith("@if(", i)) { + const parenOpen = i + 3; + const parenClose = findClosingParen(str, parenOpen); + const expression = str.substring(parenOpen + 1, parenClose).trim(); + + const trueBraceOpen = str.indexOf("{", parenClose + 1); + if (trueBraceOpen === -1) throw new Error("@if missing block"); + const trueBraceClose = findClosingBrace(str, trueBraceOpen); + const trueContent = str.substring(trueBraceOpen + 1, trueBraceClose); + + const trueTemplate = new Template(null); + trueTemplate.parts = this._parseBlock(trueContent); + + let falseTemplate = null; + let afterBlock = trueBraceClose + 1; + + const remaining = str.substring(afterBlock); + const elseMatch = remaining.match(/^\s*@else\s*\{/); + if (elseMatch) { + const falseBraceOpen = afterBlock + elseMatch[0].length - 1; + const falseBraceClose = findClosingBrace(str, falseBraceOpen); + const falseContent = str.substring( + falseBraceOpen + 1, + falseBraceClose, + ); + + falseTemplate = new Template(null); + falseTemplate.parts = this._parseBlock(falseContent); + afterBlock = falseBraceClose + 1; + } + + parts.push( + new ConditionalTemplatePart( + expression, + (val) => !!val, + trueTemplate, + falseTemplate, + ), + ); + i = afterBlock; + continue; + } + + // @let name = expression (ends at newline or end of string) + if (str.startsWith("@let ", i)) { + const lineEnd = str.indexOf("\n", i); + const end = lineEnd === -1 ? str.length : lineEnd; + const declaration = str.substring(i + 5, end).trim(); + const eqIdx = declaration.indexOf("="); + if (eqIdx === -1) throw new Error("Invalid @let: missing ="); + + const name = declaration.substring(0, eqIdx).trim(); + const expression = declaration.substring(eqIdx + 1).trim(); + + parts.push(new LetTemplatePart(name, expression)); + i = end + 1; + continue; + } + + // Static text — collect until next special token + let end = i; + while (end < str.length) { + if (str[end] === "{" && str[end + 1] === "{") break; + if ( + str[end] === "@" && + (str.startsWith("@for(", end) || + str.startsWith("@if(", end) || + str.startsWith("@let ", end)) + ) + break; + end++; + } + + if (end > i) { + parts.push(new StaticTemplatePart(str.substring(i, end))); + i = end; + } + } + + return parts; + } + + render(data, parentScope = new ScopeDict()) { + const scope = parentScope.clone(); + const proxy = createScopeProxy(data, scope); + + let result = ""; + for (const part of this.parts) { + part.declareScoped?.(scope, proxy); + result += part.render(proxy); + } + return result; + } +} diff --git a/OnlyPrompt.Frontend/js/test-template.mjs b/OnlyPrompt.Frontend/js/test-template.mjs new file mode 100644 index 0000000..7a0fe85 --- /dev/null +++ b/OnlyPrompt.Frontend/js/test-template.mjs @@ -0,0 +1,57 @@ +import { Template } from './template.js'; + +// 1. Basic variable +let t = new Template('Hello {{ name }}!'); +console.log('1:', t.render({ name: 'World' })); + +// 2. Format specifier +t = new Template('Price: {{ price:0.00 }}'); +console.log('2:', t.render({ price: 9.5 })); + +// 3. @let +t = new Template('@let short = user.name\nHello {{ short }}!'); +console.log('3:', t.render({ user: { name: 'Alice' } })); + +// 4. @for with separator via @if(!$first) +t = new Template('@for(item of items){@if(!$first){, }{{ item }}}'); +console.log('4:', t.render({ items: ['a', 'b', 'c'] })); + +// 5/6. @if / @else +t = new Template('@if(admin){Admin}@else{User}'); +console.log('5:', t.render({ admin: true })); +console.log('6:', t.render({ admin: false })); + +// 7. Expression +t = new Template('{{ name.toUpperCase() }}'); +console.log('7:', t.render({ name: 'alice' })); + +// 8. $index +t = new Template('@for(item of items){ {{ $index }}: {{ item }} }'); +console.log('8:', t.render({ items: ['x', 'y'] })); + +// 9. Multiple @let +t = new Template('@let a = first\n@let b = second\n{{ a }} and {{ b }}'); +console.log('9:', t.render({ first: 'one', second: 'two' })); + +// 10. Nested for +t = new Template('@for(group of groups){ @for(item of group.items){ {{ item }} } }'); +console.log('10:', t.render({ groups: [{ items: [1, 2] }, { items: [3, 4] }] })); + +// 11. @empty block +t = new Template('@for(item of items){ {{ item }} } @empty{No items}'); +console.log('11a:', t.render({ items: [] })); +console.log('11b:', t.render({ items: ['x'] })); + +// 12. $first, $last, $even, $odd, $count +t = new Template('@for(item of items){[{{ item }} first={{ $first }} last={{ $last }} even={{ $even }} odd={{ $odd }} count={{ $count }}]}'); +console.log('12:', t.render({ items: ['a', 'b', 'c'] })); + + +// 13. $this to access the contexts root object +t = new Template('@for(item of $this){ {{ item }} } @empty{No items}'); +console.log('13a:', t.render([])); +console.log('13b:', t.render(['x'])); + +// 14. @for with multiple variables (destructuring) +t = new Template('@for(key, value of Object.entries(obj)){@if(!$first){, }{{ key }}={{ value }}}'); +console.log('14:', t.render({ obj: { a: 1, b: 2, c: 3 } })); \ No newline at end of file diff --git a/OnlyPrompt.Frontend/js/test.js b/OnlyPrompt.Frontend/js/test.js new file mode 100644 index 0000000..95b35ae --- /dev/null +++ b/OnlyPrompt.Frontend/js/test.js @@ -0,0 +1,55 @@ +import { + initRouter, + Route, + ViewChild, + Component, + ComponentInput, + ComponentDefinition, +} from "./pwa.js"; + +class TestComponent extends Component { + static get definition() { + return new ComponentDefinition({ + name: "test-component", + template: `
+

Test Component

+

This is a test component.

+ +
`, + style: ` + .red-button { + background-color: red; + color: white; + border: none; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + } + + .green-button { + background-color: green; + color: white; + border: none; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + } + ` + }); + } + + count = new ComponentInput(0); + + incrementCount() { + this.count++; + } +} + +const route1 = new Route("/counter", TestComponent); +const route2 = new Route("/counter/:count", TestComponent); +initRouter("routerOutlet", "styleOutlet", [route1, route2]); diff --git a/OnlyPrompt.Frontend/js/utils.js b/OnlyPrompt.Frontend/js/utils.js new file mode 100644 index 0000000..e9cc0dc --- /dev/null +++ b/OnlyPrompt.Frontend/js/utils.js @@ -0,0 +1,204 @@ +// ─── parseDate (C# DateTime.ParseExact style) ─── +// Parses a date string using an exact format pattern. +// Supported tokens: +// yyyy — 4-digit year +// yy — 2-digit year (assumes 2000+) +// MM — 2-digit month (01–12) +// M — 1 or 2-digit month +// dd — 2-digit day (01–31) +// d — 1 or 2-digit day +// HH — 2-digit hour 24h (00–23) +// H — 1 or 2-digit hour 24h +// hh — 2-digit hour 12h (01–12) +// h — 1 or 2-digit hour 12h +// mm — 2-digit minute (00–59) +// m — 1 or 2-digit minute +// ss — 2-digit second (00–59) +// s — 1 or 2-digit second +// fff — 3-digit milliseconds +// ff — 2-digit (tenths + hundredths) +// f — 1-digit (tenths) +// tt — AM/PM +// t — A/P +// Z — literal 'Z' (UTC marker) +// K — timezone offset (+HH:mm, -HH:mm, or Z) +// +// All other characters are matched literally. +// Returns Date on success, null on failure. + +const TOKEN_ORDER = [ + "yyyy", "yy", + "MM", "M", + "dd", "d", + "HH", "H", "hh", "h", + "mm", "m", + "ss", "s", + "fff", "ff", "f", + "tt", "t", + "K", "Z", +]; + +const TOKEN_PATTERNS = { + yyyy: "(\\d{4})", + yy: "(\\d{2})", + MM: "(\\d{2})", + M: "(\\d{1,2})", + dd: "(\\d{2})", + d: "(\\d{1,2})", + HH: "(\\d{2})", + H: "(\\d{1,2})", + hh: "(\\d{2})", + h: "(\\d{1,2})", + mm: "(\\d{2})", + m: "(\\d{1,2})", + ss: "(\\d{2})", + s: "(\\d{1,2})", + fff: "(\\d{3})", + ff: "(\\d{2})", + f: "(\\d{1})", + tt: "(AM|PM|am|pm)", + t: "([AaPp])", + K: "(Z|[+-]\\d{2}:\\d{2})", + Z: "(Z)", +}; + +function buildFormatRegex(format) { + const groups = []; + let regexStr = ""; + let i = 0; + + while (i < format.length) { + let matched = false; + for (const token of TOKEN_ORDER) { + if (format.startsWith(token, i)) { + groups.push(token); + regexStr += TOKEN_PATTERNS[token]; + i += token.length; + matched = true; + break; + } + } + if (!matched) { + // Escape literal character + regexStr += format[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + i++; + } + } + + return { regex: new RegExp("^" + regexStr + "$"), groups }; +} + +export function parseDate(input, format) { + if (input == null || format == null) return null; + if (input instanceof Date) return input; + + const str = String(input); + const { regex, groups } = buildFormatRegex(format); + const match = str.match(regex); + if (!match) return null; + + let year = 0, month = 1, day = 1; + let hour = 0, minute = 0, second = 0, ms = 0; + let isPM = false, isAM = false; + let tzOffset = null; // minutes + + for (let g = 0; g < groups.length; g++) { + const token = groups[g]; + const val = match[g + 1]; + + switch (token) { + case "yyyy": year = parseInt(val, 10); break; + case "yy": year = 2000 + parseInt(val, 10); break; + case "MM": + case "M": month = parseInt(val, 10); break; + case "dd": + case "d": day = parseInt(val, 10); break; + case "HH": + case "H": hour = parseInt(val, 10); break; + case "hh": + case "h": hour = parseInt(val, 10); break; + case "mm": + case "m": minute = parseInt(val, 10); break; + case "ss": + case "s": second = parseInt(val, 10); break; + case "fff": ms = parseInt(val, 10); break; + case "ff": ms = parseInt(val, 10) * 10; break; + case "f": ms = parseInt(val, 10) * 100; break; + case "tt": isPM = val.toUpperCase() === "PM"; isAM = val.toUpperCase() === "AM"; break; + case "t": isPM = val.toUpperCase() === "P"; isAM = val.toUpperCase() === "A"; break; + case "Z": tzOffset = 0; break; + case "K": + if (val === "Z") { + tzOffset = 0; + } else { + const sign = val[0] === "+" ? 1 : -1; + const [h, m] = val.substring(1).split(":").map(Number); + tzOffset = sign * (h * 60 + m); + } + break; + } + } + + // 12h → 24h conversion + if (isPM && hour < 12) hour += 12; + if (isAM && hour === 12) hour = 0; + + if (tzOffset !== null) { + // Build UTC date, apply offset + const utcMs = Date.UTC(year, month - 1, day, hour, minute, second, ms); + return new Date(utcMs - tzOffset * 60000); + } + + return new Date(year, month - 1, day, hour, minute, second, ms); +} + +export function formatDate(date, spec) { + const pad = (n, l = 2) => String(n).padStart(l, "0"); + const tokens = { + yyyy: date.getFullYear(), + yy: String(date.getFullYear()).slice(-2), + MM: pad(date.getMonth() + 1), + M: date.getMonth() + 1, + dd: pad(date.getDate()), + d: date.getDate(), + HH: pad(date.getHours()), + H: date.getHours(), + hh: pad(date.getHours() % 12 || 12), + h: date.getHours() % 12 || 12, + mm: pad(date.getMinutes()), + m: date.getMinutes(), + ss: pad(date.getSeconds()), + s: date.getSeconds(), + fff: pad(date.getMilliseconds(), 3), + }; + let result = spec; + for (const [tok, val] of Object.entries(tokens).sort( + (a, b) => b[0].length - a[0].length, + )) { + result = result.replaceAll(tok, String(val)); + } + return result; +} + +// ─── Input transform presets ─── + +export const IntInput = { transform: parseInt }; +export const FloatInput = { transform: parseFloat }; +export const BoolInput = { + transform: (v) => { + if (typeof v === "boolean") return v; + if (typeof v === "string") { + if (v.toLowerCase() === "true") return true; + if (v.toLowerCase() === "false") return false; + } + return Boolean(v); + }, +}; + +export function DateInput(format) { + return { + transform: (v) => parseDate(v, format), + }; +} + +export const IsoDateInput = DateInput("yyyy-MM-ddTHH:mm:ssK"); \ No newline at end of file diff --git a/OnlyPrompt.Frontend/login.html b/OnlyPrompt.Frontend/login.html new file mode 100644 index 0000000..7432682 --- /dev/null +++ b/OnlyPrompt.Frontend/login.html @@ -0,0 +1,78 @@ + + + + + + + + + + + + OnlyPrompt - Login + + + + + + + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/marketplace.html b/OnlyPrompt.Frontend/marketplace.html new file mode 100644 index 0000000..798fad4 --- /dev/null +++ b/OnlyPrompt.Frontend/marketplace.html @@ -0,0 +1,237 @@ + + + + + + + + OnlyPrompt - Marketplace + + + + + + + + + + +
+ + + +
+ +
+ +
+ + +
+

Marketplace

+

Browse and discover high-quality AI prompts

+
+ + +
+
+ + + + + + + +
+ +
+ + +
+ + +
+ Creative Blog Post Generator +
+

Creative Blog Post Generator

+
@JaneDoe
+

Generate engaging blog posts in minutes to captivate your audience.

+
+ 4.8 + (124 reviews) +
+
$19.99
+
+ + +
+
+
+ + +
+ Python Code Assistant +
+

Python Code Assistant

+
@JaneDoe
+

Efficiently debug and write Python code with AI assistance.

+
+ 4.7 + (98 reviews) +
+
$19.99
+
+ + +
+
+
+ + +
+ Digital Art Style Guide +
+

Digital Art Style Guide

+
@JaneDoe
+

Create stunning digital art styles with this comprehensive guide.

+
+ 4.8 + (156 reviews) +
+
$19.99
+
+ + +
+
+
+ + +
+ Marketing Copywriter +
+

Marketing Copywriter

+
@JaneDoe
+

Generate engaging marketing copy in minutes.

+
+ 4.8 + (112 reviews) +
+
$19.99
+
+ + +
+
+
+ + +
+ Midjourney Image Prompts +
+

Midjourney Image Prompts

+
@JaneDoe
+

Curated prompts for stunning Midjourney images.

+
+ 4.7 + (203 reviews) +
+
$19.99
+
+ + +
+
+
+ + +
+ UI Design Assistant +
+

UI Design Assistant

+
@JaneDoe
+

Generate UI components and design systems with AI.

+
+ 4.9 + (87 reviews) +
+
$19.99
+
+ + +
+
+
+ +
+ +
+
+
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/post-detail.html b/OnlyPrompt.Frontend/post-detail.html new file mode 100644 index 0000000..886341d --- /dev/null +++ b/OnlyPrompt.Frontend/post-detail.html @@ -0,0 +1,114 @@ + + + + + + + + OnlyPrompt - Post Detail + + + + + + + + + +
+ + + +
+ +
+ +
+
+ +
+

Marketing Guru: Campaign Strategist

+ +
+ 2,109 Saves + 14.5k Views +
+
+ + +
+

PROMPT

+
+

You are an expert marketing strategist and copywriter. I will provide a product name, target audience, and key benefits. Your goal is to create a comprehensive marketing campaign plan, including:

+
    +
  • Campaign objectives
  • +
  • Target Audience: [User Input]
  • +
  • Expected Output: A detailed marketing plan with channel strategy, messaging, and sample copy
  • +
  • Budget considerations and KPIs
  • +
+
+
+ + +
+
+ 4.9 + 4.8 (241 Ratings) +
+ +
+ + +
+

EXAMPLE OUTPUT

+
+

Generated Strategy 16px

+
+

Output: EcoWare Campaign

+

Campaign Overview
Target Persona: Sarah Jenkins, 28, Eco-conscious

+

Key Messaging
Channel Strategy
Sample Copy
Social
Email

+
+ + +
+
+ + +
+ +
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/profile.html b/OnlyPrompt.Frontend/profile.html new file mode 100644 index 0000000..9d4abad --- /dev/null +++ b/OnlyPrompt.Frontend/profile.html @@ -0,0 +1,111 @@ + + + + + + + + OnlyPrompt - Profile + + + + + + + + + +
+ + + +
+ +
+ +
+ +
+ + + +
+

Sunny the Cat

+
+ @sunny_the_cat +
+ +
+ 🐾 Cat prompt creator | Nap enthusiast | AI Cat Content Curator
+ Chasing lasers and building purrfect prompts. +
+ +
Cat City, Dreamland 🐈
+
+ +
+ + +
+ +
+ + + +
+ +
+ +
+
Galactic Catventure
+
A cosmic journey of a curious cat exploring the stars.
+
+ 128 + 15 +
+
+
+ +
+ +
+
Minimalist Cat Logo
+
Sleek logo design for feline fans.
+
+ 54 + 6 +
+
+
+ +
+ +
+
+
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/settings.html b/OnlyPrompt.Frontend/settings.html new file mode 100644 index 0000000..3e18038 --- /dev/null +++ b/OnlyPrompt.Frontend/settings.html @@ -0,0 +1,197 @@ + + + + + + + + OnlyPrompt - Settings + + + + + + + + + +
+ + + +
+ +
+ +
+
+
+

Settings

+

Manage your account preferences and profile.

+
+ + +
+ + + +
+ + +
+
+
+ +
+ Avatar + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/sidebar.html b/OnlyPrompt.Frontend/sidebar.html new file mode 100644 index 0000000..7bcd816 --- /dev/null +++ b/OnlyPrompt.Frontend/sidebar.html @@ -0,0 +1,80 @@ + + + diff --git a/OnlyPrompt.Frontend/signup.html b/OnlyPrompt.Frontend/signup.html new file mode 100644 index 0000000..b2d726f --- /dev/null +++ b/OnlyPrompt.Frontend/signup.html @@ -0,0 +1,80 @@ + + + + + + + + + + + + + OnlyPrompt - Login + + + + + + + + + +
+ + +
+ + + + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/test.html b/OnlyPrompt.Frontend/test.html new file mode 100644 index 0000000..2a508a9 --- /dev/null +++ b/OnlyPrompt.Frontend/test.html @@ -0,0 +1,28 @@ + + + + + + + OnlyPrompt - Test + + +
+ +
+ + + +
+

Test Site

+ Go to count 1 + Go to count 2 + Go to count 3 +
+ Hello There +
+
+ + + + \ No newline at end of file diff --git a/OnlyPrompt.Frontend/topbar.html b/OnlyPrompt.Frontend/topbar.html new file mode 100644 index 0000000..d714f4c --- /dev/null +++ b/OnlyPrompt.Frontend/topbar.html @@ -0,0 +1,27 @@ + + +
+ + +
+ + + + + +
+
\ No newline at end of file diff --git a/OnlyPrompt.slnx b/OnlyPrompt.slnx new file mode 100644 index 0000000..6080465 --- /dev/null +++ b/OnlyPrompt.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..296df6c --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# OnlyPrompt - AI Prompt Marketplace + +## Description +OnlyPrompt is a frontend web applications where people can publish and sell their high-quality AI prompts. User can browse, filter, rate and unlock premium features such as private chats with creators for exclusive AI tips. + +This project is built with with HTML, CSS and Java Script. + +## Special Features +- 📝 Create and publish new prompts +- 🔍 Live search and category filtering +- 💬 Premium chat simulation with creators +- 🔒 Unlock premium prompts (payment simulation) +- ⭐ Rating system +- ❤️ Save favorites (LocalStorage) +- 📱 Fully responsive design (mobile & desktop) +- 🌐 External API integration (e.g., RandomUser API for creator profiles) +- 💾 Client-side data persistence +- 🔄 Basic server communication (optional file-based backend) + + +## Installment (for local use) +1. Clone the repository +2. Create an .env file and copy the example below into it +3. Run `docker compose -f docker-compose.yml --env-file .env up -d --build` +4. Website will be available at `http://localhost:1801` + +### Example envfile +``` +PORT_PREFIX=180 +DB_USER=onlyprompt +DB_NAME=onlyprompt +DB_PASSWORD=onlyprompt +``` + +## Technologies, Libraries, Frameworks +- HTML5 (Structure) +- CSS3 + Bootstrap + PureCSS (Styling & responsive layout) +- Vanilla JavaScript (Application logic) +- JavaScript Libraries Axios, JQuery +- ASP.NET Core (Backend) +- Postgres (Backend Datastorage) +- Docker (Deployment) + +## Group members and their roles +- Thuvaraka Yogarajah +- Isabelle Nachbaur +- Florian Klessascheck +- Abdul Geylani Semiz + + diff --git a/docker-compose.dcproj b/docker-compose.dcproj new file mode 100644 index 0000000..149eba7 --- /dev/null +++ b/docker-compose.dcproj @@ -0,0 +1,17 @@ + + + + 2.1 + Linux + ee26a7fb-2dee-47a7-964c-12ef03b1114a + LaunchBrowser + {Scheme}://localhost:{ServicePort}/scalar + documentapi + + + + + + + + \ No newline at end of file diff --git a/docker-compose.debug.yml b/docker-compose.debug.yml new file mode 100644 index 0000000..54ca625 --- /dev/null +++ b/docker-compose.debug.yml @@ -0,0 +1,15 @@ +services: + database: + image: postgres:latest + restart: unless-stopped + environment: + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_USER=${DB_USER} + - POSTGRES_DB=${DB_NAME} + volumes: + - 'database_data:/var/lib/postgresql' + ports: + - "${PORT_PREFIX}3:5432" + +volumes: + database_data: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ee25dcc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + onlyprompt.backend: + image: ${DOCKER_REGISTRY-}onlypromptbackend + restart: unless-stopped + build: + context: . + dockerfile: OnlyPrompt.Backend/Dockerfile + ports: + - "${PORT_PREFIX}1:8080" + - "${PORT_PREFIX}2:8081" + environment: + JWT__ISSUER: "https://onlyprompt.com" + CONNECTIONSTRINGS__DEFAULTCONNECTION: "Include Error Detail=true;User ID=${DB_USER};Password=${DB_PASSWORD};Host=database;Port=5432;Database=${DB_NAME};Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;" + ASPNETCORE_URLS: "http://*:8080" + + database: + image: postgres:latest + restart: unless-stopped + environment: + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_USER=${DB_USER} + - POSTGRES_DB=${DB_NAME} + volumes: + - 'database_data:/var/lib/postgresql' + +volumes: + database_data: \ No newline at end of file