Compare commits

...

No commits in common. "main" and "feature/api" have entirely different histories.

153 changed files with 1676 additions and 14893 deletions

View File

@ -1,30 +0,0 @@
**/.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
View File

@ -1,428 +0,0 @@
## 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

341
API.md
View File

@ -1,341 +0,0 @@
# OnlyPrompt API
This file documents the backend endpoints used by the frontend. The backend is a helper service for server communication, authentication and shared data persistence.
Base URL in local Docker setup:
```text
http://localhost:1801
```
Authentication uses a `jwt` cookie set by the login endpoint. Protected endpoints require a logged-in user.
## Auth
### Register
```http
POST /api/v1/auth/register
Content-Type: application/json
```
Request:
```json
{
"displayName": "Isabelle",
"userName": "its_isabelle",
"email": "isabelle@test.com",
"password": "1234"
}
```
Response: created user data or validation errors.
### Login
```http
POST /api/v1/auth/login
Content-Type: application/json
```
Request:
```json
{
"userNameOrEmail": "isabelle@test.com",
"password": "1234"
}
```
Response: sets the auth cookie.
### Current User
```http
GET /api/v1/auth/me
```
Response:
```json
{
"id": "uuid",
"userName": "its_isabelle",
"email": "isabelle@test.com",
"roles": ["User"]
}
```
### Logout
```http
POST /api/v1/auth/logout
```
Deletes the auth cookie and redirects to login.
## Profiles
### Current Profile
```http
GET /api/v1/profiles/self
```
Response:
```json
{
"displayName": "Isabelle",
"slug": "its_isabelle",
"bio": "AI creator",
"avatarUrl": "data:image/png;base64,...",
"specialities": null,
"averageRating": 0,
"subscribers": 0
}
```
### Creator List
```http
GET /api/v1/profiles?sort=popular&limit=50&search=belle
```
Query parameters:
- `sort`: `popular`, `prompts`, `new`, `rating`
- `limit`: number of creators
- `search`: optional search term
Response: list of creator cards including follow state and avatar URL.
### Public Profile
```http
GET /api/v1/profiles/{creatorId}
```
Used by public creator profiles. The response contains display name, slug, bio, avatar URL, average rating and subscriber count.
### Update Profile
```http
PUT /api/v1/profiles
Content-Type: application/json
```
Request:
```json
{
"displayName": "Belle",
"userName": "lady_belle",
"slug": "lady_belle",
"bio": "Prompt creator",
"avatarUrl": "data:image/png;base64,...",
"specialities": null,
"isPublic": true
}
```
Updates profile data used on My Profile, Community and the topbar.
## Prompts
### List Prompts
```http
GET /api/v1/prompts?sortBy=date&ascending=false&limit=50&search=cat
```
Used by Marketplace. Supports sorting, search and category filtering. The marketplace excludes prompts created by the logged-in user.
Response items include prompt title, description, creator id, creator name, creator avatar, example image, tier data, like/save counts, average rating, review count and access state.
### Feed
```http
GET /api/v1/feed?sortBy=date&ascending=false&limit=20
```
Used by Dashboard. Returns prompt cards from followed creators, excluding the logged-in user's own prompts.
### Create Prompt
```http
POST /api/v1/prompts
Content-Type: application/json
```
Request:
```json
{
"title": "Luxury Cat Portrait",
"description": "Create a premium cat portrait prompt.",
"content": "Write the full prompt instructions here.",
"category": "art",
"subscriptionTier": null,
"slug": null,
"exampleOutput": "Example output text",
"exampleImageUrl": "data:image/png;base64,...",
"price": null
}
```
Response: created prompt. The frontend redirects to `/post-detail?id={id}`.
`subscriptionTier` is the creator's tier level. `null` means the prompt is public/free. `price` is kept as `null` because prompt access is handled through monthly creator tiers.
### Own Prompts
```http
GET /api/v1/prompts/mine
```
Used by My Profile to show prompts created by the logged-in user.
### Update Prompt
```http
PUT /api/v1/prompts/{id}
Content-Type: application/json
```
Request body uses the same editable fields as prompt creation, including `subscriptionTier`. Used by the edit prompt flow.
### Prompt Detail
```http
GET /api/v1/prompts/{id}
```
Response includes:
- title and description
- prompt content if accessible
- category
- creator information
- tier or free state
- example output
- example image
- average rating and review count
- like/save state and counts
Tier prompts return no detail content for users without a matching creator subscription.
### Likes and Saves
```http
PUT /api/v1/prompts/{id}/likes
DELETE /api/v1/prompts/{id}/likes
PUT /api/v1/prompts/{id}/saves
DELETE /api/v1/prompts/{id}/saves
```
Used by Dashboard, prompt detail and profile tabs to store user interactions.
### Reviews
```http
GET /api/v1/prompts/{id}/reviews
PUT /api/v1/prompts/{id}/reviews
```
`PUT` request:
```json
{
"comment": "Helpful prompt",
"rating": 5
}
```
Used on the prompt detail page. Users can review prompts created by other users. Each user can have one review per prompt; submitting again updates the existing review. Reviews are displayed with username, star rating and comment.
## Categories
### Minimal Categories
```http
GET /api/v1/categories/minimal
```
Used by Create New and Marketplace filters.
Response:
```json
[
{
"name": "Art",
"slug": "art"
}
]
```
Default categories are created automatically when the backend starts.
## Subscriptions
### Follow or Subscribe to Creator
```http
GET /api/v1/subscriptions/{creatorId}
PUT /api/v1/subscriptions/{creatorId}
PUT /api/v1/subscriptions/{creatorId}/{level}
DELETE /api/v1/subscriptions/{creatorId}
```
Used by Community, public profiles and locked prompt details to read follow state, follow creators, subscribe to a monthly tier or unfollow creators.
- `PUT /api/v1/subscriptions/{creatorId}` follows a creator without a paid tier.
- `PUT /api/v1/subscriptions/{creatorId}/{level}` subscribes to one of the creator's tiers. A higher tier gives access to prompts from the same level and lower levels.
### Subscription Tiers
```http
GET /api/v1/subscriptions/tiers
GET /api/v1/subscriptions/tiers/{creatorId}
POST /api/v1/subscriptions/tiers
PUT /api/v1/subscriptions/tiers/{tierId}
DELETE /api/v1/subscriptions/tiers/{tierId}
```
Used by the Subscription Tiers page, Create Prompt and public creator profiles.
Create request:
```json
{
"name": "Supporter",
"monthlyPrice": 4.99,
"level": 1,
"description": "Access to basic premium prompts."
}
```
Response:
```json
{
"id": "uuid",
"name": "Supporter",
"level": 1,
"monthlyPrice": 4.99,
"description": "Access to basic premium prompts."
}
```
## Error Handling
Common response codes used by the frontend:
- `200 OK`: request succeeded
- `400 Bad Request`: validation error or invalid request data
- `401 Unauthorized`: user is not logged in and should be redirected to login
- `404 Not Found`: requested prompt, profile or subscription was not found
The frontend handles failed requests with visible error states or redirects to login for unauthorized users.

75
API_ENDPOINTS.md Normal file
View File

@ -0,0 +1,75 @@
# API-Endpunkte
Diese Endpunkte decken die Kernfunktionen von OnlyPrompt ab.
## Auth
### `POST /api/v1/auth/register`
- Erstellt einen neuen Benutzer.
### `POST /api/v1/auth/login`
- Loggt einen Benutzer ein.
### `GET /api/v1/auth/me`
- Gibt den aktuell eingeloggten Benutzer zurueck.
## User / Profile
### `GET /api/v1/users/{id}`
- Ruft das oeffentliche Benutzerprofil anhand der ID ab.
### `GET /api/v1/profile/me`
- Ruft das eigene Profil des eingeloggten Benutzers ab.
### `PUT /api/v1/profile/me`
- Bearbeitet das eigene Profil des eingeloggten Benutzers.
## Prompts
### `GET /api/v1/prompts`
- Gibt alle veroeffentlichten Prompts zurueck.
### `GET /api/v1/prompts/{id}`
- Ruft einen einzelnen Prompt anhand der ID ab.
### `POST /api/v1/prompts`
- Erstellt einen neuen Prompt.
### `PUT /api/v1/prompts/{id}`
- Bearbeitet einen bestehenden Prompt.
### `DELETE /api/v1/prompts/{id}`
- Loescht einen Prompt.
## Filter fuer Marketplace
### `GET /api/v1/prompts?category=coding`
- Filtert Prompts nach Kategorie.
### `GET /api/v1/prompts?search=python`
- Sucht Prompts ueber einen Suchbegriff.
### `GET /api/v1/prompts?creatorId=5`
- Filtert Prompts nach einem bestimmten Creator.
## Kategorien
### `GET /api/v1/categories`
- Gibt alle verfuegbaren Kategorien zurueck.
## Kaeufe
### `POST /api/v1/purchases`
- Erstellt einen Kauf fuer einen Prompt.
### `GET /api/v1/purchases/me`
- Gibt alle eigenen Kaeufe des eingeloggten Benutzers zurueck.
## Reviews
### `GET /api/v1/prompts/{id}/reviews`
- Ruft alle Bewertungen fuer einen Prompt ab.
### `POST /api/v1/prompts/{id}/reviews`
- Erstellt eine neue Bewertung fuer einen Prompt.

78
KERNMODELL.md Normal file
View File

