Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eaea29513 | ||
|
|
806ad23315 | ||
|
|
6abc36bc9b | ||
|
|
3c9f7323ba | ||
|
|
d466365348 | ||
|
|
22aabc8f27 | ||
| 3da8813c41 | |||
| 02b8a75947 | |||
| d2d5c0c66c | |||
| 4105659b0b | |||
| da52852fdf | |||
| 7b23b296d3 | |||
| a4a5d03f9f | |||
| 11e973ce61 |
30
.dockerignore
Normal file
@ -0,0 +1,30 @@
|
||||
**/.classpath
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
!**/.gitignore
|
||||
!.git/HEAD
|
||||
!.git/config
|
||||
!.git/packed-refs
|
||||
!.git/refs/heads/**
|
||||
428
.gitignore
vendored
Normal file
@ -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
|
||||
4
OnlyPrompt.Backend/ApiModels/Auth/Models.cs
Normal file
@ -0,0 +1,4 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Auth
|
||||
{
|
||||
public record ApiUser(Guid Id, string UserName, string Email, string[] Roles);
|
||||
}
|
||||
8
OnlyPrompt.Backend/ApiModels/Auth/Requests.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using OnlyPrompt.Backend.ApiModels.Validators;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OnlyPrompt.Backend.ApiModels.Auth
|
||||
{
|
||||
public record ApiLoginRequest(string UserNameOrEmail, string Password);
|
||||
public record ApiRegisterRequest([MaxLength(100)] string DisplayName, [MaxLength(100)][NoWhitespace] string? UserName, string Email, string Password);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/Category/Models.cs
Normal file
@ -0,0 +1,5 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Category
|
||||
{
|
||||
public record ApiCategory(Guid Id, string Name, string Slug, string? Description);
|
||||
public record ApiMinimalCategory(string Name, string Slug);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/Category/Requests.cs
Normal file
@ -0,0 +1,5 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Category
|
||||
{
|
||||
public record ApiCreateCategoryRequest(string Name, string? Slug, string? Description);
|
||||
public record ApiUpdateCategoryRequest(string? Name, string? Slug, string? Description);
|
||||
}
|
||||
8
OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public enum FeedSortType
|
||||
{
|
||||
Date,
|
||||
Rating
|
||||
}
|
||||
}
|
||||
6
OnlyPrompt.Backend/ApiModels/Prompt/Models.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public record ApiPrompt(Guid Id, string Title, string Description, string Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, int? TierLevel, string? TierName, double? AverageRating);
|
||||
public record ApiMinimalPrompt(Guid Id, string Title, DateTime TimeStamp, Guid CreatorId, string CreatorName, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess);
|
||||
public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating);
|
||||
}
|
||||
6
OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs
Normal file
@ -0,0 +1,6 @@
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
|
||||
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||
{
|
||||
public record ApiCreatePromptRequest(string Title, string Description, string Content, Identifier Category, int? SubscriptionTier, string Slug);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/Subscription/Models.cs
Normal file
@ -0,0 +1,5 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Subscription
|
||||
{
|
||||
public record ApiSubscriptionTier(Guid Id, string Name, int Level, decimal MonthlyPrice, string? Description);
|
||||
public record ApiSubscription(Guid SubscribedToId, string SubscribedToName, ApiSubscriptionTier? CurrentTier);
|
||||
}
|
||||
5
OnlyPrompt.Backend/ApiModels/Subscription/Requests.cs
Normal file
@ -0,0 +1,5 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.Subscription
|
||||
{
|
||||
public record ApiCreateSubscriptionTierRequest(string Name, decimal MonthlyPrice, int Level, string? Description);
|
||||
public record ApiUpdateSubscriptionTierRequest(string? Name, decimal? MonthlyPrice, int? Level, string? Description);
|
||||
}
|
||||
4
OnlyPrompt.Backend/ApiModels/UserProfile/Models.cs
Normal file
@ -0,0 +1,4 @@
|
||||
namespace OnlyPrompt.Backend.ApiModels.UserProfile
|
||||
{
|
||||
public record ApiUserProfile(string DisplayName, string Slug, string? Bio, string AvatarUrl, string? Specialities, double AverageRating, int Subscribers);
|
||||
}
|
||||
8
OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using OnlyPrompt.Backend.ApiModels.Validators;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OnlyPrompt.Backend.ApiModels.UserProfile
|
||||
{
|
||||
public record ApiUpdateProfileRequest([MaxLength(100)] string? DisplayName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic);
|
||||
public record ApiCreateReviewRequest(string? Comment, [Range(1, 5)] int Rating);
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
83
OnlyPrompt.Backend/Controllers/AdminController.cs
Normal file
@ -0,0 +1,83 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
|
||||
namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/v1/admin")]
|
||||
[Authorize(Roles = ModelConstants.AdminRole)]
|
||||
public class AdminController : BaseController
|
||||
{
|
||||
public AdminController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
|
||||
{
|
||||
}
|
||||
|
||||
private Task<UserModel?> GetNonAdminUserAsync(Guid id, bool isSysAdmin = false)
|
||||
{
|
||||
return _db.Users.FirstOrDefaultAsync(
|
||||
u => u.Id == id
|
||||
&& (isSysAdmin || u.Roles.Contains(ModelConstants.AdminRole) == false)
|
||||
&& u.Roles.Contains(ModelConstants.SysAdminRole) == false
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("users/{userId}/disable")]
|
||||
public async Task<Results<Ok, NotFound<string>>> DisableUserAsync(Guid userId)
|
||||
{
|
||||
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
|
||||
if (user is null)
|
||||
return TypedResults.NotFound("User not found.");
|
||||
|
||||
user.IsLockoutEnabled = true;
|
||||
await _db.SaveChangesAsync();
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("users/{userId}/enable")]
|
||||
public async Task<Results<Ok, NotFound<string>>> EnableUserAsync(Guid userId)
|
||||
{
|
||||
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
|
||||
if (user is null)
|
||||
return TypedResults.NotFound("User not found.");
|
||||
|
||||
user.IsLockoutEnabled = false;
|
||||
await _db.SaveChangesAsync();
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPut("users/{userId}/roles/{role}")]
|
||||
public async Task<Results<Ok, NotFound<string>>> UpdateUserRolesAsync(Guid userId, string role)
|
||||
{
|
||||
if (ModelConstants.AllRoles.Contains(role) == false)
|
||||
return TypedResults.NotFound($"No such role '{role}'");
|
||||
|
||||
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
|
||||
if (user is null)
|
||||
return TypedResults.NotFound("User not found.");
|
||||
|
||||
user.Roles.Add(role);
|
||||
await _db.SaveChangesAsync();
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("users/{userId}/roles/{role}")]
|
||||
public async Task<Results<Ok, NotFound<string>>> RemoveUserRoleAsync(Guid userId, string role)
|
||||
{
|
||||
if (ModelConstants.AllRoles.Contains(role) == false)
|
||||
return TypedResults.NotFound($"No such role '{role}'");
|
||||
|
||||
var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole));
|
||||
if (user is null)
|
||||
return TypedResults.NotFound("User not found.");
|
||||
|
||||
user.Roles.Remove(role);
|
||||
await _db.SaveChangesAsync();
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
109
OnlyPrompt.Backend/Controllers/AuthController.cs
Normal file
@ -0,0 +1,109 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.ApiModels.Auth;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using OnlyPrompt.Backend.Services.Jwt;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
|
||||
namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/v1/auth")]
|
||||
public class AuthController : BaseController
|
||||
{
|
||||
private static readonly CookieOptions AuthCookieOptions = new CookieOptions { Secure = true, HttpOnly = true, IsEssential = true };
|
||||
private readonly IPasswordHasher<UserModel> _passwordHasher;
|
||||
private readonly ITokenService _jwtService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(OnlyPromptContext db, IPasswordHasher<UserModel> passwordHasher, IMapper mapper, ILogger<AuthController> logger, ITokenService jwtService) : base(db, mapper)
|
||||
{
|
||||
_passwordHasher=passwordHasher;
|
||||
_logger=logger;
|
||||
_jwtService=jwtService;
|
||||
}
|
||||
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login")]
|
||||
public async Task<Results<Ok, RedirectHttpResult, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request, [FromQuery]string redirect = null)
|
||||
{
|
||||
var user = await FindUserAsync(request.UserNameOrEmail);
|
||||
if (user is null)
|
||||
return TypedResults.NotFound("User not found");
|
||||
|
||||
var verificationResult = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
|
||||
if (verificationResult == PasswordVerificationResult.Failed)
|
||||
return TypedResults.NotFound("User not found"); // Don't reveal that the user exists
|
||||
|
||||
if (user.IsLockoutEnabled)
|
||||
return TypedResults.BadRequest("User is locked out"); // Don't reveal that the user exists
|
||||
|
||||
var token = _jwtService.BuildToken(user, out var validUntil);
|
||||
this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil));
|
||||
if (string.IsNullOrEmpty(redirect) == false)
|
||||
return TypedResults.Redirect(redirect, false);
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
public async Task<Results<RedirectHttpResult, ValidationProblem, Ok<ApiUser>>> RegisterAsync([FromBody] ApiRegisterRequest request, [FromQuery] string redirect = null)
|
||||
{
|
||||
var existingUser = await FindUserAsync(request.UserName, request.Email);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
if (existingUser.UserName == request.UserName)
|
||||
errors.Add(nameof(request.UserName), ["Username is already taken"]);
|
||||
|
||||
if (existingUser.Email == request.Email)
|
||||
errors.Add(nameof(request.Email), ["Email is already registered"]);
|
||||
|
||||
return TypedResults.ValidationProblem(errors);
|
||||
}
|
||||
|
||||
var id = Guid.CreateVersion7();
|
||||
var slug = await SlugHelper.GenerateUniqueSlugAsync(request.UserName, s => _db.UserProfiles.AnyAsync(up => up.Slug == s), 32);
|
||||
var avatarUrl = $"https://api.dicebear.com/9.x/bottts/svg?seed={id}";
|
||||
var newUser = new UserModel
|
||||
{
|
||||
Id = id,
|
||||
Profile = new UserProfileModel
|
||||
{
|
||||
AvatarUrl = avatarUrl,
|
||||
DisplayName = request.DisplayName,
|
||||
Slug = slug,
|
||||
},
|
||||
Roles = [ModelConstants.UserRole],
|
||||
PasswordHash = null,
|
||||
UserName = request.UserName ?? request.Email,
|
||||
Email = request.Email,
|
||||
IsLockoutEnabled = false,
|
||||
};
|
||||
|
||||
newUser.PasswordHash = _passwordHasher.HashPassword(newUser, request.Password);
|
||||
_db.Users.Add(newUser);
|
||||
await _db.SaveChangesAsync();
|
||||
if(string.IsNullOrEmpty(redirect) == false)
|
||||
return TypedResults.Redirect(redirect, false);
|
||||
|
||||
return TypedResults.Ok(_mapper.Map<ApiUser>(newUser));
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("logout")]
|
||||
public RedirectHttpResult Logout()
|
||||
{
|
||||
this.Response.Cookies.Delete("jwt", AuthCookieOptions);
|
||||
return TypedResults.Redirect("login");
|
||||
}
|
||||
}
|
||||
}
|
||||
45
OnlyPrompt.Backend/Controllers/BaseController.cs
Normal file
@ -0,0 +1,45 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
|
||||
namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
public abstract class BaseController : Controller
|
||||
{
|
||||
protected OnlyPromptContext _db;
|
||||
protected IMapper _mapper;
|
||||
|
||||
public BaseController(OnlyPromptContext db, IMapper mapper)
|
||||
{
|
||||
_db=db;
|
||||
_mapper=mapper;
|
||||
}
|
||||
|
||||
public Task<UserModel?> FindUserAsync(Guid id) => _db.Users.FirstOrDefaultAsync(x => x.Id == id);
|
||||
public Task<UserModel?> FindUserAsync(string userName, string email) => _db.Users.FirstOrDefaultAsync(x => x.Email == email || x.UserName == userName);
|
||||
public Task<UserModel?> FindUserAsync(string emailOrUsername) => _db.Users.FirstOrDefaultAsync(x => x.Email == emailOrUsername || x.UserName == emailOrUsername);
|
||||
|
||||
public async Task<UserProfileModel?> GetUserProfileAsync()
|
||||
{
|
||||
var id = User.GetUserId();
|
||||
if (id.HasValue == false)
|
||||
return null;
|
||||
|
||||
var profile = await _db.UserProfiles.FirstOrDefaultAsync(x => x.Id == id.Value);
|
||||
return profile;
|
||||
}
|
||||
|
||||
public async Task<UserModel?> GetUserAsync()
|
||||
{
|
||||
var id = User.GetUserId();
|
||||
if (id.HasValue == false)
|
||||
return null;
|
||||
|
||||
var user = await _db.Users.FindAsync(id.Value);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
123
OnlyPrompt.Backend/Controllers/CategoryController.cs
Normal file
@ -0,0 +1,123 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.ApiModels.Category;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/v1/categories")]
|
||||
[Authorize(Roles = ModelConstants.AdminRole, AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
|
||||
public class CategoryController : BaseController
|
||||
{
|
||||
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
{ nameof(CategoryModel.Slug), new[] { "Slug already exists." } }
|
||||
});
|
||||
|
||||
public CategoryController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet("minimal")]
|
||||
[Authorize(Roles = ModelConstants.UserRole)]
|
||||
public async Task<ApiMinimalCategory[]> GetMinimalCategoriesAsync()
|
||||
{
|
||||
var categories = await _db.Categories
|
||||
.ProjectTo<ApiMinimalCategory>(_mapper.ConfigurationProvider)
|
||||
.ToArrayAsync();
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize(Roles = ModelConstants.UserRole)]
|
||||
public async Task<ApiCategory[]> GetCategoriesAsync([Range(0, double.MaxValue)][FromQuery] int offset = 0, [Range(1, 100)][FromQuery] int limit = 20)
|
||||
{
|
||||
var categories = await _db.Categories
|
||||
.OrderBy(c => c.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ProjectTo<ApiCategory>(_mapper.ConfigurationProvider)
|
||||
.ToArrayAsync();
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<Results<Ok<ApiCategory>, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request)
|
||||
{
|
||||
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug);
|
||||
if (exists)
|
||||
return SlugExistsProblem;
|
||||
|
||||
var model = _mapper.Map<CategoryModel>(request);
|
||||
if (string.IsNullOrWhiteSpace(model.Slug))
|
||||
model.Slug = await SlugHelper.GenerateUniqueSlugAsync(request.Name, slug => _db.Categories.AnyAsync(c => c.Slug == slug), ModelConstants.MaxSlugLength);
|
||||
|
||||
_db.Categories.Add(model);
|
||||
await _db.SaveChangesAsync();
|
||||
return TypedResults.Ok(_mapper.Map<ApiCategory>(model));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<Results<Ok<ApiCategory>, NotFound<string>, ValidationProblem>> UpdateCategoryAsync(Identifier id, [FromBody] ApiUpdateCategoryRequest request)
|
||||
{
|
||||
var category = await _db.Categories.FindByIdentifierAsync(id);
|
||||
if (category is null)
|
||||
return TypedResults.NotFound("Category not found");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) == false)
|
||||
category.Name = request.Name;
|
||||
|
||||
if(string.IsNullOrWhiteSpace(request.Description) == false)
|
||||
category.Description = request.Description;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Slug) == false && request.Slug != category.Slug)
|
||||
{
|
||||
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug && c.Id != category.Id);
|
||||
if (exists)
|
||||
return SlugExistsProblem;
|
||||
|
||||
category.Slug = request.Slug;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return TypedResults.Ok(_mapper.Map<ApiCategory>(category));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<Results<NoContent, BadRequest<string>, NotFound<string>>> DeleteCategoryAsync(Identifier id, [FromQuery] Identifier? replaceWith = null)
|
||||
{
|
||||
var hasPrompts = await _db.Prompts.AnyAsync(p => p.CategoryId == id.Id);
|
||||
if (hasPrompts)
|
||||
{
|
||||
if (replaceWith.HasValue == false)
|
||||
return TypedResults.BadRequest("Category has associated prompts. Provide a replacement category to reassign them to.");
|
||||
|
||||
var replacement = await _db.Categories.FindByIdentifierAsync(replaceWith.Value);
|
||||
if(replacement is null)
|
||||
return TypedResults.NotFound("Replacement category not found.");
|
||||
|
||||
await _db.Prompts.Where(p => p.CategoryId == id.Id)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(p => p.CategoryId, replacement.Id));
|
||||
}
|
||||
|
||||
var count = await _db.Categories.OfIdentifer(id)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (count == 0)
|
||||
return TypedResults.NotFound("Category not found");
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
73
OnlyPrompt.Backend/Controllers/FeedController.cs
Normal file
@ -0,0 +1,73 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.ApiModels.Prompt;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/v1/feed")]
|
||||
[Authorize(Roles = ModelConstants.UserRole)]
|
||||
public class FeedController : BaseController
|
||||
{
|
||||
public FeedController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ApiMinimalPrompt[]> GetFeedAsync(
|
||||
[Range(0, double.MaxValue)][FromQuery]int offset = 0,
|
||||
[Range(1, 100)][FromQuery]int limit = 20,
|
||||
[FromQuery]FeedSortType sortBy = FeedSortType.Date,
|
||||
[FromQuery]bool ascending = false,
|
||||
[FromQuery]Identifier? category = null,
|
||||
[FromQuery]DateTime? fromDate = null,
|
||||
[FromQuery]DateTime? toDate = null
|
||||
)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var query = _db.Prompts
|
||||
.Where(
|
||||
x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId)
|
||||
&& x.CreatorId != userId
|
||||
);
|
||||
|
||||
if (category.HasValue)
|
||||
query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug);
|
||||
|
||||
if (fromDate.HasValue)
|
||||
query = query.Where(x => x.UpdatedAt >= fromDate.Value);
|
||||
|
||||
if (toDate.HasValue)
|
||||
query = query.Where(x => x.UpdatedAt <= toDate.Value);
|
||||
|
||||
query = sortBy switch {
|
||||
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
|
||||
FeedSortType.Rating => query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 2.5, ascending),
|
||||
_ => query
|
||||
};
|
||||
|
||||
var prompts = await query
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(x => new ApiMinimalPrompt(
|
||||
x.Id,
|
||||
x.Title,
|
||||
x.UpdatedAt,
|
||||
x.CreatorId,
|
||||
x.Creator.Profile.DisplayName,
|
||||
x.SubscriptionTier.Level,
|
||||
x.SubscriptionTier.Name,
|
||||
x.Reviews.Average(r => (double?)r.Rating),
|
||||
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level)
|
||||
)).ToArrayAsync();
|
||||
|
||||
return prompts;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
OnlyPrompt.Backend/Controllers/ProfileController.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.ApiModels.UserProfile;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
|
||||
namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/v1/profiles")]
|
||||
[Authorize(Roles = ModelConstants.UserRole)]
|
||||
public class ProfileController : BaseController
|
||||
{
|
||||
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
{ nameof(UserProfileModel.Slug), new[] { "Slug already exists." } }
|
||||
});
|
||||
|
||||
public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetProfileAsync(Identifier id)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var profile = await _db.UserProfiles.OfIdentifer(id)
|
||||
.Where(up => up.IsPublic || up.Id == userId)
|
||||
.ProjectTo<ApiUserProfile>(_mapper.ConfigurationProvider)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (profile is null)
|
||||
return TypedResults.NotFound("Profile not found or is private.");
|
||||
|
||||
return TypedResults.Ok(profile);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<Results<ValidationProblem, NotFound<string>, Ok<ApiUserProfile>>> UpdateProfileAsync([FromBody] ApiUpdateProfileRequest request)
|
||||
{
|
||||
var self = await GetUserProfileAsync();
|
||||
if (self is null)
|
||||
return TypedResults.NotFound("Profile not found.");
|
||||
|
||||
if (string.IsNullOrEmpty(request.Slug) == false)
|
||||
{
|
||||
if (await _db.UserProfiles.AnyAsync(up => up.Slug == request.Slug && up.Id != self.Id))
|
||||
return SlugExistsProblem;
|
||||
|
||||
self.Slug = request.Slug;
|
||||
}
|
||||
|
||||
if(string.IsNullOrEmpty(request.AvatarUrl) == false)
|
||||
self.AvatarUrl = request.AvatarUrl;
|
||||
|
||||
if(string.IsNullOrEmpty(request.Bio) == false)
|
||||
self.Bio = request.Bio;
|
||||
|
||||
if(string.IsNullOrEmpty(request.Specialities) == false)
|
||||
self.Specialities = request.Specialities;
|
||||
|
||||
if (string.IsNullOrEmpty(request.DisplayName) == false)
|
||||
self.DisplayName = request.DisplayName;
|
||||
|
||||
self.IsPublic = request.IsPublic;
|
||||
await _db.SaveChangesAsync();
|
||||
var result = _mapper.Map<ApiUserProfile>(self);
|
||||
return TypedResults.Ok(result);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
183
OnlyPrompt.Backend/Controllers/PromptController.cs
Normal file
@ -0,0 +1,183 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.ApiModels.Prompt;
|
||||
using OnlyPrompt.Backend.ApiModels.UserProfile;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/v1/prompts")]
|
||||
[Authorize(Roles = ModelConstants.UserRole)]
|
||||
public class PromptController : BaseController
|
||||
{
|
||||
public PromptController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
|
||||
{
|
||||
}
|
||||
|
||||
private IQueryable<PromptModel> GetAccessiblePrompts(Guid userId)
|
||||
{
|
||||
return _db.Prompts.Where(
|
||||
p => p.SubscriptionTier == null
|
||||
|| p.Creator.Subscribers.Any(
|
||||
sub => sub.SubscriberId == userId
|
||||
&& p.SubscriptionTier!.Level <= sub.SubscriptionTier!.Level
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> GetPromptAsync(Identifier id)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var prompt = await GetAccessiblePrompts(userId.Value)
|
||||
.OfIdentifer(id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (prompt is null)
|
||||
return TypedResults.NotFound("Prompt not found or no permission");
|
||||
|
||||
var apiPrompt = _mapper.Map<ApiPrompt>(prompt);
|
||||
return TypedResults.Ok(apiPrompt);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<Results<NoContent, NotFound<string>>> DeletePromptAsync(Identifier id)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var isAdmin = User.IsInRole(ModelConstants.AdminRole);
|
||||
var count = await _db.Prompts
|
||||
.OfIdentifer(id)
|
||||
.Where(p => p.CreatorId == userId || isAdmin)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (count == 0)
|
||||
return TypedResults.NotFound("Prompt not found or no permission");
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> CreatePromptAsync([FromBody] ApiCreatePromptRequest request)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var category = await _db.Categories.FindByIdentifierAsync(request.Category);
|
||||
if (category is null)
|
||||
return TypedResults.NotFound("Category not found");
|
||||
|
||||
SubscriptionTierModel? subscriptionTier = null;
|
||||
if (request.SubscriptionTier.HasValue)
|
||||
{
|
||||
subscriptionTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(
|
||||
t => t.Level == request.SubscriptionTier.Value
|
||||
&& t.UserId == userId
|
||||
);
|
||||
|
||||
if (subscriptionTier is null)
|
||||
return TypedResults.NotFound("Subscription tier not found");
|
||||
}
|
||||
|
||||
var slug = request.Slug;
|
||||
if (string.IsNullOrEmpty(slug))
|
||||
slug = await SlugHelper.GenerateUniqueSlugAsync(request.Title, slug => _db.Prompts.AnyAsync(p => p.Slug == slug), ModelConstants.MaxSlugLength);
|
||||
|
||||
var prompt = new PromptModel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Prompt = request.Content,
|
||||
CreatorId = userId.Value,
|
||||
SubscriptionTier = subscriptionTier,
|
||||
Category = category,
|
||||
Slug = slug
|
||||
};
|
||||
|
||||
_db.Prompts.Add(prompt);
|
||||
await _db.SaveChangesAsync();
|
||||
var apiPrompt = _mapper.Map<ApiPrompt>(prompt);
|
||||
return TypedResults.Ok(apiPrompt);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/reviews")]
|
||||
public async Task<Ok<ApiReview[]>> GetReviewsAsync(Identifier id, [FromQuery] int offset = 0, [Range(1, 200)][FromQuery] int limit = 20)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var accessiblePrompts = GetAccessiblePrompts(userId!.Value);
|
||||
var reviews = await accessiblePrompts.Select(x => x.Reviews)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ProjectTo<ApiReview>(_mapper.ConfigurationProvider)
|
||||
.ToArrayAsync();
|
||||
|
||||
return TypedResults.Ok(reviews);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/reviews")]
|
||||
public async Task<Results<Ok<ApiReview>, BadRequest<string>, NotFound<string>>> AddReviewAsync(Identifier id, [FromBody] ApiCreateReviewRequest request)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var prompt = await GetAccessiblePrompts(userId!.Value)
|
||||
.OfIdentifer(id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (prompt is null)
|
||||
return TypedResults.NotFound("Prompt not found or no permission");
|
||||
|
||||
if(prompt.CreatorId == userId)
|
||||
return TypedResults.BadRequest("Cannot review your own prompt");
|
||||
|
||||
var review = await _db.Reviews.FirstOrDefaultAsync(
|
||||
r => r.PromptId == prompt.Id
|
||||
&& r.ReviewerId == userId
|
||||
);
|
||||
|
||||
if (review is null)
|
||||
{
|
||||
review = new ReviewModel
|
||||
{
|
||||
PromptId = prompt.Id,
|
||||
ReviewerId = userId.Value,
|
||||
Comment = request.Comment,
|
||||
Rating = request.Rating
|
||||
};
|
||||
|
||||
_db.Reviews.Add(review);
|
||||
}
|
||||
else
|
||||
{
|
||||
review.Comment = request.Comment;
|
||||
review.Rating = request.Rating;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
var apiReview = _mapper.Map<ApiReview>(review);
|
||||
return TypedResults.Ok(apiReview);
|
||||
}
|
||||
|
||||
[HttpDelete("{promptId}/reviews/{reviewerId}")]
|
||||
public async Task<Results<NoContent, NotFound<string>>> DeleteReviewAsync(Identifier promptId, Guid reviewerId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var isAdmin = User.IsInRole(ModelConstants.AdminRole);
|
||||
var count = await _db.Reviews
|
||||
.Where(
|
||||
r => (promptId.Id.HasValue ? r.PromptId == promptId.Id : r.Prompt.Slug == promptId.Slug)
|
||||
&& (r.ReviewerId == reviewerId || isAdmin)
|
||||
)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (count == 0)
|
||||
return TypedResults.NotFound("Review not found or no permission");
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
180
OnlyPrompt.Backend/Controllers/SubscriptionController.cs
Normal file
@ -0,0 +1,180 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.ApiModels.Subscription;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Formats.Asn1;
|
||||
|
||||
namespace OnlyPrompt.Backend.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/v1/subscriptions")]
|
||||
[Authorize(Roles = ModelConstants.UserRole)]
|
||||
public class SubscriptionController : BaseController
|
||||
{
|
||||
private static ValidationProblem TierLevelExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
{ nameof(SubscriptionTierModel.Level), new[] { "Tier with this level already exists." } }
|
||||
});
|
||||
public SubscriptionController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
|
||||
{
|
||||
}
|
||||
|
||||
[HttpPut("{userId}/{level}")]
|
||||
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> SubscribeAsync(Identifier subscribeToId, int? level = null)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var subscribeTo = await _db.Users.Include(x => x.SubscriptionTiers.Where(st => st.Level == level))
|
||||
.FirstOrDefaultAsync(
|
||||
user => subscribeToId.Id.HasValue ? user.Id == subscribeToId.Id.Value : user.Profile.Slug == subscribeToId.Slug
|
||||
);
|
||||
|
||||
if (subscribeTo is null)
|
||||
return TypedResults.NotFound($"No user found with identifier {subscribeToId}");
|
||||
|
||||
if (subscribeTo.Id == userId)
|
||||
return TypedResults.BadRequest("Cannot subscribe to yourself");
|
||||
|
||||
SubscriptionTierModel? tier = subscribeTo.SubscriptionTiers.FirstOrDefault();
|
||||
if (level.HasValue && tier is null)
|
||||
return TypedResults.NotFound($"No subscription tier found for user {subscribeToId} with level {level.Value}");
|
||||
|
||||
var existingSubscription = await _db.Subscriptions.FirstOrDefaultAsync(
|
||||
sub => subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug
|
||||
&& sub.SubscriberId == userId
|
||||
);
|
||||
|
||||
if (existingSubscription is null)
|
||||
{
|
||||
existingSubscription = new SubscriptionModel
|
||||
{
|
||||
SubscribedTo = subscribeTo,
|
||||
SubscriberId = userId.Value,
|
||||
SubscriptionTier = tier
|
||||
};
|
||||
|
||||
_db.Subscriptions.Add(existingSubscription);
|
||||
}
|
||||
else
|
||||
{
|
||||
existingSubscription.SubscriptionTier = tier;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ApiSubscription[]> GetSubscriptionsAsync([Range(0, double.MaxValue)]int offset = 0, [Range(1, 100)]int limit = 20)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var subscriptions = await _db.Subscriptions
|
||||
.Where(x => x.SubscriberId == userId)
|
||||
.OrderBy(x => x.SubscribedToId)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ProjectTo<ApiSubscription>(_mapper.ConfigurationProvider)
|
||||
.ToArrayAsync();
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
[HttpGet("{userId}")]
|
||||
public async Task<ApiSubscription?> GetCurrentSubscriptionAsync(Identifier subscribeToId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var subscription = await _db.Subscriptions
|
||||
.Where(
|
||||
sub => subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug
|
||||
&& sub.SubscriberId == userId
|
||||
)
|
||||
.ProjectTo<ApiSubscription>(_mapper.ConfigurationProvider)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
[HttpDelete("{userId}")]
|
||||
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync(Identifier subscribeToId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var count = await _db.Subscriptions
|
||||
.Where(
|
||||
sub => subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug
|
||||
&& sub.SubscriberId == userId
|
||||
)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (count == 0)
|
||||
return TypedResults.NotFound($"No subscription found for user {subscribeToId}");
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
|
||||
[HttpPost("tiers")]
|
||||
public async Task<Results<Ok<ApiSubscriptionTier>, ValidationProblem>> CreateOrUpdateSubscriptionTierAsync([FromBody] ApiCreateSubscriptionTierRequest tier)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level);
|
||||
if (levelExists)
|
||||
return TierLevelExistsProblem;
|
||||
|
||||
var model = _mapper.Map<SubscriptionTierModel>(tier);
|
||||
model.UserId = userId!.Value;
|
||||
_db.SubscriptionTiers.Add(model);
|
||||
await _db.SaveChangesAsync();
|
||||
return TypedResults.Ok(_mapper.Map<ApiSubscriptionTier>(model));
|
||||
}
|
||||
|
||||
[HttpPut("tiers/{id}")]
|
||||
public async Task<Results<Ok<ApiSubscriptionTier>, NotFound<string>, ValidationProblem>> UpdateSubscriptionTierAsync(Guid id, [FromBody] ApiUpdateSubscriptionTierRequest tier)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var existingTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(t => t.Id == id && t.UserId == userId);
|
||||
if (existingTier is null)
|
||||
return TypedResults.NotFound($"No subscription tier found with id {id}");
|
||||
|
||||
if (existingTier.Level != tier.Level)
|
||||
{
|
||||
var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level);
|
||||
if (levelExists)
|
||||
return TierLevelExistsProblem;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(tier.Name) == false)
|
||||
existingTier.Name = tier.Name;
|
||||
|
||||
if (string.IsNullOrEmpty(tier.Description) == false)
|
||||
existingTier.Description = tier.Description;
|
||||
|
||||
if (tier.Level.HasValue)
|
||||
existingTier.Level = tier.Level.Value;
|
||||
|
||||
if (tier.MonthlyPrice.HasValue)
|
||||
existingTier.MonthlyPrice = tier.MonthlyPrice.Value;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return TypedResults.Ok(_mapper.Map<ApiSubscriptionTier>(existingTier));
|
||||
}
|
||||
|
||||
[HttpDelete("tiers/{id}")]
|
||||
public async Task<Results<Ok, NotFound<string>>> DeleteSubscriptionTierAsync(Guid id)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var count = await _db.SubscriptionTiers
|
||||
.Where(t => t.Id == id && t.UserId == userId)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (count == 0)
|
||||
return TypedResults.NotFound($"No subscription tier found with id {id}");
|
||||
|
||||
return TypedResults.Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
15
OnlyPrompt.Backend/Database/Core/EntityBase.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database.Core
|
||||
{
|
||||
public class EntityBase : IEntity, ITrackableEntity
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
7
OnlyPrompt.Backend/Database/Core/IEntity.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace OnlyPrompt.Backend.Database.Core
|
||||
{
|
||||
public interface IEntity
|
||||
{
|
||||
public Guid Id { get; }
|
||||
}
|
||||
}
|
||||
7
OnlyPrompt.Backend/Database/Core/IHasSlug.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace OnlyPrompt.Backend.Database.Core
|
||||
{
|
||||
public interface IHasSlug
|
||||
{
|
||||
public string Slug { get; set; }
|
||||
}
|
||||
}
|
||||
8
OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace OnlyPrompt.Backend.Database.Core
|
||||
{
|
||||
public interface ITrackableEntity
|
||||
{
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
14
OnlyPrompt.Backend/Database/ModelConstants.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using System.Collections.Frozen;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database
|
||||
{
|
||||
public static class ModelConstants
|
||||
{
|
||||
public const int MaxSlugLength = 100;
|
||||
public const string UserRole = "user";
|
||||
public const string AdminRole = "admin";
|
||||
public const string SysAdminRole = "sys-admin";
|
||||
|
||||
public static readonly FrozenSet<string> AllRoles = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, UserRole, AdminRole, SysAdminRole);
|
||||
}
|
||||
}
|
||||
19
OnlyPrompt.Backend/Database/Models/CategoryModel.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.Database.Core;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database.Models
|
||||
{
|
||||
[Index(nameof(Slug), IsUnique = true)]
|
||||
public class CategoryModel : EntityBase, IHasSlug
|
||||
{
|
||||
[MaxLength(ModelConstants.MaxSlugLength)]
|
||||
[Column(TypeName = "citext")]
|
||||
public required string Slug { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
|
||||
}
|
||||
}
|
||||
45
OnlyPrompt.Backend/Database/Models/PromptModel.cs
Normal file
@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.Database.Core;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database.Models
|
||||
{
|
||||
[Index(nameof(Slug), IsUnique = true)]
|
||||
public class PromptModel : EntityBase, IHasSlug
|
||||
{
|
||||
[Required]
|
||||
[ForeignKey(nameof(Creator))]
|
||||
public Guid CreatorId { get; set; }
|
||||
|
||||
[DeleteBehavior(DeleteBehavior.Cascade)]
|
||||
public virtual UserModel Creator { get; set; }
|
||||
|
||||
[Required]
|
||||
[ForeignKey(nameof(Category))]
|
||||
public Guid CategoryId { get; set; }
|
||||
|
||||
[DeleteBehavior(DeleteBehavior.Cascade)]
|
||||
public virtual CategoryModel Category { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public required string Title { get; set; }
|
||||
|
||||
[MaxLength(4000)]
|
||||
public required string Prompt { get; set; }
|
||||
|
||||
[MaxLength(1000)]
|
||||
public required string Description { get; set; }
|
||||
|
||||
[MaxLength(ModelConstants.MaxSlugLength)]
|
||||
[Column(TypeName = "citext")]
|
||||
public required string Slug { get; set; }
|
||||
|
||||
[ForeignKey(nameof(SubscriptionTier))]
|
||||
public Guid? SubscriptionTierId { get; set; }
|
||||
|
||||
[DeleteBehavior(DeleteBehavior.SetNull)]
|
||||
public virtual SubscriptionTierModel? SubscriptionTier { get; set; }
|
||||
public virtual IList<ReviewModel> Reviews { get; set; } = new List<ReviewModel>();
|
||||
}
|
||||
}
|
||||
34
OnlyPrompt.Backend/Database/Models/ReviewModel.cs
Normal file
@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.Database.Core;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database.Models
|
||||
{
|
||||
[PrimaryKey(nameof(ReviewerId), nameof(PromptId))]
|
||||
public class ReviewModel : ITrackableEntity
|
||||
{
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
[Required]
|
||||
[ForeignKey(nameof(Reviewer))]
|
||||
public Guid ReviewerId { get; set; }
|
||||
|
||||
[DeleteBehavior(DeleteBehavior.Cascade)]
|
||||
public virtual UserModel Reviewer { get; set; }
|
||||
|
||||
[Required]
|
||||
[ForeignKey(nameof(Prompt))]
|
||||
public Guid PromptId { get; set; }
|
||||
|
||||
[DeleteBehavior(DeleteBehavior.Cascade)]
|
||||
public virtual PromptModel Prompt { get; set; }
|
||||
|
||||
[Range(1, 5)]
|
||||
public int Rating { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? Comment { get; set; } = null;
|
||||
}
|
||||
}
|
||||
32
OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database.Models
|
||||
{
|
||||
[Table("UserSubscriptions")]
|
||||
[PrimaryKey(nameof(SubscriberId), nameof(SubscribedToId))]
|
||||
public class SubscriptionModel
|
||||
{
|
||||
[Required]
|
||||
[ForeignKey(nameof(SubscribedTo))]
|
||||
public Guid SubscribedToId { get; set; }
|
||||
|
||||
[DeleteBehavior(DeleteBehavior.Cascade)]
|
||||
public virtual UserModel SubscribedTo { get; set; }
|
||||
|
||||
[Required]
|
||||
[ForeignKey(nameof(Subscriber))]
|
||||
public Guid SubscriberId { get; set; }
|
||||
|
||||
[DeleteBehavior(DeleteBehavior.Cascade)]
|
||||
public virtual UserModel Subscriber { get; set; }
|
||||
|
||||
|
||||
[ForeignKey(nameof(SubscriptionTier))]
|
||||
public virtual Guid? SubscriptionTierId { get; set; }
|
||||
|
||||
[DeleteBehavior(DeleteBehavior.SetNull)]
|
||||
public virtual SubscriptionTierModel? SubscriptionTier { get; set; }
|
||||
}
|
||||
}
|
||||
30
OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.Database.Core;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database.Models
|
||||
{
|
||||
[Index(nameof(Level), nameof(UserId), IsUnique = true)]
|
||||
public class SubscriptionTierModel : EntityBase
|
||||
{
|
||||
[Required]
|
||||
public required string Name { get; set; }
|
||||
|
||||
[MaxLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
[ForeignKey(nameof(User))]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[DeleteBehavior(DeleteBehavior.Cascade)]
|
||||
public virtual UserModel User { get; set; }
|
||||
|
||||
public decimal MonthlyPrice { get; set; }
|
||||
public int Level { get; set; }
|
||||
|
||||
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
|
||||
public virtual IList<SubscriptionModel> Subscriptions { get; set; } = new List<SubscriptionModel>();
|
||||
}
|
||||
}
|
||||
31
OnlyPrompt.Backend/Database/Models/UserModel.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.Database.Core;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database.Models
|
||||
{
|
||||
[Index(nameof(Email), IsUnique = true)]
|
||||
[Index(nameof(UserName), IsUnique = true)]
|
||||
public class UserModel : EntityBase
|
||||
{
|
||||
[MaxLength(100)]
|
||||
[Column(TypeName = "citext")]
|
||||
public required string UserName { get; set; }
|
||||
|
||||
[Column(TypeName = "citext")]
|
||||
public required string Email { get; set; }
|
||||
public required string PasswordHash { get; set; }
|
||||
public required List<string> Roles { get; set; } = new List<string>();
|
||||
|
||||
[Required]
|
||||
public required virtual UserProfileModel Profile { get; set; }
|
||||
|
||||
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
|
||||
public virtual IList<SubscriptionModel> Subscriptions { get; set; } = new List<SubscriptionModel>();
|
||||
public virtual IList<SubscriptionModel> Subscribers { get; set; } = new List<SubscriptionModel>();
|
||||
public virtual IList<SubscriptionTierModel> SubscriptionTiers { get; set; } = new List<SubscriptionTierModel>();
|
||||
|
||||
public bool IsLockoutEnabled { get; set; } = false;
|
||||
}
|
||||
}
|
||||
29
OnlyPrompt.Backend/Database/Models/UserProfileModel.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.Database.Core;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database.Models
|
||||
{
|
||||
[Index(nameof(Slug), IsUnique = true)]
|
||||
public class UserProfileModel : EntityBase, IHasSlug
|
||||
{
|
||||
[MaxLength(100)]
|
||||
public required string DisplayName { get; set; }
|
||||
|
||||
[MaxLength(ModelConstants.MaxSlugLength)]
|
||||
[Column(TypeName = "citext")]
|
||||
public required string Slug { get; set; }
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string? Bio { get; set; }
|
||||
|
||||
public required string AvatarUrl { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? Specialities { get; set; }
|
||||
|
||||
public virtual UserModel User { get; set; }
|
||||
public bool IsPublic { get; set; } = false;
|
||||
}
|
||||
}
|
||||
91
OnlyPrompt.Backend/Database/OnlyPromptContext.cs
Normal file
@ -0,0 +1,91 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using OnlyPrompt.Backend.Database.Core;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
|
||||
namespace OnlyPrompt.Backend.Database
|
||||
{
|
||||
public class OnlyPromptContext : DbContext
|
||||
{
|
||||
public DbSet<UserModel> Users { get; set; }
|
||||
public DbSet<UserProfileModel> UserProfiles { get; set; }
|
||||
public DbSet<CategoryModel> Categories { get; set; }
|
||||
public DbSet<PromptModel> Prompts { get; set; }
|
||||
public DbSet<SubscriptionTierModel> SubscriptionTiers { get; set; }
|
||||
public DbSet<SubscriptionModel> Subscriptions { get; set; }
|
||||
public DbSet<ReviewModel> Reviews { get; set; }
|
||||
|
||||
public OnlyPromptContext(DbContextOptions<OnlyPromptContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
private void HandleEntityTimestamps()
|
||||
{
|
||||
var entries = ChangeTracker.Entries<ITrackableEntity>();
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
entry.Entity.CreatedAt = now;
|
||||
entry.Entity.UpdatedAt = now;
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
entry.Entity.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
HandleEntityTimestamps();
|
||||
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
}
|
||||
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
HandleEntityTimestamps();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
if(optionsBuilder.IsConfigured == false)
|
||||
{
|
||||
optionsBuilder.UseNpgsql();
|
||||
optionsBuilder.UseLazyLoadingProxies();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
modelBuilder.Entity<UserModel>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Profile)
|
||||
.WithOne(p => p.User)
|
||||
.HasForeignKey<UserProfileModel>(p => p.Id);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SubscriptionModel>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Subscriber)
|
||||
.WithMany(s => s.Subscriptions)
|
||||
.HasForeignKey(e => e.SubscriberId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.SubscribedTo)
|
||||
.WithMany(c => c.Subscribers)
|
||||
.HasForeignKey(e => e.SubscribedToId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.SubscriptionTier)
|
||||
.WithMany(t => t.Subscriptions)
|
||||
.HasForeignKey(e => e.SubscriptionTierId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
31
OnlyPrompt.Backend/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
|
||||
|
||||
# This stage is used when running from VS in fast mode (Default for Debug configuration)
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
|
||||
# This stage is used to build the service project
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"]
|
||||
ADD ["OnlyPrompt.Frontend", "OnlyPrompt.Backend/wwwroot"]
|
||||
RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/OnlyPrompt.Backend"
|
||||
RUN dotnet build "./OnlyPrompt.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
# This stage is used to publish the service project to be copied to the final stage
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./OnlyPrompt.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "OnlyPrompt.Backend.dll"]
|
||||
419
OnlyPrompt.Backend/Migrations/20260411191205_Initial.Designer.cs
generated
Normal file
@ -0,0 +1,419 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OnlyPrompt.Backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(OnlyPromptContext))]
|
||||
[Migration("20260411191205_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Proxies:ChangeTracking", false)
|
||||
.HasAnnotation("Proxies:CheckEquality", false)
|
||||
.HasAnnotation("Proxies:LazyLoading", true)
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CategoryId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<Guid?>("SubscriptionTierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("CreatorId");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("SubscriptionTierId");
|
||||
|
||||
b.ToTable("Prompts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("PromptId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Rating")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("ReviewerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PromptId");
|
||||
|
||||
b.HasIndex("ReviewerId", "PromptId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Reviews");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
|
||||
{
|
||||
b.Property<Guid>("SubscriberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("SubscribedToId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("SubscriptionTierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SubscriberId", "SubscribedToId");
|
||||
|
||||
b.HasIndex("SubscribedToId");
|
||||
|
||||
b.HasIndex("SubscriptionTierId");
|
||||
|
||||
b.ToTable("UserSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("Level", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionTiers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<bool>("IsLockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Roles")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<string>("Specialities")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserProfiles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category")
|
||||
.WithMany("Prompts")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator")
|
||||
.WithMany("Prompts")
|
||||
.HasForeignKey("CreatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
|
||||
.WithMany("Prompts")
|
||||
.HasForeignKey("SubscriptionTierId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("SubscriptionTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt")
|
||||
.WithMany("Reviews")
|
||||
.HasForeignKey("PromptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Prompt");
|
||||
|
||||
b.Navigation("Reviewer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo")
|
||||
.WithMany("Subscribers")
|
||||
.HasForeignKey("SubscribedToId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("SubscriberId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("SubscriptionTierId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SubscribedTo");
|
||||
|
||||
b.Navigation("Subscriber");
|
||||
|
||||
b.Navigation("SubscriptionTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
|
||||
.WithOne("Profile")
|
||||
.HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
|
||||
{
|
||||
b.Navigation("Prompts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
|
||||
{
|
||||
b.Navigation("Reviews");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
|
||||
{
|
||||
b.Navigation("Prompts");
|
||||
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
|
||||
{
|
||||
b.Navigation("Profile")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Prompts");
|
||||
|
||||
b.Navigation("Subscribers");
|
||||
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
300
OnlyPrompt.Backend/Migrations/20260411191205_Initial.cs
Normal file
@ -0,0 +1,300 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OnlyPrompt.Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:citext", ",,");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Categories",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Slug = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Categories", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserName = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
|
||||
Email = table.Column<string>(type: "citext", nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "text", nullable: false),
|
||||
Roles = table.Column<string[]>(type: "text[]", nullable: false),
|
||||
IsLockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SubscriptionTiers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MonthlyPrice = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
Level = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SubscriptionTiers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SubscriptionTiers_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserProfiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Slug = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
|
||||
Bio = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||
AvatarUrl = table.Column<string>(type: "text", nullable: false),
|
||||
Specialities = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
IsPublic = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserProfiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserProfiles_Users_Id",
|
||||
column: x => x.Id,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Prompts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CategoryId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
Slug = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
|
||||
SubscriptionTierId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Prompts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Prompts_Categories_CategoryId",
|
||||
column: x => x.CategoryId,
|
||||
principalTable: "Categories",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Prompts_SubscriptionTiers_SubscriptionTierId",
|
||||
column: x => x.SubscriptionTierId,
|
||||
principalTable: "SubscriptionTiers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Prompts_Users_CreatorId",
|
||||
column: x => x.CreatorId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserSubscriptions",
|
||||
columns: table => new
|
||||
{
|
||||
SubscribedToId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SubscriberId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SubscriptionTierId = table.Column<Guid>(type: "uuid", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserSubscriptions", x => new { x.SubscriberId, x.SubscribedToId });
|
||||
table.ForeignKey(
|
||||
name: "FK_UserSubscriptions_SubscriptionTiers_SubscriptionTierId",
|
||||
column: x => x.SubscriptionTierId,
|
||||
principalTable: "SubscriptionTiers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserSubscriptions_Users_SubscribedToId",
|
||||
column: x => x.SubscribedToId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserSubscriptions_Users_SubscriberId",
|
||||
column: x => x.SubscriberId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Reviews",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ReviewerId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PromptId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Rating = table.Column<int>(type: "integer", nullable: false),
|
||||
Comment = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Reviews", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Reviews_Prompts_PromptId",
|
||||
column: x => x.PromptId,
|
||||
principalTable: "Prompts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Reviews_Users_ReviewerId",
|
||||
column: x => x.ReviewerId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Categories_Slug",
|
||||
table: "Categories",
|
||||
column: "Slug",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Prompts_CategoryId",
|
||||
table: "Prompts",
|
||||
column: "CategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Prompts_CreatorId",
|
||||
table: "Prompts",
|
||||
column: "CreatorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Prompts_Slug",
|
||||
table: "Prompts",
|
||||
column: "Slug",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Prompts_SubscriptionTierId",
|
||||
table: "Prompts",
|
||||
column: "SubscriptionTierId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reviews_PromptId",
|
||||
table: "Reviews",
|
||||
column: "PromptId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reviews_ReviewerId_PromptId",
|
||||
table: "Reviews",
|
||||
columns: new[] { "ReviewerId", "PromptId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SubscriptionTiers_Level_UserId",
|
||||
table: "SubscriptionTiers",
|
||||
columns: new[] { "Level", "UserId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SubscriptionTiers_UserId",
|
||||
table: "SubscriptionTiers",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserProfiles_Slug",
|
||||
table: "UserProfiles",
|
||||
column: "Slug",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_Email",
|
||||
table: "Users",
|
||||
column: "Email",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_UserName",
|
||||
table: "Users",
|
||||
column: "UserName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserSubscriptions_SubscribedToId",
|
||||
table: "UserSubscriptions",
|
||||
column: "SubscribedToId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserSubscriptions_SubscriptionTierId",
|
||||
table: "UserSubscriptions",
|
||||
column: "SubscriptionTierId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Reviews");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserProfiles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserSubscriptions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Prompts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Categories");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SubscriptionTiers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
419
OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.Designer.cs
generated
Normal file
@ -0,0 +1,419 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OnlyPrompt.Backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(OnlyPromptContext))]
|
||||
[Migration("20260412002927_ReviewManyToMany")]
|
||||
partial class ReviewManyToMany
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Proxies:ChangeTracking", false)
|
||||
.HasAnnotation("Proxies:CheckEquality", false)
|
||||
.HasAnnotation("Proxies:LazyLoading", true)
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CategoryId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<Guid?>("SubscriptionTierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("CreatorId");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("SubscriptionTierId");
|
||||
|
||||
b.ToTable("Prompts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
|
||||
{
|
||||
b.Property<Guid>("ReviewerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("PromptId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Rating")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("ReviewerId", "PromptId");
|
||||
|
||||
b.HasIndex("PromptId");
|
||||
|
||||
b.ToTable("Reviews");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
|
||||
{
|
||||
b.Property<Guid>("SubscriberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("SubscribedToId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("SubscriptionTierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SubscriberId", "SubscribedToId");
|
||||
|
||||
b.HasIndex("SubscribedToId");
|
||||
|
||||
b.HasIndex("SubscriptionTierId");
|
||||
|
||||
b.ToTable("UserSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("Level", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionTiers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<bool>("IsLockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Roles")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<string>("Specialities")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserProfiles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category")
|
||||
.WithMany("Prompts")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator")
|
||||
.WithMany("Prompts")
|
||||
.HasForeignKey("CreatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
|
||||
.WithMany("Prompts")
|
||||
.HasForeignKey("SubscriptionTierId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("SubscriptionTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt")
|
||||
.WithMany("Reviews")
|
||||
.HasForeignKey("PromptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Prompt");
|
||||
|
||||
b.Navigation("Reviewer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo")
|
||||
.WithMany("Subscribers")
|
||||
.HasForeignKey("SubscribedToId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("SubscriberId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("SubscriptionTierId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SubscribedTo");
|
||||
|
||||
b.Navigation("Subscriber");
|
||||
|
||||
b.Navigation("SubscriptionTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
|
||||
.WithOne("Profile")
|
||||
.HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
|
||||
{
|
||||
b.Navigation("Prompts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
|
||||
{
|
||||
b.Navigation("Reviews");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
|
||||
{
|
||||
b.Navigation("Prompts");
|
||||
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
|
||||
{
|
||||
b.Navigation("Profile")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Prompts");
|
||||
|
||||
b.Navigation("Subscribers");
|
||||
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OnlyPrompt.Backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReviewManyToMany : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_Reviews",
|
||||
table: "Reviews");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Reviews_ReviewerId_PromptId",
|
||||
table: "Reviews");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Id",
|
||||
table: "Reviews");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Content",
|
||||
table: "Prompts");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Description",
|
||||
table: "Prompts",
|
||||
type: "character varying(1000)",
|
||||
maxLength: 1000,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Prompt",
|
||||
table: "Prompts",
|
||||
type: "character varying(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_Reviews",
|
||||
table: "Reviews",
|
||||
columns: new[] { "ReviewerId", "PromptId" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_Reviews",
|
||||
table: "Reviews");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Description",
|
||||
table: "Prompts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Prompt",
|
||||
table: "Prompts");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "Id",
|
||||
table: "Reviews",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Content",
|
||||
table: "Prompts",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_Reviews",
|
||||
table: "Reviews",
|
||||
column: "Id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Reviews_ReviewerId_PromptId",
|
||||
table: "Reviews",
|
||||
columns: new[] { "ReviewerId", "PromptId" },
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
416
OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs
Normal file
@ -0,0 +1,416 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OnlyPrompt.Backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(OnlyPromptContext))]
|
||||
partial class OnlyPromptContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Proxies:ChangeTracking", false)
|
||||
.HasAnnotation("Proxies:CheckEquality", false)
|
||||
.HasAnnotation("Proxies:LazyLoading", true)
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CategoryId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<Guid?>("SubscriptionTierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.HasIndex("CreatorId");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("SubscriptionTierId");
|
||||
|
||||
b.ToTable("Prompts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
|
||||
{
|
||||
b.Property<Guid>("ReviewerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("PromptId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Rating")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("ReviewerId", "PromptId");
|
||||
|
||||
b.HasIndex("PromptId");
|
||||
|
||||
b.ToTable("Reviews");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
|
||||
{
|
||||
b.Property<Guid>("SubscriberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("SubscribedToId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("SubscriptionTierId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SubscriberId", "SubscribedToId");
|
||||
|
||||
b.HasIndex("SubscribedToId");
|
||||
|
||||
b.HasIndex("SubscriptionTierId");
|
||||
|
||||
b.ToTable("UserSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<int>("Level")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("MonthlyPrice")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.HasIndex("Level", "UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SubscriptionTiers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<bool>("IsLockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Roles")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("citext");
|
||||
|
||||
b.Property<string>("Specialities")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserProfiles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category")
|
||||
.WithMany("Prompts")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator")
|
||||
.WithMany("Prompts")
|
||||
.HasForeignKey("CreatorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
|
||||
.WithMany("Prompts")
|
||||
.HasForeignKey("SubscriptionTierId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("SubscriptionTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt")
|
||||
.WithMany("Reviews")
|
||||
.HasForeignKey("PromptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Prompt");
|
||||
|
||||
b.Navigation("Reviewer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo")
|
||||
.WithMany("Subscribers")
|
||||
.HasForeignKey("SubscribedToId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("SubscriberId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("SubscriptionTierId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("SubscribedTo");
|
||||
|
||||
b.Navigation("Subscriber");
|
||||
|
||||
b.Navigation("SubscriptionTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
|
||||
{
|
||||
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
|
||||
.WithOne("Profile")
|
||||
.HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
|
||||
{
|
||||
b.Navigation("Prompts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
|
||||
{
|
||||
b.Navigation("Reviews");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
|
||||
{
|
||||
b.Navigation("Prompts");
|
||||
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
|
||||
{
|
||||
b.Navigation("Profile")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Prompts");
|
||||
|
||||
b.Navigation("Subscribers");
|
||||
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
36
OnlyPrompt.Backend/OnlyPrompt.Backend.csproj
Normal file
@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="16.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" NoWarn="NU1605" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.5" NoWarn="NU1605" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="3.14.1" />
|
||||
<PackageReference Include="Microsoft.Identity.Web.DownstreamApi" Version="3.14.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.13.22" />
|
||||
<PackageReference Include="Scalar.AspNetCore.Microsoft" Version="2.13.22" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\" />
|
||||
<Folder Include="wwwroot\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
OnlyPrompt.Backend/OnlyPrompt.Backend.http
Normal file
@ -0,0 +1,6 @@
|
||||
@OnlyPrompt.Backend_HostAddress = http://localhost:5093
|
||||
|
||||
GET {{OnlyPrompt.Backend_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
105
OnlyPrompt.Backend/Program.cs
Normal file
@ -0,0 +1,105 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Rewrite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Identity.Web;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OnlyPrompt.Backend.Database;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using OnlyPrompt.Backend.Services.Jwt;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
using Scalar.AspNetCore;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var config = builder.Configuration;
|
||||
// Add services to the container.
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme);
|
||||
builder.Services.AddDbContext<OnlyPromptContext>(opts =>
|
||||
{
|
||||
opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
|
||||
opts.UseLazyLoadingProxies();
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
|
||||
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
|
||||
builder.Services.AddAutoMapper(AutoMapperSetup.Setup);
|
||||
builder.Services.AddValidation(opts =>
|
||||
{
|
||||
opts.MaxDepth = 10;
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts =>
|
||||
{
|
||||
opts.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = config["Jwt:Issuer"],
|
||||
ValidAudience = config["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]))
|
||||
};
|
||||
opts.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
if (context.Request.Cookies.ContainsKey("jwt"))
|
||||
context.Token = context.Request.Cookies["jwt"];
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddControllers().AddJsonOptions(jsonOpts =>
|
||||
{
|
||||
jsonOpts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
});
|
||||
builder.Services.AddOpenApi(opts => opts.AddScalarTransformers());
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
var rewrite = new RewriteOptions()
|
||||
.AddRewrite(@"^(?!scalar\/?|api\/?)([^.]+)$", "$1.html", skipRemainingRules: true);
|
||||
|
||||
app.UseRewriter(rewrite);
|
||||
app.UseAuthorization();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
var dir = Path.GetFullPath("./../OnlyPrompt.Frontend");
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(dir),
|
||||
RedirectToAppendTrailingSlash = true,
|
||||
HttpsCompression = HttpsCompressionMode.Compress,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
app.UseStaticFiles();
|
||||
}
|
||||
|
||||
app.MapControllers();
|
||||
app.MapFallbackToFile("/login.html");
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
app.Run();
|
||||
25
OnlyPrompt.Backend/Properties/launchSettings.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "https://localhost:7163/scalar",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://localhost:7163;http://localhost:5093"
|
||||
},
|
||||
"Container (Dockerfile)": {
|
||||
"commandName": "Docker",
|
||||
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_HTTPS_PORTS": "8081",
|
||||
"ASPNETCORE_HTTP_PORTS": "8080"
|
||||
},
|
||||
"publishAllPorts": true,
|
||||
"useSSL": true
|
||||
}
|
||||
},
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json"
|
||||
}
|
||||
15
OnlyPrompt.Backend/Services/Jwt/ITokenService.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OnlyPrompt.Backend.Services.Jwt
|
||||
{
|
||||
public interface ITokenService
|
||||
{
|
||||
string BuildToken(UserModel user, out DateTime validUntil);
|
||||
bool ValidateToken(string token, out ClaimsPrincipal claims);
|
||||
}
|
||||
}
|
||||
72
OnlyPrompt.Backend/Services/Jwt/JwtTokenService.cs
Normal file
@ -0,0 +1,72 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OnlyPrompt.Backend.Services.Jwt
|
||||
{
|
||||
public class JwtTokenService : ITokenService
|
||||
{
|
||||
private string _key;
|
||||
private string _issuer;
|
||||
private string _audience;
|
||||
private TimeSpan _valid;
|
||||
|
||||
public JwtTokenService(IConfiguration config)
|
||||
{
|
||||
config = config.GetSection("Jwt");
|
||||
_key = config["Key"];
|
||||
_issuer = config["Issuer"];
|
||||
_audience = config["Audience"];
|
||||
_valid = config.GetValue<TimeSpan>("Valid");
|
||||
}
|
||||
|
||||
public string BuildToken(UserModel user, out DateTime validUntil)
|
||||
{
|
||||
var claims = user.GetClaims().ToList();
|
||||
validUntil = DateTime.UtcNow.Add(_valid);
|
||||
claims.Add(new Claim("exp", new DateTimeOffset(validUntil).ToUnixTimeSeconds().ToString()));
|
||||
claims.Add(new Claim("amr", "pwd"));
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
|
||||
var tokenDescriptor = new JwtSecurityToken(_issuer, _audience, claims, expires: validUntil, signingCredentials: credentials);
|
||||
return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
|
||||
}
|
||||
|
||||
public bool ValidateToken(string token, out ClaimsPrincipal claims)
|
||||
{
|
||||
var mySecret = Encoding.UTF8.GetBytes(_key);
|
||||
var mySecurityKey = new SymmetricSecurityKey(mySecret);
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
try
|
||||
{
|
||||
claims = tokenHandler.ValidateToken(token,
|
||||
new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidIssuer = _issuer,
|
||||
ValidAudience = _audience,
|
||||
IssuerSigningKey = mySecurityKey,
|
||||
}, out SecurityToken validatedToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
claims = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
93
OnlyPrompt.Backend/Utils/AutoMapperSetup.cs
Normal file
@ -0,0 +1,93 @@
|
||||
using AutoMapper;
|
||||
using OnlyPrompt.Backend.ApiModels.Auth;
|
||||
using OnlyPrompt.Backend.ApiModels.Category;
|
||||
using OnlyPrompt.Backend.ApiModels.Prompt;
|
||||
using OnlyPrompt.Backend.ApiModels.Subscription;
|
||||
using OnlyPrompt.Backend.ApiModels.UserProfile;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
|
||||
namespace OnlyPrompt.Backend.Utils
|
||||
{
|
||||
public static class AutoMapperSetup
|
||||
{
|
||||
public static void Setup(IMapperConfigurationExpression config)
|
||||
{
|
||||
config.CreateMap<UserModel, ApiUser>()
|
||||
.MapCtorParamFrom(x => x.Id, x => x.Id)
|
||||
.MapCtorParamFrom(x => x.UserName, x => x.UserName)
|
||||
.MapCtorParamFrom(x => x.Roles, x => x.Roles)
|
||||
.MapCtorParamFrom(x => x.Email, x => x.Email);
|
||||
|
||||
config.CreateMap<UserProfileModel, ApiUserProfile>()
|
||||
.MapCtorParamFrom(x => x.DisplayName, x => x.DisplayName)
|
||||
.MapCtorParamFrom(x => x.Slug, x => x.Slug)
|
||||
.MapCtorParamFrom(x => x.Bio, x => x.Bio)
|
||||
.MapCtorParamFrom(x => x.AvatarUrl, x => x.AvatarUrl)
|
||||
.MapCtorParamFrom(x => x.Specialities, x => x.Specialities)
|
||||
.MapCtorParamFrom(x => x.AverageRating, x => x.User.Prompts.Average(p => p.Reviews.Average(r => r.Rating)))
|
||||
.MapCtorParamFrom(x => x.Subscribers, x => x.User.Subscribers.Count());
|
||||
|
||||
config.CreateMap<PromptModel, ApiPrompt>()
|
||||
.MapCtorParamFrom(x => x.Id, x => x.Id)
|
||||
.MapCtorParamFrom(x => x.Title, x => x.Title)
|
||||
.MapCtorParamFrom(x => x.Description, x => x.Description)
|
||||
.MapCtorParamFrom(x => x.Content, x => x.Prompt)
|
||||
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
|
||||
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
|
||||
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
|
||||
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
|
||||
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
|
||||
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating));
|
||||
|
||||
config.CreateMap<PromptModel, ApiMinimalPrompt>()
|
||||
.MapCtorParamFrom(x => x.Id, x => x.Id)
|
||||
.MapCtorParamFrom(x => x.Title, x => x.Title)
|
||||
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
|
||||
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
|
||||
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
|
||||
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
|
||||
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
|
||||
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
|
||||
.MapCtorParamFrom(x => x.CanAccess, x => true);
|
||||
|
||||
config.CreateMap<ReviewModel, ApiReview>()
|
||||
.MapCtorParamFrom(x => x.CreatorId, x => x.ReviewerId)
|
||||
.MapCtorParamFrom(x => x.CreatorName, x => x.Reviewer.Profile.DisplayName)
|
||||
.MapCtorParamFrom(x => x.Comment, x => x.Comment)
|
||||
.MapCtorParamFrom(x => x.Rating, x => x.Rating);
|
||||
|
||||
config.CreateMap<CategoryModel, ApiCategory>()
|
||||
.MapCtorParamFrom(x => x.Id, x => x.Id)
|
||||
.MapCtorParamFrom(x => x.Name, x => x.Name)
|
||||
.MapCtorParamFrom(x => x.Description, x => x.Description)
|
||||
.MapCtorParamFrom(x => x.Slug, x => x.Slug);
|
||||
|
||||
config.CreateMap<CategoryModel, ApiMinimalCategory>()
|
||||
.MapCtorParamFrom(x => x.Name, x => x.Name)
|
||||
.MapCtorParamFrom(x => x.Slug, x => x.Slug);
|
||||
|
||||
config.CreateMap<ApiCreateCategoryRequest, CategoryModel>()
|
||||
.MapMemberFrom(x => x.Description, x => x.Description)
|
||||
.MapMemberFrom(x => x.Name, x => x.Name)
|
||||
.MapMemberFrom(x => x.Slug, x => x.Slug);
|
||||
|
||||
config.CreateMap<SubscriptionTierModel, ApiSubscriptionTier>()
|
||||
.MapCtorParamFrom(x => x.Id, x => x.Id)
|
||||
.MapCtorParamFrom(x => x.Name, x => x.Name)
|
||||
.MapCtorParamFrom(x => x.Level, x => x.Level)
|
||||
.MapCtorParamFrom(x => x.MonthlyPrice, x => x.MonthlyPrice)
|
||||
.MapCtorParamFrom(x => x.Description, x => x.Description);
|
||||
|
||||
config.CreateMap<ApiCreateSubscriptionTierRequest, SubscriptionTierModel>()
|
||||
.MapMemberFrom(x => x.Name, x => x.Name)
|
||||
.MapMemberFrom(x => x.Level, x => x.Level)
|
||||
.MapMemberFrom(x => x.MonthlyPrice, x => x.MonthlyPrice)
|
||||
.MapMemberFrom(x => x.Description, x => x.Description);
|
||||
|
||||
config.CreateMap<SubscriptionModel, ApiSubscription>()
|
||||
.MapCtorParamFrom(x => x.SubscribedToId, x => x.SubscribedToId)
|
||||
.MapCtorParamFrom(x => x.SubscribedToName, x => x.SubscribedTo.Profile.DisplayName)
|
||||
.MapCtorParamFrom(x => x.CurrentTier, x => x.SubscriptionTier);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
OnlyPrompt.Backend/Utils/AutomapperExtensions.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OnlyPrompt.Backend.Utils
|
||||
{
|
||||
public static class AutomapperExtensions
|
||||
{
|
||||
|
||||
public static IMappingExpression<TSource, TDestination> CreateUpdateMap<TSource, TDestination>(this IMapperConfigurationExpression cfg, MemberList memberList = MemberList.Source)
|
||||
{
|
||||
return cfg.CreateMap<TSource, TDestination>(memberList)
|
||||
.IgnoreNullMembers();
|
||||
}
|
||||
|
||||
public static IMappingExpression<TSource, TDestination> MapMemberFrom<TSource, TDestination, TMember, TSourceMember>(this IMappingExpression<TSource, TDestination> mapping, Expression<Func<TDestination, TMember>> destinationMember, Expression<Func<TSource, TSourceMember>> sourceMember)
|
||||
{
|
||||
mapping.ForMember(destinationMember, x => x.MapFrom(sourceMember));
|
||||
return mapping;
|
||||
}
|
||||
|
||||
public static IMappingExpression<TSource, TDestination> IgnoreNullMembers<TSource, TDestination>(this IMappingExpression<TSource, TDestination> mapping)
|
||||
{
|
||||
mapping.ForAllMembers(opts => opts.Condition((src, dest, member) => src != null));
|
||||
return mapping;
|
||||
}
|
||||
|
||||
public static IMappingExpression<TSource, TDestination> MapCtorParamFrom<TSource, TDestination, TMember, TSourceMember>(this IMappingExpression<TSource, TDestination> mapping, Expression<Func<TDestination, TMember>> destinationMember, Expression<Func<TSource, TSourceMember>> sourceMember)
|
||||
{
|
||||
mapping.ForCtorParam(destinationMember, x => x.MapFrom(sourceMember));
|
||||
return mapping;
|
||||
}
|
||||
|
||||
public static IMappingExpression<TSource, TDestination> ForCtorParam<TSource, TDestination, DValue>(this IMappingExpression<TSource, TDestination> mapping, Expression<Func<TDestination, DValue>> paramSelector, Action<ICtorParamConfigurationExpression<TSource>> configure)
|
||||
{
|
||||
var ctorParamName = ((MemberExpression)paramSelector.Body).Member.Name;
|
||||
mapping.ForCtorParam(ctorParamName, configure);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
28
OnlyPrompt.Backend/Utils/EntityExtensions.cs
Normal file
@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OnlyPrompt.Backend.Database.Core;
|
||||
|
||||
namespace OnlyPrompt.Backend.Utils
|
||||
{
|
||||
public static class EntityExtensions
|
||||
{
|
||||
public static Task<T?> FindBySlugAsync<T>(this IQueryable<T> queryable, string slug) where T : class, IHasSlug
|
||||
{
|
||||
return queryable.FirstOrDefaultAsync(e => e.Slug == slug);
|
||||
}
|
||||
|
||||
public static Task<T?> FindByIdentifierAsync<T>(this IQueryable<T> queryable, Identifier identifier) where T : class, IHasSlug, IEntity
|
||||
{
|
||||
if (identifier.Id.HasValue)
|
||||
return queryable.FirstOrDefaultAsync(e => e.Id == identifier.Id.Value);
|
||||
|
||||
return queryable.FindBySlugAsync(identifier.Slug);
|
||||
}
|
||||
|
||||
public static IQueryable<T> OfIdentifer<T>(this IQueryable<T> queryable, Identifier identifier) where T : class, IHasSlug, IEntity
|
||||
{
|
||||
if (identifier.Id.HasValue)
|
||||
return queryable.Where(e => e.Id == identifier.Id.Value);
|
||||
return queryable.Where(e => e.Slug == identifier.Slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
80
OnlyPrompt.Backend/Utils/Extensions.cs
Normal file
@ -0,0 +1,80 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using OnlyPrompt.Backend.Database.Models;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq.Expressions;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace OnlyPrompt.Backend.Utils
|
||||
{
|
||||
public static class Extensions
|
||||
{
|
||||
public static string? GetIdentifier(this ClaimsPrincipal principal) => principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
public static bool TryGetIdentifier(this ClaimsPrincipal principal, [NotNullWhen(true)]out string? identifier)
|
||||
{
|
||||
identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return identifier != null;
|
||||
}
|
||||
|
||||
public static Guid? GetUserId(this ClaimsPrincipal principal)
|
||||
{
|
||||
if (principal.TryGetIdentifier(out var identifier) && Guid.TryParse(identifier, out var userId))
|
||||
return userId;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static IEnumerable<Claim> GetClaims(this UserModel user)
|
||||
{
|
||||
yield return new Claim(ClaimTypes.NameIdentifier, user.Id.ToString());
|
||||
yield return new Claim(ClaimTypes.Name, user.UserName);
|
||||
yield return new Claim(ClaimTypes.Email, user.Email);
|
||||
|
||||
foreach (var role in user.Roles)
|
||||
yield return new Claim(ClaimTypes.Role, role);
|
||||
}
|
||||
|
||||
public static IQueryable<T> OrderBy<T, TKey>(this IQueryable<T> source, Expression<Func<T, TKey>> selecter, bool ascending)
|
||||
{
|
||||
if(ascending)
|
||||
return source.OrderBy(selecter);
|
||||
else
|
||||
return source.OrderByDescending(selecter);
|
||||
}
|
||||
|
||||
public static CookieOptions Copy(this CookieOptions options, Action<CookieOptions>? modify = null)
|
||||
{
|
||||
var newOptions = new CookieOptions
|
||||
{
|
||||
Domain = options.Domain,
|
||||
Expires = options.Expires,
|
||||
HttpOnly = options.HttpOnly,
|
||||
IsEssential = options.IsEssential,
|
||||
MaxAge = options.MaxAge,
|
||||
Path = options.Path,
|
||||
SameSite = options.SameSite,
|
||||
Secure = options.Secure
|
||||
};
|
||||
|
||||
modify?.Invoke(newOptions);
|
||||
return newOptions;
|
||||
}
|
||||
|
||||
public static string Limit(this string @string, int maxLength)
|
||||
{
|
||||
if (@string.Length <= maxLength)
|
||||
return @string;
|
||||
return @string.Substring(0, maxLength);
|
||||
}
|
||||
|
||||
public const string LowerAlphabet = "abcdefghijklmnopqrstuvwxyz";
|
||||
public static string GetString(this Random @random, int lenght, string alphabet = LowerAlphabet)
|
||||
{
|
||||
Span<char> chars = stackalloc char[lenght];
|
||||
for (int i = 0; i < lenght; i++)
|
||||
chars[i] = alphabet[@random.Next(alphabet.Length)];
|
||||
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
OnlyPrompt.Backend/Utils/Identifier.cs
Normal file
@ -0,0 +1,34 @@
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
namespace OnlyPrompt.Backend.Utils
|
||||
{
|
||||
public struct Identifier
|
||||
{
|
||||
public string Slug { get; init; }
|
||||
public Guid? Id { get; init; }
|
||||
|
||||
public Identifier(string slug)
|
||||
{
|
||||
this.Slug = slug;
|
||||
this.Id = null;
|
||||
}
|
||||
|
||||
public Identifier(Guid guid)
|
||||
{
|
||||
this.Id = guid;
|
||||
this.Slug = string.Empty;
|
||||
}
|
||||
|
||||
public static implicit operator Identifier(string slug) => new Identifier(slug);
|
||||
public static implicit operator Identifier(Guid guid) => new Identifier(guid);
|
||||
|
||||
public static bool TryParse(string input, out Identifier identifier)
|
||||
{
|
||||
identifier= new Identifier(input);
|
||||
if (Guid.TryParse(input, out var guid))
|
||||
identifier = new Identifier(guid);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
OnlyPrompt.Backend/Utils/SlugHelper.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace OnlyPrompt.Backend.Utils
|
||||
{
|
||||
public static class SlugHelper
|
||||
{
|
||||
private static readonly Regex InvalidCharacters = new(@"[^a-z0-9\-]", RegexOptions.Compiled);
|
||||
private static readonly Regex MultipleDashes = new(@"-+", RegexOptions.Compiled);
|
||||
|
||||
public static string GenerateSlug(string input, int? maxLength = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return string.Empty;
|
||||
|
||||
var slug = input.ToLowerInvariant().Replace(" ", "-").Replace("_", "-");
|
||||
slug = InvalidCharacters.Replace(slug, string.Empty);
|
||||
slug = MultipleDashes.Replace(slug, "-");
|
||||
slug = slug.Trim('-');
|
||||
if (maxLength.HasValue)
|
||||
slug = slug.Limit(maxLength.Value);
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
public static async Task<string> GenerateUniqueSlugAsync(string input, Func<string, Task<bool>> existsFunc, int? maxLenght)
|
||||
{
|
||||
var slug = GenerateSlug(input);
|
||||
var exists = await existsFunc(slug);
|
||||
if (exists)
|
||||
{
|
||||
var suffix = Random.Shared.GetString(8, SuffixChars);
|
||||
if (maxLenght.HasValue)
|
||||
slug = slug.Limit(maxLenght.Value - 9);
|
||||
|
||||
slug = $"{slug}-{suffix}";
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
OnlyPrompt.Backend/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
OnlyPrompt.Backend/appsettings.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Include Error Detail=true;User ID=onlyprompt;Password=onlyprompt;Host=localhost;Port=1803;Database=onlyprompt;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "https://onlyprompts.com",
|
||||
"Audience": "https://onlyprompts.com",
|
||||
"Key": "TfZi@!CC!b5UoD81gs&%tvY4J0M$p3cI",
|
||||
"Valid": "3.0:0:0"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
16
OnlyPrompt.Frontend/.vscode/launch.json
vendored
Normal file
@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
OnlyPrompt.Frontend/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.indentSize": 2
|
||||
}
|
||||
129
OnlyPrompt.Frontend/chats.html
Normal file
@ -0,0 +1,129 @@
|
||||
<!-- OnlyPrompt - Chats page:
|
||||
- Direct messaging interface with conversation list and active chat window -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OnlyPrompt - Chats</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/chats.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex:1; display: flex; flex-direction: column;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="chats-main">
|
||||
<!-- Chat Container: Left column (list) + Right column (active chat) -->
|
||||
<div class="chat-container">
|
||||
|
||||
<!-- Left Column: Chat Overview -->
|
||||
<div class="chat-list">
|
||||
<div class="chat-list-header">
|
||||
<h2>Messages</h2>
|
||||
<button class="new-chat-btn"><i class="bi bi-pencil-square"></i></button>
|
||||
</div>
|
||||
<div class="chat-list-items">
|
||||
<!-- Chat Entry 1 (active) -->
|
||||
<div class="chat-item active">
|
||||
<img src="../images/content/creator2.png" alt="Alex Chen" class="chat-avatar">
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">Alex Chen</div>
|
||||
<div class="chat-last-msg">Hey Sarah! Really loved your last video on minimalism...</div>
|
||||
</div>
|
||||
<div class="chat-time">10:17 AM</div>
|
||||
</div>
|
||||
<!-- Chat Entry 2 -->
|
||||
<div class="chat-item">
|
||||
<img src="../images/content/creator3.png" alt="Mia Wong" class="chat-avatar">
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">Mia Wong</div>
|
||||
<div class="chat-last-msg">Thanks for the prompt tips! They worked perfectly.</div>
|
||||
</div>
|
||||
<div class="chat-time">Yesterday</div>
|
||||
</div>
|
||||
<!-- Chat Entry 3 -->
|
||||
<div class="chat-item">
|
||||
<img src="../images/content/creator4.png" alt="Tom Rivera" class="chat-avatar">
|
||||
<div class="chat-item-info">
|
||||
<div class="chat-name">Tom Rivera</div>
|
||||
<div class="chat-last-msg">Let's schedule a call for the collab?</div>
|
||||
</div>
|
||||
<div class="chat-time">Yesterday</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Active Chat (with Alex Chen) -->
|
||||
<div class="chat-active">
|
||||
<div class="chat-header">
|
||||
<img src="../images/content/creator2.png" alt="Alex Chen" class="chat-avatar-large">
|
||||
<div class="chat-header-info">
|
||||
<div class="chat-header-name">Alex Chen</div>
|
||||
<div class="chat-header-status"><span class="online-dot"></span> Online</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-messages">
|
||||
<!-- Message from Alex -->
|
||||
<div class="message received">
|
||||
<div class="message-bubble">Hey Sarah! Really loved your last video on minimalism. Quick question about your workspace layout?</div>
|
||||
<div class="message-time">10:15 AM</div>
|
||||
</div>
|
||||
<!-- Reply from Sarah -->
|
||||
<div class="message sent">
|
||||
<div class="message-bubble">Thanks Alex! Appreciate it. Yes, happy to share! The desk is from Article, and the shelving unit is custom-built. Highly recommend a clean setup!</div>
|
||||
<div class="message-time">10:16 AM</div>
|
||||
</div>
|
||||
<!-- Alex replies -->
|
||||
<div class="message received">
|
||||
<div class="message-bubble">Thanks so much! Your aesthetic is exactly what I'm aiming for. Can't wait for your next piece!</div>
|
||||
<div class="message-time">10:17 AM</div>
|
||||
</div>
|
||||
<!-- Sarah replies -->
|
||||
<div class="message sent">
|
||||
<div class="message-bubble">Awesome! Let me know if you need more tips. Enjoy the process! 😊</div>
|
||||
<div class="message-time">10:18 AM</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<input type="text" placeholder="Type your message...">
|
||||
<button class="send-btn">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
fetch('../html/sidebar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
document.getElementById('sidebar-container').innerHTML = data;
|
||||
// Remove 'active' from all sidebar links
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
});
|
||||
// Set 'active' on the Chats link (4th link, index 3)
|
||||
const chatsLink = document.querySelectorAll('#sidebar-container .sidebar li a')[3];
|
||||
if (chatsLink) chatsLink.classList.add('active');
|
||||
});
|
||||
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
161
OnlyPrompt.Frontend/community.html
Normal file
@ -0,0 +1,161 @@
|
||||
<!-- OnlyPrompt - Marketplace page:
|
||||
- Browse and filter AI prompts with buy/view details buttons and pricing -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OnlyPrompt - Discover Creators</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/community.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex:1; margin:40px auto; max-width:950px;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="creators-main">
|
||||
|
||||
<!-- Header / Titel -->
|
||||
<div class="creators-header">
|
||||
<h1>Discover Creators</h1>
|
||||
<p>Follow your favorite prompt artists and get inspired.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active">Popular</button>
|
||||
<button class="filter-btn">Rising</button>
|
||||
<button class="filter-btn">New</button>
|
||||
<button class="filter-btn">Top Rated</button>
|
||||
</div>
|
||||
|
||||
<!-- Creators Grid -->
|
||||
<div class="creators-grid">
|
||||
|
||||
<!-- Creator Card 1 -->
|
||||
<div class="creator-card">
|
||||
<img src="../images/content/creator1.png" alt="Sarah Jenkins" class="creator-avatar">
|
||||
<div class="creator-info">
|
||||
<h3 class="creator-name">Sarah Jenkins</h3>
|
||||
<div class="creator-handle">@sarahj</div>
|
||||
<p class="creator-bio">AI Explorer | Prompt Curator | Exploring creativity through generative AI.</p>
|
||||
<div class="creator-stats">
|
||||
<span><i class="bi bi-puzzle"></i> 42 prompts</span>
|
||||
<span><i class="bi bi-star-fill"></i> 4.9</span>
|
||||
</div>
|
||||
<button class="follow-btn">Follow</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator Card 2 -->
|
||||
<div class="creator-card">
|
||||
<img src="../images/content/creator2.png" alt="Alex Chen" class="creator-avatar">
|
||||
<div class="creator-info">
|
||||
<h3 class="creator-name">Alex Chen</h3>
|
||||
<div class="creator-handle">@alexchen</div>
|
||||
<p class="creator-bio">Digital artist & prompt engineer. Creating surreal landscapes.</p>
|
||||
<div class="creator-stats">
|
||||
<span><i class="bi bi-puzzle"></i> 87 prompts</span>
|
||||
<span><i class="bi bi-star-fill"></i> 4.8</span>
|
||||
</div>
|
||||
<button class="follow-btn">Follow</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator Card 3 -->
|
||||
<div class="creator-card">
|
||||
<img src="../images/content/creator3.png" alt="Mia Wong" class="creator-avatar">
|
||||
<div class="creator-info">
|
||||
<h3 class="creator-name">Mia Wong</h3>
|
||||
<div class="creator-handle">@miawong</div>
|
||||
<p class="creator-bio">Midjourney master | UI/UX prompts | Design systems.</p>
|
||||
<div class="creator-stats">
|
||||
<span><i class="bi bi-puzzle"></i> 124 prompts</span>
|
||||
<span><i class="bi bi-star-fill"></i> 5.0</span>
|
||||
</div>
|
||||
<button class="follow-btn">Follow</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator Card 4 -->
|
||||
<div class="creator-card">
|
||||
<img src="../images/content/creator4.png" alt="Tom Rivera" class="creator-avatar">
|
||||
<div class="creator-info">
|
||||
<h3 class="creator-name">Tom Rivera</h3>
|
||||
<div class="creator-handle">@tomrivera</div>
|
||||
<p class="creator-bio">3D artist | Character design | Sci-fi & fantasy prompts.</p>
|
||||
<div class="creator-stats">
|
||||
<span><i class="bi bi-puzzle"></i> 33 prompts</span>
|
||||
<span><i class="bi bi-star-fill"></i> 4.7</span>
|
||||
</div>
|
||||
<button class="follow-btn">Follow</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator Card 5 -->
|
||||
<div class="creator-card">
|
||||
<img src="../images/content/creator5.png" alt="Emma Watson" class="creator-avatar">
|
||||
<div class="creator-info">
|
||||
<h3 class="creator-name">Emma Watson</h3>
|
||||
<div class="creator-handle">@emmawatson</div>
|
||||
<p class="creator-bio">Watercolor & pet portraits | Whimsical art prompts.</p>
|
||||
<div class="creator-stats">
|
||||
<span><i class="bi bi-puzzle"></i> 56 prompts</span>
|
||||
<span><i class="bi bi-star-fill"></i> 4.9</span>
|
||||
</div>
|
||||
<button class="follow-btn">Follow</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator Card 6 -->
|
||||
<div class="creator-card">
|
||||
<img src="../images/content/creator6.png" alt="Liam O'Brien" class="creator-avatar">
|
||||
<div class="creator-info">
|
||||
<h3 class="creator-name">Liam O'Brien</h3>
|
||||
<div class="creator-handle">@liamob</div>
|
||||
<p class="creator-bio">Minimalist logo designer | Brand identity prompts.</p>
|
||||
<div class="creator-stats">
|
||||
<span><i class="bi bi-puzzle"></i> 28 prompts</span>
|
||||
<span><i class="bi bi-star-fill"></i> 4.6</span>
|
||||
</div>
|
||||
<button class="follow-btn">Follow</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
fetch('../html/sidebar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
document.getElementById('sidebar-container').innerHTML = data;
|
||||
// Remove 'active' from all sidebar links
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
});
|
||||
// Set 'active' on the third link (Community)
|
||||
const thirdLink = document.querySelectorAll('#sidebar-container .sidebar li a')[2];
|
||||
if (thirdLink) thirdLink.classList.add('active');
|
||||
});
|
||||
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
181
OnlyPrompt.Frontend/create.html
Normal file
@ -0,0 +1,181 @@
|
||||
<!-- OnlyPrompt - Create Prompt page:
|
||||
- Form to publish new AI prompts with title, description, category, content, example output, image upload, and pricing toggle -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OnlyPrompt - Create New Prompt</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/create.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex:1; display: flex; flex-direction: column;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="create-main">
|
||||
<div class="create-container">
|
||||
<div class="create-header">
|
||||
<h1>Create AI Prompt</h1>
|
||||
<p>Design and save custom prompts for your AI workflows.</p>
|
||||
</div>
|
||||
|
||||
<form id="createPromptForm" class="create-form" enctype="multipart/form-data">
|
||||
<!-- Title -->
|
||||
<div class="form-group">
|
||||
<label for="title">Prompt Title *</label>
|
||||
<input type="text" id="title" name="title" placeholder="e.g., Write an inspiring startup story about innovation" required>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<label for="description">Description *</label>
|
||||
<textarea id="description" name="description" rows="2" placeholder="Draft a narrative about a small team overcoming challenges to launch a groundbreaking app" required></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div class="form-group">
|
||||
<label for="category">Category *</label>
|
||||
<select id="category" name="category" required>
|
||||
<option value="creative-writing">Creative Writing</option>
|
||||
<option value="coding">Coding</option>
|
||||
<option value="art">Art</option>
|
||||
<option value="marketing">Marketing</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="data">Data</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Prompt Content -->
|
||||
<div class="form-group">
|
||||
<label for="promptContent">Prompt Content *</label>
|
||||
<textarea id="promptContent" name="promptContent" rows="6" placeholder="Write your prompt instructions here..." required></textarea>
|
||||
<small class="form-hint">Use clear, step-by-step instructions for the AI.</small>
|
||||
</div>
|
||||
|
||||
<!-- Example Output (Text) -->
|
||||
<div class="form-group">
|
||||
<label for="exampleOutput">Example Output (optional)</label>
|
||||
<textarea id="exampleOutput" name="exampleOutput" rows="4" placeholder="Show an example of what the AI might generate..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Example Image (optional) -->
|
||||
<div class="form-group">
|
||||
<label for="exampleImage">Example Image (optional)</label>
|
||||
<input type="file" id="exampleImage" name="exampleImage" accept="image/png, image/jpeg, image/jpg">
|
||||
<small class="form-hint">Upload a PNG or JPG – preview will appear below.</small>
|
||||
<div id="imagePreview" style="margin-top: 10px; display: none;">
|
||||
<img id="previewImg" src="#" alt="Preview" style="max-width: 100%; max-height: 200px; border-radius: 12px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing (with toggle) -->
|
||||
<div class="form-group pricing-group">
|
||||
<label>Pricing</label>
|
||||
<div class="pricing-toggle">
|
||||
<button type="button" id="freeBtn" class="price-option active">Free</button>
|
||||
<button type="button" id="paidBtn" class="price-option">Paid</button>
|
||||
</div>
|
||||
<div id="priceField" style="display: none;">
|
||||
<input type="number" id="price" name="price" step="0.01" min="0" placeholder="Price in USD (e.g., 19.99)">
|
||||
</div>
|
||||
<small class="form-hint">You can set a price later or keep it free.</small>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn">Publish Prompt</button>
|
||||
<button type="button" class="cancel-btn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toggle between free and paid
|
||||
const freeBtn = document.getElementById('freeBtn');
|
||||
const paidBtn = document.getElementById('paidBtn');
|
||||
const priceField = document.getElementById('priceField');
|
||||
const priceInput = document.getElementById('price');
|
||||
|
||||
freeBtn.addEventListener('click', () => {
|
||||
freeBtn.classList.add('active');
|
||||
paidBtn.classList.remove('active');
|
||||
priceField.style.display = 'none';
|
||||
priceInput.removeAttribute('required');
|
||||
});
|
||||
paidBtn.addEventListener('click', () => {
|
||||
paidBtn.classList.add('active');
|
||||
freeBtn.classList.remove('active');
|
||||
priceField.style.display = 'block';
|
||||
priceInput.setAttribute('required', 'required');
|
||||
});
|
||||
|
||||
// Image preview for example image
|
||||
const imageInput = document.getElementById('exampleImage');
|
||||
const imagePreview = document.getElementById('imagePreview');
|
||||
const previewImg = document.getElementById('previewImg');
|
||||
|
||||
if (imageInput) {
|
||||
imageInput.addEventListener('change', function(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file && (file.type === 'image/png' || file.type === 'image/jpeg' || file.type === 'image/jpg')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
previewImg.src = e.target.result;
|
||||
imagePreview.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
imagePreview.style.display = 'none';
|
||||
previewImg.src = '#';
|
||||
if (file) alert('Please upload a PNG or JPG image.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle form submission (demo only)
|
||||
document.getElementById('createPromptForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
alert('Prompt published! (Demo)');
|
||||
// Here you would normally send data to a backend (including the image file)
|
||||
});
|
||||
|
||||
// Cancel button (go back)
|
||||
document.querySelector('.cancel-btn').addEventListener('click', () => {
|
||||
window.history.back();
|
||||
});
|
||||
|
||||
// Fetch sidebar and topbar
|
||||
fetch('../html/sidebar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
document.getElementById('sidebar-container').innerHTML = data;
|
||||
// Remove active class from all sidebar links
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
});
|
||||
// Optionally set active on "Create New" if it exists, otherwise keep none
|
||||
const createLink = document.querySelector('#sidebar-container a[href="create.html"]');
|
||||
if (createLink) createLink.classList.add('active');
|
||||
});
|
||||
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
41
OnlyPrompt.Frontend/css/base.css
Normal file
@ -0,0 +1,41 @@
|
||||
/*
|
||||
This file contains global base styles and resets
|
||||
--> ensures consistent spacing, font usage, and box sizing
|
||||
across all elements in the application
|
||||
*/
|
||||
|
||||
/* Reset default browser styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Global body styling */
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Form errors */
|
||||
.form-error {
|
||||
color: red;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-error ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
list-style: '*';
|
||||
}
|
||||
|
||||
.form-error li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.form-error li .error {
|
||||
color: red;
|
||||
font-style: italic;
|
||||
}
|
||||
247
OnlyPrompt.Frontend/css/chats.css
Normal file
@ -0,0 +1,247 @@
|
||||
/* Chats page - Two column layout: chat list + active chat window */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.chats-main {
|
||||
flex: 1;
|
||||
padding: 20px 32px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Chat container (flex, two columns) */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 120px); /* Adjust based on topbar height */
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* LEFT COLUMN: Chat list */
|
||||
.chat-list {
|
||||
width: 320px;
|
||||
border-right: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chat-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.chat-list-header h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.new-chat-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-list-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
.chat-item:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.chat-item.active {
|
||||
background: #eef2ff;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.chat-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chat-last-msg {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chat-time {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* RIGHT COLUMN: Active chat */
|
||||
.chat-active {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.chat-avatar-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.chat-header-info {
|
||||
flex: 1;
|
||||
}
|
||||
.chat-header-name {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.chat-header-status {
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.online-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #10b981;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Chat messages area */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70%;
|
||||
}
|
||||
.message.received {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.message.sent {
|
||||
align-items: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
.message-bubble {
|
||||
padding: 10px 14px;
|
||||
border-radius: 18px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
}
|
||||
.message.sent .message-bubble {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* Input area */
|
||||
.chat-input-area {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
background: #fff;
|
||||
}
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 30px;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
.chat-input-area input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.send-btn {
|
||||
background: var(--gradient);
|
||||
border: none;
|
||||
padding: 0 24px;
|
||||
border-radius: 30px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.send-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.chats-main {
|
||||
padding: 16px;
|
||||
}
|
||||
.chat-list {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.chat-container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
.chat-list {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.chat-active {
|
||||
height: 500px;
|
||||
}
|
||||
}
|
||||
170
OnlyPrompt.Frontend/css/community.css
Normal file
@ -0,0 +1,170 @@
|
||||
/* Creators page - Discover creators, filter buttons, creator cards */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.creators-main {
|
||||
background: transparent !important;
|
||||
padding: 20px 32px !important;
|
||||
margin: 0 auto !important;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.creators-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.creators-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.creators-header p {
|
||||
color: #64748b;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Filter buttons */
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.filter-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-radius: 30px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.filter-btn.active {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
.filter-btn:hover:not(.active) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Creators grid */
|
||||
.creators-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Creator card */
|
||||
.creator-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.creator-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
|
||||
}
|
||||
|
||||
.creator-avatar {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.creator-info {
|
||||
flex: 1;
|
||||
}
|
||||
.creator-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.creator-handle {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.creator-bio {
|
||||
color: #334155;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.creator-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.creator-stats i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.follow-btn {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 6px 16px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.follow-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.creators-main {
|
||||
padding: 16px !important;
|
||||
}
|
||||
.filter-buttons {
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.creators-main {
|
||||
padding: 12px !important;
|
||||
}
|
||||
.creator-card {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.creator-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
.follow-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
155
OnlyPrompt.Frontend/css/create.css
Normal file
@ -0,0 +1,155 @@
|
||||
/* Create page - Form for publishing new AI prompts */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.create-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px 32px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.create-container {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
padding: 32px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.create-container:hover {
|
||||
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.create-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.create-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.create-header p {
|
||||
color: #64748b;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
background: #ffffff;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124,58,237,0.1);
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Pricing toggle */
|
||||
.pricing-group .pricing-toggle {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.price-option {
|
||||
background: #f1f5f9;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 30px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #475569;
|
||||
}
|
||||
.price-option.active {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
#priceField {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.submit-btn, .cancel-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.submit-btn {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
.cancel-btn {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.submit-btn:hover, .cancel-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.create-container {
|
||||
padding: 24px;
|
||||
}
|
||||
.create-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.create-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
217
OnlyPrompt.Frontend/css/dashboard.css
Normal file
@ -0,0 +1,217 @@
|
||||
/* Feed page - Multi-column grid, square images, like/comment/save actions */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.feed-main {
|
||||
background: transparent !important;
|
||||
padding: 20px 32px !important;
|
||||
margin: 0 auto !important;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Feed Header (centered) */
|
||||
.feed-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.feed-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.feed-header p {
|
||||
color: #64748b;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Filter Buttons (centered) */
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.filter-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-radius: 30px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.filter-btn.active {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
.filter-btn:hover:not(.active) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Posts Grid - 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;
|
||||
}
|
||||
}
|
||||
161
OnlyPrompt.Frontend/css/login.css
Normal file
@ -0,0 +1,161 @@
|
||||
/*
|
||||
File contains the styles for the login page
|
||||
--> defines the layout of the login screen
|
||||
*/
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex; /* enables flexbox layout for centering */
|
||||
justify-content: center; /* horizontally centers the card */
|
||||
align-items: center; /* vertically centers the card */
|
||||
padding: 24px; /* space inside the page edges */
|
||||
/* Layered background: two soft radial gradients for color accents, then the main background color */
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 35%),
|
||||
radial-gradient(circle at bottom right, rgba(236, 72, 153, 0.10), transparent 30%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* Main login card container */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 430px; /* prevents the card from getting too wide on large screens */
|
||||
background: var(--card); /* uses card color from variables.css */
|
||||
border-radius: 24px; /* rounded corners */
|
||||
box-shadow: var(--shadow); /* soft shadow for card elevation */
|
||||
padding: 40px 32px; /* inner spacing for content */
|
||||
}
|
||||
|
||||
/* Logo area above the form */
|
||||
.login-logo-wrapper {
|
||||
display: flex; /* centers logo horizontally */
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
overflow: hidden; /* hides any part of the logo that overflows the container */
|
||||
}
|
||||
|
||||
/* Full logo styling */
|
||||
.login-logo {
|
||||
width: 100%;
|
||||
max-width: 220px; /* logo never exceeds this width */
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: contain; /* keeps logo aspect ratio, prevents stretching */
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* Form layout */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column; /* stack form fields vertically */
|
||||
gap: 18px; /* vertical space between form groups */
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column; /* label above input */
|
||||
gap: 8px; /* space between label and input */
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 14px 16px; /* vertical and horizontal padding for input */
|
||||
border: 1px solid #dbe2ea; /* subtle border */
|
||||
border-radius: 14px; /* rounded input corners */
|
||||
background: #ffffff;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Highlight input when focused */
|
||||
.form-group input:focus {
|
||||
outline: none; /* removes default browser outline */
|
||||
border-color: #7c3aed; /* purple border on focus */
|
||||
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.10); /* soft glow for focus state */
|
||||
}
|
||||
|
||||
/* Password field with button inside the same row */
|
||||
.password-wrapper {
|
||||
display: flex; /* input and button in one row */
|
||||
align-items: center; /* vertically center input and button */
|
||||
gap: 10px; /* space between input and show/hide button */
|
||||
}
|
||||
|
||||
.password-wrapper input {
|
||||
flex: 1; /* input takes all available space, button stays compact */
|
||||
}
|
||||
|
||||
.toggle-password {
|
||||
border: none;
|
||||
background: transparent; /* no background for button */
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
cursor: pointer; /* pointer cursor for better UX */
|
||||
}
|
||||
|
||||
/* Main login action button */
|
||||
.login-button {
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
background: var(--gradient); /* uses a gradient background from variables */
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.22); /* blue shadow for button depth */
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
opacity: 0.95; /* slight fade on hover for feedback */
|
||||
}
|
||||
|
||||
.signup-text {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
/* used for the 'Don't have an account?' and link below the form */
|
||||
}
|
||||
|
||||
.signup-text a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
/* blue link for sign up/login */
|
||||
}
|
||||
|
||||
/* Smaller spacing and sizing for narrow screens */
|
||||
@media (max-width: 480px) {
|
||||
/* Responsive adjustments for small screens (mobile) */
|
||||
.login-card {
|
||||
padding: 28px 20px; /* less padding on mobile */
|
||||
border-radius: 20px; /* slightly less rounded */
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.7rem; /* smaller title on mobile */
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
max-width: 170px; /* smaller logo on mobile */
|
||||
}
|
||||
}
|
||||
225
OnlyPrompt.Frontend/css/marketplace.css
Normal file
@ -0,0 +1,225 @@
|
||||
/* Marketplace Page - Prompt cards, filter buttons, full width layout */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.marketplace-main {
|
||||
background: transparent !important;
|
||||
padding: 20px 32px !important;
|
||||
margin: 0 auto !important;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Header centering */
|
||||
.marketplace-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.marketplace-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.marketplace-header p {
|
||||
color: #64748b;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Filter + Sort Row */
|
||||
.filter-sort-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Filter buttons - centered */
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-radius: 30px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.filter-btn.active {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
.filter-btn:hover:not(.active) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Sort Dropdown - right aligned */
|
||||
.sort-dropdown {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 30px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Prompts grid */
|
||||
.prompts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Prompt card */
|
||||
.prompt-card {
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.prompt-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
|
||||
}
|
||||
|
||||
.prompt-img {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.prompt-info {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prompt-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prompt-author {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.prompt-description {
|
||||
color: #334155;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prompt-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: #f59e0b;
|
||||
}
|
||||
.prompt-rating span:first-child i {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.prompt-rating span:last-child {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.prompt-price {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.prompt-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.buy-btn, .details-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.buy-btn {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
.details-btn {
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
.buy-btn:hover, .details-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 700px) {
|
||||
.filter-sort-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.sort-dropdown {
|
||||
align-self: flex-end;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.marketplace-main {
|
||||
padding: 16px !important;
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.marketplace-main {
|
||||
padding: 12px !important;
|
||||
}
|
||||
.prompt-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
217
OnlyPrompt.Frontend/css/post-detail.css
Normal file
@ -0,0 +1,217 @@
|
||||
/* Post Detail page - Full prompt view, rating, example output, unlock button */
|
||||
|
||||
/* Full width layout */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.post-detail-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 32px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.post-detail-container {
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
padding: 32px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.post-detail-container:hover {
|
||||
box-shadow: 0 8px 20px rgba(59,130,246,0.12);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.post-header {
|
||||
margin-bottom: 28px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.post-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.post-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.category {
|
||||
background: #f1f5f9;
|
||||
padding: 4px 12px;
|
||||
border-radius: 30px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.updated {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.post-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
}
|
||||
.post-stats i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* Prompt Section */
|
||||
.prompt-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.prompt-section h2 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.prompt-content {
|
||||
background: #f8fafc;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
.prompt-content ul {
|
||||
margin-top: 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.prompt-content li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Rating & Like */
|
||||
.rating-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
.rating-stars {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.rating-stars i {
|
||||
color: #f59e0b;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.like-btn {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 8px 18px;
|
||||
border-radius: 30px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.like-btn:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
.like-btn i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* Example Output Section */
|
||||
.example-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.example-section h2 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.example-content {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
.example-content h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.example-output-text {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
}
|
||||
.example-output-text p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.example-image {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.example-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Unlock Section */
|
||||
.unlock-section {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.unlock-btn {
|
||||
background: var(--gradient);
|
||||
border: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 40px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.unlock-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.post-detail-main {
|
||||
padding: 16px;
|
||||
}
|
||||
.post-detail-container {
|
||||
padding: 24px;
|
||||
}
|
||||
.post-title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.post-detail-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.rating-section {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.prompt-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
94
OnlyPrompt.Frontend/css/profile.css
Normal file
@ -0,0 +1,94 @@
|
||||
/* Profile Page - Full width layout, darker share button, responsive grid */
|
||||
|
||||
/* Force main content container to full width, remove centering and max-width */
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Inner spacing for the profile card */
|
||||
.profile-main {
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
padding: 20px 32px !important;
|
||||
margin: 0 auto !important;
|
||||
width: 100%;
|
||||
max-width: 1600px; /* Limits content on very large screens, but still wide */
|
||||
}
|
||||
|
||||
/* Make prompts grid use more columns on large screens */
|
||||
.profile-main section:last-child {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)) !important;
|
||||
gap: 24px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Share button: darker background and text */
|
||||
.profile-header button:last-child {
|
||||
background: #cbd5e1 !important; /* darker gray */
|
||||
color: #1e293b !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Buttons keep rounded corners */
|
||||
.login-button {
|
||||
border-radius: 14px !important;
|
||||
}
|
||||
|
||||
/* Prompt cards: rounded corners */
|
||||
.profile-main section > div {
|
||||
border-radius: 18px !important;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
}
|
||||
|
||||
/* Prompt images: rounded corners */
|
||||
.profile-main section img {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
/* Avatar remains round */
|
||||
.profile-avatar {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
/* All outer containers stay square */
|
||||
.layout,
|
||||
.profile-main,
|
||||
.profile-header,
|
||||
.profile-tabs,
|
||||
nav {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Responsive: tablets */
|
||||
@media (max-width: 768px) {
|
||||
.profile-main {
|
||||
padding: 16px !important;
|
||||
}
|
||||
.profile-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
.profile-header > div:last-child {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive: mobile */
|
||||
@media (max-width: 480px) {
|
||||
.profile-main {
|
||||
padding: 12px !important;
|
||||
}
|
||||
.profile-header > div:last-child {
|
||||
flex-direction: column;
|
||||
}
|
||||
.profile-main section:last-child {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
184
OnlyPrompt.Frontend/css/settings.css
Normal file
@ -0,0 +1,184 @@
|
||||
/* Settings page - tabs, form styling */
|
||||
|
||||
.layout > div[style*="flex:1"] {
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px 32px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.06);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.settings-header h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.settings-header p {
|
||||
color: #64748b;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
.tab-btn:hover:not(.active) {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* Tab content */
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 3px rgba(124,58,237,0.1);
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-label input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Avatar upload */
|
||||
.avatar-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.settings-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.upload-btn {
|
||||
background: #f1f5f9;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 30px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-btn:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Save button */
|
||||
.save-btn {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.save-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
padding: 24px;
|
||||
}
|
||||
.settings-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.settings-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.settings-tabs {
|
||||
gap: 4px;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.avatar-upload {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
170
OnlyPrompt.Frontend/css/sidebar.css
Normal file
@ -0,0 +1,170 @@
|
||||
/*
|
||||
Sidebar styles for OnlyPrompt
|
||||
- modern soft card look
|
||||
- responsive: full sidebar on desktop, icon-only on smaller screens
|
||||
- logout button appears directly after the last menu item with separator line
|
||||
*/
|
||||
|
||||
.sidebar-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #eef2f7;
|
||||
padding: 24px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 72px;
|
||||
margin-bottom: 32px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.sidebar-logo-full {
|
||||
max-width: 170px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Navigation – normal block layout, no flex grow */
|
||||
.sidebar {
|
||||
/* No flex:1 – keeps navigation at its natural height */
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
text-decoration: none;
|
||||
color: #475569;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar a:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.sidebar i {
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-blue {
|
||||
color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
.icon-purple {
|
||||
color: #8b5cf6 !important;
|
||||
}
|
||||
|
||||
.icon-pink {
|
||||
color: #ec4899 !important;
|
||||
}
|
||||
|
||||
/* Active item */
|
||||
.sidebar a.active {
|
||||
background: #eef2ff;
|
||||
color: #2563eb;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
/* Bottom logout area – directly after the menu, with separator line */
|
||||
.sidebar-bottom {
|
||||
margin-top: 16px; /* Small gap above the separator */
|
||||
border-top: 1px solid #eef2f7;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.sidebar-logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
text-decoration: none;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-logout:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.logout-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-logout i {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.logout-arrow {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Responsive: icon-only sidebar */
|
||||
@media (max-width: 900px) {
|
||||
.sidebar-shell {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.sidebar-logo-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-logo-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar .nav-text,
|
||||
.sidebar-logout .nav-text,
|
||||
.logout-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar a,
|
||||
.sidebar-logout {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sidebar a.active {
|
||||
border-left: none;
|
||||
border-right: 3px solid #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.sidebar a,
|
||||
.sidebar-logout {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
169
OnlyPrompt.Frontend/css/signup.css
Normal file
@ -0,0 +1,169 @@
|
||||
/*
|
||||
File contains the styles for the signup page
|
||||
--> defines the layout of the signup screen
|
||||
*/
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 35%),
|
||||
radial-gradient(circle at bottom right, rgba(236, 72, 153, 0.10), transparent 30%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* Main signup card container */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
background: var(--card); /*variable.css*/
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
/* Logo area above the form */
|
||||
.login-logo-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Full logo styling */
|
||||
.login-logo {
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* Form layout */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Highlight input when focused */
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.10);
|
||||
}
|
||||
|
||||
/* Password field with button inside the same row */
|
||||
.password-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.password-wrapper input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toggle-password {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.signup-terms {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin: 18px 0 0 0;
|
||||
}
|
||||
.signup-terms a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Main login action button */
|
||||
.login-button {
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.22);
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.signup-text {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.signup-text a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Smaller spacing and sizing for narrow screens */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 28px 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
max-width: 170px;
|
||||
}
|
||||
}
|
||||
125
OnlyPrompt.Frontend/css/topbar.css
Normal file
@ -0,0 +1,125 @@
|
||||
/*
|
||||
Topbar styles for OnlyPrompt
|
||||
- clean, modern, full-width
|
||||
- search bar centered (expands on full screen), profile avatar always on the right
|
||||
- ONLY search bar and avatar have rounded corners
|
||||
*/
|
||||
|
||||
.topbar-shell {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
flex: 1; /* Takes all available space */
|
||||
max-width: none; /* No upper limit, expands freely */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 10px 20px;
|
||||
border-radius: 14px; /* Rounded like login inputs */
|
||||
}
|
||||
|
||||
.topbar-search i {
|
||||
color: #94a3b8;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.topbar-search input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.topbar-search input::placeholder {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Icons and avatar container */
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topbar-icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.topbar-icon-btn:hover {
|
||||
background: #f1f5f9;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.topbar-avatar-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topbar-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 50%; /* Avatar round */
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.topbar-shell {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
.topbar-search {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.topbar-search i {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.topbar-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.topbar-icon-btn {
|
||||
font-size: 1.2rem;
|
||||
padding: 6px;
|
||||
}
|
||||
.topbar-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.topbar-shell {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.topbar-search {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
27
OnlyPrompt.Frontend/css/variables.css
Normal file
@ -0,0 +1,27 @@
|
||||
/*
|
||||
This file contains global design variables such as colors,
|
||||
gradients, spacing, and shadows
|
||||
--> these variables ensure a consistent design accross the whole application
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Main gradient used for buttons and highlights */
|
||||
--gradient: linear-gradient(90deg, #7c3aed, #3b82f6, #ec4899);
|
||||
|
||||
/* Background color of the application */
|
||||
--bg: #f8fafc;
|
||||
|
||||
/* Default text color */
|
||||
--text: #0f172a;
|
||||
|
||||
/* Container background */
|
||||
--card: #ffffff;
|
||||
|
||||
/* Border radius for rounded elements */
|
||||
--radius: 16px;
|
||||
|
||||
/* Standard shadow for cards and components */
|
||||
--shadow: 0 10px 30px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
|
||||
166
OnlyPrompt.Frontend/dashboard.html
Normal file
@ -0,0 +1,166 @@
|
||||
<!-- OnlyPrompt - Feed page:
|
||||
- Social media style post feed with likes, comments, saves, and share actions (following/foryou tabs) -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OnlyPrompt - Feed</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<link rel="stylesheet" href="../css/sidebar.css">
|
||||
<link rel="stylesheet" href="../css/login.css">
|
||||
<link rel="stylesheet" href="../css/topbar.css">
|
||||
<link rel="stylesheet" href="../css/dashboard.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout" style="display: flex; min-height: 100vh; background: var(--bg);">
|
||||
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div style="flex:1; margin:40px auto; max-width:950px;">
|
||||
|
||||
<div id="topbar-container"></div>
|
||||
|
||||
<main class="feed-main">
|
||||
|
||||
<!-- Optional: Feed Header -->
|
||||
<div class="feed-header">
|
||||
<h1>Feed</h1>
|
||||
<p>Latest prompts and inspiration from creators you follow</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons (optional) -->
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active">For You</button>
|
||||
<button class="filter-btn">Following</button>
|
||||
<button class="filter-btn">Trending</button>
|
||||
<button class="filter-btn">Recent</button>
|
||||
</div>
|
||||
|
||||
<!-- Posts Grid (einfach als Liste / Grid – hier als Grid wie Marketplace) -->
|
||||
<div class="posts-grid">
|
||||
|
||||
<!-- Post 1 -->
|
||||
<div class="post-card" onclick="location.href='post-detail.html?id=1'">
|
||||
<div class="post-header">
|
||||
<img src="../images/content/creator1.png" alt="Sarah Jenkins" class="post-avatar">
|
||||
<div class="post-author">
|
||||
<span class="post-name">Sarah Jenkins</span>
|
||||
<span class="post-handle">@sarahj</span>
|
||||
</div>
|
||||
<span class="post-date">2 hours ago</span>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<h3 class="post-title">Conceptual Landscape Art</h3>
|
||||
<p class="post-description">Enchanting, vintage, antique vibes. A journey through surreal landscapes.</p>
|
||||
<img src="../images/content/feed1.png" alt="Conceptual Landscape" class="post-image">
|
||||
</div>
|
||||
<!-- Like, Comment, Share, Save Buttons -->
|
||||
<div class="post-actions">
|
||||
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
|
||||
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
|
||||
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
|
||||
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post 2 -->
|
||||
<div class="post-card" onclick="location.href='post-detail.html?id=2'">
|
||||
<div class="post-header">
|
||||
<img src="../images/content/creator2.png" alt="Alex Chen" class="post-avatar">
|
||||
<div class="post-author">
|
||||
<span class="post-name">Alex Chen</span>
|
||||
<span class="post-handle">@alexchen</span>
|
||||
</div>
|
||||
<span class="post-date">Yesterday</span>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<h3 class="post-title">Minimalist Logo Design</h3>
|
||||
<p class="post-description">Clean, modern, minimalist logo for tech startups.</p>
|
||||
<img src="../images/content/feed2.png" alt="Minimalist Logo" class="post-image">
|
||||
</div>
|
||||
<!-- Like, Comment, Share, Save Buttons -->
|
||||
<div class="post-actions">
|
||||
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
|
||||
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
|
||||
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
|
||||
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post 3 -->
|
||||
<div class="post-card" onclick="location.href='post-detail.html?id=3'">
|
||||
<div class="post-header">
|
||||
<img src="../images/content/creator3.png" alt="Mia Wong" class="post-avatar">
|
||||
<div class="post-author">
|
||||
<span class="post-name">Mia Wong</span>
|
||||
<span class="post-handle">@miawong</span>
|
||||
</div>
|
||||
<span class="post-date">3 days ago</span>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<h3 class="post-title">Futuristic Cityscape</h3>
|
||||
<p class="post-description">Cyberpunk neon city with flying cars and rain.</p>
|
||||
<img src="../images/content/feed3.png" alt="Cityscape" class="post-image">
|
||||
</div>
|
||||
<!-- Like, Comment, Share, Save Buttons -->
|
||||
<div class="post-actions">
|
||||
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
|
||||
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
|
||||
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
|
||||
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post 4 -->
|
||||
<div class="post-card" onclick="location.href='post-detail.html?id=4'">
|
||||
<div class="post-header">
|
||||
<img src="../images/content/creator4.png" alt="Tom Rivera" class="post-avatar">
|
||||
<div class="post-author">
|
||||
<span class="post-name">Tom Rivera</span>
|
||||
<span class="post-handle">@tomrivera</span>
|
||||
</div>
|
||||
<span class="post-date">5 days ago</span>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<h3 class="post-title">Watercolor Pet Portrait</h3>
|
||||
<p class="post-description">Soft watercolor style, cute pet portrait.</p>
|
||||
<img src="../images/content/feed4.png" alt="Watercolor Pet" class="post-image">
|
||||
</div>
|
||||
<!-- Like, Comment, Share, Save Buttons -->
|
||||
<div class="post-actions">
|
||||
<button class="action-btn like-btn"><i class="bi bi-heart"></i> <span>128</span></button>
|
||||
<button class="action-btn comment-btn"><i class="bi bi-chat"></i> <span>15</span></button>
|
||||
<button class="action-btn share-btn"><i class="bi bi-share"></i></button>
|
||||
<button class="action-btn save-btn"><i class="bi bi-bookmark"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
fetch('../html/sidebar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => {
|
||||
document.getElementById('sidebar-container').innerHTML = data;
|
||||
// Remove 'active' from all sidebar links
|
||||
document.querySelectorAll('#sidebar-container .sidebar a').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
});
|
||||
// Set 'active' on the first link (Dashboard) - index 0
|
||||
const firstLink = document.querySelectorAll('#sidebar-container .sidebar li a')[0];
|
||||
if (firstLink) firstLink.classList.add('active');
|
||||
});
|
||||
|
||||
fetch('../html/topbar.html')
|
||||
.then(r => r.text())
|
||||
.then(data => document.getElementById('topbar-container').innerHTML = data);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
OnlyPrompt.Frontend/images/content/cat.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
OnlyPrompt.Frontend/images/content/creator1.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
OnlyPrompt.Frontend/images/content/creator2.png
Normal file
|
After Width: | Height: | Size: 579 KiB |
BIN
OnlyPrompt.Frontend/images/content/creator3.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
OnlyPrompt.Frontend/images/content/creator4.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
OnlyPrompt.Frontend/images/content/creator5.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
OnlyPrompt.Frontend/images/content/creator6.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
OnlyPrompt.Frontend/images/content/feed1.png
Normal file
|
After Width: | Height: | Size: 660 KiB |
BIN
OnlyPrompt.Frontend/images/content/feed2.png
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
BIN
OnlyPrompt.Frontend/images/content/feed3.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
OnlyPrompt.Frontend/images/content/feed4.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
OnlyPrompt.Frontend/images/content/market1.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
OnlyPrompt.Frontend/images/content/market2.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
OnlyPrompt.Frontend/images/content/market3.png
Normal file
|
After Width: | Height: | Size: 659 KiB |
BIN
OnlyPrompt.Frontend/images/content/market4.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
OnlyPrompt.Frontend/images/content/market5.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
OnlyPrompt.Frontend/images/content/market6.png
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
OnlyPrompt.Frontend/images/content/post1.png
Normal file
|
After Width: | Height: | Size: 868 KiB |
BIN
OnlyPrompt.Frontend/images/content/post2.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
OnlyPrompt.Frontend/images/logo_full.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
OnlyPrompt.Frontend/images/logo_icon.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
OnlyPrompt.Frontend/images/logo_text.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
140
OnlyPrompt.Frontend/js/linq.js
Normal file
@ -0,0 +1,140 @@
|
||||
// LINQ-like Enumerable class wrapping lazy generator chains
|
||||
|
||||
class Enumerable {
|
||||
constructor(iteratorFn) {
|
||||
this._iteratorFn = iteratorFn;
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this._iteratorFn();
|
||||
}
|
||||
|
||||
_chain(generatorFn) {
|
||||
const source = this;
|
||||
return new Enumerable(function* () {
|
||||
yield* generatorFn(source);
|
||||
});
|
||||
}
|
||||
|
||||
where(predicate) {
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source)
|
||||
if (predicate(item))
|
||||
yield item;
|
||||
});
|
||||
}
|
||||
|
||||
select(selector) {
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source)
|
||||
yield selector(item);
|
||||
});
|
||||
}
|
||||
|
||||
take(count) {
|
||||
count = Math.max(0, count);
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source) {
|
||||
if (count-- <= 0) break;
|
||||
yield item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
skip(count) {
|
||||
count = Math.max(0, count);
|
||||
return this._chain(function* (source) {
|
||||
for (const item of source) {
|
||||
if (count-- > 0) continue;
|
||||
yield item;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isLast() {
|
||||
return this._chain(function* (source) {
|
||||
const iter = source[Symbol.iterator]();
|
||||
let current = iter.next();
|
||||
let index = 0;
|
||||
while (!current.done) {
|
||||
const next = iter.next();
|
||||
yield [current.value, next.done, index];
|
||||
current = next;
|
||||
index++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
forEach(action) {
|
||||
for (const item of this) {
|
||||
if (Array.isArray(item)) {
|
||||
action(...item);
|
||||
} else {
|
||||
action(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return Array.from(this);
|
||||
}
|
||||
|
||||
firstOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
first(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
throw new Error("No elements in sequence.");
|
||||
}
|
||||
|
||||
lastOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
let lastValue = undefined;
|
||||
for (const item of source) lastValue = item;
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
last(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
let lastValue = undefined;
|
||||
let found = false;
|
||||
for (const item of source) {
|
||||
lastValue = item;
|
||||
found = true;
|
||||
}
|
||||
if (!found) throw new Error("No elements in sequence.");
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
any(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const _ of source) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
all(predicate) {
|
||||
for (const item of this)
|
||||
if (!predicate(item))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
count(predicate) {
|
||||
let count = 0;
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const _ of source) count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
Array.prototype.asEnumerable = function () {
|
||||
const arr = this;
|
||||
return new Enumerable(function* () {
|
||||
for (const item of arr)
|
||||
yield item;
|
||||
});
|
||||
}
|
||||
21
OnlyPrompt.Frontend/js/login.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { sendFormAsync } from "./shared.js";
|
||||
|
||||
function togglePassword() {
|
||||
const passwordInput = document.getElementById('password');
|
||||
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
|
||||
passwordInput.type = newInputType;
|
||||
}
|
||||
|
||||
async function submitLoginForm(){
|
||||
const form = document.getElementById('loginForm');
|
||||
await sendFormAsync(form);
|
||||
}
|
||||
|
||||
|
||||
const togglePasswordButton = document.getElementById('togglePassword');
|
||||
togglePasswordButton.addEventListener('click', togglePassword);
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
loginForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault(); // Prevent the default form submission
|
||||
await submitLoginForm();
|
||||
});
|
||||
160
OnlyPrompt.Frontend/js/shared.js
Normal file
@ -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(`
|
||||
<div class="form-error">
|
||||
An error occurred while submitting the form. Please try again later.
|
||||
{{ $this }}
|
||||
</div>
|
||||
`);
|
||||
|
||||
function handleGenericFormError(response, responseText, form) {
|
||||
if (!response.ok) {
|
||||
const html = genericFormErrorTemplate.render(responseText);
|
||||
form.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
}
|
||||
|
||||
const validationErrorTemplate = new Template(`
|
||||
<div class="form-error">
|
||||
<ul>
|
||||
@for(error of $this) {
|
||||
<li class="error">{{error}}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const unknownInputErrorTemplate = new Template(`
|
||||
<div class="form-error">
|
||||
<p>An error occurred with the following fields:</p>
|
||||
@for(field, errors of Object.entries($this)) {
|
||||
<ul>
|
||||
@for(error of errors) {
|
||||
<li class="error">{{field}}: {{error}}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
`);
|
||||
|
||||
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;
|
||||
}
|
||||