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;
|
||||||
|
}
|
||||||