@ -0,0 +1,78 @@
# Kernmodell
Dieses Kernmodell beschreibt die wichtigsten Klassen, die OnlyPrompt fuer Login, Profile, Marketplace und Prompt-Kaeufe benoetigt.
## Klassen
### User
- `id: int`
- `username: string`
- `email: string`
- `passwordHash: string`
- `role: string`
- `createdAt: datetime`
### Profile
- `id: int`
- `userId: int`
- `displayName: string`
- `bio: string`
- `avatarUrl: string`
- `specialties: string`
### Prompt
- `id: int`
- `creatorId: int`
- `categoryId: int`
- `title: string`
- `description: string`
- `content: text`
- `price: decimal`
- `thumbnailUrl: string`
- `ratingAverage: float`
- `reviewCount: int`
- `status: string`
- `createdAt: datetime`
- `updatedAt: datetime`
### Category
- `id: int`
- `name: string`
- `slug: string`
### Purchase
- `id: int`
- `buyerId: int`
- `promptId: int`
- `pricePaid: decimal`
- `purchasedAt: datetime`
### Review
- `id: int`
- `promptId: int`
- `userId: int`
- `rating: int`
- `comment: string`
- `createdAt: datetime`
## Beziehungen
- Ein `User` hat genau ein `Profile`.
- Ein `User` kann viele `Prompts` erstellen.
- Ein `Prompt` gehoert zu genau einer `Category`.
- Ein `User` kann viele `Prompts` ueber `Purchase` kaufen.
- Ein `User` kann viele `Reviews` schreiben.
- Ein `Prompt` kann viele `Reviews` erhalten.
## UML-Kurzform
```text
User 1 --- 1 Profile
User 1 --- * Prompt
Category 1 --- * Prompt
User 1 --- * Purchase
Prompt 1 --- * Purchase
User 1 --- * Review
Prompt 1 --- * Review
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
namespace OnlyPrompt.Backend.ApiModels.UserProfile
{
public record ApiUserProfile(string DisplayName, string Slug, string? Bio, string AvatarUrl, string? Specialities, double AverageRating, int Subscribers);
public record ApiCreatorCard(Guid UserId, string DisplayName, string Slug, string? Bio, string AvatarUrl, double AverageRating, int Subscribers, int PromptCount, bool IsFollowing);
}

View File

@ -1,8 +0,0 @@
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? UserName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic);
public record ApiCreateReviewRequest(string? Comment, [Range(1, 5)] int Rating);
}

View File

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

View File

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

View File

@ -1,121 +0,0 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.Auth;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Services.Jwt;
using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/auth")]
public class AuthController : BaseController
{
private static readonly CookieOptions AuthCookieOptions = new CookieOptions { Secure = false, HttpOnly = true, IsEssential = true, SameSite = SameSiteMode.Lax };
private readonly IPasswordHasher<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,
IsPublic = true,
},
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");
}
[Authorize]
[HttpGet("me")]
public async Task<Results<Ok<ApiUser>, NotFound<string>>> GetCurrentUserAsync()
{
var user = await GetUserAsync();
if (user is null)
return TypedResults.NotFound("User not found");
return TypedResults.Ok(_mapper.Map<ApiUser>(user));
}
}
}

View File

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

View File

@ -1,126 +0,0 @@
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(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]
[Authorize(Roles = ModelConstants.AdminRole)]
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}")]
[Authorize(Roles = ModelConstants.AdminRole)]
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}")]
[Authorize(Roles = ModelConstants.AdminRole)]
public async Task<Results<NoContent, BadRequest<string>, NotFound<string>>> DeleteCategoryAsync(Identifier id, [FromQuery] Identifier? replaceWith = null)
{
var hasPrompts = await _db.Prompts.AnyAsync(p => p.CategoryId == id.Id);
if (hasPrompts)
{
if (replaceWith.HasValue == false)
return TypedResults.BadRequest("Category has associated prompts. Provide a replacement category to reassign them to.");
var replacement = await _db.Categories.FindByIdentifierAsync(replaceWith.Value);
if(replacement is null)
return TypedResults.NotFound("Replacement category not found.");
await _db.Prompts.Where(p => p.CategoryId == id.Id)
.ExecuteUpdateAsync(p => p.SetProperty(p => p.CategoryId, replacement.Id));
}
var count = await _db.Categories.OfIdentifer(id)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound("Category not found");
return TypedResults.NoContent();
}
}
}

View File

@ -1,83 +0,0 @@
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.CreatorId != userId)
.Where(x => x.Creator.Subscribers.Any(s => s.SubscriberId == 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 => ascending
? query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenBy(x => x.Reviews.Count)
: query.OrderByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenByDescending(x => x.Reviews.Count),
_ => query
};
var prompts = await query
.Skip(offset)
.Take(limit)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level <= s.SubscriptionTier.Level)
)).ToArrayAsync();
return prompts;
}
}
}

View File

@ -1,168 +0,0 @@
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;
using System.ComponentModel.DataAnnotations;
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." } }
});
private static ValidationProblem UserNameExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(UserModel.UserName), new[] { "Username is already taken." } }
});
public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[HttpGet("self")]
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetSelfProfileAsync()
{
var userId = User.GetUserId();
if (userId is null)
return TypedResults.NotFound("Profile not found.");
var profile = await _db.UserProfiles
.Where(up => up.Id == userId.Value)
.Select(up => new ApiUserProfile(
up.DisplayName,
up.Slug,
up.Bio,
up.AvatarUrl,
up.Specialities,
_db.Reviews.Where(r => r.Prompt.CreatorId == up.Id).Average(r => (double?)r.Rating) ?? 0.0,
_db.Subscriptions.Count(s => s.SubscribedToId == up.Id)
))
.FirstOrDefaultAsync();
if (profile is null)
return TypedResults.NotFound("Profile not found.");
return TypedResults.Ok(profile);
}
[HttpGet("{id}")]
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetProfileAsync(Identifier id)
{
var userId = User.GetUserId();
var profile = await _db.UserProfiles.OfIdentifer(id)
.ProjectTo<ApiUserProfile>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
if (profile is null)
return TypedResults.NotFound("Profile not found or is private.");
return TypedResults.Ok(profile);
}
[HttpGet]
public async Task<ApiCreatorCard[]> GetCreatorsAsync(
[Range(0, int.MaxValue)] int offset = 0,
[Range(1, 100)] int limit = 20,
[FromQuery] string sort = "popular",
[FromQuery] string? search = null
)
{
var userId = User.GetUserId();
var query = _db.UserProfiles.Where(up => up.Id != userId);
if (string.IsNullOrWhiteSpace(search) == false)
query = query.Where(up =>
up.DisplayName.Contains(search) ||
up.Slug.Contains(search) ||
up.User.UserName.Contains(search) ||
(up.Bio != null && up.Bio.Contains(search)));
var projected = query.Select(up => new ApiCreatorCard(
up.Id,
up.DisplayName,
up.Slug,
up.Bio,
up.AvatarUrl,
_db.Reviews.Where(r => r.Prompt.CreatorId == up.Id).Average(r => (double?)r.Rating) ?? 0.0,
_db.Subscriptions.Count(s => s.SubscribedToId == up.Id),
_db.Prompts.Count(p => p.CreatorId == up.Id),
_db.Subscriptions.Any(s => s.SubscribedToId == up.Id && s.SubscriberId == userId)
));
var allCreators = await projected.ToArrayAsync();
return (sort switch
{
"rating" => allCreators.OrderByDescending(c => c.AverageRating),
"new" => allCreators.OrderByDescending(c => c.UserId),
"prompts" => allCreators.OrderByDescending(c => c.PromptCount),
_ => allCreators.OrderByDescending(c => c.Subscribers),
}).Skip(offset).Take(limit).ToArray();
}
[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.");
var user = await GetUserAsync();
if (user is null)
return TypedResults.NotFound("User not found.");
if (string.IsNullOrEmpty(request.UserName) == false)
{
if (await _db.Users.AnyAsync(u => u.UserName == request.UserName && u.Id != user.Id))
return UserNameExistsProblem;
user.UserName = request.UserName;
}
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(request.AvatarUrl is not null)
self.AvatarUrl = request.AvatarUrl;
if(request.Bio is not null)
self.Bio = request.Bio;
if(request.Specialities is not null)
self.Specialities = request.Specialities;
if (string.IsNullOrEmpty(request.DisplayName) == false)
self.DisplayName = request.DisplayName;
self.IsPublic = request.IsPublic;
await _db.SaveChangesAsync();
var result = new ApiUserProfile(
self.DisplayName,
self.Slug,
self.Bio,
self.AvatarUrl,
self.Specialities,
await _db.Reviews.Where(r => r.Prompt.CreatorId == self.Id).AverageAsync(r => (double?)r.Rating) ?? 0.0,
await _db.Subscriptions.CountAsync(s => s.SubscribedToId == self.Id)
);
return TypedResults.Ok(result);
}
}
}

View File

@ -1,419 +0,0 @@
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]
public async Task<ApiMinimalPrompt[]> GetPromptsAsync(
[Range(0, int.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] string? search = null
)
{
var userId = User.GetUserId();
var query = _db.Prompts
.Where(x => 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 (!string.IsNullOrWhiteSpace(search))
query = query.Where(x => x.Title.Contains(search) || x.Description.Contains(search));
query = sortBy switch
{
FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending),
FeedSortType.Rating => ascending
? query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenBy(x => x.Reviews.Count)
: query.OrderByDescending(x => x.Reviews.Average(r => (double?)r.Rating) ?? 0).ThenByDescending(x => x.Reviews.Count),
_ => query
};
var prompts = await query
.Skip(offset)
.Take(limit)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
x.CreatorId == userId || x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level <= s.SubscriptionTier.Level)
)).ToArrayAsync();
return prompts;
}
[HttpGet("mine")]
public async Task<ApiMinimalPrompt[]> GetOwnPromptsAsync()
{
var userId = User.GetUserId();
var prompts = await _db.Prompts
.Where(x => x.CreatorId == userId)
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new ApiMinimalPrompt(
x.Id,
x.Title,
x.Description,
x.UpdatedAt,
x.CreatorId,
x.Creator.Profile.DisplayName,
x.Creator.Profile.AvatarUrl,
x.ExampleImageUrl,
x.Price,
x.Likes.Count,
x.Likes.Any(l => l.UserId == userId),
x.Saves.Count,
x.Saves.Any(s => s.UserId == userId),
x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level,
x.SubscriptionTier == null ? null : x.SubscriptionTier.Name,
x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice,
x.Reviews.Average(r => (double?)r.Rating),
x.Reviews.Count,
true
)).ToArrayAsync();
return prompts;
}
[HttpGet("{id}")]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> GetPromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
var canAccess = await GetAccessiblePrompts(userId.Value).AnyAsync(p => p.Id == prompt.Id);
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with
{
Content = canAccess ? prompt.Prompt : null,
CanAccess = canAccess,
IsLiked = prompt.Likes.Any(l => l.UserId == userId),
LikeCount = prompt.Likes.Count,
IsSaved = prompt.Saves.Any(s => s.UserId == userId),
SaveCount = prompt.Saves.Count
};
return TypedResults.Ok(apiPrompt);
}
[HttpPut("{id}")]
public async Task<Results<Ok<ApiPrompt>, NotFound<string>>> UpdatePromptAsync(Identifier id, [FromBody] ApiUpdatePromptRequest request)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync(p => p.CreatorId == userId);
if (prompt is null)
return TypedResults.NotFound("Prompt not found or no permission");
var category = await _db.Categories.FindByIdentifierAsync(new Identifier(request.Category));
if (category is null)
return TypedResults.NotFound("Category not found");
prompt.Title = request.Title;
prompt.Description = request.Description;
prompt.Prompt = request.Content;
prompt.Category = category;
prompt.ExampleOutput = request.ExampleOutput;
prompt.ExampleImageUrl = request.ExampleImageUrl;
prompt.Price = null;
prompt.SubscriptionTier = null;
if (request.SubscriptionTier.HasValue)
{
var 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");
prompt.SubscriptionTier = subscriptionTier;
}
await _db.SaveChangesAsync();
var apiPrompt = _mapper.Map<ApiPrompt>(prompt) with { Content = prompt.Prompt, CanAccess = true };
return TypedResults.Ok(apiPrompt);
}
[HttpPut("{id}/likes")]
public async Task<Results<Ok<ApiLikeState>, NotFound<string>>> LikePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
var exists = await _db.PromptLikes.AnyAsync(l => l.PromptId == prompt.Id && l.UserId == userId);
if (exists == false)
{
_db.PromptLikes.Add(new PromptLikeModel
{
PromptId = prompt.Id,
UserId = userId!.Value
});
await _db.SaveChangesAsync();
}
var count = await _db.PromptLikes.CountAsync(l => l.PromptId == prompt.Id);
return TypedResults.Ok(new ApiLikeState(count, true));
}
[HttpDelete("{id}/likes")]
public async Task<Results<Ok<ApiLikeState>, NotFound<string>>> UnlikePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
await _db.PromptLikes
.Where(l => l.PromptId == prompt.Id && l.UserId == userId)
.ExecuteDeleteAsync();
var count = await _db.PromptLikes.CountAsync(l => l.PromptId == prompt.Id);
return TypedResults.Ok(new ApiLikeState(count, false));
}
[HttpPut("{id}/saves")]
public async Task<Results<Ok<ApiSaveState>, NotFound<string>>> SavePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
var exists = await _db.PromptSaves.AnyAsync(s => s.PromptId == prompt.Id && s.UserId == userId);
if (exists == false)
{
_db.PromptSaves.Add(new PromptSaveModel
{
PromptId = prompt.Id,
UserId = userId!.Value
});
await _db.SaveChangesAsync();
}
var count = await _db.PromptSaves.CountAsync(s => s.PromptId == prompt.Id);
return TypedResults.Ok(new ApiSaveState(count, true));
}
[HttpDelete("{id}/saves")]
public async Task<Results<Ok<ApiSaveState>, NotFound<string>>> UnsavePromptAsync(Identifier id)
{
var userId = User.GetUserId();
var prompt = await _db.Prompts
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found");
await _db.PromptSaves
.Where(s => s.PromptId == prompt.Id && s.UserId == userId)
.ExecuteDeleteAsync();
var count = await _db.PromptSaves.CountAsync(s => s.PromptId == prompt.Id);
return TypedResults.Ok(new ApiSaveState(count, false));
}
[HttpDelete("{id}")]
public async Task<Results<NoContent, NotFound<string>>> DeletePromptAsync(Identifier id)
{
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(new Identifier(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,
ExampleOutput = request.ExampleOutput,
ExampleImageUrl = request.ExampleImageUrl,
Price = null,
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
.OfIdentifer(id)
.SelectMany(x => x.Reviews)
.OrderByDescending(x => x.UpdatedAt)
.Skip(offset)
.Take(limit)
.ProjectTo<ApiReview>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return TypedResults.Ok(reviews);
}
[HttpPut("{id}/reviews")]
public async Task<Results<Ok<ApiReview>, BadRequest<string>, NotFound<string>>> AddReviewAsync(Identifier id, [FromBody] ApiCreateReviewRequest request)
{
var userId = User.GetUserId();
var prompt = await GetAccessiblePrompts(userId!.Value)
.OfIdentifer(id)
.FirstOrDefaultAsync();
if (prompt is null)
return TypedResults.NotFound("Prompt not found or no permission");
if(prompt.CreatorId == userId)
return TypedResults.BadRequest("Cannot review your own prompt");
var review = await _db.Reviews.FirstOrDefaultAsync(
r => r.PromptId == prompt.Id
&& r.ReviewerId == userId
);
if (review is null)
{
review = new ReviewModel
{
PromptId = prompt.Id,
ReviewerId = userId.Value,
Comment = request.Comment,
Rating = request.Rating
};
_db.Reviews.Add(review);
}
else
{
review.Comment = request.Comment;
review.Rating = request.Rating;
}
await _db.SaveChangesAsync();
var apiReview = _mapper.Map<ApiReview>(review);
return TypedResults.Ok(apiReview);
}
[HttpDelete("{promptId}/reviews/{reviewerId}")]
public async Task<Results<NoContent, NotFound<string>>> DeleteReviewAsync(Identifier promptId, Guid reviewerId)
{
var userId = User.GetUserId();
var isAdmin = User.IsInRole(ModelConstants.AdminRole);
var count = await _db.Reviews
.Where(
r => (promptId.Id.HasValue ? r.PromptId == promptId.Id : r.Prompt.Slug == promptId.Slug)
&& (r.ReviewerId == reviewerId || isAdmin)
)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound("Review not found or no permission");
return TypedResults.NoContent();
}
}
}

View File

@ -1,209 +0,0 @@
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([FromRoute(Name = "userId")] 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([FromRoute(Name = "userId")] 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;
}
[HttpGet("tiers")]
public async Task<ApiSubscriptionTier[]> GetOwnSubscriptionTiersAsync()
{
var userId = User.GetUserId();
return await _db.SubscriptionTiers
.Where(t => t.UserId == userId)
.OrderBy(t => t.Level)
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
.ToArrayAsync();
}
[HttpGet("tiers/{userId}")]
public async Task<Results<Ok<ApiSubscriptionTier[]>, NotFound<string>>> GetCreatorSubscriptionTiersAsync([FromRoute(Name = "userId")] Identifier creatorId)
{
var creatorExists = await _db.Users.AnyAsync(
user => creatorId.Id.HasValue ? user.Id == creatorId.Id.Value : user.Profile.Slug == creatorId.Slug
);
if (creatorExists == false)
return TypedResults.NotFound($"No user found with identifier {creatorId}");
var tiers = await _db.SubscriptionTiers
.Where(t => creatorId.Id.HasValue ? t.UserId == creatorId.Id.Value : t.User.Profile.Slug == creatorId.Slug)
.OrderBy(t => t.Level)
.ProjectTo<ApiSubscriptionTier>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return TypedResults.Ok(tiers);
}
[HttpDelete("{userId}")]
public async Task<Results<Ok, NotFound<string>>> UnsubscribeAsync([FromRoute(Name = "userId")] Identifier subscribeToId)
{
var userId = User.GetUserId();
var count = await _db.Subscriptions
.Where(
sub => (subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug)
&& sub.SubscriberId == userId
)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound($"No subscription found for user {subscribeToId}");
return TypedResults.Ok();
}
[HttpPost("tiers")]
public async Task<Results<Ok<ApiSubscriptionTier>, ValidationProblem>> CreateOrUpdateSubscriptionTierAsync([FromBody] ApiCreateSubscriptionTierRequest tier)
{
var userId = User.GetUserId();
var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level);
if (levelExists)
return TierLevelExistsProblem;
var model = _mapper.Map<SubscriptionTierModel>(tier);
model.UserId = userId!.Value;
_db.SubscriptionTiers.Add(model);
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiSubscriptionTier>(model));
}
[HttpPut("tiers/{id}")]
public async Task<Results<Ok<ApiSubscriptionTier>, NotFound<string>, ValidationProblem>> UpdateSubscriptionTierAsync(Guid id, [FromBody] ApiUpdateSubscriptionTierRequest tier)
{
var userId = User.GetUserId();
var existingTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(t => t.Id == id && t.UserId == userId);
if (existingTier is null)
return TypedResults.NotFound($"No subscription tier found with id {id}");
if (existingTier.Level != tier.Level)
{
var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level);
if (levelExists)
return TierLevelExistsProblem;
}
if (string.IsNullOrEmpty(tier.Name) == false)
existingTier.Name = tier.Name;
if (string.IsNullOrEmpty(tier.Description) == false)
existingTier.Description = tier.Description;
if (tier.Level.HasValue)
existingTier.Level = tier.Level.Value;
if (tier.MonthlyPrice.HasValue)
existingTier.MonthlyPrice = tier.MonthlyPrice.Value;
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiSubscriptionTier>(existingTier));
}
[HttpDelete("tiers/{id}")]
public async Task<Results<Ok, NotFound<string>>> DeleteSubscriptionTierAsync(Guid id)
{
var userId = User.GetUserId();
var count = await _db.SubscriptionTiers
.Where(t => t.Id == id && t.UserId == userId)
.ExecuteDeleteAsync();
if (count == 0)
return TypedResults.NotFound($"No subscription tier found with id {id}");
return TypedResults.Ok();
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,52 +0,0 @@
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; }
public required string Prompt { get; set; }
[MaxLength(1000)]
public required string Description { get; set; }
public string? ExampleOutput { get; set; }
public string? ExampleImageUrl { get; set; }
public decimal? Price { 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>();
public virtual IList<PromptLikeModel> Likes { get; set; } = new List<PromptLikeModel>();
public virtual IList<PromptSaveModel> Saves { get; set; } = new List<PromptSaveModel>();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,29 +0,0 @@
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; } = true;
}
}

View File

@ -1,123 +0,0 @@
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 DbSet<PromptLikeModel> PromptLikes { get; set; }
public DbSet<PromptSaveModel> PromptSaves { 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);
});
modelBuilder.Entity<PromptLikeModel>(entity =>
{
entity.HasKey(e => new { e.PromptId, e.UserId });
entity.HasOne(e => e.Prompt)
.WithMany(p => p.Likes)
.HasForeignKey(e => e.PromptId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<PromptSaveModel>(entity =>
{
entity.HasKey(e => new { e.PromptId, e.UserId });
entity.HasOne(e => e.Prompt)
.WithMany(p => p.Saves)
.HasForeignKey(e => e.PromptId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,48 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class PromptCreateDetails : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExampleImageUrl",
table: "Prompts",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExampleOutput",
table: "Prompts",
type: "text",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Price",
table: "Prompts",
type: "numeric",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ExampleImageUrl",
table: "Prompts");
migrationBuilder.DropColumn(
name: "ExampleOutput",
table: "Prompts");
migrationBuilder.DropColumn(
name: "Price",
table: "Prompts");
}
}
}

View File

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

View File

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

View File

@ -1,424 +0,0 @@
// <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>("ExampleImageUrl")
.HasColumnType("text");
b.Property<string>("ExampleOutput")
.HasColumnType("text");
b.Property<decimal?>("Price")
.HasColumnType("numeric");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("CreatorId");
b.HasIndex("Slug")
.IsUnique();
b.HasIndex("SubscriptionTierId");
b.ToTable("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.Property<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ReviewerId", "PromptId");
b.HasIndex("PromptId");
b.ToTable("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.Property<Guid>("SubscriberId")
.HasColumnType("uuid");
b.Property<Guid>("SubscribedToId")
.HasColumnType("uuid");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.HasKey("SubscriberId", "SubscribedToId");
b.HasIndex("SubscribedToId");
b.HasIndex("SubscriptionTierId");
b.ToTable("UserSubscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("Level", "UserId")
.IsUnique();
b.ToTable("SubscriptionTiers");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("citext");
b.Property<bool>("IsLockoutEnabled")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Bio")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsPublic")
.HasColumnType("boolean");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<string>("Specialities")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("UserProfiles");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category")
.WithMany("Prompts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator")
.WithMany("Prompts")
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Prompts")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
b.Navigation("Creator");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt")
.WithMany("Reviews")
.HasForeignKey("PromptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer")
.WithMany()
.HasForeignKey("ReviewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Prompt");
b.Navigation("Reviewer");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo")
.WithMany("Subscribers")
.HasForeignKey("SubscribedToId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber")
.WithMany("Subscriptions")
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier")
.WithMany("Subscriptions")
.HasForeignKey("SubscriptionTierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SubscribedTo");
b.Navigation("Subscriber");
b.Navigation("SubscriptionTier");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b =>
{
b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User")
.WithOne("Profile")
.HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b =>
{
b.Navigation("Prompts");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b =>
{
b.Navigation("Reviews");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b =>
{
b.Navigation("Prompts");
b.Navigation("Subscriptions");
});
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b =>
{
b.Navigation("Profile")
.IsRequired();
b.Navigation("Prompts");
b.Navigation("Subscribers");
b.Navigation("Subscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

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

View File

@ -1,211 +0,0 @@
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.EntityFrameworkCore.Diagnostics;
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();
opts.ConfigureWarnings(warnings =>
warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
});
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.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
var dir = Path.GetFullPath("./../OnlyPrompt.Frontend");
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(dir),
RedirectToAppendTrailingSlash = true,
HttpsCompression = HttpsCompressionMode.Compress,
});
}
else
{
app.UseStaticFiles();
}
app.MapControllers();
app.MapGet("/", async (HttpContext context) =>
{
var authResult = await context.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
return authResult.Succeeded
? Results.Redirect("/dashboard")
: Results.Redirect("/login");
});
app.MapFallbackToFile("/login.html");
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
await db.Database.MigrateAsync();
await EnsurePromptDetailColumnsAsync(db);
await EnsurePromptLikesTableAsync(db);
await EnsurePromptSavesTableAsync(db);
await SeedDefaultCategoriesAsync(db);
app.Run();
static async Task EnsurePromptDetailColumnsAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "ExampleImageUrl" text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "ExampleOutput" text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ADD COLUMN IF NOT EXISTS "Price" numeric;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ALTER COLUMN "Prompt" TYPE text;
""");
await db.Database.ExecuteSqlRawAsync("""
ALTER TABLE "Prompts"
ALTER COLUMN "ExampleOutput" TYPE text;
""");
}
static async Task EnsurePromptLikesTableAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "PromptLikes" (
"PromptId" uuid NOT NULL,
"UserId" uuid NOT NULL,
CONSTRAINT "PK_PromptLikes" PRIMARY KEY ("PromptId", "UserId"),
CONSTRAINT "FK_PromptLikes_Prompts_PromptId" FOREIGN KEY ("PromptId") REFERENCES "Prompts" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_PromptLikes_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS "IX_PromptLikes_UserId" ON "PromptLikes" ("UserId");
""");
}
static async Task EnsurePromptSavesTableAsync(OnlyPromptContext db)
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "PromptSaves" (
"PromptId" uuid NOT NULL,
"UserId" uuid NOT NULL,
CONSTRAINT "PK_PromptSaves" PRIMARY KEY ("PromptId", "UserId"),
CONSTRAINT "FK_PromptSaves_Prompts_PromptId" FOREIGN KEY ("PromptId") REFERENCES "Prompts" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_PromptSaves_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS "IX_PromptSaves_UserId" ON "PromptSaves" ("UserId");
""");
}
static async Task SeedDefaultCategoriesAsync(OnlyPromptContext db)
{
var defaults = new[]
{
("creative-writing", "Creative Writing"),
("coding", "Coding"),
("art", "Art"),
("marketing", "Marketing"),
("video", "Video"),
("data", "Data")
};
foreach (var (slug, name) in defaults)
{
if (await db.Categories.AnyAsync(c => c.Slug == slug))
continue;
db.Categories.Add(new CategoryModel
{
Id = Guid.CreateVersion7(),
Slug = slug,
Name = name,
Description = $"{name} prompts"
});
}
await db.SaveChangesAsync();
}

View File

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

View File

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

View File

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

View File

@ -1,115 +0,0 @@
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.CategoryName, x => x.Category.Name)
.MapCtorParamFrom(x => x.CategorySlug, x => x.Category.Slug)
.MapCtorParamFrom(x => x.ExampleOutput, x => x.ExampleOutput)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.Price, x => x.Price)
.MapCtorParamFrom(x => x.LikeCount, x => x.Likes.Count)
.MapCtorParamFrom(x => x.IsLiked, x => false)
.MapCtorParamFrom(x => x.SaveCount, x => x.Saves.Count)
.MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
.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))
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
.MapCtorParamFrom(x => x.CanAccess, x => false);
config.CreateMap<PromptModel, ApiMinimalPrompt>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Title, x => x.Title)
.MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName)
.MapCtorParamFrom(x => x.CreatorAvatarUrl, x => x.Creator.Profile.AvatarUrl)
.MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt)
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.ExampleImageUrl, x => x.ExampleImageUrl)
.MapCtorParamFrom(x => x.Price, x => x.Price)
.MapCtorParamFrom(x => x.LikeCount, x => x.Likes.Count)
.MapCtorParamFrom(x => x.IsLiked, x => false)
.MapCtorParamFrom(x => x.SaveCount, x => x.Saves.Count)
.MapCtorParamFrom(x => x.IsSaved, x => false)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level)
.MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name)
.MapCtorParamFrom(x => x.TierMonthlyPrice, x => x.SubscriptionTier == null ? (decimal?)null : x.SubscriptionTier.MonthlyPrice)
.MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating))
.MapCtorParamFrom(x => x.ReviewCount, x => x.Reviews.Count)
.MapCtorParamFrom(x => x.CanAccess, x => true);
config.CreateMap<ReviewModel, ApiReview>()
.MapCtorParamFrom(x => x.CreatorId, x => x.ReviewerId)
.MapCtorParamFrom(x => x.CreatorName, x => x.Reviewer.Profile.DisplayName)
.MapCtorParamFrom(x => x.Comment, x => x.Comment)
.MapCtorParamFrom(x => x.Rating, x => x.Rating);
config.CreateMap<CategoryModel, ApiCategory>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Description, x => x.Description)
.MapCtorParamFrom(x => x.Slug, x => x.Slug);
config.CreateMap<CategoryModel, ApiMinimalCategory>()
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Slug, x => x.Slug);
config.CreateMap<ApiCreateCategoryRequest, CategoryModel>()
.MapMemberFrom(x => x.Description, x => x.Description)
.MapMemberFrom(x => x.Name, x => x.Name)
.MapMemberFrom(x => x.Slug, x => x.Slug);
config.CreateMap<SubscriptionTierModel, ApiSubscriptionTier>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Level, x => x.Level)
.MapCtorParamFrom(x => x.MonthlyPrice, x => x.MonthlyPrice)
.MapCtorParamFrom(x => x.Description, x => x.Description);
config.CreateMap<ApiCreateSubscriptionTierRequest, SubscriptionTierModel>()
.MapMemberFrom(x => x.Name, x => x.Name)
.MapMemberFrom(x => x.Level, x => x.Level)
.MapMemberFrom(x => x.MonthlyPrice, x => x.MonthlyPrice)
.MapMemberFrom(x => x.Description, x => x.Description);
config.CreateMap<SubscriptionModel, ApiSubscription>()
.MapCtorParamFrom(x => x.SubscribedToId, x => x.SubscribedToId)
.MapCtorParamFrom(x => x.SubscribedToName, x => x.SubscribedTo.Profile.DisplayName)
.MapCtorParamFrom(x => x.CurrentTier, x => x.SubscriptionTier);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +0,0 @@
{
"ConnectionStrings": {
"DefaultConnection": "Include Error Detail=true;User ID=onlyprompt;Password=onlyprompt;Host=localhost;Port=2803;Database=onlyprompt;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;"
},
"Jwt": {
"Issuer": "https://onlyprompts.com",
"Audience": "https://onlyprompts.com",
"Key": "TfZi@!CC!b5UoD81gs&%tvY4J0M$p3cI",
"Valid": "3.0:0:0"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

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

View File

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

View File

@ -1,381 +0,0 @@
<!-- 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" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<main class="chats-main" id="main-content" tabindex="-1">
<!-- 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 type="button" class="new-chat-btn" id="newChatBtn" aria-label="New chat" aria-expanded="false" aria-controls="new-chat-panel">
<i class="bi bi-pencil-square" aria-hidden="true"></i>
</button>
</div>
<div class="new-chat-panel" id="new-chat-panel" hidden>
<label for="creatorSearch" class="sr-only">Search creator</label>
<input type="search" id="creatorSearch" placeholder="Search creator..." autocomplete="off" />
<div class="creator-search-results" id="creatorSearchResults" role="listbox" aria-label="Creator results"></div>
</div>
<div class="chat-list-items" id="chatList" role="list" aria-label="Conversations">
</div>
</div>
<!-- Right Column: Active Chat -->
<div class="chat-active">
<div class="chat-header">
<img
src="../images/content/cat.png"
alt=""
class="chat-avatar-large"
id="activeChatAvatar"
/>
<div class="chat-header-info">
<div class="chat-header-name" id="activeChatName">Select a chat</div>
<div class="chat-header-status">
<span class="online-dot" aria-hidden="true"></span>
<span id="activeChatStatus">Ready</span>
</div>
</div>
</div>
<div class="chat-messages" id="chatMessages" aria-live="polite" aria-label="Conversation">
</div>
<form class="chat-input-area" id="chatForm">
<input type="text" id="messageInput" placeholder="Type your message..." aria-label="Message" autocomplete="off" />
<button type="submit" class="send-btn">Send</button>
</form>
</div>
</div>
</main>
</div>
</div>
<script>
fetch("/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");
link.removeAttribute("aria-current");
});
// 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");
chatsLink.setAttribute("aria-current", "page");
}
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
const STORAGE_KEY = "onlyprompt-chat-conversations";
const chatList = document.getElementById("chatList");
const chatMessages = document.getElementById("chatMessages");
const chatForm = document.getElementById("chatForm");
const messageInput = document.getElementById("messageInput");
const activeChatAvatar = document.getElementById("activeChatAvatar");
const activeChatName = document.getElementById("activeChatName");
const activeChatStatus = document.getElementById("activeChatStatus");
const newChatBtn = document.getElementById("newChatBtn");
const newChatPanel = document.getElementById("new-chat-panel");
const creatorSearch = document.getElementById("creatorSearch");
const creatorSearchResults = document.getElementById("creatorSearchResults");
let conversations = loadConversations();
let creators = [];
let activeConversationId = null;
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function loadConversations() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
return Array.isArray(saved) && saved.length ? saved : demoConversations();
} catch {
return demoConversations();
}
}
function saveConversations() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
}
function demoConversations() {
const now = Date.now();
return [
{
id: "demo-alex",
userId: "demo-alex",
name: "Alex Chen",
avatar: "../images/content/creator2.png",
updatedAt: now - 60000,
messages: [
{ from: "them", text: "Hey, I liked your last prompt idea.", createdAt: now - 180000 },
{ from: "me", text: "Thanks. Which part was useful?", createdAt: now - 120000 },
{ from: "them", text: "The structure was easy to adapt.", createdAt: now - 60000 },
],
},
{
id: "demo-mia",
userId: "demo-mia",
name: "Mia Wong",
avatar: "../images/content/creator3.png",
updatedAt: now - 86400000,
messages: [
{ from: "them", text: "Thanks for the prompt tips.", createdAt: now - 86400000 },
],
},
];
}
function formatTime(timestamp) {
const date = new Date(timestamp);
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
function getLastMessage(conversation) {
return conversation.messages.at(-1)?.text || "No messages yet.";
}
function renderChatList() {
conversations.sort((a, b) => b.updatedAt - a.updatedAt);
if (!conversations.length) {
chatList.innerHTML = '<div class="chat-empty">Start a chat with a creator.</div>';
return;
}
chatList.innerHTML = conversations
.map((conversation) => `
<button type="button"
class="chat-item ${conversation.id === activeConversationId ? "active" : ""}"
data-chat-id="${escapeHtml(conversation.id)}"
aria-label="Open chat with ${escapeHtml(conversation.name)}"
${conversation.id === activeConversationId ? 'aria-current="true"' : ""}>
<img src="${escapeHtml(conversation.avatar)}" alt="" class="chat-avatar" />
<div class="chat-item-info">
<div class="chat-name">${escapeHtml(conversation.name)}</div>
<div class="chat-last-msg">${escapeHtml(getLastMessage(conversation))}</div>
</div>
<time class="chat-time" datetime="${new Date(conversation.updatedAt).toISOString()}">${formatTime(conversation.updatedAt)}</time>
</button>
`)
.join("");
chatList.querySelectorAll("[data-chat-id]").forEach((button) => {
button.addEventListener("click", () => selectConversation(button.dataset.chatId));
});
}
function renderMessages() {
const conversation = conversations.find((item) => item.id === activeConversationId);
if (!conversation) {
activeChatAvatar.src = "../images/content/cat.png";
activeChatName.textContent = "Select a chat";
activeChatStatus.textContent = "Ready";
chatMessages.innerHTML = '<div class="chat-empty">Choose a conversation or start a new chat.</div>';
messageInput.disabled = true;
chatForm.querySelector("button").disabled = true;
return;
}
activeChatAvatar.src = conversation.avatar;
activeChatName.textContent = conversation.name;
activeChatStatus.textContent = "Online";
chatMessages.setAttribute("aria-label", `Conversation with ${conversation.name}`);
messageInput.disabled = false;
chatForm.querySelector("button").disabled = false;
chatMessages.innerHTML = conversation.messages
.map((message) => `
<div class="message ${message.from === "me" ? "sent" : "received"}">
<div class="message-bubble">${escapeHtml(message.text)}</div>
<time class="message-time" datetime="${new Date(message.createdAt).toISOString()}">${formatTime(message.createdAt)}</time>
</div>
`)
.join("");
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function selectConversation(id) {
activeConversationId = id;
renderChatList();
renderMessages();
}
function startConversation(creator) {
const id = `user-${creator.userId}`;
let conversation = conversations.find((item) => item.id === id);
if (!conversation) {
conversation = {
id,
userId: creator.userId,
name: creator.displayName,
avatar: creator.avatarUrl || "../images/content/cat.png",
updatedAt: Date.now(),
messages: [],
};
conversations.push(conversation);
saveConversations();
}
activeConversationId = id;
newChatPanel.hidden = true;
newChatBtn.setAttribute("aria-expanded", "false");
creatorSearch.value = "";
renderCreatorResults();
renderChatList();
renderMessages();
messageInput.focus();
}
function addMessage(text) {
const conversation = conversations.find((item) => item.id === activeConversationId);
if (!conversation || !text.trim()) return;
conversation.messages.push({
from: "me",
text: text.trim(),
createdAt: Date.now(),
});
conversation.updatedAt = Date.now();
saveConversations();
renderChatList();
renderMessages();
}
async function loadCreators() {
try {
const response = await fetch("/api/v1/profiles?limit=100", {
credentials: "same-origin",
});
if (!response.ok) throw new Error("Creators could not be loaded.");
creators = await response.json();
} catch {
creators = [
{ userId: "demo-alex", displayName: "Alex Chen", slug: "alex", avatarUrl: "../images/content/creator2.png" },
{ userId: "demo-mia", displayName: "Mia Wong", slug: "mia", avatarUrl: "../images/content/creator3.png" },
{ userId: "demo-tom", displayName: "Tom Rivera", slug: "tom", avatarUrl: "../images/content/creator4.png" },
];
}
renderCreatorResults();
}
function renderCreatorResults() {
const search = creatorSearch.value.trim().toLowerCase();
const results = creators
.filter((creator) =>
`${creator.displayName || ""} ${creator.slug || ""}`.toLowerCase().includes(search),
)
.slice(0, 8);
if (!results.length) {
creatorSearchResults.innerHTML = '<div class="creator-result-empty">No creators found.</div>';
return;
}
creatorSearchResults.innerHTML = results
.map((creator) => `
<button type="button" class="creator-result" data-user-id="${escapeHtml(creator.userId)}" role="option">
<img src="${escapeHtml(creator.avatarUrl || "../images/content/cat.png")}" alt="" />
<span>
<strong>${escapeHtml(creator.displayName)}</strong>
<small>@${escapeHtml(creator.slug || "creator")}</small>
</span>
</button>
`)
.join("");
creatorSearchResults.querySelectorAll("[data-user-id]").forEach((button) => {
button.addEventListener("click", () => {
const creator = creators.find((item) => item.userId === button.dataset.userId);
if (creator) startConversation(creator);
});
});
}
function openConversationFromUrl() {
const params = new URLSearchParams(location.search);
const userId = params.get("userId");
if (!userId) return false;
startConversation({
userId,
displayName: params.get("name") || "Creator",
slug: "creator",
avatarUrl: params.get("avatar") || "../images/content/cat.png",
});
history.replaceState(null, "", "/chats.html");
return true;
}
newChatBtn.addEventListener("click", () => {
const willOpen = newChatPanel.hidden;
newChatPanel.hidden = !willOpen;
newChatBtn.setAttribute("aria-expanded", String(willOpen));
if (willOpen) creatorSearch.focus();
});
creatorSearch.addEventListener("input", renderCreatorResults);
chatForm.addEventListener("submit", (event) => {
event.preventDefault();
addMessage(messageInput.value);
messageInput.value = "";
});
loadCreators().then(() => {
if (!openConversationFromUrl()) {
activeConversationId = conversations[0]?.id || null;
renderChatList();
renderMessages();
}
});
</script>
</body>
</html>

View File

@ -1,247 +0,0 @@
<!-- OnlyPrompt - Community page:
- Discover creators, follow/unfollow, dynamic via API -->
<!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" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<main class="creators-main" id="main-content" tabindex="-1">
<div class="creators-header">
<h1>Discover Creators</h1>
<p>Follow your favorite prompt artists and get inspired.</p>
</div>
<div class="filter-buttons" role="group" aria-label="Sort creators">
<button type="button" class="filter-btn active" data-sort="popular" aria-pressed="true">
Popular
</button>
<button type="button" class="filter-btn" data-sort="prompts" aria-pressed="false">Rising</button>
<button type="button" class="filter-btn" data-sort="new" aria-pressed="false">New</button>
<button type="button" class="filter-btn" data-sort="rating" aria-pressed="false">Top Rated</button>
</div>
<div class="creators-grid" id="creators-grid" aria-live="polite"></div>
<div id="creators-empty" class="state-empty" role="status" aria-live="polite">
<i class="bi bi-people state-icon" aria-hidden="true"></i>
<h3 id="creators-empty-title" class="state-title">
No creators found
</h3>
<p id="creators-empty-text">
Check back later for new creators to follow.
</p>
</div>
<div id="creators-error" class="state-error" role="alert" aria-live="assertive">
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
<h3 class="state-title">Could not load creators</h3>
<p id="creators-error-msg"></p>
</div>
</main>
</div>
</div>
<script type="module">
// ── Sidebar & Topbar ─────────────────────────────────────────────
fetch("/sidebar.html")
.then((r) => r.text())
.then((data) => {
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((l) => {
l.classList.remove("active");
l.removeAttribute("aria-current");
});
const thirdLink = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[2];
if (thirdLink) {
thirdLink.classList.add("active");
thirdLink.setAttribute("aria-current", "page");
}
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
// ── Helpers ──────────────────────────────────────────────────────
function renderStars(rating) {
if (!rating) return "";
const stars = Math.round(rating);
return `<span class="creator-stars">${"★".repeat(stars)}${"☆".repeat(5 - stars)}</span> <span class="creator-stars-value">${rating.toFixed(1)}</span>`;
}
function renderCard(c) {
const profileHref = `/profile?id=${encodeURIComponent(c.userId)}`;
const chatHref = `/chats.html?userId=${encodeURIComponent(c.userId)}&name=${encodeURIComponent(c.displayName)}&avatar=${encodeURIComponent(c.avatarUrl || "../images/content/cat.png")}`;
return `
<div class="creator-card">
<a class="creator-avatar-link" href="${profileHref}" aria-label="Open profile for ${c.displayName}">
<img class="creator-avatar"
src="${c.avatarUrl || "../images/content/cat.png"}"
alt="${c.displayName}">
</a>
<div class="creator-info">
<h3 class="creator-name"><a href="${profileHref}">${c.displayName}</a></h3>
<div class="creator-handle">@${c.slug}</div>
<p class="creator-bio">${c.bio ?? "No bio yet."}</p>
<div class="creator-stats">
<span><i class="bi bi-puzzle" aria-hidden="true"></i> ${c.promptCount} prompts</span>
<span><i class="bi bi-people" aria-hidden="true"></i> ${c.subscribers} subscribers</span>
${c.averageRating > 0 ? `<span>${renderStars(c.averageRating)}</span>` : ""}
</div>
<div class="creator-actions">
<button type="button" class="follow-btn ${c.isFollowing ? "following" : ""}"
data-userid="${c.userId}"
data-following="${c.isFollowing}"
aria-pressed="${c.isFollowing}"
aria-label="${c.isFollowing ? "Unfollow" : "Follow"} ${c.displayName}">
${c.isFollowing ? "Following" : "Follow"}
</button>
<a class="creator-chat-btn" href="${chatHref}" aria-label="Chat with ${c.displayName}">
<i class="bi bi-chat-dots" aria-hidden="true"></i>
Chat
</a>
</div>
</div>
</div>`;
}
// ── Follow / Unfollow ────────────────────────────────────────────
async function toggleFollow(btn) {
const userId = btn.dataset.userid;
const isFollowing = btn.dataset.following === "true";
btn.disabled = true;
const res = await fetch(`/api/v1/subscriptions/${userId}`, {
method: isFollowing ? "DELETE" : "PUT",
credentials: "same-origin",
});
if (res.status === 401) {
location.href = "/login";
return;
}
if (res.ok) {
const nowFollowing = !isFollowing;
btn.dataset.following = nowFollowing;
btn.textContent = nowFollowing ? "Following" : "Follow";
btn.setAttribute("aria-pressed", String(nowFollowing));
btn.classList.toggle("following", nowFollowing);
}
btn.disabled = false;
}
// ── Load Creators ────────────────────────────────────────────────
const grid = document.getElementById("creators-grid");
const emptyEl = document.getElementById("creators-empty");
const emptyTitle = document.getElementById("creators-empty-title");
const emptyText = document.getElementById("creators-empty-text");
const errorEl = document.getElementById("creators-error");
const errorMsg = document.getElementById("creators-error-msg");
let activeSort = "popular";
let currentSearch =
new URLSearchParams(location.search).get("search") || "";
function getSearchTerm() {
return currentSearch.trim();
}
async function loadCreators(sort = activeSort) {
activeSort = sort;
grid.innerHTML = "";
emptyEl.style.display = "none";
errorEl.style.display = "none";
try {
const params = new URLSearchParams({
sort,
limit: "50",
});
const search = getSearchTerm();
if (search) params.set("search", search);
const res = await fetch(`/api/v1/profiles?${params}`);
if (res.status === 401) {
location.href = "/login";
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
const creators = await res.json();
if (creators.length === 0) {
const search = getSearchTerm();
emptyTitle.textContent = search
? "No matching creators"
: "No creators found";
emptyText.textContent = search
? `No creator matches "${search}". Try another name or clear the search.`
: "Create another local user to see creators here.";
emptyEl.style.display = "block";
return;
}
grid.innerHTML = creators.map(renderCard).join("");
grid.querySelectorAll(".follow-btn").forEach((btn) => {
btn.addEventListener("click", () => toggleFollow(btn));
});
} catch (e) {
errorEl.style.display = "block";
errorMsg.textContent = e.message;
}
}
// ── Filter buttons ───────────────────────────────────────────────
document.querySelectorAll(".filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document
.querySelectorAll(".filter-btn")
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
loadCreators(btn.dataset.sort);
});
});
window.applyCreatorSearch = (value) => {
currentSearch = value.trim();
loadCreators(activeSort);
};
loadCreators();
</script>
</body>
</html>

View File

@ -1,344 +0,0 @@
<!-- 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">
<script src="../js/profile-shared.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<main class="create-main" id="main-content" tabindex="-1">
<div class="create-container">
<div class="create-header">
<h1 id="create-title">Create AI Prompt</h1>
<p id="create-subtitle">Design and save custom prompts for your AI workflows.</p>
</div>
<form id="createPromptForm" class="create-form" enctype="multipart/form-data">
<!-- 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" autocomplete="off" 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..." aria-describedby="promptContentHint" required></textarea>
<small class="form-hint" id="promptContentHint">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" aria-describedby="exampleImageHint">
<small class="form-hint" id="exampleImageHint">Upload a PNG or JPG. Preview will appear below.</small>
<div id="imagePreview" aria-live="polite">
<img id="previewImg" src="#" alt="Selected example image preview">
</div>
</div>
<!-- Pricing (with toggle) -->
<div class="form-group pricing-group">
<span class="form-label" id="access-label">Access</span>
<div class="pricing-toggle" role="group" aria-labelledby="access-label">
<button type="button" id="freeBtn" class="price-option active" aria-pressed="true">Free</button>
<button type="button" id="tierBtn" class="price-option" aria-pressed="false">Tier</button>
</div>
<div id="tierField">
<label for="subscriptionTier" class="sr-only">Subscription tier</label>
<select id="subscriptionTier" name="subscriptionTier">
<option value="">No tiers created yet</option>
</select>
<a class="tier-manage-link" href="subscription-tiers.html">Manage tiers</a>
</div>
<small class="form-hint">Free prompts are public. Tier prompts require a monthly creator subscription.</small>
</div>
<!-- Submit Button -->
<div class="form-actions">
<button type="submit" class="submit-btn" id="submitPromptBtn">Publish Prompt</button>
<button type="button" class="cancel-btn">Cancel</button>
</div>
<p id="create-status" role="status" aria-live="polite"></p>
</form>
</div>
</main>
</div>
</div>
<script>
// Toggle between free and paid
const freeBtn = document.getElementById('freeBtn');
const tierBtn = document.getElementById('tierBtn');
const tierField = document.getElementById('tierField');
const tierSelect = document.getElementById('subscriptionTier');
const editPromptId = new URLSearchParams(location.search).get('id');
const submitPromptBtn = document.getElementById('submitPromptBtn');
let ownSubscriptionTiers = [];
freeBtn.addEventListener('click', () => {
freeBtn.classList.add('active');
tierBtn.classList.remove('active');
freeBtn.setAttribute('aria-pressed', 'true');
tierBtn.setAttribute('aria-pressed', 'false');
tierField.style.display = 'none';
tierSelect.removeAttribute('required');
});
tierBtn.addEventListener('click', () => {
tierBtn.classList.add('active');
freeBtn.classList.remove('active');
tierBtn.setAttribute('aria-pressed', 'true');
freeBtn.setAttribute('aria-pressed', 'false');
tierField.style.display = 'grid';
tierSelect.setAttribute('required', 'required');
});
// Image preview for example image
const imageInput = document.getElementById('exampleImage');
const imagePreview = document.getElementById('imagePreview');
const previewImg = document.getElementById('previewImg');
let exampleImageUrl = '';
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) {
exampleImageUrl = e.target.result;
previewImg.src = exampleImageUrl;
imagePreview.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
imagePreview.style.display = 'none';
previewImg.src = '#';
exampleImageUrl = '';
if (file) alert('Please upload a PNG or JPG image.');
}
});
}
async function loadCategories() {
const categorySelect = document.getElementById('category');
try {
const response = await fetch('/api/v1/categories/minimal');
if (!response.ok) return;
const categories = await response.json();
if (!categories.length) return;
categorySelect.innerHTML = categories
.map((category) => `<option value="${category.slug}">${category.name}</option>`)
.join('');
} catch {
// Keep the static fallback categories.
}
}
async function loadSubscriptionTiers() {
try {
const response = await fetch('/api/v1/subscriptions/tiers', {
credentials: 'same-origin'
});
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) return;
ownSubscriptionTiers = await response.json();
if (!ownSubscriptionTiers.length) {
tierSelect.innerHTML = '<option value="">Create a tier first</option>';
return;
}
tierSelect.innerHTML = ownSubscriptionTiers
.map((tier) => `<option value="${tier.level}">${tier.name} - $${Number(tier.monthlyPrice || 0).toFixed(2)}/mo</option>`)
.join('');
} catch {
tierSelect.innerHTML = '<option value="">Tiers could not be loaded</option>';
}
}
async function loadPromptForEdit() {
if (!editPromptId) return;
document.getElementById('create-title').textContent = 'Edit AI Prompt';
document.getElementById('create-subtitle').textContent = 'Update your published prompt.';
submitPromptBtn.textContent = 'Save Changes';
const status = document.getElementById('create-status');
status.textContent = 'Loading prompt...';
try {
const response = await fetch(`/api/v1/prompts/${editPromptId}`);
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) throw new Error('Prompt could not be loaded.');
const prompt = await response.json();
document.getElementById('title').value = prompt.title || '';
document.getElementById('description').value = prompt.description || '';
document.getElementById('category').value = prompt.categorySlug || document.getElementById('category').value;
document.getElementById('promptContent').value = prompt.content || '';
document.getElementById('exampleOutput').value = prompt.exampleOutput || '';
exampleImageUrl = prompt.exampleImageUrl || '';
if (exampleImageUrl) {
previewImg.src = exampleImageUrl;
imagePreview.style.display = 'block';
}
if (prompt.tierLevel != null) {
tierBtn.click();
tierSelect.value = String(prompt.tierLevel);
} else {
freeBtn.click();
}
status.textContent = '';
} catch (error) {
status.textContent = error.message;
}
}
// Handle form submission
document.getElementById('createPromptForm').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('create-status');
const submitBtn = document.querySelector('.submit-btn');
status.textContent = editPromptId ? 'Saving...' : 'Publishing...';
submitBtn.disabled = true;
try {
const isTier = tierBtn.classList.contains('active');
const payload = {
title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(),
category: document.getElementById('category').value,
content: document.getElementById('promptContent').value.trim(),
exampleOutput: document.getElementById('exampleOutput').value.trim() || null,
exampleImageUrl: exampleImageUrl || null,
price: null,
subscriptionTier: isTier ? Number(tierSelect.value) : null,
slug: null
};
const response = await fetch(editPromptId ? `/api/v1/prompts/${editPromptId}` : '/api/v1/prompts', {
method: editPromptId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload)
});
if (response.status === 401) {
location.href = '/login';
return;
}
if (!response.ok) {
const error = await response.text();
throw new Error(getCreateErrorMessage(error, response.status));
}
const prompt = await response.json();
location.href = `/post-detail?id=${prompt.id}`;
} catch (error) {
status.textContent = error.message;
submitBtn.disabled = false;
}
});
function getCreateErrorMessage(errorText, status) {
if (!errorText) return `Server error ${status}`;
try {
const error = JSON.parse(errorText);
const messages = error.errors
? Object.values(error.errors).flat()
: [error.title || errorText];
return messages.join(' ');
} catch {
return errorText;
}
}
// Cancel button (go back)
document.querySelector('.cancel-btn').addEventListener('click', () => {
window.history.back();
});
// Fetch sidebar and topbar
fetch('/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');
link.removeAttribute('aria-current');
});
// 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');
createLink.setAttribute('aria-current', 'page');
}
});
fetch('/topbar.html')
.then(r => r.text())
.then(data => document.getElementById('topbar-container').innerHTML = data);
Promise.all([loadCategories(), loadSubscriptionTiers()]).then(loadPromptForEdit);
</script>
</body>
</html>

View File

@ -1,164 +0,0 @@
/*
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);
}
.skip-link {
position: fixed;
top: 12px;
left: 12px;
z-index: 1000;
transform: translateY(-160%);
padding: 10px 14px;
border-radius: 8px;
background: #111827;
color: #ffffff;
font-weight: 700;
text-decoration: none;
transition: transform 0.2s ease;
}
.skip-link + .skip-link {
top: 58px;
}
.skip-link:focus {
transform: translateY(0);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 3px;
outline-style: solid !important;
outline-width: 3px !important;
}
button:disabled,
[aria-disabled="true"] {
cursor: not-allowed;
}
/* 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;
}
/* ── Layout ──────────────────────────────────────────────────────────── */
.layout {
display: flex;
min-height: 100vh;
width: 100%;
background: var(--bg);
}
#sidebar-container {
flex-shrink: 0;
display: flex;
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
overflow-y: auto;
}
@media (max-width: 700px) {
.layout {
padding-bottom: 74px;
}
#sidebar-container {
position: fixed;
top: auto;
right: 0;
bottom: 0;
left: 0;
z-index: 100;
width: 100%;
height: auto;
overflow: visible;
border-top: 1px solid #e2e8f0;
}
}
/* Main content area - flex child that fills remaining space */
.page-body {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
margin: 0;
padding: 0;
}
/* ── Reusable empty / error state components ─────────────────────────── */
.state-empty,
.state-error {
display: none;
text-align: center;
padding: 60px 20px;
}
.state-empty {
color: #64748b;
}
.state-error {
color: #ef4444;
}
.state-icon {
font-size: 3rem;
display: block;
margin-bottom: 16px;
}
.state-title {
margin-bottom: 8px;
}

View File

@ -1,330 +0,0 @@
/* Chats page - Two column layout: chat list + active chat window */
.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;
border-radius: 999px;
font-size: 1.2rem;
color: #3b82f6;
cursor: pointer;
padding: 8px;
}
.new-chat-btn:hover,
.new-chat-btn[aria-expanded="true"] {
background: #eef2ff;
}
.new-chat-panel {
border-bottom: 1px solid #eef2f7;
padding: 14px 16px;
}
.new-chat-panel input {
width: 100%;
border: 1px solid #dbe2ea;
border-radius: 12px;
padding: 10px 12px;
font: inherit;
}
.creator-search-results {
display: grid;
gap: 8px;
margin-top: 10px;
max-height: 260px;
overflow-y: auto;
}
.creator-result {
align-items: center;
background: #f8fafc;
border: 1px solid transparent;
border-radius: 12px;
color: inherit;
cursor: pointer;
display: flex;
gap: 10px;
padding: 10px;
text-align: left;
width: 100%;
}
.creator-result:hover,
.creator-result:focus-visible {
background: #eef2ff;
border-color: #c7d2fe;
}
.creator-result img {
border-radius: 50%;
height: 36px;
object-fit: cover;
width: 36px;
}
.creator-result span {
display: grid;
}
.creator-result small,
.creator-result-empty,
.chat-empty {
color: #64748b;
font-size: 0.82rem;
}
.creator-result-empty,
.chat-empty {
padding: 18px;
text-align: center;
}
.chat-list-items {
flex: 1;
overflow-y: auto;
}
.chat-item {
background: transparent;
border: 0;
border-bottom: 1px solid #f0f2f5;
color: inherit;
display: flex;
align-items: center;
gap: 12px;
font: inherit;
text-align: left;
padding: 16px 20px;
cursor: pointer;
transition: background 0.2s;
width: 100%;
}
.chat-item:hover,
.chat-item:focus-visible {
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;
margin: 0;
}
.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;
}
.chat-input-area input:disabled {
background: #f8fafc;
cursor: not-allowed;
}
.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;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.chats-main {
padding: 16px;
}
.chat-list {
width: 280px;
}
}
@media (max-width: 640px) {
.chat-container {
flex-direction: column;
height: auto;
}
.chat-list {
width: 100%;
border-right: none;
border-bottom: 1px solid #eef2f7;
}
.chat-active {
height: 500px;
}
}

View File

@ -1,181 +0,0 @@
/* Create page - Form for publishing new AI prompts */
.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,
.form-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;
}
#tierField {
display: none;
gap: 8px;
margin-top: 8px;
}
.tier-manage-link {
color: #3b82f6;
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
}
.tier-manage-link:hover {
text-decoration: underline;
}
/* Image preview */
#imagePreview {
margin-top: 10px;
display: none;
}
#imagePreview img {
max-width: 100%;
max-height: 200px;
border-radius: 12px;
}
/* Status message */
#create-status {
text-align: center;
color: #64748b;
margin: 0;
}
/* Buttons */
.form-actions {
display: flex;
gap: 16px;
margin-top: 8px;
}
.submit-btn,
.cancel-btn {
flex: 1;
border: none;
padding: 12px;
border-radius: 12px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: opacity 0.2s;
}
.submit-btn {
background: var(--gradient);
color: white;
}
.cancel-btn {
background: #f1f5f9;
color: #475569;
}
.submit-btn:hover,
.cancel-btn:hover {
opacity: 0.85;
}
/* Responsive */
@media (max-width: 768px) {
.create-container {
padding: 24px;
}
.create-header h1 {
font-size: 1.6rem;
}
}
@media (max-width: 480px) {
.create-container {
padding: 20px;
}
.form-actions {
flex-direction: column;
}
}

View File

@ -1,272 +0,0 @@
/* Feed page - Multi-column grid, square images, like/comment/save actions */
.feed-main {
background: transparent !important;
padding: 20px 32px !important;
margin: 0 auto !important;
width: 100%;
max-width: 1400px;
}
/* Feed Header (centered) */
.feed-header {
text-align: center;
margin-bottom: 24px;
}
.feed-header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
.feed-header p {
color: #64748b;
font-size: 1rem;
}
/* Filter Buttons (centered) */
.filter-buttons {
display: flex;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 32px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 16px;
}
.filter-btn {
background: transparent;
border: none;
padding: 8px 20px;
font-size: 0.9rem;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-radius: 30px;
transition: all 0.2s;
}
.filter-btn.active {
background: var(--gradient);
color: white;
}
.filter-btn:hover:not(.active) {
background: #f1f5f9;
}
/* Posts Grid - multicolumn like marketplace */
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
/* Post Card */
.post-card {
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
overflow: hidden;
transition:
transform 0.2s,
box-shadow 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
}
.post-card:hover,
.post-card:focus-within {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
}
.post-card-link {
color: inherit;
display: flex;
flex: 1;
flex-direction: column;
text-decoration: none;
}
/* 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: 10px 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;
}
.action-btn.active {
font-weight: 700;
}
.like-btn.active {
color: #ef4444;
}
.save-btn.active {
color: #f59e0b;
}
.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;
}
}
/* Avatar initials fallback */
.post-avatar-initials {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary, #6366f1);
color: #fff;
font-weight: 700;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Locked post */
.post-locked {
opacity: 0.75;
}
.post-image-locked {
filter: blur(6px);
pointer-events: none;
}
.post-locked-msg {
color: #94a3b8;
font-size: 0.85rem;
margin: 6px 0 0;
}
/* Star rating */
.post-rating {
display: flex;
align-items: center;
gap: 3px;
color: #f59e0b;
font-size: 0.85rem;
margin-top: 6px;
}
.post-rating span {
color: #64748b;
margin-left: 4px;
}
@media (max-width: 480px) {
.feed-main {
padding: 12px !important;
}
.post-header {
padding: 10px;
}
.post-content {
padding: 10px;
}
.post-title {
font-size: 1rem;
}
.post-actions {
padding: 8px 10px 10px 10px;
}
}

View File

@ -1,500 +0,0 @@
/* Post Detail page - Full prompt view, rating, example output, unlock button */
.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;
}
/* Reviews */
.reviews-section {
margin-top: 18px;
}
.reviews-section h2 {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 28px;
}
.review-form {
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: 16px;
padding: 22px;
margin-bottom: 28px;
}
.review-form h3 {
font-size: 1.2rem;
margin-bottom: 16px;
}
.review-star-input {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.review-star-input button {
border: none;
background: transparent;
color: #f59e0b;
cursor: pointer;
font-family: inherit;
font-size: 2.4rem;
line-height: 1;
padding: 0 2px;
}
.review-form textarea {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 12px;
color: #111827;
display: block;
font: inherit;
font-size: 1rem;
line-height: 1.5;
margin-bottom: 16px;
min-height: 96px;
padding: 14px 16px;
resize: vertical;
}
.review-form textarea:focus {
border-color: #a855f7;
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.14);
outline: none;
}
.review-form button#submit-review-btn {
background: var(--gradient);
border: none;
border-radius: 14px;
color: #fff;
cursor: pointer;
font: inherit;
font-size: 1rem;
font-weight: 700;
padding: 12px 24px;
}
.review-form button#submit-review-btn:disabled {
cursor: wait;
opacity: 0.7;
}
#review-message {
color: #64748b;
font-size: 0.9rem;
margin-top: 12px;
}
.reviews-list {
display: grid;
gap: 14px;
}
.review-card {
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 16px 18px;
}
.review-card-header {
align-items: center;
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.review-card-user {
font-weight: 700;
}
.review-card-stars {
color: #f59e0b;
font-size: 1.2rem;
letter-spacing: 1px;
margin-left: auto;
}
.review-card-comment {
color: #334155;
line-height: 1.45;
margin: 0;
}
/* 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;
}
}
/* ── Loading / error states ──────────────────────────────────────────── */
#detail-loading {
text-align: center;
padding: 60px 20px;
color: #64748b;
}
/* Smaller state icons for this page */
#detail-loading .state-icon,
#detail-error .state-icon {
font-size: 2.5rem;
margin-bottom: 12px;
}
#detail-error-msg {
color: #64748b;
margin-top: 8px;
}
.detail-back-btn {
margin-top: 20px;
padding: 10px 24px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
}
/* ── Detail body ─────────────────────────────────────────────────────── */
#detail-body {
display: none;
}
.detail-creator-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
#creator-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: #6366f1;
color: #fff;
font-weight: 700;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
#creator-name {
font-weight: 600;
font-size: 0.95rem;
}
#prompt-date {
display: block;
font-size: 0.8rem;
color: #94a3b8;
}
.detail-actions-right {
margin-left: auto;
}
#edit-prompt-btn {
display: none;
margin-left: 10px;
padding: 6px 14px;
border: none;
border-radius: 10px;
background: #f1f5f9;
color: #334155;
font-weight: 700;
cursor: pointer;
}
#prompt-body {
white-space: pre-wrap;
font-family: monospace;
background: #f8fafc;
border-radius: 10px;
padding: 16px;
font-size: 0.9rem;
line-height: 1.7;
}
#example-section {
display: none;
}
#example-output-text {
white-space: pre-wrap;
}
#example-image {
display: none;
}
/* ── Locked section ──────────────────────────────────────────────────── */
#locked-section {
display: none;
text-align: center;
padding: 40px 20px;
background: #f8fafc;
border-radius: 12px;
margin-bottom: 28px;
}
.locked-icon {
font-size: 2.5rem;
color: #94a3b8;
display: block;
margin-bottom: 12px;
}
.locked-title {
margin-bottom: 8px;
}
.locked-desc {
color: #64748b;
margin-bottom: 20px;
}
#locked-subscribe-btn {
padding: 12px 28px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
.detail-heart-icon {
color: #ef4444;
}
.detail-bookmark-span {
margin-left: 12px;
}
.detail-bookmark-icon {
color: #f59e0b;
}
.detail-loading-text {
color: #94a3b8;
}
.detail-error-text {
color: #ef4444;
}
.rating-stars-display {
font-size: 1.1rem;
color: #f59e0b;
}
.rating-value {
margin-left: 8px;
font-weight: 600;
}
.rating-count {
font-size: 0.85rem;
color: #94a3b8;
margin-left: 4px;
}
.rating-none {
font-size: 0.9rem;
color: #94a3b8;
}
.tier-badge-tier {
background: #f1f5f9;
color: #475569;
border-radius: 20px;
padding: 4px 14px;
font-size: 0.8rem;
font-weight: 600;
}
.tier-badge-free {
background: #dcfce7;
color: #166534;
border-radius: 20px;
padding: 4px 14px;
font-size: 0.8rem;
font-weight: 600;
}

View File

@ -1,337 +0,0 @@
/* Profile Page - Full width layout, darker share button, responsive grid */
/* ── Profile header ──────────────────────────────────────────────────── */
.profile-header {
display: flex;
align-items: center;
gap: 32px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 24px;
}
/* ── Profile avatar ──────────────────────────────────────────────────── */
.profile-avatar {
width: 110px;
height: 110px;
object-fit: cover;
}
/* ── Profile info column ─────────────────────────────────────────────── */
.profile-info {
flex: 1;
}
#profileDisplayName {
font-size: 2rem;
font-weight: 700;
margin-bottom: 4px;
}
#profileSlug {
color: #64748b;
margin-bottom: 8px;
}
.profile-badge-icon {
color: #3b82f6;
}
#profileBio {
margin-bottom: 8px;
}
#profileSpecialities {
color: #64748b;
}
#profileStats {
display: flex;
gap: 18px;
color: #64748b;
margin-top: 12px;
font-size: 0.95rem;
}
#profileStats strong {
color: #111827;
}
/* ── Profile actions column ──────────────────────────────────────────── */
#profileActions {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 220px;
}
#profileActions .login-button {
box-sizing: border-box;
width: 100%;
}
.profile-tier-list {
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 16px;
display: grid;
gap: 10px;
margin-top: 6px;
padding: 14px;
}
.profile-tier-list h3 {
font-size: 0.95rem;
margin: 0 0 4px;
}
.profile-tier-option {
align-items: center;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
color: #334155;
cursor: pointer;
display: flex;
gap: 12px;
justify-content: space-between;
padding: 10px 12px;
text-align: left;
}
.profile-tier-option.active {
background: #eef2ff;
border-color: #818cf8;
color: #2563eb;
}
.profile-tier-option strong,
.profile-tier-option small {
display: block;
}
.profile-tier-option small {
color: #64748b;
font-size: 0.75rem;
margin-top: 2px;
}
.profile-tier-option b {
white-space: nowrap;
}
/* ── Profile tabs ────────────────────────────────────────────────────── */
.profile-tabs {
display: flex;
gap: 24px;
border-bottom: 2px solid #e5e7eb;
margin: 32px 0 18px 0;
flex-wrap: wrap;
}
/* ── Prompts grid ────────────────────────────────────────────────────── */
#profile-prompts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
}
.profile-grid-loading {
grid-column: 1 / -1;
color: #64748b;
text-align: center;
padding: 28px;
}
.profile-grid-empty {
grid-column: 1 / -1;
color: #64748b;
text-align: center;
padding: 28px;
}
.profile-grid-error {
grid-column: 1 / -1;
color: #ef4444;
text-align: center;
padding: 28px;
}
.profile-prompt-card {
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
padding: 18px;
display: flex;
gap: 16px;
align-items: flex-start;
}
.profile-prompt-card:focus-within,
.profile-prompt-card:hover {
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
}
.profile-prompt-link {
color: inherit;
display: flex;
flex: 1;
gap: 16px;
min-width: 0;
text-decoration: none;
}
.profile-prompt-img {
width: 72px;
height: 72px;
border-radius: 12px;
object-fit: cover;
}
.profile-prompt-body {
flex: 1;
min-width: 0;
}
.profile-prompt-title {
font-weight: 700;
}
.profile-prompt-desc {
color: #64748b;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.profile-prompt-meta {
display: flex;
gap: 16px;
color: #64748b;
align-items: center;
flex-wrap: wrap;
}
.profile-prompt-edit-btn {
border: none;
background: #f1f5f9;
color: #334155;
border-radius: 10px;
padding: 6px 10px;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
}
/* ── 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 */
#shareProfileButton {
background: #cbd5e1 !important; /* darker gray */
color: #1e293b !important;
box-shadow: none !important;
border: none !important;
}
#manageTiersButton {
background: #f3e8ff !important;
color: #7c3aed !important;
box-shadow: none !important;
border: 1px solid #d8b4fe !important;
}
/* Buttons keep rounded corners */
.login-button {
align-items: center;
border-radius: 14px !important;
display: flex;
gap: 8px;
justify-content: center;
text-decoration: none;
}
.login-button i {
font-size: 1rem;
}
.profile-tab {
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 10px 0;
border-bottom: 2px solid transparent;
font-size: 1rem;
}
.profile-tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.profile-tab:hover:not(.active) {
color: #334155;
}
/* Prompt cards: rounded corners */
.profile-main section > div {
border-radius: 18px !important;
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;
width: 110px;
height: 110px;
object-fit: cover;
}
/* All outer containers stay square */
.layout,
.profile-main,
.profile-header,
.profile-tabs,
nav {
border-radius: 0 !important;
}
/* Responsive: tablets */
@media (max-width: 768px) {
.profile-main {
padding: 16px !important;
}
.profile-header {
flex-direction: column;
align-items: flex-start !important;
}
.profile-header > div:last-child {
flex-direction: row;
width: 100%;
}
}
/* Responsive: mobile */
@media (max-width: 480px) {
.profile-main {
padding: 12px !important;
}
.profile-header > div:last-child {
flex-direction: column;
}
.profile-main section:last-child {
grid-template-columns: 1fr !important;
}
}

View File

@ -1,184 +0,0 @@
/* Settings page - tabs, form styling */
.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;
}
}
/* Save status message */
#profileSaveStatus {
margin-top: 10px;
color: #64748b;
text-align: center;
}

View File

@ -1,271 +0,0 @@
/* Subscription tiers page - manage monthly creator access levels */
.tiers-main {
flex: 1;
padding: 20px 32px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.tiers-header {
text-align: center;
margin-bottom: 28px;
}
.tiers-header h1 {
font-size: 2rem;
font-weight: 800;
margin-bottom: 8px;
}
.tiers-header p {
color: #64748b;
}
.tiers-layout {
display: grid;
grid-template-columns: minmax(280px, 380px) 1fr;
gap: 24px;
align-items: start;
}
.tiers-tabs {
border-bottom: 2px solid #e5e7eb;
display: flex;
gap: 24px;
margin-bottom: 24px;
}
.tiers-tab {
background: transparent;
border: none;
border-bottom: 3px solid transparent;
color: #64748b;
cursor: pointer;
font: inherit;
font-weight: 800;
padding: 10px 0;
}
.tiers-tab.active {
border-bottom-color: #3b82f6;
color: #2563eb;
}
.subscriptions-panel {
display: none;
}
.tier-panel,
.tier-card {
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
}
.tier-panel {
padding: 24px;
}
.tier-panel h2,
.tier-list-header h2 {
font-size: 1.25rem;
margin-bottom: 16px;
}
.tier-form {
display: grid;
gap: 16px;
}
.tier-form label {
display: grid;
gap: 8px;
color: #334155;
font-weight: 700;
}
.tier-form input,
.tier-form textarea {
border: 1px solid #dbe2ea;
border-radius: 12px;
font: inherit;
padding: 12px 14px;
}
.tier-form textarea {
min-height: 88px;
resize: vertical;
}
.tier-form input:focus,
.tier-form textarea:focus {
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.12);
outline: none;
}
.tier-form-actions {
display: flex;
gap: 12px;
}
.tier-primary-btn,
.tier-secondary-btn,
.tier-card button {
border: none;
border-radius: 14px;
cursor: pointer;
font: inherit;
font-weight: 800;
padding: 12px 16px;
}
.tier-primary-btn {
background: var(--gradient);
color: #ffffff;
flex: 1;
}
.tier-secondary-btn {
background: #f1f5f9;
color: #334155;
}
#tier-status {
color: #64748b;
min-height: 20px;
}
.tier-list-header {
margin-bottom: 16px;
}
.tier-list-header p {
color: #64748b;
}
.tiers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.subscriptions-grid {
display: grid;
gap: 14px;
}
.subscription-card {
align-items: center;
background: #ffffff;
border: 1px solid #eef2f7;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
display: flex;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
}
.subscription-card h3 {
margin-bottom: 4px;
}
.subscription-card p {
color: #64748b;
}
.subscription-price {
color: #3b82f6;
font-weight: 900;
white-space: nowrap;
}
.tier-card {
padding: 20px;
}
.tier-card-top {
align-items: start;
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.tier-card h3 {
font-size: 1.2rem;
margin-bottom: 4px;
}
.tier-level {
color: #64748b;
font-size: 0.9rem;
font-weight: 700;
}
.tier-price {
color: #3b82f6;
font-size: 1.4rem;
font-weight: 900;
white-space: nowrap;
}
.tier-desc {
color: #475569;
line-height: 1.45;
min-height: 44px;
}
.tier-card-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.tier-card button {
background: #f1f5f9;
color: #334155;
padding: 9px 12px;
}
.tier-delete-btn {
color: #dc2626 !important;
}
.tiers-empty,
.tiers-error {
background: #ffffff;
border-radius: 18px;
color: #64748b;
padding: 32px;
text-align: center;
}
.tiers-error {
color: #ef4444;
}
@media (max-width: 900px) {
.tiers-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {
.tiers-main {
padding: 16px;
}
.tiers-tabs {
gap: 16px;
}
.subscription-card {
align-items: flex-start;
flex-direction: column;
}
}

View File

@ -1,281 +0,0 @@
<!-- 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" />
<script src="../js/profile-shared.js"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
</head>
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<div class="layout">
<div id="sidebar-container"></div>
<div class="page-body">
<div id="topbar-container"></div>
<main class="feed-main" id="main-content" tabindex="-1">
<!-- Optional: Feed Header -->
<div class="feed-header">
<h1>Feed</h1>
<p>Latest prompts and inspiration from creators you follow</p>
</div>
<!-- Filter Buttons -->
<div class="filter-buttons" role="group" aria-label="Sort feed">
<button
type="button"
class="filter-btn active"
data-sort="date"
data-ascending="false"
aria-pressed="true"
>
Recent
</button>
<button
type="button"
class="filter-btn"
data-sort="rating"
data-ascending="false"
aria-pressed="false"
>
Top Rated
</button>
<button type="button" class="filter-btn" data-sort="date" data-ascending="true" aria-pressed="false">
Oldest
</button>
</div>
<!-- Posts Grid -->
<div class="posts-grid" id="posts-grid" aria-live="polite"></div>
<!-- Empty State -->
<div id="feed-empty" class="state-empty" role="status" aria-live="polite">
<i class="bi bi-inbox state-icon" aria-hidden="true"></i>
<h3 class="state-title">No posts yet</h3>
<p>Follow some creators to see their prompts here.</p>
</div>
<!-- Error State -->
<div id="feed-error" class="state-error" role="alert" aria-live="assertive">
<i class="bi bi-exclamation-circle state-icon" aria-hidden="true"></i>
<h3 class="state-title">Could not load feed</h3>
<p id="feed-error-msg"></p>
</div>
</main>
</div>
</div>
<script type="module">
// ── Sidebar & Topbar ──────────────────────────────────────────────
fetch("/sidebar.html")
.then((r) => r.text())
.then((data) => {
document.getElementById("sidebar-container").innerHTML = data;
document
.querySelectorAll("#sidebar-container .sidebar a")
.forEach((link) => {
link.classList.remove("active");
link.removeAttribute("aria-current");
});
const firstLink = document.querySelectorAll(
"#sidebar-container .sidebar li a",
)[0];
if (firstLink) {
firstLink.classList.add("active");
firstLink.setAttribute("aria-current", "page");
}
});
fetch("/topbar.html")
.then((r) => r.text())
.then(
(data) =>
(document.getElementById("topbar-container").innerHTML = data),
);
// ── Feed ──────────────────────────────────────────────────────────
const grid = document.getElementById("posts-grid");
const emptyEl = document.getElementById("feed-empty");
const errorEl = document.getElementById("feed-error");
const errorMsg = document.getElementById("feed-error-msg");
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function renderStars(rating) {
if (rating == null) return "";
const stars = Math.round(rating);
return `<span class="post-rating" title="${rating.toFixed(1)} / 5" aria-label="${rating.toFixed(1)} out of 5 stars">
${'<i class="bi bi-star-fill" aria-hidden="true"></i>'.repeat(stars)}${'<i class="bi bi-star" aria-hidden="true"></i>'.repeat(5 - stars)}
<span aria-hidden="true">${rating.toFixed(1)}</span>
</span>`;
}
function feedImg(id) {
return `/images/content/feed${(parseInt(id.slice(-1), 16) % 4) + 1}.png`;
}
function profileUrl(userId) {
return `/profile.html?id=${encodeURIComponent(userId)}`;
}
function renderCard(prompt) {
const locked = !prompt.canAccess;
const liked = prompt.isLiked;
const saved = prompt.isSaved;
return `
<article class="post-card${locked ? " post-locked" : ""}">
<a class="post-card-link" href="${profileUrl(prompt.creatorId)}" aria-label="Open profile for ${prompt.creatorName}">
<div class="post-header">
<img class="post-avatar" src="${prompt.creatorAvatarUrl || "../images/content/cat.png"}" alt="${prompt.creatorName}">
<div class="post-author">
<span class="post-name">${prompt.creatorName}</span>
</div>
<span class="post-date"><time datetime="${prompt.timeStamp}">${timeAgo(prompt.timeStamp)}</time></span>
</div>
<div class="post-content">
${prompt.exampleImageUrl ? `<img class="post-image${locked ? " post-image-locked" : ""}" src="${prompt.exampleImageUrl}" alt="${prompt.title}">` : `<img class="post-image${locked ? " post-image-locked" : ""}" src="${feedImg(prompt.id)}" alt="${prompt.title}">`}
<h3 class="post-title">${prompt.title}</h3>
<p class="post-description">${prompt.description || ""}</p>
${locked ? `<p class="post-locked-msg"><i class="bi bi-lock-fill" aria-hidden="true"></i> ${prompt.tierName ?? "Subscription"} tier required</p>` : ""}
${renderStars(prompt.averageRating)}
</div>
</a>
<div class="post-actions">
<button type="button" class="action-btn like-btn ${liked ? "active" : ""}" aria-pressed="${liked}" aria-label="${liked ? "Unlike" : "Like"} ${prompt.title}. ${prompt.likeCount || 0} likes" onclick="toggleLike(event, '${prompt.id}', ${liked})"><i class="bi ${liked ? "bi-heart-fill" : "bi-heart"}" aria-hidden="true"></i> <span>Like (${prompt.likeCount || 0})</span></button>
<button type="button" class="action-btn comment-btn" aria-label="Review ${prompt.title}" onclick="event.stopPropagation(); location.href='/post-detail?id=${prompt.id}#rating-section'"><i class="bi bi-chat" aria-hidden="true"></i> <span>Review</span></button>
<button type="button" class="action-btn share-btn" aria-label="Share ${prompt.title}" onclick="sharePrompt(event, '${prompt.id}')"><i class="bi bi-share" aria-hidden="true"></i> <span>Share</span></button>
<button type="button" class="action-btn save-btn ${saved ? "active" : ""}" aria-pressed="${saved}" aria-label="${saved ? "Remove saved" : "Save"} ${prompt.title}. ${prompt.saveCount || 0} saves" onclick="toggleSave(event, '${prompt.id}', ${saved})"><i class="bi ${saved ? "bi-bookmark-fill" : "bi-bookmark"}" aria-hidden="true"></i> <span>Save (${prompt.saveCount || 0})</span></button>
</div>
</article>`;
}
window.toggleLike = async function (event, id, isLiked) {
event.stopPropagation();
const response = await fetch(`/api/v1/prompts/${id}/likes`, {
method: isLiked ? "DELETE" : "PUT",
credentials: "same-origin",
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) return;
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending ===
"true",
);
};
window.toggleFeedState = function (event, type, id) {
event.stopPropagation();
const key = `prompt-${type}-${id}`;
const next = localStorage.getItem(key) !== "true";
localStorage.setItem(key, next);
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending ===
"true",
);
};
window.toggleSave = async function (event, id, isSaved) {
event.stopPropagation();
const response = await fetch(`/api/v1/prompts/${id}/saves`, {
method: isSaved ? "DELETE" : "PUT",
credentials: "same-origin",
});
if (response.status === 401) {
location.href = "/login";
return;
}
if (!response.ok) return;
loadFeed(
document.querySelector(".filter-btn.active")?.dataset.sort || "date",
document.querySelector(".filter-btn.active")?.dataset.ascending ===
"true",
);
};
window.sharePrompt = function (event, id) {
event.stopPropagation();
navigator.clipboard.writeText(
`${location.origin}/post-detail?id=${id}`,
);
};
async function loadFeed(sortBy = "date", ascending = false) {
grid.innerHTML = "";
emptyEl.style.display = "none";
errorEl.style.display = "none";
try {
const res = await fetch(
`/api/v1/feed?sortBy=${sortBy}&ascending=${ascending}&limit=20`,
);
if (res.status === 401) {
location.href = "/login";
return;
}
if (!res.ok) throw new Error(`Server error ${res.status}`);
const prompts = await res.json();
if (prompts.length === 0) {
emptyEl.style.display = "block";
return;
}
grid.innerHTML = prompts.map(renderCard).join("");
} catch (e) {
errorEl.style.display = "block";
errorMsg.textContent = e.message;
}
}
// ── Filter buttons ────────────────────────────────────────────────
document.querySelectorAll(".filter-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document
.querySelectorAll(".filter-btn")
.forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
loadFeed(btn.dataset.sort, btn.dataset.ascending === "true");
});
});
// Initial load
loadFeed();
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

View File

@ -1,150 +0,0 @@
// LINQ-like Enumerable class wrapping lazy generator chains
class Enumerable {
constructor(iteratorFn) {
this._iteratorFn = iteratorFn;
}
[Symbol.iterator]() {
return this._iteratorFn();
}
_chain(generatorFn) {
const source = this;
return new Enumerable(function* () {
yield* generatorFn(source);
});
}
where(predicate) {
return this._chain(function* (source) {
for (const item of source) if (predicate(item)) yield item;
});
}
select(selector) {
return this._chain(function* (source) {
for (const item of source) yield selector(item);
});
}
take(count) {
count = Math.max(0, count);
return this._chain(function* (source) {
for (const item of source) {
if (count-- <= 0) break;
yield item;
}
});
}
skip(count) {
count = Math.max(0, count);
return this._chain(function* (source) {
for (const item of source) {
if (count-- > 0) continue;
yield item;
}
});
}
isLast() {
return this._chain(function* (source) {
const iter = source[Symbol.iterator]();
let current = iter.next();
let index = 0;
while (!current.done) {
const next = iter.next();
yield [current.value, next.done, index];
current = next;
index++;
}
});
}
forEach(action) {
for (const item of this) {
if (Array.isArray(item)) {
action(...item);
} else {
action(item);
}
}
}
toArray() {
return Array.from(this);
}
orderByDescending(keySelector) {
return this._chain(function* (source) {
const items = Array.from(source);
items.sort((a, b) => keySelector(b) - keySelector(a));
yield* items;
});
}
orderBy(keySelector) {
return this._chain(function* (source) {
const items = Array.from(source);
items.sort((a, b) => keySelector(a) - keySelector(b));
yield* items;
});
}
firstOrDefault(predicate) {
const source = predicate ? this.where(predicate) : this;
for (const item of source) return item;
return undefined;
}
first(predicate) {
const source = predicate ? this.where(predicate) : this;
for (const item of source) return item;
throw new Error("No elements in sequence.");
}
lastOrDefault(predicate) {
const source = predicate ? this.where(predicate) : this;
let lastValue = undefined;
for (const item of source) lastValue = item;
return lastValue;
}
last(predicate) {
const source = predicate ? this.where(predicate) : this;
let lastValue = undefined;
let found = false;
for (const item of source) {
lastValue = item;
found = true;
}
if (!found) throw new Error("No elements in sequence.");
return lastValue;
}
any(predicate) {
const source = predicate ? this.where(predicate) : this;
for (const _ of source) return true;
return false;
}
all(predicate) {
for (const item of this) if (!predicate(item)) return false;
return true;
}
count(predicate) {
let count = 0;
const source = predicate ? this.where(predicate) : this;
for (const _ of source) count++;
return count;
}
}
Array.prototype.asEnumerable = function () {
const arr = this;
return new Enumerable(function* () {
for (const item of arr) yield item;
});
};

View File

@ -1,38 +0,0 @@
import { sendFormAsync } from "./shared.js";
async function redirectIfAlreadySignedIn() {
const response = await fetch('/api/v1/auth/me', {
method: 'GET',
credentials: 'same-origin'
});
if (response.ok) {
window.location.replace('/dashboard');
}
}
function togglePassword() {
const passwordInput = document.getElementById('password');
const togglePasswordButton = document.getElementById('togglePassword');
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
passwordInput.type = newInputType;
const isVisible = newInputType === 'text';
togglePasswordButton.textContent = isVisible ? 'Hide' : 'Show';
togglePasswordButton.setAttribute('aria-pressed', String(isVisible));
}
async function submitLoginForm(){
const form = document.getElementById('loginForm');
await sendFormAsync(form);
}
const togglePasswordButton = document.getElementById('togglePassword');
togglePasswordButton.addEventListener('click', togglePassword);
const loginForm = document.getElementById('loginForm');
loginForm.addEventListener('submit', async (event) => {
event.preventDefault(); // Prevent the default form submission
await submitLoginForm();
});
await redirectIfAlreadySignedIn();

View File

@ -1,556 +0,0 @@
import "./linq.js";
import { NodeTemplate } from "./node-template.js";
// ─── Route matching ───
class RouteMatch {
constructor(route, path, params) {
this.route = route;
this.path = path;
this.params = params;
this.segmentCount = route.fragments.length;
}
}
export class Route {
constructor(pattern, componentClass) {
this.id = crypto.randomUUID();
this.componentClass = componentClass;
this.componentDefinition = componentClass.definition;
this.fragments = pattern.split("/");
}
match(path) {
const parsedUrl = new URL(path, window.location.origin);
const params = {};
parsedUrl.searchParams.forEach((value, key) => {
params[key] = value;
});
const pathFragments = parsedUrl.pathname.split("/");
for (let i = 0; i < this.fragments.length; i++) {
const fragment = this.fragments[i];
const pathFragment = pathFragments[i];
if (fragment.startsWith(":")) {
params[fragment.substring(1)] = pathFragment;
continue;
} else if (fragment === "*") {
continue;
} else if (fragment === "**") {
return new RouteMatch(this, path, params);
} else if (fragment !== pathFragment) {
return null;
}
}
return new RouteMatch(this, path, params);
}
}
// ─── ComponentInput (signal-like) ───
export class ComponentInput {
constructor(defaultValue, options = {}) {
this._value = defaultValue;
this.transform = options.transform || ((v) => v);
this.validate = options.validate || (() => true);
this.alias = options.alias || null;
this._isComponentInput = true;
this._owner = null;
const self = this;
const fn = function () {
return self._value;
};
return new Proxy(fn, {
get(target, prop) {
if (prop === "set") return (v) => self._set(v);
if (prop === "_isComponentInput") return true;
if (prop === "_self") return self;
if (prop === "alias") return self.alias;
if (prop === Symbol.toPrimitive) return () => self._value;
if (prop === "valueOf") return () => self._value;
if (prop === "toString") return () => String(self._value);
const inner = self._value;
if (inner == null) return undefined;
const val = inner[prop];
return typeof val === "function" ? val.bind(inner) : val;
},
set(target, prop, value) {
if (self._value != null) {
self._value[prop] = value;
if (self._owner) self._owner.requestUpdate();
}
return true;
},
apply(target, thisArg, args) {
return self._value;
},
});
}
_set(newValue) {
const transformed = this.transform(newValue);
if (!this.validate(transformed)) {
throw new Error("Invalid value");
}
this._value = transformed;
if (this._owner) this._owner.requestUpdate();
}
static [Symbol.hasInstance](instance) {
return instance?._isComponentInput === true;
}
}
// ─── ViewChild ───
export class ViewChild {
constructor(options = { selector: null, id: null, multiple: false }) {
this._selector = options.selector;
this._id = options.id;
this._multiple = options.multiple;
this._element = null;
this._isViewChild = true;
return new Proxy(this, {
get(target, prop) {
if (prop in target) return target[prop];
if (target._element) {
const value = target._element[prop];
return typeof value === "function"
? value.bind(target._element)
: value;
}
},
});
}
_setValue(element) {
this._element = element;
}
static [Symbol.hasInstance](instance) {
return instance?._isViewChild === true;
}
}
// ─── ComponentDefinition ───
export class ComponentDefinition {
constructor(
options = {
templatesPath: null,
template: null,
stylesPath: null,
style: null,
scriptsPath: null,
},
) {
this.templatesPath = options.templatesPath;
this.template = options.template;
this.stylesPath = options.stylesPath;
this.style = options.style;
this.scriptsPath = options.scriptsPath;
}
}
// ─── EventListener ───
class EventBinding {
constructor(event, element, handler) {
this.event = event;
this.element = element;
this.handler = handler;
}
attach() {
this.element.addEventListener(this.event, this.handler);
}
detach() {
this.element.removeEventListener(this.event, this.handler);
}
}
// ─── Component ───
export class Component extends EventTarget {
constructor() {
super();
this._eventBindings = [];
return new Proxy(this, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, target);
if (typeof value === "function" && typeof value.bind === "function") {
return prop in EventTarget.prototype
? value.bind(target)
: value.bind(receiver);
}
return value;
},
set(target, prop, value) {
if (target[prop] instanceof ComponentInput) {
target[prop].set(value);
return true;
} else if (target[prop] instanceof ViewChild) {
target[prop]._setValue(value);
} else {
target[prop] = value;
}
target.requestUpdate();
return true;
},
});
}
requestUpdate() {
this.dispatchEvent(new Event("requestUpdate"));
}
static get definition() {
throw new Error("Component definition is not defined");
}
onInit() { }
onBeforeRender() { }
onAfterRender() { }
onDestroy() {
this._eventBindings.forEach((b) => b.detach());
}
}
// ─── Router init ───
export function initRouter(outletId, routes) {
const outletRef = document.getElementById(outletId);
if (!outletRef) {
console.error(`Outlet element with id '${outletId}' not found`);
return null;
}
return new Router(outletRef, routes);
}
// ─── Router ───
export class Router {
constructor(outletRef, routes) {
this.outletRef = outletRef;
this.shadowRoot = outletRef.attachShadow({ mode: "open" });
this.routes = routes;
this.templateCache = new Map();
this.activeController = null;
this.activeRenderResult = null;
this._boundUpdateHandler = null;
this._rendering = false;
this._pendingUpdate = false;
this.shadowRoot.textContent = "Loading...";
navigation.addEventListener("navigate", this.handleNavigate.bind(this));
}
findMatchingRoute(path) {
return this.routes
.asEnumerable()
.select((route) => route.match(path))
.where((match) => match !== null)
.orderByDescending((match) => match.segmentCount)
.firstOrDefault();
}
async handleNavigate(event) {
if (event.navigationType === 'replace' && event.destination.url === this.activeRoute) return;
const routeMatch = this.findMatchingRoute(event.destination.url);
if (!routeMatch) return;
event.preventDefault();
const template = await this.getCachedTemplate(routeMatch.route);
const controller = this.createComponentInstance(
routeMatch.route.componentClass,
routeMatch.params,
);
this.destroyCurrent();
this.activeController = controller;
this.activeTemplate = template;
this.activeRoute = event.destination.url;
this.insertIncludes(routeMatch.route);
this._boundUpdateHandler = this.handleUpdateRequest.bind(this);
this.activeController.addEventListener(
"requestUpdate",
this._boundUpdateHandler,
);
this.renderActiveComponent();
history.replaceState({}, "", event.destination.url);
}
destroyCurrent() {
if (this.activeController) {
this.activeController.removeEventListener(
"requestUpdate",
this._boundUpdateHandler,
);
this.activeController.onDestroy();
this.activeController = null;
}
if (this.activeRenderResult) {
this.activeRenderResult.destroy();
this.activeRenderResult = null;
}
}
handleUpdateRequest() {
if (this._rendering) {
this._pendingUpdate = true;
return;
}
this.updateActiveComponent();
}
insertIncludes(route) {
// Remove previous component styles from shadow root
this.shadowRoot
.querySelectorAll("link[data-component-style], style[data-component-style]")
.forEach((l) => l.remove());
if (route.componentDefinition.style) {
const styleEl = document.createElement("style");
styleEl.textContent = route.componentDefinition.style;
styleEl.setAttribute("data-component-style", "");
this.shadowRoot.appendChild(styleEl);
}
const paths = route.componentDefinition.stylesPath;
if (!paths) return;
const list = Array.isArray(paths) ? paths : [paths];
for (const p of list) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = p;
link.setAttribute("data-component-style", "");
this.shadowRoot.appendChild(link);
}
}
// ─── First render: build DOM from scratch ───
renderActiveComponent() {
this._rendering = true;
this._pendingUpdate = false;
this.activeController.onBeforeRender();
const result = this.activeTemplate.render(this.activeController);
this.activeRenderResult = result;
// Clear shadow root content (keep style links), append fragment
for (const child of Array.from(this.shadowRoot.childNodes)) {
if (
child.nodeType !== Node.ELEMENT_NODE ||
!child.hasAttribute("data-component-style")
) {
child.remove();
}
}
this.shadowRoot.appendChild(result.fragment);
// Wire events + two-way bindings on the live shadow DOM
this.wireEvents(this.activeController, this.shadowRoot);
this.wireViewChildren(this.activeController, this.shadowRoot);
this.activeController.onAfterRender();
this._rendering = false;
if (this._pendingUpdate) {
this.updateActiveComponent();
}
}
// ─── Subsequent updates: patch bindings only ───
updateActiveComponent() {
this._rendering = true;
this._pendingUpdate = false;
this.activeController.onBeforeRender();
this.activeRenderResult.update(this.activeController);
this.activeController.onAfterRender();
this._rendering = false;
if (this._pendingUpdate) {
this.updateActiveComponent();
}
}
// ─── ViewChild wiring ───
wireViewChildren(controller, root) {
Object.entries(controller)
.filter(([_, value]) => value instanceof ViewChild)
.forEach(([key, viewChild]) => {
if (viewChild._id) {
const el = root.querySelector(`#${viewChild._id}`);
if (el) viewChild._setValue(el);
} else if (viewChild._selector) {
const el = root.querySelector(viewChild._selector);
if (el) viewChild._setValue(el);
} else {
console.warn(`ViewChild ${key}: no selector or id`);
}
});
}
// ─── Event wiring ───
// Supports:
// (click)="methodName()" — event binding
// [(value)]="propName" — two-way binding (banana-in-a-box)
wireEvents(controller, root) {
const allElements = root.querySelectorAll("*");
allElements.forEach((element) => {
// ─── Two-way bindings from __templateMeta ───
if (element.__templateMeta) {
for (const meta of element.__templateMeta) {
if (meta.bracket === "[()]" && meta.expression) {
const domProp = meta.name.replace(/^\(|\)$/g, ""); // strip parens
this._bindTwoWay(controller, element, domProp, meta.expression);
}
}
}
// ─── Event bindings from DOM attributes ───
for (const attr of Array.from(element.attributes)) {
const name = attr.name;
const eventMatch = name.match(/^\((\w+)\)$/);
if (eventMatch) {
const eventName = eventMatch[1];
const attrValue = attr.value.trim();
const methodName = attrValue.endsWith("()")
? attrValue.slice(0, -2)
: attrValue;
let binding;
if (typeof controller[methodName] === "function") {
const handler = controller[methodName].bind(controller);
binding = new EventBinding(eventName, element, handler);
} else {
const fn = new Function("event", attrValue).bind(controller);
binding = new EventBinding(eventName, element, fn);
}
binding.attach();
controller._eventBindings.push(binding);
}
}
});
}
// ─── Two-way bind: DOM property ↔ controller field ───
// Sets DOM prop from controller on each update.
// Listens for input/change events to write back to controller.
_bindTwoWay(controller, element, domProp, field) {
// Controller → DOM: set initial value
const getValue = () => {
const v = controller[field];
return typeof v === "function" ? v() : v;
};
element[domProp] = getValue() ?? "";
// DOM → Controller: on input (for text) + change (for select/checkbox)
const writeBack = () => {
const domValue = element[domProp];
if (controller[field] instanceof ComponentInput) {
controller[field].set(domValue);
} else {
controller[field] = domValue;
}
};
const inputBinding = new EventBinding("input", element, writeBack);
const changeBinding = new EventBinding("change", element, writeBack);
inputBinding.attach();
changeBinding.attach();
controller._eventBindings.push(inputBinding, changeBinding);
// Controller → DOM: patch on every update via RenderResult hook
// We piggyback on requestUpdate listener — the attr binding in
// node-template handles {{expr}} attrs, but for [(prop)] we need
// to sync the DOM *property* (not attribute) on update.
const updateBinding = new EventBinding("requestUpdate", controller, () => {
const val = getValue() ?? "";
if (element[domProp] !== val) element[domProp] = val;
});
updateBinding.attach();
controller._eventBindings.push(updateBinding);
}
// ─── Component instance creation ───
createComponentInstance(component, params) {
const instance = new component();
// Wire _owner for ComponentInput reactivity
for (const key of Object.keys(instance)) {
const val = instance[key];
if (val instanceof ComponentInput) {
val._self._owner = instance;
}
}
instance.onInit();
// Bind route/query params → ComponentInputs (by name or alias)
for (const [paramKey, paramValue] of Object.entries(params)) {
if (instance[paramKey] instanceof ComponentInput) {
instance[paramKey].set(paramValue);
continue;
}
for (const key of Object.keys(instance)) {
const val = instance[key];
if (val instanceof ComponentInput && val.alias === paramKey) {
val.set(paramValue);
break;
}
}
}
return instance;
}
// ─── Template caching ───
async getCachedTemplate(route) {
if (this.templateCache.has(route.id)) {
return this.templateCache.get(route.id);
}
let templateContent = route.componentClass.definition.template;
if (!templateContent && route.componentClass.definition.templatesPath) {
const response = await fetch(
route.componentClass.definition.templatesPath,
);
if (!response.ok) {
return new NodeTemplate(
`<div class="error"><h1>Failed to load template</h1><p>${response.status} ${response.statusText}</p></div>`,
);
}
templateContent = await response.text();
}
try {
const template = new NodeTemplate(templateContent);
this.templateCache.set(route.id, template);
return template;
} catch (error) {
return new NodeTemplate(
`<div class="error"><h1>Failed to compile template</h1><p>${error.message}</p></div>`,
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,65 +0,0 @@
import { initRouter, Route, ViewChild, Component, ComponentInput, ComponentDefinition } from "./node-pwa.js";
import { IntInput } from "./utils.js";
class TestComponent extends Component {
static get definition() {
return new ComponentDefinition({
name: "test-component",
template: `<div>
<h1>Test Component</h1>
<p>This is a test component.</p>
<button
id="test-button"
[class.red-button]="count() % 2 === 0"
[class.green-button]="count() % 2 !== 0"
class="counter-button"
(click)="incrementCount()"
>Count: {{count()}}</button>
<input type="text" [(value)]="name" placeholder="Enter your name" />
<p>Hello, {{name()}}! Counter is {{count()}}</p>
</div>`,
style: `
.red-button {
background-color: red;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
.green-button {
background-color: green;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
.counter-button {
border-radius: 10px;
margin: 15px 30px;
font-size: 18px;
transition: opacity 0.3s ease;
}
.counter-button:hover {
opacity: 0.8;
}
`
});
}
count = new ComponentInput(0, IntInput);
name = new ComponentInput("World");
incrementCount() {
const currentCount = this.count();
this.count.set(currentCount + 1);
}
}
const route1 = new Route("/counter", TestComponent);
const route2 = new Route("/counter/:count", TestComponent);
initRouter("routerOutlet", [route1, route2]);

View File

@ -1,102 +0,0 @@
(function () {
let currentProfilePromise = null;
let currentProfile = null;
async function fetchJson(url) {
const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) {
location.href = "/login";
return null;
}
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
}
async function loadCurrentProfile(force = false) {
if (!force && currentProfilePromise) return currentProfilePromise;
currentProfilePromise = Promise.all([
fetchJson("/api/v1/profiles/self"),
fetchJson("/api/v1/auth/me"),
]).then(([profile, user]) => {
currentProfile = { ...profile, user };
return currentProfile;
});
return currentProfilePromise;
}
function applyTopbarProfile(profile) {
const avatar = document.getElementById("topbarAvatar");
if (!avatar || !profile) return;
if (profile.avatarUrl) avatar.src = profile.avatarUrl;
avatar.alt = profile.displayName || profile.user?.userName || "Profile";
avatar.title = profile.displayName || profile.user?.userName || "Profile";
}
function wireTopbarSearch() {
const input = document.getElementById("topbarSearchInput");
if (!input || input.dataset.searchReady === "true") return;
input.dataset.searchReady = "true";
const params = new URLSearchParams(location.search);
if (location.pathname.includes("community") && params.has("search")) {
input.value = params.get("search") || "";
}
let searchTimeout;
input.addEventListener("input", () => {
if (!location.pathname.includes("community")) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (typeof window.applyCreatorSearch === "function") {
window.applyCreatorSearch(input.value);
}
}, 300);
});
input.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
if (location.pathname.includes("marketplace")) return;
const search = input.value.trim();
location.href = search
? `/community?search=${encodeURIComponent(search)}`
: "/community";
});
}
async function hydrateTopbarProfile() {
try {
const profile = currentProfile || (await loadCurrentProfile());
applyTopbarProfile(profile);
} catch {
// Keep the fallback avatar if the profile cannot be loaded.
}
}
function setCurrentProfile(profile) {
currentProfile = profile;
currentProfilePromise = Promise.resolve(profile);
applyTopbarProfile(profile);
}
window.loadCurrentProfile = loadCurrentProfile;
window.hydrateTopbarProfile = hydrateTopbarProfile;
window.setCurrentProfile = setCurrentProfile;
const observer = new MutationObserver(() => {
if (document.getElementById("topbarAvatar")) hydrateTopbarProfile();
wireTopbarSearch();
});
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
} else {
document.addEventListener("DOMContentLoaded", () => {
observer.observe(document.body, { childList: true, subtree: true });
});
}
})();

