From d98e8ef0f8639abbd0efe375a5f7f43702a31b42 Mon Sep 17 00:00:00 2001 From: Baumgartner Marcel Date: Fri, 27 Oct 2023 15:10:44 +0200 Subject: [PATCH] Added test logo file. Started adding community posts and comments --- Moonlight/App/Database/DataContext.cs | 6 + .../App/Database/Entities/Community/Post.cs | 16 + .../Entities/Community/PostComment.cs | 10 + .../Database/Entities/Community/PostLike.cs | 8 + Moonlight/App/Database/Enums/PostType.cs | 8 + .../20231027105412_AddPostsModels.Designer.cs | 539 ++++++++++++++++++ .../20231027105412_AddPostsModels.cs | 131 +++++ .../Migrations/DataContextModelSnapshot.cs | 136 +++++ Moonlight/App/Event/Events.cs | 7 + Moonlight/App/Helpers/Formatter.cs | 28 + Moonlight/App/Models/Enums/Permission.cs | 1 + .../App/Services/Community/PostService.cs | 135 +++++ Moonlight/Moonlight.csproj | 1 - Moonlight/Program.cs | 4 + .../Components/Community/PostView.razor | 190 ++++++ .../Shared/Components/Partials/Sidebar.razor | 5 +- Moonlight/Shared/Views/Community/Index.razor | 33 ++ Moonlight/wwwroot/img/logo.png | Bin 0 -> 15347 bytes 18 files changed, 1254 insertions(+), 4 deletions(-) create mode 100644 Moonlight/App/Database/Entities/Community/Post.cs create mode 100644 Moonlight/App/Database/Entities/Community/PostComment.cs create mode 100644 Moonlight/App/Database/Entities/Community/PostLike.cs create mode 100644 Moonlight/App/Database/Enums/PostType.cs create mode 100644 Moonlight/App/Database/Migrations/20231027105412_AddPostsModels.Designer.cs create mode 100644 Moonlight/App/Database/Migrations/20231027105412_AddPostsModels.cs create mode 100644 Moonlight/App/Services/Community/PostService.cs create mode 100644 Moonlight/Shared/Components/Community/PostView.razor create mode 100644 Moonlight/Shared/Views/Community/Index.razor create mode 100644 Moonlight/wwwroot/img/logo.png diff --git a/Moonlight/App/Database/DataContext.cs b/Moonlight/App/Database/DataContext.cs index 34a9d61b..1789594d 100644 --- a/Moonlight/App/Database/DataContext.cs +++ b/Moonlight/App/Database/DataContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Moonlight.App.Database.Entities; +using Moonlight.App.Database.Entities.Community; using Moonlight.App.Database.Entities.Store; using Moonlight.App.Database.Entities.Tickets; using Moonlight.App.Services; @@ -25,6 +26,11 @@ public class DataContext : DbContext public DbSet Coupons { get; set; } public DbSet CouponUses { get; set; } + + // Posts + public DbSet Posts { get; set; } + public DbSet PostComments { get; set; } + public DbSet PostLikes { get; set; } public DataContext(ConfigService configService) { diff --git a/Moonlight/App/Database/Entities/Community/Post.cs b/Moonlight/App/Database/Entities/Community/Post.cs new file mode 100644 index 00000000..2d4af22f --- /dev/null +++ b/Moonlight/App/Database/Entities/Community/Post.cs @@ -0,0 +1,16 @@ +using Moonlight.App.Database.Enums; + +namespace Moonlight.App.Database.Entities.Community; + +public class Post +{ + public int Id { get; set; } + public string Title { get; set; } = ""; + public string Content { get; set; } = ""; + public User Author { get; set; } + public PostType Type { get; set; } + public List Comments { get; set; } = new(); + public List Likes { get; set; } = new(); + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Moonlight/App/Database/Entities/Community/PostComment.cs b/Moonlight/App/Database/Entities/Community/PostComment.cs new file mode 100644 index 00000000..633bf410 --- /dev/null +++ b/Moonlight/App/Database/Entities/Community/PostComment.cs @@ -0,0 +1,10 @@ +namespace Moonlight.App.Database.Entities.Community; + +public class PostComment +{ + public int Id { get; set; } + public string Content { get; set; } = ""; + public User Author { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Moonlight/App/Database/Entities/Community/PostLike.cs b/Moonlight/App/Database/Entities/Community/PostLike.cs new file mode 100644 index 00000000..0d0085d1 --- /dev/null +++ b/Moonlight/App/Database/Entities/Community/PostLike.cs @@ -0,0 +1,8 @@ +namespace Moonlight.App.Database.Entities.Community; + +public class PostLike +{ + public int Id { get; set; } + public User User { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Moonlight/App/Database/Enums/PostType.cs b/Moonlight/App/Database/Enums/PostType.cs new file mode 100644 index 00000000..4555a3b2 --- /dev/null +++ b/Moonlight/App/Database/Enums/PostType.cs @@ -0,0 +1,8 @@ +namespace Moonlight.App.Database.Enums; + +public enum PostType +{ + Project = 0, + Announcement = 1, + Event = 2 +} \ No newline at end of file diff --git a/Moonlight/App/Database/Migrations/20231027105412_AddPostsModels.Designer.cs b/Moonlight/App/Database/Migrations/20231027105412_AddPostsModels.Designer.cs new file mode 100644 index 00000000..b1a2f6e1 --- /dev/null +++ b/Moonlight/App/Database/Migrations/20231027105412_AddPostsModels.Designer.cs @@ -0,0 +1,539 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Moonlight.App.Database; + +#nullable disable + +namespace Moonlight.App.Database.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20231027105412_AddPostsModels")] + partial class AddPostsModels + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.2"); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.PostComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PostId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("PostId"); + + b.ToTable("PostComments"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.PostLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PostId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("PostLikes"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Coupon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Percent") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Coupons"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.CouponUse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CouponId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CouponId"); + + b.HasIndex("UserId"); + + b.ToTable("CouponUses"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.GiftCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("GiftCodes"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.GiftCodeUse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("GiftCodeId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GiftCodeId"); + + b.HasIndex("UserId"); + + b.ToTable("GiftCodeUses"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("MaxPerUser") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("REAL"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Stock") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConfigJsonOverride") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("INTEGER"); + + b.Property("ProductId") + .HasColumnType("INTEGER"); + + b.Property("RenewAt") + .HasColumnType("TEXT"); + + b.Property("Suspended") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ProductId"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.ServiceShare", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ServiceId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.HasIndex("UserId"); + + b.ToTable("ServiceShares"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Price") + .HasColumnType("REAL"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Transaction"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Avatar") + .HasColumnType("TEXT"); + + b.Property("Balance") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Flags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.Property("TokenValidTimestamp") + .HasColumnType("TEXT"); + + b.Property("TotpKey") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.Post", b => + { + b.HasOne("Moonlight.App.Database.Entities.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.PostComment", b => + { + b.HasOne("Moonlight.App.Database.Entities.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Moonlight.App.Database.Entities.Community.Post", null) + .WithMany("Comments") + .HasForeignKey("PostId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.PostLike", b => + { + b.HasOne("Moonlight.App.Database.Entities.Community.Post", null) + .WithMany("Likes") + .HasForeignKey("PostId"); + + b.HasOne("Moonlight.App.Database.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.CouponUse", b => + { + b.HasOne("Moonlight.App.Database.Entities.Store.Coupon", "Coupon") + .WithMany() + .HasForeignKey("CouponId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Moonlight.App.Database.Entities.User", null) + .WithMany("CouponUses") + .HasForeignKey("UserId"); + + b.Navigation("Coupon"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.GiftCodeUse", b => + { + b.HasOne("Moonlight.App.Database.Entities.Store.GiftCode", "GiftCode") + .WithMany() + .HasForeignKey("GiftCodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Moonlight.App.Database.Entities.User", null) + .WithMany("GiftCodeUses") + .HasForeignKey("UserId"); + + b.Navigation("GiftCode"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Product", b => + { + b.HasOne("Moonlight.App.Database.Entities.Store.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Service", b => + { + b.HasOne("Moonlight.App.Database.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Moonlight.App.Database.Entities.Store.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.ServiceShare", b => + { + b.HasOne("Moonlight.App.Database.Entities.Store.Service", null) + .WithMany("Shares") + .HasForeignKey("ServiceId"); + + b.HasOne("Moonlight.App.Database.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Transaction", b => + { + b.HasOne("Moonlight.App.Database.Entities.User", null) + .WithMany("Transactions") + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.Post", b => + { + b.Navigation("Comments"); + + b.Navigation("Likes"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Service", b => + { + b.Navigation("Shares"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.User", b => + { + b.Navigation("CouponUses"); + + b.Navigation("GiftCodeUses"); + + b.Navigation("Transactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Moonlight/App/Database/Migrations/20231027105412_AddPostsModels.cs b/Moonlight/App/Database/Migrations/20231027105412_AddPostsModels.cs new file mode 100644 index 00000000..7910ab27 --- /dev/null +++ b/Moonlight/App/Database/Migrations/20231027105412_AddPostsModels.cs @@ -0,0 +1,131 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Moonlight.App.Database.Migrations +{ + /// + public partial class AddPostsModels : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Posts", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: false), + Content = table.Column(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "INTEGER", nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Posts", x => x.Id); + table.ForeignKey( + name: "FK_Posts_Users_AuthorId", + column: x => x.AuthorId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PostComments", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Content = table.Column(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + PostId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PostComments", x => x.Id); + table.ForeignKey( + name: "FK_PostComments_Posts_PostId", + column: x => x.PostId, + principalTable: "Posts", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PostComments_Users_AuthorId", + column: x => x.AuthorId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PostLikes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + PostId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PostLikes", x => x.Id); + table.ForeignKey( + name: "FK_PostLikes_Posts_PostId", + column: x => x.PostId, + principalTable: "Posts", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PostLikes_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PostComments_AuthorId", + table: "PostComments", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_PostComments_PostId", + table: "PostComments", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_PostLikes_PostId", + table: "PostLikes", + column: "PostId"); + + migrationBuilder.CreateIndex( + name: "IX_PostLikes_UserId", + table: "PostLikes", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Posts_AuthorId", + table: "Posts", + column: "AuthorId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PostComments"); + + migrationBuilder.DropTable( + name: "PostLikes"); + + migrationBuilder.DropTable( + name: "Posts"); + } + } +} diff --git a/Moonlight/App/Database/Migrations/DataContextModelSnapshot.cs b/Moonlight/App/Database/Migrations/DataContextModelSnapshot.cs index 6cfcd7f2..c759c094 100644 --- a/Moonlight/App/Database/Migrations/DataContextModelSnapshot.cs +++ b/Moonlight/App/Database/Migrations/DataContextModelSnapshot.cs @@ -17,6 +17,94 @@ namespace Moonlight.App.Database.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "7.0.2"); + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.PostComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PostId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("PostId"); + + b.ToTable("PostComments"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.PostLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PostId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.HasIndex("UserId"); + + b.ToTable("PostLikes"); + }); + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Category", b => { b.Property("Id") @@ -299,6 +387,47 @@ namespace Moonlight.App.Database.Migrations b.ToTable("Users"); }); + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.Post", b => + { + b.HasOne("Moonlight.App.Database.Entities.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.PostComment", b => + { + b.HasOne("Moonlight.App.Database.Entities.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Moonlight.App.Database.Entities.Community.Post", null) + .WithMany("Comments") + .HasForeignKey("PostId"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.PostLike", b => + { + b.HasOne("Moonlight.App.Database.Entities.Community.Post", null) + .WithMany("Likes") + .HasForeignKey("PostId"); + + b.HasOne("Moonlight.App.Database.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.CouponUse", b => { b.HasOne("Moonlight.App.Database.Entities.Store.Coupon", "Coupon") @@ -381,6 +510,13 @@ namespace Moonlight.App.Database.Migrations .HasForeignKey("UserId"); }); + modelBuilder.Entity("Moonlight.App.Database.Entities.Community.Post", b => + { + b.Navigation("Comments"); + + b.Navigation("Likes"); + }); + modelBuilder.Entity("Moonlight.App.Database.Entities.Store.Service", b => { b.Navigation("Shares"); diff --git a/Moonlight/App/Event/Events.cs b/Moonlight/App/Event/Events.cs index 35d822e4..d2967282 100644 --- a/Moonlight/App/Event/Events.cs +++ b/Moonlight/App/Event/Events.cs @@ -1,4 +1,5 @@ using Moonlight.App.Database.Entities; +using Moonlight.App.Database.Entities.Community; using Moonlight.App.Database.Entities.Store; using Moonlight.App.Event.Args; @@ -12,4 +13,10 @@ public class Events public static EventHandler OnUserMailVerify; public static EventHandler OnServiceOrdered; public static EventHandler OnTransactionCreated; + public static EventHandler OnPostCreated; + public static EventHandler OnPostUpdated; + public static EventHandler OnPostDeleted; + public static EventHandler OnPostLiked; + public static EventHandler OnPostCommentCreated; + public static EventHandler OnPostCommentDeleted; } \ No newline at end of file diff --git a/Moonlight/App/Helpers/Formatter.cs b/Moonlight/App/Helpers/Formatter.cs index 2c4dc708..dadf0495 100644 --- a/Moonlight/App/Helpers/Formatter.cs +++ b/Moonlight/App/Helpers/Formatter.cs @@ -225,6 +225,34 @@ public static class Formatter } }; } + + public static string FormatAgoFromDateTime(DateTime dt) + { + TimeSpan timeSince = DateTime.UtcNow.Subtract(dt); + + if (timeSince.TotalMilliseconds < 1) + return "just now"; + + if (timeSince.TotalMinutes < 1) + return "less than a minute ago"; + + if (timeSince.TotalMinutes < 2) + return "1 minute ago"; + + if (timeSince.TotalMinutes < 60) + return Math.Round(timeSince.TotalMinutes) + " minutes ago"; + + if (timeSince.TotalHours < 2) + return "1 hour ago"; + + if (timeSince.TotalHours < 24) + return Math.Round(timeSince.TotalHours) + " hours ago"; + + if (timeSince.TotalDays < 2) + return "1 day ago"; + + return Math.Round(timeSince.TotalDays) + " days ago"; + } // This will replace every placeholder with the respective value if specified in the model // For example: diff --git a/Moonlight/App/Models/Enums/Permission.cs b/Moonlight/App/Models/Enums/Permission.cs index 3bb7e627..677bf9d8 100644 --- a/Moonlight/App/Models/Enums/Permission.cs +++ b/Moonlight/App/Models/Enums/Permission.cs @@ -9,6 +9,7 @@ public enum Permission AdminSessions = 1002, AdminUsersEdit = 1003, AdminTickets = 1004, + AdminCommunity = 1030, AdminStore = 1900, AdminViewExceptions = 1999, AdminRoot = 2000 diff --git a/Moonlight/App/Services/Community/PostService.cs b/Moonlight/App/Services/Community/PostService.cs new file mode 100644 index 00000000..aed5986f --- /dev/null +++ b/Moonlight/App/Services/Community/PostService.cs @@ -0,0 +1,135 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Moonlight.App.Database.Entities; +using Moonlight.App.Database.Entities.Community; +using Moonlight.App.Database.Enums; +using Moonlight.App.Event; +using Moonlight.App.Exceptions; +using Moonlight.App.Extensions; +using Moonlight.App.Repositories; + +namespace Moonlight.App.Services.Community; + +public class PostService +{ + private readonly Repository PostRepository; + private readonly Repository PostLikeRepository; + private readonly Repository PostCommentRepository; + + public PostService(Repository postRepository, Repository postLikeRepository, Repository postCommentRepository) + { + PostRepository = postRepository; + PostLikeRepository = postLikeRepository; + PostCommentRepository = postCommentRepository; + } + + // Posts + public async Task Create(User user, string title, string content, PostType type) + { + var post = new Post() + { + Author = user, + Title = title, + Content = content, + Type = type + }; + + var finishedPost = PostRepository.Add(post); + + await Events.OnPostCreated.InvokeAsync(finishedPost); + + return finishedPost; + } + + public async Task Update(Post post, string title, string content) + { + post.Title = title; + post.Content = content; + + PostRepository.Update(post); + + await Events.OnPostUpdated.InvokeAsync(post); + } + + public async Task Delete(Post post) + { + PostRepository.Delete(post); + await Events.OnPostDeleted.InvokeAsync(post); + } + + // Comments + public async Task CreateComment(Post post, User user, string content) + { + // As the comment feature has no edit form or model to validate we do the validation here + if (string.IsNullOrEmpty(content)) + throw new DisplayException("Comment content cannot be empty"); + + if (content.Length > 1024) + throw new DisplayException("Comment content cannot be longer than 1024 characters"); + + if (!Regex.IsMatch(content, "^[a-zA-Z0-9äöüßÄÖÜẞ,.;_\\n\\t-]+$")) + throw new DisplayException("Illegal characters in comment content"); + + //TODO: Swear word filter + + var comment = new PostComment() + { + Author = user, + Content = content + }; + + post.Comments.Add(comment); + PostRepository.Update(post); + + await Events.OnPostCommentCreated.InvokeAsync(comment); + + return comment; + } + + public async Task DeleteComment(Post post, PostComment comment) + { + var postWithComments = PostRepository + .Get() + .Include(x => x.Comments) + .First(x => x.Id == post.Id); + + var commentToRemove = postWithComments.Comments.First(x => x.Id == comment.Id); + postWithComments.Comments.Remove(commentToRemove); + + PostRepository.Update(postWithComments); + PostCommentRepository.Delete(commentToRemove); + + await Events.OnPostCommentCreated.InvokeAsync(commentToRemove); + } + + // Other + public async Task ToggleLike(Post post, User user) + { + var postWithLikes = PostRepository + .Get() + .Include(x => x.Likes) + .ThenInclude(x => x.User) + .First(x => x.Id == post.Id); + + var userLike = postWithLikes.Likes.FirstOrDefault(x => x.User.Id == user.Id); + + if (userLike != null) // Check if person already liked + { + postWithLikes.Likes.Remove(userLike); + + PostRepository.Update(postWithLikes); + PostLikeRepository.Delete(userLike); + } + else + { + postWithLikes.Likes.Add(new() + { + User = user + }); + + PostRepository.Update(postWithLikes); + + await Events.OnPostLiked.InvokeAsync(postWithLikes); + } + } +} \ No newline at end of file diff --git a/Moonlight/Moonlight.csproj b/Moonlight/Moonlight.csproj index 79add5b0..e6aec375 100644 --- a/Moonlight/Moonlight.csproj +++ b/Moonlight/Moonlight.csproj @@ -19,7 +19,6 @@ - diff --git a/Moonlight/Program.cs b/Moonlight/Program.cs index fe9ebe16..dca2ceb4 100644 --- a/Moonlight/Program.cs +++ b/Moonlight/Program.cs @@ -8,6 +8,7 @@ using Moonlight.App.Helpers.LogMigrator; using Moonlight.App.Repositories; using Moonlight.App.Services; using Moonlight.App.Services.Background; +using Moonlight.App.Services.Community; using Moonlight.App.Services.Interop; using Moonlight.App.Services.ServiceManage; using Moonlight.App.Services.Store; @@ -58,6 +59,9 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +// Services / Community +builder.Services.AddScoped(); + // Services / Users builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Moonlight/Shared/Components/Community/PostView.razor b/Moonlight/Shared/Components/Community/PostView.razor new file mode 100644 index 00000000..b428f2b3 --- /dev/null +++ b/Moonlight/Shared/Components/Community/PostView.razor @@ -0,0 +1,190 @@ +@using Moonlight.App.Database.Entities.Community +@using Ganss.Xss +@using Microsoft.EntityFrameworkCore +@using Moonlight.App.Repositories +@using Moonlight.App.Services +@using Moonlight.App.Services.Community + +@inject Repository PostRepository +@inject IdentityService IdentityService +@inject PostService PostService + +
+
+
+
+ +
+
+ @(Post.Author.Username) + @(Formatter.FormatAgoFromDateTime(Post.CreatedAt)) +
+
+
+ +
+
+
+
+ @{ + var sanitizer = new HtmlSanitizer(); + var content = sanitizer.Sanitize(Post.Content); + @((MarkupString)content) + } +
+
+ +
+ +@code +{ + [Parameter] + public Post Post { get; set; } + + private int CommentsCount = -1; + private int LikesCount = -1; + private bool HasLiked = false; + + private bool ShowComments = false; + private PostComment[] Comments = Array.Empty(); + private string Comment = ""; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await UpdateCounts(); + } + } + + private Task LoadComments(LazyLoader _) + { + Comments = PostRepository + .Get() + .Include(x => x.Comments) + .ThenInclude(x => x.Author) + .First(x => x.Id == Post.Id) + .Comments + .ToArray(); + + return Task.CompletedTask; + } + + private async Task UpdateCounts() + { + CommentsCount = PostRepository + .Get() + .Where(x => x.Id == Post.Id) + .SelectMany(x => x.Comments) + .Count(); + + LikesCount = PostRepository + .Get() + .Where(x => x.Id == Post.Id) + .SelectMany(x => x.Likes) + .Count(); + + HasLiked = PostRepository + .Get() + .Where(x => x.Id == Post.Id) + .SelectMany(x => x.Likes) + .Any(x => x.User.Id == IdentityService.CurrentUser.Id); + + await InvokeAsync(StateHasChanged); + } + + private async Task CreateComment() + { + await PostService.CreateComment(Post, IdentityService.CurrentUser, Comment); + + Comment = ""; + ShowComments = true; + await InvokeAsync(StateHasChanged); + await UpdateCounts(); + } + + private async Task DeleteComment(PostComment comment) + { + await PostService.DeleteComment(Post, comment); + + await InvokeAsync(StateHasChanged); + await UpdateCounts(); + } + + private async Task ToggleComments() + { + ShowComments = !ShowComments; + await InvokeAsync(StateHasChanged); + + if (!ShowComments) + Comments = Array.Empty(); // Clear unused data + } + + private async Task ToggleLike() + { + await PostService.ToggleLike(Post, IdentityService.CurrentUser); + await UpdateCounts(); + } +} \ No newline at end of file diff --git a/Moonlight/Shared/Components/Partials/Sidebar.razor b/Moonlight/Shared/Components/Partials/Sidebar.razor index 2b4382cb..004e62aa 100644 --- a/Moonlight/Shared/Components/Partials/Sidebar.razor +++ b/Moonlight/Shared/Components/Partials/Sidebar.razor @@ -6,9 +6,8 @@
diff --git a/Moonlight/Shared/Views/Community/Index.razor b/Moonlight/Shared/Views/Community/Index.razor new file mode 100644 index 00000000..a577f0db --- /dev/null +++ b/Moonlight/Shared/Views/Community/Index.razor @@ -0,0 +1,33 @@ +@page "/community" + +@using Moonlight.App.Repositories +@using Moonlight.App.Database.Entities.Community +@using Microsoft.EntityFrameworkCore +@using Moonlight.Shared.Components.Community + +@inject Repository PostRepository + +
+ + @foreach (var post in Posts) + { + +
+ } +
+
+ +@code +{ + private Post[] Posts; + + private Task Load(LazyLoader _) + { + Posts = PostRepository + .Get() + .Include(x => x.Author) + .ToArray(); + + return Task.CompletedTask; + } +} diff --git a/Moonlight/wwwroot/img/logo.png b/Moonlight/wwwroot/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..77737b9d8e2925c1f574c327172ada0b76e8e888 GIT binary patch literal 15347 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