From 48ee43c4f63fd50c024a9de02c5550c4862b2c29 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 19 Jan 2026 22:06:34 -0500 Subject: [PATCH] [FA-24] Reading lists --- .../GraphQL/Mutation.cs | 389 ++++++++++++++++ .../GraphQL/Query.cs | 91 ++++ ...20260120014840_AddReadingLists.Designer.cs | 289 ++++++++++++ .../20260120014840_AddReadingLists.cs | 89 ++++ ...rNovelDataServiceDbContextModelSnapshot.cs | 91 ++++ .../Models/DTOs/AddToReadingListInput.cs | 3 + .../Models/DTOs/CreateReadingListInput.cs | 3 + .../Models/DTOs/DeleteReadingListPayload.cs | 6 + .../Models/DTOs/ReadingListDto.cs | 13 + .../Models/DTOs/ReadingListItemDto.cs | 10 + .../Models/DTOs/ReadingListPayload.cs | 7 + .../DTOs/ReorderReadingListItemInput.cs | 3 + .../Models/DTOs/UpdateReadingListInput.cs | 3 + .../Models/Database/ReadingList.cs | 14 + .../Models/Database/ReadingListItem.cs | 12 + .../Services/UserNovelDataServiceDbContext.cs | 29 ++ .../components/AddToReadingListButton.svelte | 368 +++++++++++++++ .../src/lib/components/HomePage.svelte | 3 +- .../src/lib/components/Navbar.svelte | 6 + .../src/lib/components/NovelDetailPage.svelte | 2 + .../components/ReadingListDetailPage.svelte | 393 ++++++++++++++++ .../lib/components/ReadingListsPage.svelte | 432 ++++++++++++++++++ .../src/lib/graphql/__generated__/graphql.ts | 204 +++++++++ .../mutations/addToReadingList.graphql | 17 + .../mutations/createReadingList.graphql | 19 + .../mutations/deleteReadingList.graphql | 10 + .../mutations/removeFromReadingList.graphql | 17 + .../mutations/reorderReadingListItem.graphql | 12 + .../mutations/updateReadingList.graphql | 19 + .../lib/graphql/queries/readingList.graphql | 14 + .../lib/graphql/queries/readingLists.graphql | 9 + .../queries/readingListsWithItems.graphql | 14 + .../src/pages/reading-lists/[id].astro | 10 + .../src/pages/reading-lists/index.astro | 8 + 34 files changed, 2607 insertions(+), 2 deletions(-) create mode 100644 FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.Designer.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/AddToReadingListInput.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/CreateReadingListInput.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/DeleteReadingListPayload.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListDto.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListItemDto.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListPayload.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/ReorderReadingListItemInput.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/DTOs/UpdateReadingListInput.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/Database/ReadingList.cs create mode 100644 FictionArchive.Service.UserNovelDataService/Models/Database/ReadingListItem.cs create mode 100644 fictionarchive-web-astro/src/lib/components/AddToReadingListButton.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/ReadingListDetailPage.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/ReadingListsPage.svelte create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/addToReadingList.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/createReadingList.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/deleteReadingList.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/removeFromReadingList.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/reorderReadingListItem.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/updateReadingList.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/queries/readingList.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/queries/readingLists.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/queries/readingListsWithItems.graphql create mode 100644 fictionarchive-web-astro/src/pages/reading-lists/[id].astro create mode 100644 fictionarchive-web-astro/src/pages/reading-lists/index.astro diff --git a/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs b/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs index 09a46e1..9c9188e 100644 --- a/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.UserNovelDataService/GraphQL/Mutation.cs @@ -106,4 +106,393 @@ public class Mutation return new BookmarkPayload { Success = true }; } + + [Authorize] + [Error] + public async Task CreateReadingList( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + CreateReadingListInput input) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + throw new InvalidOperationException("Unable to determine current user identity"); + } + + if (string.IsNullOrWhiteSpace(input.Name)) + { + throw new InvalidOperationException("Reading list name is required"); + } + + var user = await dbContext.Users + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + user = new User { OAuthProviderId = oAuthProviderId }; + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + } + + var readingList = new ReadingList + { + UserId = user.Id, + Name = input.Name.Trim(), + Description = input.Description?.Trim() + }; + + dbContext.ReadingLists.Add(readingList); + await dbContext.SaveChangesAsync(); + + return new ReadingListPayload + { + Success = true, + ReadingList = new ReadingListDto + { + Id = readingList.Id, + Name = readingList.Name, + Description = readingList.Description, + Items = [], + ItemCount = 0, + CreatedTime = readingList.CreatedTime + } + }; + } + + [Authorize] + [Error] + public async Task UpdateReadingList( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + UpdateReadingListInput input) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + throw new InvalidOperationException("Unable to determine current user identity"); + } + + if (string.IsNullOrWhiteSpace(input.Name)) + { + throw new InvalidOperationException("Reading list name is required"); + } + + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + return new ReadingListPayload { Success = false }; + } + + var readingList = await dbContext.ReadingLists + .Include(r => r.Items) + .FirstOrDefaultAsync(r => r.Id == input.Id && r.UserId == user.Id); + + if (readingList == null) + { + return new ReadingListPayload { Success = false }; + } + + readingList.Name = input.Name.Trim(); + readingList.Description = input.Description?.Trim(); + + await dbContext.SaveChangesAsync(); + + return new ReadingListPayload + { + Success = true, + ReadingList = new ReadingListDto + { + Id = readingList.Id, + Name = readingList.Name, + Description = readingList.Description, + Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto + { + NovelId = i.NovelId, + Order = i.Order, + AddedTime = i.CreatedTime + }), + ItemCount = readingList.Items.Count, + CreatedTime = readingList.CreatedTime + } + }; + } + + [Authorize] + [Error] + public async Task DeleteReadingList( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + int id) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + throw new InvalidOperationException("Unable to determine current user identity"); + } + + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + return new DeleteReadingListPayload { Success = false }; + } + + var readingList = await dbContext.ReadingLists + .FirstOrDefaultAsync(r => r.Id == id && r.UserId == user.Id); + + if (readingList == null) + { + return new DeleteReadingListPayload { Success = false }; + } + + dbContext.ReadingLists.Remove(readingList); + await dbContext.SaveChangesAsync(); + + return new DeleteReadingListPayload { Success = true }; + } + + [Authorize] + [Error] + public async Task AddToReadingList( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + AddToReadingListInput input) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + throw new InvalidOperationException("Unable to determine current user identity"); + } + + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + return new ReadingListPayload { Success = false }; + } + + var readingList = await dbContext.ReadingLists + .Include(r => r.Items) + .FirstOrDefaultAsync(r => r.Id == input.ReadingListId && r.UserId == user.Id); + + if (readingList == null) + { + return new ReadingListPayload { Success = false }; + } + + // Idempotent: if already in list, return success + var existingItem = readingList.Items.FirstOrDefault(i => i.NovelId == input.NovelId); + if (existingItem != null) + { + return new ReadingListPayload + { + Success = true, + ReadingList = new ReadingListDto + { + Id = readingList.Id, + Name = readingList.Name, + Description = readingList.Description, + Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto + { + NovelId = i.NovelId, + Order = i.Order, + AddedTime = i.CreatedTime + }), + ItemCount = readingList.Items.Count, + CreatedTime = readingList.CreatedTime + } + }; + } + + // Add at the end (highest order + 1) + var maxOrder = readingList.Items.Any() ? readingList.Items.Max(i => i.Order) : -1; + var newItem = new ReadingListItem + { + ReadingListId = readingList.Id, + NovelId = input.NovelId, + Order = maxOrder + 1 + }; + + dbContext.ReadingListItems.Add(newItem); + await dbContext.SaveChangesAsync(); + + // Reload to get updated items + readingList = await dbContext.ReadingLists + .AsNoTracking() + .Include(r => r.Items.OrderBy(i => i.Order)) + .FirstAsync(r => r.Id == input.ReadingListId); + + return new ReadingListPayload + { + Success = true, + ReadingList = new ReadingListDto + { + Id = readingList.Id, + Name = readingList.Name, + Description = readingList.Description, + Items = readingList.Items.Select(i => new ReadingListItemDto + { + NovelId = i.NovelId, + Order = i.Order, + AddedTime = i.CreatedTime + }), + ItemCount = readingList.Items.Count, + CreatedTime = readingList.CreatedTime + } + }; + } + + [Authorize] + [Error] + public async Task RemoveFromReadingList( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + int listId, + uint novelId) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + throw new InvalidOperationException("Unable to determine current user identity"); + } + + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + return new ReadingListPayload { Success = false }; + } + + var readingList = await dbContext.ReadingLists + .Include(r => r.Items) + .FirstOrDefaultAsync(r => r.Id == listId && r.UserId == user.Id); + + if (readingList == null) + { + return new ReadingListPayload { Success = false }; + } + + var item = readingList.Items.FirstOrDefault(i => i.NovelId == novelId); + if (item != null) + { + dbContext.ReadingListItems.Remove(item); + await dbContext.SaveChangesAsync(); + } + + // Reload to get updated items + readingList = await dbContext.ReadingLists + .AsNoTracking() + .Include(r => r.Items.OrderBy(i => i.Order)) + .FirstAsync(r => r.Id == listId); + + return new ReadingListPayload + { + Success = true, + ReadingList = new ReadingListDto + { + Id = readingList.Id, + Name = readingList.Name, + Description = readingList.Description, + Items = readingList.Items.Select(i => new ReadingListItemDto + { + NovelId = i.NovelId, + Order = i.Order, + AddedTime = i.CreatedTime + }), + ItemCount = readingList.Items.Count, + CreatedTime = readingList.CreatedTime + } + }; + } + + [Authorize] + [Error] + public async Task ReorderReadingListItem( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + ReorderReadingListItemInput input) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + throw new InvalidOperationException("Unable to determine current user identity"); + } + + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + return new ReadingListPayload { Success = false }; + } + + var readingList = await dbContext.ReadingLists + .Include(r => r.Items) + .FirstOrDefaultAsync(r => r.Id == input.ReadingListId && r.UserId == user.Id); + + if (readingList == null) + { + return new ReadingListPayload { Success = false }; + } + + var item = readingList.Items.FirstOrDefault(i => i.NovelId == input.NovelId); + if (item == null) + { + throw new InvalidOperationException("Novel not found in reading list"); + } + + var oldOrder = item.Order; + var newOrder = input.NewOrder; + + // Shift other items + if (newOrder < oldOrder) + { + // Moving up: shift items between newOrder and oldOrder down + foreach (var i in readingList.Items.Where(x => x.Order >= newOrder && x.Order < oldOrder)) + { + i.Order++; + } + } + else if (newOrder > oldOrder) + { + // Moving down: shift items between oldOrder and newOrder up + foreach (var i in readingList.Items.Where(x => x.Order > oldOrder && x.Order <= newOrder)) + { + i.Order--; + } + } + + item.Order = newOrder; + await dbContext.SaveChangesAsync(); + + return new ReadingListPayload + { + Success = true, + ReadingList = new ReadingListDto + { + Id = readingList.Id, + Name = readingList.Name, + Description = readingList.Description, + Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto + { + NovelId = i.NovelId, + Order = i.Order, + AddedTime = i.CreatedTime + }), + ItemCount = readingList.Items.Count, + CreatedTime = readingList.CreatedTime + } + }; + } } diff --git a/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs b/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs index 0b6e297..41ea5ac 100644 --- a/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs +++ b/FictionArchive.Service.UserNovelDataService/GraphQL/Query.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using FictionArchive.Service.UserNovelDataService.Models.Database; using FictionArchive.Service.UserNovelDataService.Models.DTOs; using FictionArchive.Service.UserNovelDataService.Services; using HotChocolate.Authorization; @@ -42,4 +43,94 @@ public class Query CreatedTime = b.CreatedTime }); } + + [Authorize] + public async Task> GetReadingLists( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + return []; + } + + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + return []; + } + + var lists = await dbContext.ReadingLists + .AsNoTracking() + .Include(r => r.Items) + .Where(r => r.UserId == user.Id) + .OrderByDescending(r => r.LastUpdatedTime) + .ToListAsync(); + + return lists.Select(r => new ReadingListDto + { + Id = r.Id, + Name = r.Name, + Description = r.Description, + ItemCount = r.Items.Count, + Items = r.Items.Select(i => new ReadingListItemDto + { + NovelId = i.NovelId, + Order = i.Order, + AddedTime = i.CreatedTime + }), + CreatedTime = r.CreatedTime + }); + } + + [Authorize] + public async Task GetReadingList( + UserNovelDataServiceDbContext dbContext, + ClaimsPrincipal claimsPrincipal, + int id) + { + var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(oAuthProviderId)) + { + return null; + } + + var user = await dbContext.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId); + + if (user == null) + { + return null; + } + + var readingList = await dbContext.ReadingLists + .AsNoTracking() + .Include(r => r.Items.OrderBy(i => i.Order)) + .FirstOrDefaultAsync(r => r.Id == id && r.UserId == user.Id); + + if (readingList == null) + { + return null; + } + + return new ReadingListDto + { + Id = readingList.Id, + Name = readingList.Name, + Description = readingList.Description, + ItemCount = readingList.Items.Count, + Items = readingList.Items.Select(i => new ReadingListItemDto + { + NovelId = i.NovelId, + Order = i.Order, + AddedTime = i.CreatedTime + }), + CreatedTime = readingList.CreatedTime + }; + } } diff --git a/FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.Designer.cs b/FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.Designer.cs new file mode 100644 index 0000000..b577662 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.Designer.cs @@ -0,0 +1,289 @@ +// +using System; +using FictionArchive.Service.UserNovelDataService.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FictionArchive.Service.UserNovelDataService.Migrations +{ + [DbContext(typeof(UserNovelDataServiceDbContext))] + [Migration("20260120014840_AddReadingLists")] + partial class AddReadingLists + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChapterId") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ChapterId") + .IsUnique(); + + b.HasIndex("UserId", "NovelId"); + + b.ToTable("Bookmarks"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("VolumeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ReadingLists"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ReadingListId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ReadingListId", "NovelId") + .IsUnique(); + + b.HasIndex("ReadingListId", "Order"); + + b.ToTable("ReadingListItems"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("OAuthProviderId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.ToTable("Volumes"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReadingList"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel") + .WithMany("Volumes") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.cs b/FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.cs new file mode 100644 index 0000000..a926b57 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Migrations/20260120014840_AddReadingLists.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FictionArchive.Service.UserNovelDataService.Migrations +{ + /// + public partial class AddReadingLists : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ReadingLists", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + LastUpdatedTime = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReadingLists", x => x.Id); + table.ForeignKey( + name: "FK_ReadingLists_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ReadingListItems", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ReadingListId = table.Column(type: "integer", nullable: false), + NovelId = table.Column(type: "bigint", nullable: false), + Order = table.Column(type: "integer", nullable: false), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + LastUpdatedTime = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReadingListItems", x => x.Id); + table.ForeignKey( + name: "FK_ReadingListItems_ReadingLists_ReadingListId", + column: x => x.ReadingListId, + principalTable: "ReadingLists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListItems_ReadingListId_NovelId", + table: "ReadingListItems", + columns: new[] { "ReadingListId", "NovelId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListItems_ReadingListId_Order", + table: "ReadingListItems", + columns: new[] { "ReadingListId", "Order" }); + + migrationBuilder.CreateIndex( + name: "IX_ReadingLists_UserId", + table: "ReadingLists", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReadingListItems"); + + migrationBuilder.DropTable( + name: "ReadingLists"); + } + } +} diff --git a/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs b/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs index 507067f..0741d48 100644 --- a/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs +++ b/FictionArchive.Service.UserNovelDataService/Migrations/UserNovelDataServiceDbContextModelSnapshot.cs @@ -102,6 +102,70 @@ namespace FictionArchive.Service.UserNovelDataService.Migrations b.ToTable("Novels"); }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ReadingLists"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ReadingListId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ReadingListId", "NovelId") + .IsUnique(); + + b.HasIndex("ReadingListId", "Order"); + + b.ToTable("ReadingListItems"); + }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", b => { b.Property("Id") @@ -169,6 +233,28 @@ namespace FictionArchive.Service.UserNovelDataService.Migrations b.Navigation("Volume"); }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b => + { + b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReadingList"); + }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => { b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel") @@ -185,6 +271,11 @@ namespace FictionArchive.Service.UserNovelDataService.Migrations b.Navigation("Volumes"); }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b => + { + b.Navigation("Items"); + }); + modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b => { b.Navigation("Chapters"); diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/AddToReadingListInput.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/AddToReadingListInput.cs new file mode 100644 index 0000000..fb517f6 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/AddToReadingListInput.cs @@ -0,0 +1,3 @@ +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public record AddToReadingListInput(int ReadingListId, uint NovelId); diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/CreateReadingListInput.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/CreateReadingListInput.cs new file mode 100644 index 0000000..caac597 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/CreateReadingListInput.cs @@ -0,0 +1,3 @@ +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public record CreateReadingListInput(string Name, string? Description); diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/DeleteReadingListPayload.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/DeleteReadingListPayload.cs new file mode 100644 index 0000000..3413d2c --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/DeleteReadingListPayload.cs @@ -0,0 +1,6 @@ +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public class DeleteReadingListPayload +{ + public bool Success { get; init; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListDto.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListDto.cs new file mode 100644 index 0000000..875babe --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListDto.cs @@ -0,0 +1,13 @@ +using NodaTime; + +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public class ReadingListDto +{ + public int Id { get; init; } + public required string Name { get; init; } + public string? Description { get; init; } + public IEnumerable Items { get; init; } = []; + public int ItemCount { get; init; } + public Instant CreatedTime { get; init; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListItemDto.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListItemDto.cs new file mode 100644 index 0000000..0f0cfe8 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListItemDto.cs @@ -0,0 +1,10 @@ +using NodaTime; + +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public class ReadingListItemDto +{ + public uint NovelId { get; init; } + public int Order { get; init; } + public Instant AddedTime { get; init; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListPayload.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListPayload.cs new file mode 100644 index 0000000..78e05a3 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReadingListPayload.cs @@ -0,0 +1,7 @@ +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public class ReadingListPayload +{ + public ReadingListDto? ReadingList { get; init; } + public bool Success { get; init; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReorderReadingListItemInput.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReorderReadingListItemInput.cs new file mode 100644 index 0000000..9886ddc --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/ReorderReadingListItemInput.cs @@ -0,0 +1,3 @@ +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public record ReorderReadingListItemInput(int ReadingListId, uint NovelId, int NewOrder); diff --git a/FictionArchive.Service.UserNovelDataService/Models/DTOs/UpdateReadingListInput.cs b/FictionArchive.Service.UserNovelDataService/Models/DTOs/UpdateReadingListInput.cs new file mode 100644 index 0000000..324b73f --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/DTOs/UpdateReadingListInput.cs @@ -0,0 +1,3 @@ +namespace FictionArchive.Service.UserNovelDataService.Models.DTOs; + +public record UpdateReadingListInput(int Id, string Name, string? Description); diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/ReadingList.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/ReadingList.cs new file mode 100644 index 0000000..b2399a4 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/ReadingList.cs @@ -0,0 +1,14 @@ +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.UserNovelDataService.Models.Database; + +public class ReadingList : BaseEntity +{ + public Guid UserId { get; set; } + public virtual User User { get; set; } = null!; + + public required string Name { get; set; } + public string? Description { get; set; } + + public virtual ICollection Items { get; set; } = new List(); +} diff --git a/FictionArchive.Service.UserNovelDataService/Models/Database/ReadingListItem.cs b/FictionArchive.Service.UserNovelDataService/Models/Database/ReadingListItem.cs new file mode 100644 index 0000000..d77d150 --- /dev/null +++ b/FictionArchive.Service.UserNovelDataService/Models/Database/ReadingListItem.cs @@ -0,0 +1,12 @@ +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.UserNovelDataService.Models.Database; + +public class ReadingListItem : BaseEntity +{ + public int ReadingListId { get; set; } + public virtual ReadingList ReadingList { get; set; } = null!; + + public uint NovelId { get; set; } + public int Order { get; set; } +} diff --git a/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs b/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs index 289eb48..93abe19 100644 --- a/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs +++ b/FictionArchive.Service.UserNovelDataService/Services/UserNovelDataServiceDbContext.cs @@ -11,6 +11,8 @@ public class UserNovelDataServiceDbContext : FictionArchiveDbContext public DbSet Novels { get; set; } public DbSet Volumes { get; set; } public DbSet Chapters { get; set; } + public DbSet ReadingLists { get; set; } + public DbSet ReadingListItems { get; set; } public UserNovelDataServiceDbContext(DbContextOptions options, ILogger logger) : base(options, logger) { @@ -34,5 +36,32 @@ public class UserNovelDataServiceDbContext : FictionArchiveDbContext .HasForeignKey(b => b.UserId) .OnDelete(DeleteBehavior.Cascade); }); + + modelBuilder.Entity(entity => + { + // Index for fetching user's lists + entity.HasIndex(r => r.UserId); + + // User relationship with cascade delete + entity.HasOne(r => r.User) + .WithMany() + .HasForeignKey(r => r.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + // Unique constraint: one entry per novel per list + entity.HasIndex(i => new { i.ReadingListId, i.NovelId }).IsUnique(); + + // Index for efficient ordered retrieval + entity.HasIndex(i => new { i.ReadingListId, i.Order }); + + // ReadingList relationship with cascade delete + entity.HasOne(i => i.ReadingList) + .WithMany(r => r.Items) + .HasForeignKey(i => i.ReadingListId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/fictionarchive-web-astro/src/lib/components/AddToReadingListButton.svelte b/fictionarchive-web-astro/src/lib/components/AddToReadingListButton.svelte new file mode 100644 index 0000000..b65f6fb --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/AddToReadingListButton.svelte @@ -0,0 +1,368 @@ + + +{#if $isAuthenticated} + + +
+ + + {#snippet child({ props })} + + {/snippet} + + +
+
+

Add to Reading List

+

+ Select lists to add or remove this novel. +

+
+ + {#if fetching} +
+ +
+ {:else if error} +

{error}

+ {:else if readingLists.length === 0 && !showQuickCreate} +
+

No reading lists yet

+ +
+ {:else} + +
+ {#each readingLists as list (list.id)} + {@const isInList = novelInLists.has(list.id)} + {@const isLoading = loadingListIds.has(list.id)} + + {/each} +
+ + + {#if showQuickCreate} +
+
+ + +
+ {#if createError} +

{createError}

+ {/if} +
+ {:else} +
+ +
+ {/if} + {/if} +
+
+
+
+{/if} diff --git a/fictionarchive-web-astro/src/lib/components/HomePage.svelte b/fictionarchive-web-astro/src/lib/components/HomePage.svelte index 3ddc7b1..ca21812 100644 --- a/fictionarchive-web-astro/src/lib/components/HomePage.svelte +++ b/fictionarchive-web-astro/src/lib/components/HomePage.svelte @@ -19,11 +19,10 @@ description="Explore and read archived novels." /> Novels + {#if $isAuthenticated} + + Reading Lists + + {/if}
diff --git a/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte b/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte index ff2c07d..d539dbf 100644 --- a/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte @@ -55,6 +55,7 @@ import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time'; import { sanitizeHtml } from '$lib/utils/sanitize'; import ChapterBookmarkButton from './ChapterBookmarkButton.svelte'; + import AddToReadingListButton from './AddToReadingListButton.svelte'; // Direct imports for faster builds import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ExternalLink from '@lucide/svelte/icons/external-link'; @@ -491,6 +492,7 @@ Delete + {/if} {#if refreshSuccess} diff --git a/fictionarchive-web-astro/src/lib/components/ReadingListDetailPage.svelte b/fictionarchive-web-astro/src/lib/components/ReadingListDetailPage.svelte new file mode 100644 index 0000000..e14918c --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/ReadingListDetailPage.svelte @@ -0,0 +1,393 @@ + + +
+ + + + {#if !$isAuthenticated} + + + +
+ +
+

Sign in to view Reading Lists

+

+ Sign in to view and manage your reading lists. +

+
+ +
+
+
+ {:else if fetching} + + + +
+
+
+
+
+ {:else if error} + + + +
+

+ {error === 'Reading list not found' ? 'Reading List Not Found' : 'Error Loading Reading List'} +

+

{error}

+ +
+
+
+ {:else if readingList} + + + + {readingList.name} + {#if readingList.description} + {readingList.description} + {/if} +

+ {readingList.itemCount} {readingList.itemCount === 1 ? 'novel' : 'novels'} +

+
+
+ + + {#if operationError} + + +

{operationError}

+
+
+ {/if} + + + {#if sortedItems.length === 0} + + + +
+ +
+

No novels in this list

+

+ Add novels to this reading list from a novel's detail page. +

+
+ +
+
+
+ {:else} + + +
+ {#each sortedItems as item, index (item.novelId)} + {@const novel = novels.get(item.novelId)} +
+ + + {#if novel?.coverImage?.newPath} +
+ {novel?.name +
+ {:else} +
+ +
+ {/if} +
+ + +
+ + {novel?.name ?? `Novel #${item.novelId}`} + + {#if novel?.description} +

+ {novel.description} +

+ {/if} +
+ + +
+ + + + + + + + +
+
+ {/each} +
+
+
+ {/if} + {/if} +
diff --git a/fictionarchive-web-astro/src/lib/components/ReadingListsPage.svelte b/fictionarchive-web-astro/src/lib/components/ReadingListsPage.svelte new file mode 100644 index 0000000..05e0f0d --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/ReadingListsPage.svelte @@ -0,0 +1,432 @@ + + +
+
+
+

Reading Lists

+

Organize your novels into collections

+
+ {#if $isAuthenticated} + + {/if} +
+ + {#if !$isAuthenticated} + + + +
+ +
+

Sign in to use Reading Lists

+

+ Create and manage your personal reading lists to organize novels. +

+
+ +
+
+
+ {:else if fetching} + +
+
Loading your reading lists...
+
+ {:else if error} + + + +

{error}

+ +
+
+ {:else if readingLists.length === 0} + + + +
+ +
+

No reading lists yet

+

+ Create your first reading list to start organizing your novels. +

+
+ +
+
+
+ {:else} + + + {/if} +
+ + + + + + +
+ + {dialogMode === 'create' ? 'Create Reading List' : 'Edit Reading List'} + + + {dialogMode === 'create' ? 'Create a new reading list to organize your novels.' : 'Update your reading list details.'} + +
+
+
+ + +
+
+ +