View File

@ -1,432 +0,0 @@
import "./linq.js";
import { Template } from "./template.js";
class RouteMatch {
constructor(route, path, params) {
this.route = route;
this.path = path;
this.params = params;
this.segmentCount = route.fragments.length;
}
}
export class Route {
constructor(pattern, componentClass) {
this.id = crypto.randomUUID();
this.componentClass = componentClass;
this.ComponentDefinition = componentClass.definition;
this.fragments = pattern.split("/");
}
match(path) {
const parsedUrl = new URL(path, window.location.origin);
const params = {};
parsedUrl.searchParams.forEach((value, key) => {
params[key] = value;
});
const pathFragments = parsedUrl.pathname
.split("/")
.filter((f) => f.length > 0);
for (let i = 0; i < this.fragments.length; i++) {
const fragment = this.fragments[i];
const pathFragment = pathFragments[i];
if (fragment.startsWith(":")) {
params[fragment.substring(1)] = pathFragment;
continue;
} else if (fragment === "*") {
continue;
} else if (fragment === "**") {
return new RouteMatch(this, path, params);
} else if (fragment !== pathFragment) {
return null;
}
}
return new RouteMatch(this, path, params);
}
}
export class ComponentInput {
constructor(defaultValue, options = {}) {
this._value = defaultValue;
this.transform = options.transform || ((v) => v);
this.validate = options.validate || (() => true);
this.alias = options.alias || null;
this._isComponentInput = true;
this._owner = null;
const self = this;
const fn = function () {
return self._value;
};
return new Proxy(fn, {
get(target, prop) {
if (prop === "set") return (v) => self._set(v);
if (prop === "_isComponentInput") return true;
if (prop === "_self") return self;
if (prop === "alias") return self.alias;
if (prop === Symbol.toPrimitive) return () => self._value;
if (prop === "valueOf") return () => self._value;
if (prop === "toString") return () => String(self._value);
const inner = self._value;
if (inner == null) return undefined;
const val = inner[prop];
return typeof val === "function" ? val.bind(inner) : val;
},
set(target, prop, value) {
if (self._value != null) {
self._value[prop] = value;
if (self._owner) self._owner.requestUpdate();
}
return true;
},
apply(target, thisArg, args) {
return self._value;
},
});
}
_set(newValue) {
const transformed = this.transform(newValue);
if (!this.validate(transformed)) {
throw new Error("Invalid value");
}
this._value = transformed;
if (this._owner) this._owner.requestUpdate();
}
static [Symbol.hasInstance](instance) {
return instance?._isComponentInput === true;
}
}
export class ViewChild {
constructor(options = { selector: null, id: null, multiple: false }) {
this._selector = options.selector;
this._id = options.id;
this._multiple = options.multiple;
this._element = null;
this._isViewChild = true;
return new Proxy(this, {
get(target, prop) {
if (prop in target) return target[prop];
if (target._element) {
const value = target._element[prop];
return typeof value === "function"
? value.bind(target._element)
: value;
}
},
});
}
_setValue(element) {
this._element = element;
}
static [Symbol.hasInstance](instance) {
return instance?._isViewChild === true;
}
}
export class ComponentDefinition {
constructor(
options = {
templatesPath: null,
template: null,
stylesPath: null,
style: null,
scriptsPath: null,
},
) {
this.templatesPath = options.templatesPath;
this.template = options.template;
this.stylesPath = options.stylesPath;
this.scriptsPath = options.scriptsPath;
}
}
class EventListener {
constructor(event, element, handler) {
this.event = event;
this.element = element;
this.handler = handler;
}
attach() {
this.element.addEventListener(this.event, this.handler);
}
detach() {
this.element.removeEventListener(this.event, this.handler);
}
}
export class Component extends EventTarget {
constructor() {
super();
this._eventListeners = [];
return new Proxy(this, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, target);
if (typeof value === "function" && typeof value.bind === "function") {
return prop in EventTarget.prototype
? value.bind(target)
: value.bind(receiver);
}
return value;
},
set(target, prop, value) {
if (target[prop] instanceof ComponentInput) {
target[prop].set(value);
return true;
} else if (target[prop] instanceof ViewChild) {
target[prop]._setValue(value);
} else {
target[prop] = value;
}
target.requestUpdate();
return true;
},
});
}
requestUpdate() {
this.dispatchEvent(new Event("requestUpdate", { component: this }));
}
static get definition() {
throw new Error("Component definition is not defined");
}
onInit() { }
onBeforeRender() { }
onAfterRender() { }
onDestroy() {
this._eventListeners.forEach((listener) => listener.detach());
}
}
export function initRouter(outletId, includeId, routes) {
const outletRef = document.getElementById(outletId);
const includeRef = document.getElementById(includeId);
if (!outletRef) {
console.error(`Outlet element with id '${outletId}' not found`);
return null;
}
if (!includeRef) {
console.error(`Include element with id '${includeId}' not found`);
return null;
}
return new Router(outletRef, includeRef, routes);
}
export class Router {
constructor(outletRef, includeRef, routes) {
this.outletRef = outletRef;
this.includeRef = includeRef;
this.routes = routes;
this.templateCache = new Map();
outletRef.innerHTML = "Loading...";
includeRef.innerHTML = "";
navigation.addEventListener("navigate", this.handleNavigate.bind(this));
}
findMatchingRoute(path) {
const routeMatch = this.routes
.asEnumerable()
.select((route) => route.match(path))
.where((match) => match !== null)
.orderByDescending((match) => match.segmentCount)
.firstOrDefault();
return routeMatch;
}
async handleNavigate(event) {
const routeMatch = this.findMatchingRoute(event.destination.url);
if (routeMatch) {
event.preventDefault();
const template = await this.getCachedTemplate(routeMatch.route);
const controller = this.createComponentInstance(
routeMatch.route.componentClass,
routeMatch.params,
);
this.destroyCurrent();
this.activeController = controller;
this.activeTemplate = template;
this.insertIncludes(routeMatch.route);
this._boundUpdateHandler = this.handleUpdateRequest.bind(this);
this.activeController.addEventListener(
"requestUpdate",
this._boundUpdateHandler,
);
this.renderActiveComponent();
history.pushState(null, "", routeMatch.path);
}
}
destroyCurrent() {
if (this.activeController) {
this.activeController.removeEventListener(
"requestUpdate",
this._boundUpdateHandler,
);
this.activeController.onDestroy();
this.activeController = null;
}
}
handleUpdateRequest(args) {
if (this._rendering) {
this._pendingUpdate = true;
return;
} else {
this.renderActiveComponent();
}
}
insertIncludes(route) {
if (Array.isArray(route.ComponentDefinition.stylesPath)) {
route.ComponentDefinition.stylesPath.forEach((path) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = path;
this.includeRef.appendChild(link);
});
} else if (route.ComponentDefinition.stylesPath) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = route.ComponentDefinition.stylesPath;
this.includeRef.appendChild(link);
}
}
renderActiveComponent() {
this._rendering = true;
this._pendingUpdate = false;
this.activeController.onBeforeRender();
const renderedTemplate = this.activeTemplate.render(this.activeController);
this.outletRef.innerHTML = renderedTemplate;
this.wireEvents(this.activeController);
this.wireViewChildren(this.activeController);
this.activeController.onAfterRender();
this._rendering = false;
if (this._pendingUpdate) {
this.renderActiveComponent();
}
}
wireViewChildren(controller) {
const viewChildProps = Object.entries(controller)
.filter(([_, value]) => value instanceof ViewChild)
.forEach(([key, viewChild]) => {
if (viewChild._id) {
const element = this.outletRef.querySelector(`#${viewChild._id}`);
if (element) viewChild._setValue(element);
} else if (viewChild._selector) {
const element = this.outletRef.querySelector(viewChild._selector);
if (element) viewChild._setValue(element);
} else {
console.warn(`ViewChild ${key} has no selector or id defined`);
}
});
}
wireEvents(controller) {
const allElements = this.outletRef.querySelectorAll("*");
allElements.forEach((element) => {
element.getAttributeNames().forEach((attr) => {
const match = attr.match(/^\((\w+)\)$/);
if (!match) return;
const eventName = match[1];
const attrValue = element.getAttribute(attr).trim();
const methodName = attrValue.endsWith("()")
? attrValue.slice(0, -2)
: attrValue;
let listener;
if (typeof controller[methodName] === "function") {
// Named method — wire directly
const handler = controller[methodName].bind(controller);
listener = new EventListener(eventName, element, handler);
} else {
const fn = new Function("event", attrValue).bind(controller);
listener = new EventListener(eventName, element, fn);
}
listener.attach();
controller._eventListeners.push(listener);
});
});
}
createComponentInstance(component, params) {
const instance = new component();
for (const key of Object.keys(instance)) {
const val = instance[key];
if (val instanceof ComponentInput) {
val._self._owner = instance;
}
}
instance.onInit();
for (const [paramKey, paramValue] of Object.entries(params)) {
if (instance[paramKey] instanceof ComponentInput) {
instance[paramKey].set(paramValue);
continue;
}
for (const key of Object.keys(instance)) {
const val = instance[key];
if (val instanceof ComponentInput && val.alias === paramKey) {
val.set(paramValue);
break;
}
}
}
return instance;
}
async getCachedTemplate(route) {
if (this.templateCache.has(route.id)) {
return this.templateCache.get(route.id);
}
let templateContent = route.componentClass.definition.template;
if (!templateContent && route.componentClass.definition.templatesPath) {
const response = await fetch(
route.componentClass.definition.templatesPath,
);
if (response.ok === false) {
return new Template(`
<div class="error">
<h1>Failed to load template</h1>
<p>${response.status} ${response.statusText}</p>
</div>
`);
}
templateContent = await response.text();
}
try {
const template = new Template(templateContent);
this.templateCache.set(route.id, template);
return template;
} catch (error) {
return new Template(`
<div class="error">
<h1>Failed to compile template</h1>
<p>${error.message}</p>
</div>
`);
}
}
}

View File

@ -1,180 +0,0 @@
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" role="alert" aria-live="assertive">
{{ $this }}
</div>
`);
function handleGenericFormError(response, responseText, form) {
if (!response.ok) {
// Remove all existing form-level errors before adding a new one
form.querySelectorAll(":scope > .form-error").forEach((el) => el.remove());
let message = responseText;
try {
message = JSON.parse(responseText);
} catch (_) {}
const html = genericFormErrorTemplate.render(message);
form.insertAdjacentHTML("beforeend", html);
}
}
const validationErrorTemplate = new Template(`
<div class="form-error" role="alert" aria-live="assertive">
<ul>
@for(error of $this) {
<li class="error">{{error}}</li>
}
</ul>
</div>
`);
const unknownInputErrorTemplate = new Template(`
<div class="form-error" role="alert" aria-live="assertive">
<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) {
form
.querySelectorAll(":scope > .form-error")
.forEach((el) => el.remove());
const html = unknownInputErrorTemplate.render(unknownInputErrors);
form.insertAdjacentHTML("beforeend", html);
}
return true;
}
return false;
}

View File

@ -1,24 +0,0 @@
import { sendFormAsync } from "./shared.js";
async function signupAsync(params) {
const form = document.getElementById('signupForm');
await sendFormAsync(form);
}
function togglePassword() {
const passwordInput = document.getElementById('password');
const togglePasswordButton = document.getElementById('togglePassword');
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
passwordInput.type = newInputType;
const isVisible = newInputType === 'text';
togglePasswordButton.textContent = isVisible ? 'Hide' : 'Show';
togglePasswordButton.setAttribute('aria-pressed', String(isVisible));
}
document.getElementById('togglePassword')?.addEventListener('click', togglePassword);
const signupForm = document.getElementById('signupForm');
signupForm.addEventListener('submit', async (event) => {
event.preventDefault(); // Prevent the default form submission
await signupAsync();
});

View File

@ -1,476 +0,0 @@
import "./linq.js";
function selectPath(data, expression) {
if (!expression) return data;
try {
return new Function("$data", `with($data) { return (${expression}); }`)(
data,
);
} catch {
console.error(`Error evaluating Template expression: ${expression}`);
return undefined;
}
}
class ScopeDict {
constructor(initial = {}) {
this._entries = { ...initial };
}
set(name, value) {
if (name.startsWith("$")) {
throw new Error(
`Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`,
);
}
this._entries[name] = value;
}
setBuiltin(name, value) {
this._entries[name] = value;
}
has(name) {
return name in this._entries;
}
get(name) {
return this._entries[name];
}
clone() {
return new ScopeDict(this._entries);
}
}
function createScopeProxy(data, scope) {
if (data === null || data === undefined || typeof data !== "object") {
scope.setBuiltin("$this", data);
return new Proxy(
{},
{
get(_target, prop) {
if (prop === "$this") return data;
if (scope.has(prop)) return scope.get(prop);
return undefined;
},
has(_target, prop) {
return prop === "$this" || scope.has(prop);
},
},
);
}
scope.setBuiltin("$this", data);
return new Proxy(data, {
get(target, prop) {
if (prop === "$this") return data;
if (scope.has(prop)) return scope.get(prop);
return target[prop];
},
has(target, prop) {
return prop === "$this" || scope.has(prop) || prop in target;
},
});
}
function skipString(str, i) {
const quote = str[i];
i++;
while (i < str.length && str[i] !== quote) {
if (str[i] === "\\") i++;
i++;
}
return i;
}
function findClosingParen(str, openPos) {
let depth = 1;
let i = openPos + 1;
while (i < str.length && depth > 0) {
if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
i = skipString(str, i) + 1;
continue;
}
if (str[i] === "(") depth++;
else if (str[i] === ")") depth--;
if (depth > 0) i++;
}
if (depth !== 0) throw new Error("Unclosed ( in template");
return i;
}
function findClosingBrace(str, openPos) {
let depth = 1;
let i = openPos + 1;
while (i < str.length && depth > 0) {
if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
i = skipString(str, i) + 1;
continue;
}
if (str[i] === "{" && str[i + 1] === "{") {
const end = str.indexOf("}}", i + 2);
if (end === -1) throw new Error("Unclosed {{ }} in template");
i = end + 2;
continue;
}
if (str[i] === "{") depth++;
else if (str[i] === "}") depth--;
if (depth > 0) i++;
}
if (depth !== 0) throw new Error("Unclosed { in template");
return i;
}
function formatNumber(value, spec) {
const hasThousands = spec.includes(",");
const dotIdx = spec.indexOf(".");
let decimals = 0;
if (dotIdx !== -1) {
decimals = spec.length - dotIdx - 1;
}
let result = value.toFixed(decimals);
if (hasThousands) {
const parts = result.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
result = parts.join(".");
}
if (!spec.includes(".") && !hasThousands) {
const padLength = spec.replace(/[^0]/g, "").length;
if (padLength > 0) {
const isNeg = value < 0;
let abs = Math.abs(Math.round(value)).toString();
abs = abs.padStart(padLength, "0");
result = isNeg ? "-" + abs : abs;
}
}
return result;
}
function formatDate(date, spec) {
const pad = (n, len = 2) => String(n).padStart(len, "0");
const tokens = {
yyyy: date.getFullYear(),
yy: String(date.getFullYear()).slice(-2),
MM: pad(date.getMonth() + 1),
M: date.getMonth() + 1,
dd: pad(date.getDate()),
d: date.getDate(),
HH: pad(date.getHours()),
H: date.getHours(),
hh: pad(date.getHours() % 12 || 12),
h: date.getHours() % 12 || 12,
mm: pad(date.getMinutes()),
m: date.getMinutes(),
ss: pad(date.getSeconds()),
s: date.getSeconds(),
fff: pad(date.getMilliseconds(), 3),
};
let result = spec;
for (const [token, value] of Object.entries(tokens).sort(
(a, b) => b[0].length - a[0].length,
)) {
result = result.replaceAll(token, String(value));
}
return result;
}
function createFormatter(formatSpec) {
return (value) => {
if (value == null) return "";
if (value instanceof Date) return formatDate(value, formatSpec);
if (typeof value === "number") return formatNumber(value, formatSpec);
return String(value);
};
}
class StaticTemplatePart {
constructor(text) {
this.text = text;
}
render(_data) {
return this.text;
}
}
class VariableTemplatePart {
constructor(contextPath, formatter) {
this.contextPath = contextPath;
this.formatter = formatter || ((x) => x);
}
render(data) {
return this.formatter(selectPath(data, this.contextPath));
}
}
class LetTemplatePart {
constructor(name, expression) {
this.name = name;
this.expression = expression;
}
declareScoped(scope, data) {
scope.set(this.name, selectPath(data, this.expression));
}
render(_data) {
return "";
}
}
class ListTemplatePart {
constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
this.varNames = varNames;
this.contextPath = contextPath;
this.formatter = formatter || ((x) => x);
this.innerTemplate = innerTemplate;
this.emptyTemplate = emptyTemplate;
}
render(data) {
const list = this.formatter(selectPath(data, this.contextPath));
const arr = Array.isArray(list) ? list : Array.from(list);
if (arr.length === 0) {
return this.emptyTemplate ? this.emptyTemplate.render(data) : "";
}
let result = "";
const count = arr.length;
for (let index = 0; index < count; index++) {
const scope = new ScopeDict();
const item = arr[index];
if (this.varNames.length === 1) {
scope.setBuiltin(this.varNames[0], item);
} else {
for (let v = 0; v < this.varNames.length; v++) {
scope.setBuiltin(this.varNames[v], item[v]);
}
}
scope.setBuiltin("$index", index);
scope.setBuiltin("$first", index === 0);
scope.setBuiltin("$last", index === count - 1);
scope.setBuiltin("$even", index % 2 === 0);
scope.setBuiltin("$odd", index % 2 === 1);
scope.setBuiltin("$count", count);
result += this.innerTemplate.render(data, scope);
}
return result;
}
}
class ConditionalTemplatePart {
constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
this.contextPath = contextPath;
this.formatter = formatter || ((x) => x);
this.condition = condition;
this.trueTemplate = trueTemplate;
this.falseTemplate = falseTemplate;
}
render(data) {
const selectedData = this.formatter(selectPath(data, this.contextPath));
if (this.condition(selectedData)) {
return this.trueTemplate ? this.trueTemplate.render(data) : "";
} else {
return this.falseTemplate ? this.falseTemplate.render(data) : "";
}
}
}
export class Template {
constructor(templateString) {
if (templateString != null) {
this.parts = this._parseBlock(templateString);
}
}
_parseBlock(str) {
const parts = [];
let i = 0;
while (i < str.length) {
// {{ expression }} or {{ expression:format }}
if (str[i] === "{" && str[i + 1] === "{") {
const end = str.indexOf("}}", i + 2);
if (end === -1) throw new Error("Unclosed {{ }}");
const content = str.substring(i + 2, end).trim();
let expression,
formatter = null;
const colonIdx = content.lastIndexOf(":");
if (colonIdx > 0) {
const possibleFormat = content.substring(colonIdx + 1).trim();
if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
expression = content.substring(0, colonIdx).trim();
formatter = createFormatter(possibleFormat);
} else {
expression = content;
}
} else {
expression = content;
}
parts.push(new VariableTemplatePart(expression, formatter));
i = end + 2;
continue;
}
// @for(item of expression){...} @empty{...}
if (str.startsWith("@for(", i)) {
const parenOpen = i + 4;
const parenClose = findClosingParen(str, parenOpen);
const forClause = str.substring(parenOpen + 1, parenClose).trim();
const ofIdx = forClause.indexOf(" of ");
if (ofIdx === -1)
throw new Error('@for missing "of": @for(item of expression)');
const varNames = forClause
.substring(0, ofIdx)
.split(",")
.map((v) => v.trim());
const expression = forClause.substring(ofIdx + 4).trim();
const braceOpen = str.indexOf("{", parenClose + 1);
if (braceOpen === -1) throw new Error("@for missing block");
const braceClose = findClosingBrace(str, braceOpen);
const blockContent = str.substring(braceOpen + 1, braceClose);
const innerTemplate = new Template(null);
innerTemplate.parts = this._parseBlock(blockContent);
let emptyTemplate = null;
let afterBlock = braceClose + 1;
const remaining = str.substring(afterBlock);
const emptyMatch = remaining.match(/^\s*@empty\s*\{/);
if (emptyMatch) {
const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1;
const emptyBraceClose = findClosingBrace(str, emptyBraceOpen);
const emptyContent = str.substring(
emptyBraceOpen + 1,
emptyBraceClose,
);
emptyTemplate = new Template(null);
emptyTemplate.parts = this._parseBlock(emptyContent);
afterBlock = emptyBraceClose + 1;
}
parts.push(
new ListTemplatePart(
varNames,
expression,
innerTemplate,
emptyTemplate,
),
);
i = afterBlock;
continue;
}
// @if(expression){...}@else{...}
if (str.startsWith("@if(", i)) {
const parenOpen = i + 3;
const parenClose = findClosingParen(str, parenOpen);
const expression = str.substring(parenOpen + 1, parenClose).trim();
const trueBraceOpen = str.indexOf("{", parenClose + 1);
if (trueBraceOpen === -1) throw new Error("@if missing block");
const trueBraceClose = findClosingBrace(str, trueBraceOpen);
const trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
const trueTemplate = new Template(null);
trueTemplate.parts = this._parseBlock(trueContent);
let falseTemplate = null;
let afterBlock = trueBraceClose + 1;
const remaining = str.substring(afterBlock);
const elseMatch = remaining.match(/^\s*@else\s*\{/);
if (elseMatch) {
const falseBraceOpen = afterBlock + elseMatch[0].length - 1;
const falseBraceClose = findClosingBrace(str, falseBraceOpen);
const falseContent = str.substring(
falseBraceOpen + 1,
falseBraceClose,
);
falseTemplate = new Template(null);
falseTemplate.parts = this._parseBlock(falseContent);
afterBlock = falseBraceClose + 1;
}
parts.push(
new ConditionalTemplatePart(
expression,
(val) => !!val,
trueTemplate,
falseTemplate,
),
);
i = afterBlock;
continue;
}
// @let name = expression (ends at newline or end of string)
if (str.startsWith("@let ", i)) {
const lineEnd = str.indexOf("\n", i);
const end = lineEnd === -1 ? str.length : lineEnd;
const declaration = str.substring(i + 5, end).trim();
const eqIdx = declaration.indexOf("=");
if (eqIdx === -1) throw new Error("Invalid @let: missing =");
const name = declaration.substring(0, eqIdx).trim();
const expression = declaration.substring(eqIdx + 1).trim();
parts.push(new LetTemplatePart(name, expression));
i = end + 1;
continue;
}
// Static text — collect until next special token
let end = i;
while (end < str.length) {
if (str[end] === "{" && str[end + 1] === "{") break;
if (
str[end] === "@" &&
(str.startsWith("@for(", end) ||
str.startsWith("@if(", end) ||
str.startsWith("@let ", end))
)
break;
end++;
}
if (end > i) {
parts.push(new StaticTemplatePart(str.substring(i, end)));
i = end;
}
}
return parts;
}
render(data, parentScope = new ScopeDict()) {
const scope = parentScope.clone();
const proxy = createScopeProxy(data, scope);
let result = "";
for (const part of this.parts) {
part.declareScoped?.(scope, proxy);
result += part.render(proxy);
}
return result;
}
}

View File

@ -1,57 +0,0 @@
import { Template } from './template.js';
// 1. Basic variable
let t = new Template('Hello {{ name }}!');
console.log('1:', t.render({ name: 'World' }));
// 2. Format specifier
t = new Template('Price: {{ price:0.00 }}');
console.log('2:', t.render({ price: 9.5 }));
// 3. @let
t = new Template('@let short = user.name\nHello {{ short }}!');
console.log('3:', t.render({ user: { name: 'Alice' } }));
// 4. @for with separator via @if(!$first)
t = new Template('@for(item of items){@if(!$first){, }{{ item }}}');
console.log('4:', t.render({ items: ['a', 'b', 'c'] }));
// 5/6. @if / @else
t = new Template('@if(admin){Admin}@else{User}');
console.log('5:', t.render({ admin: true }));
console.log('6:', t.render({ admin: false }));
// 7. Expression
t = new Template('{{ name.toUpperCase() }}');
console.log('7:', t.render({ name: 'alice' }));
// 8. $index
t = new Template('@for(item of items){ {{ $index }}: {{ item }} }');
console.log('8:', t.render({ items: ['x', 'y'] }));
// 9. Multiple @let
t = new Template('@let a = first\n@let b = second\n{{ a }} and {{ b }}');
console.log('9:', t.render({ first: 'one', second: 'two' }));
// 10. Nested for
t = new Template('@for(group of groups){ @for(item of group.items){ {{ item }} } }');
console.log('10:', t.render({ groups: [{ items: [1, 2] }, { items: [3, 4] }] }));
// 11. @empty block
t = new Template('@for(item of items){ {{ item }} } @empty{No items}');
console.log('11a:', t.render({ items: [] }));
console.log('11b:', t.render({ items: ['x'] }));
// 12. $first, $last, $even, $odd, $count
t = new Template('@for(item of items){[{{ item }} first={{ $first }} last={{ $last }} even={{ $even }} odd={{ $odd }} count={{ $count }}]}');
console.log('12:', t.render({ items: ['a', 'b', 'c'] }));
// 13. $this to access the contexts root object
t = new Template('@for(item of $this){ {{ item }} } @empty{No items}');
console.log('13a:', t.render([]));
console.log('13b:', t.render(['x']));
// 14. @for with multiple variables (destructuring)
t = new Template('@for(key, value of Object.entries(obj)){@if(!$first){, }{{ key }}={{ value }}}');
console.log('14:', t.render({ obj: { a: 1, b: 2, c: 3 } }));

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