From 81e4e88ad4a23154182d0dfe0fcc056eba993966 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 8 Dec 2025 18:30:00 -0500 Subject: [PATCH 1/2] [FA-misc] Switches to using DTOs, updates frontend with details and reader page, updates novel import to be an upsert --- .../GraphQL/Query.cs | 187 +++++- ...230154_FA-misc_NovelConstraint.Designer.cs | 547 ++++++++++++++++++ .../20251208230154_FA-misc_NovelConstraint.cs | 69 +++ .../NovelServiceDbContextModelSnapshot.cs | 13 +- .../Models/DTOs/BaseDto.cs | 10 + .../Models/DTOs/ChapterDto.cs | 11 + .../Models/DTOs/ChapterReaderDto.cs | 18 + .../Models/DTOs/ImageDto.cs | 6 + .../Models/DTOs/NovelDto.cs | 20 + .../Models/DTOs/NovelTagDto.cs | 11 + .../Models/DTOs/PersonDto.cs | 7 + .../Models/DTOs/SourceDto.cs | 8 + .../Models/Novels/Chapter.cs | 8 + .../Program.cs | 1 + .../Services/NovelServiceDbContext.cs | 10 + .../Services/NovelUpdateService.cs | 354 ++++++++++-- .../GraphQL/Query.cs | 22 +- .../Models/DTOs/TranslationRequestDto.cs | 19 + .../GraphQL/Mutation.cs | 30 +- .../GraphQL/Query.cs | 28 +- .../Models/DTOs/UserDto.cs | 15 + fictionarchive-web-astro/package-lock.json | 530 ++++++++++++++++- fictionarchive-web-astro/package.json | 1 + .../src/layouts/GatedLayout.astro | 38 ++ .../src/lib/auth/authStore.ts | 4 + .../components/AuthenticationDisplay.svelte | 8 +- .../lib/components/ChapterNavigation.svelte | 54 ++ .../lib/components/ChapterProgressBar.svelte | 21 + .../lib/components/ChapterReaderPage.svelte | 183 ++++++ .../lib/components/GatedAuthDisplay.svelte | 12 + .../src/lib/components/NovelCard.svelte | 10 +- .../src/lib/components/NovelDetailPage.svelte | 382 +++++++++++- .../src/lib/components/NovelFilters.svelte | 222 +++++++ .../src/lib/components/NovelsPage.svelte | 82 ++- .../src/lib/components/ui/tabs/index.ts | 18 + .../src/lib/graphql/__generated__/graphql.ts | 317 +++++----- .../src/lib/graphql/queries/chapter.graphql | 23 + .../src/lib/graphql/queries/novel.graphql | 47 ++ .../src/lib/graphql/queries/novels.graphql | 30 +- .../src/lib/utils/filterParams.ts | 115 ++++ .../src/lib/utils/sanitize.ts | 2 +- .../src/lib/utils/sanitizeChapter.ts | 74 +++ fictionarchive-web-astro/src/middleware.ts | 22 + fictionarchive-web-astro/src/pages/404.astro | 2 - .../src/pages/gated-404.astro | 22 + .../src/pages/index.astro | 2 - .../[id]/chapters/[chapterNumber].astro | 10 + .../src/pages/novels/index.astro | 2 - 48 files changed, 3298 insertions(+), 329 deletions(-) create mode 100644 FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.Designer.cs create mode 100644 FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.cs create mode 100644 FictionArchive.Service.NovelService/Models/DTOs/BaseDto.cs create mode 100644 FictionArchive.Service.NovelService/Models/DTOs/ChapterDto.cs create mode 100644 FictionArchive.Service.NovelService/Models/DTOs/ChapterReaderDto.cs create mode 100644 FictionArchive.Service.NovelService/Models/DTOs/ImageDto.cs create mode 100644 FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs create mode 100644 FictionArchive.Service.NovelService/Models/DTOs/NovelTagDto.cs create mode 100644 FictionArchive.Service.NovelService/Models/DTOs/PersonDto.cs create mode 100644 FictionArchive.Service.NovelService/Models/DTOs/SourceDto.cs create mode 100644 FictionArchive.Service.TranslationService/Models/DTOs/TranslationRequestDto.cs create mode 100644 FictionArchive.Service.UserService/Models/DTOs/UserDto.cs create mode 100644 fictionarchive-web-astro/src/layouts/GatedLayout.astro create mode 100644 fictionarchive-web-astro/src/lib/components/ChapterNavigation.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/ChapterProgressBar.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/GatedAuthDisplay.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/NovelFilters.svelte create mode 100644 fictionarchive-web-astro/src/lib/components/ui/tabs/index.ts create mode 100644 fictionarchive-web-astro/src/lib/graphql/queries/chapter.graphql create mode 100644 fictionarchive-web-astro/src/lib/graphql/queries/novel.graphql create mode 100644 fictionarchive-web-astro/src/lib/utils/filterParams.ts create mode 100644 fictionarchive-web-astro/src/lib/utils/sanitizeChapter.ts create mode 100644 fictionarchive-web-astro/src/middleware.ts create mode 100644 fictionarchive-web-astro/src/pages/gated-404.astro create mode 100644 fictionarchive-web-astro/src/pages/novels/[id]/chapters/[chapterNumber].astro diff --git a/FictionArchive.Service.NovelService/GraphQL/Query.cs b/FictionArchive.Service.NovelService/GraphQL/Query.cs index c594e2f..2f0211b 100644 --- a/FictionArchive.Service.NovelService/GraphQL/Query.cs +++ b/FictionArchive.Service.NovelService/GraphQL/Query.cs @@ -1,4 +1,5 @@ -using FictionArchive.Service.NovelService.Models.Novels; +using FictionArchive.Common.Enums; +using FictionArchive.Service.NovelService.Models.DTOs; using FictionArchive.Service.NovelService.Services; using HotChocolate.Authorization; using HotChocolate.Data; @@ -13,8 +14,186 @@ public class Query [UseProjection] [UseFiltering] [UseSorting] - public IQueryable GetNovels(NovelServiceDbContext dbContext) + public IQueryable GetNovels( + NovelServiceDbContext dbContext, + Language preferredLanguage = Language.En) { - return dbContext.Novels.AsQueryable(); + return dbContext.Novels.Select(novel => new NovelDto + { + Id = novel.Id, + CreatedTime = novel.CreatedTime, + LastUpdatedTime = novel.LastUpdatedTime, + Url = novel.Url, + RawLanguage = novel.RawLanguage, + RawStatus = novel.RawStatus, + StatusOverride = novel.StatusOverride, + ExternalId = novel.ExternalId, + + Name = novel.Name.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? novel.Name.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + + Description = novel.Description.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? novel.Description.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + + Author = new PersonDto + { + Id = novel.Author.Id, + CreatedTime = novel.Author.CreatedTime, + LastUpdatedTime = novel.Author.LastUpdatedTime, + Name = novel.Author.Name.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? novel.Author.Name.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + ExternalUrl = novel.Author.ExternalUrl + }, + + Source = new SourceDto + { + Id = novel.Source.Id, + CreatedTime = novel.Source.CreatedTime, + LastUpdatedTime = novel.Source.LastUpdatedTime, + Name = novel.Source.Name, + Key = novel.Source.Key, + Url = novel.Source.Url + }, + + CoverImage = novel.CoverImage != null + ? new ImageDto + { + Id = novel.CoverImage.Id, + CreatedTime = novel.CoverImage.CreatedTime, + LastUpdatedTime = novel.CoverImage.LastUpdatedTime, + NewPath = novel.CoverImage.NewPath + } + : null, + + Chapters = novel.Chapters.Select(chapter => new ChapterDto + { + Id = chapter.Id, + CreatedTime = chapter.CreatedTime, + LastUpdatedTime = chapter.LastUpdatedTime, + Revision = chapter.Revision, + Order = chapter.Order, + Url = chapter.Url, + Name = chapter.Name.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + Body = chapter.Body.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + Images = chapter.Images.Select(image => new ImageDto + { + Id = image.Id, + CreatedTime = image.CreatedTime, + LastUpdatedTime = image.LastUpdatedTime, + NewPath = image.NewPath + }).ToList() + }).ToList(), + + Tags = novel.Tags.Select(tag => new NovelTagDto + { + Id = tag.Id, + CreatedTime = tag.CreatedTime, + LastUpdatedTime = tag.LastUpdatedTime, + Key = tag.Key, + TagType = tag.TagType, + DisplayName = tag.DisplayName.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? tag.DisplayName.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + Source = tag.Source != null + ? new SourceDto + { + Id = tag.Source.Id, + CreatedTime = tag.Source.CreatedTime, + LastUpdatedTime = tag.Source.LastUpdatedTime, + Name = tag.Source.Name, + Key = tag.Source.Key, + Url = tag.Source.Url + } + : null + }).ToList() + }); } -} \ No newline at end of file + + [Authorize] + [UseFirstOrDefault] + [UseProjection] + public IQueryable GetChapter( + NovelServiceDbContext dbContext, + uint novelId, + uint chapterOrder, + Language preferredLanguage = Language.En) + { + return dbContext.Chapters + .Where(c => c.Novel.Id == novelId && c.Order == chapterOrder) + .Select(chapter => new ChapterReaderDto + { + Id = chapter.Id, + CreatedTime = chapter.CreatedTime, + LastUpdatedTime = chapter.LastUpdatedTime, + Revision = chapter.Revision, + Order = chapter.Order, + Url = chapter.Url, + + Name = chapter.Name.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + + Body = chapter.Body.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + + Images = chapter.Images.Select(image => new ImageDto + { + Id = image.Id, + CreatedTime = image.CreatedTime, + LastUpdatedTime = image.LastUpdatedTime, + NewPath = image.NewPath + }).ToList(), + + NovelId = chapter.Novel.Id, + NovelName = chapter.Novel.Name.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? chapter.Novel.Name.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + TotalChapters = chapter.Novel.Chapters.Count, + PrevChapterOrder = chapter.Novel.Chapters + .Where(c => c.Order < chapterOrder) + .OrderByDescending(c => c.Order) + .Select(c => (uint?)c.Order) + .FirstOrDefault(), + NextChapterOrder = chapter.Novel.Chapters + .Where(c => c.Order > chapterOrder) + .OrderBy(c => c.Order) + .Select(c => (uint?)c.Order) + .FirstOrDefault() + }); + } +} diff --git a/FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.Designer.cs b/FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.Designer.cs new file mode 100644 index 0000000..fc33404 --- /dev/null +++ b/FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.Designer.cs @@ -0,0 +1,547 @@ +// +using System; +using FictionArchive.Service.NovelService.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.NovelService.Migrations +{ + [DbContext(typeof(NovelServiceDbContext))] + [Migration("20251208230154_FA-misc_NovelConstraint")] + partial class FAmisc_NovelConstraint + { + /// + 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.NovelService.Models.Images.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChapterId") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NewPath") + .HasColumnType("text"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("LocalizationKeys"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EngineId") + .HasColumnType("bigint"); + + b.Property("KeyRequestedForTranslationId") + .HasColumnType("uuid"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TranslateTo") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EngineId"); + + b.HasIndex("KeyRequestedForTranslationId"); + + b.ToTable("LocalizationRequests"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Language") + .HasColumnType("integer"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LocalizationKeyId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.Property("TranslationEngineId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LocalizationKeyId"); + + b.HasIndex("TranslationEngineId"); + + b.ToTable("LocalizationText"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BodyId") + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BodyId"); + + b.HasIndex("NameId"); + + b.HasIndex("NovelId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CoverImageId") + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DescriptionId") + .HasColumnType("uuid"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("RawLanguage") + .HasColumnType("integer"); + + b.Property("RawStatus") + .HasColumnType("integer"); + + b.Property("SourceId") + .HasColumnType("bigint"); + + b.Property("StatusOverride") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("CoverImageId"); + + b.HasIndex("DescriptionId"); + + b.HasIndex("NameId"); + + b.HasIndex("SourceId"); + + b.HasIndex("ExternalId", "SourceId") + .IsUnique(); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayNameId") + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .HasColumnType("bigint"); + + b.Property("TagType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DisplayNameId"); + + b.HasIndex("SourceId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalUrl") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NameId"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Source", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sources"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("TranslationEngines"); + }); + + modelBuilder.Entity("NovelNovelTag", b => + { + b.Property("NovelsId") + .HasColumnType("bigint"); + + b.Property("TagsId") + .HasColumnType("bigint"); + + b.HasKey("NovelsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("NovelNovelTag"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Chapter", "Chapter") + .WithMany("Images") + .HasForeignKey("ChapterId"); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine") + .WithMany() + .HasForeignKey("EngineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation") + .WithMany() + .HasForeignKey("KeyRequestedForTranslationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Engine"); + + b.Navigation("KeyRequestedForTranslation"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null) + .WithMany("Texts") + .HasForeignKey("LocalizationKeyId"); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "TranslationEngine") + .WithMany() + .HasForeignKey("TranslationEngineId"); + + b.Navigation("TranslationEngine"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Body") + .WithMany() + .HasForeignKey("BodyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel") + .WithMany("Chapters") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Body"); + + b.Navigation("Name"); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Person", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Images.Image", "CoverImage") + .WithMany() + .HasForeignKey("CoverImageId"); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Description") + .WithMany() + .HasForeignKey("DescriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("CoverImage"); + + b.Navigation("Description"); + + b.Navigation("Name"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "DisplayName") + .WithMany() + .HasForeignKey("DisplayNameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source") + .WithMany() + .HasForeignKey("SourceId"); + + b.Navigation("DisplayName"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Name"); + }); + + modelBuilder.Entity("NovelNovelTag", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null) + .WithMany() + .HasForeignKey("NovelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.NovelTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b => + { + b.Navigation("Texts"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.cs b/FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.cs new file mode 100644 index 0000000..9cdd543 --- /dev/null +++ b/FictionArchive.Service.NovelService/Migrations/20251208230154_FA-misc_NovelConstraint.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FictionArchive.Service.NovelService.Migrations +{ + /// + public partial class FAmisc_NovelConstraint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Chapter_Novels_NovelId", + table: "Chapter"); + + migrationBuilder.AlterColumn( + name: "NovelId", + table: "Chapter", + type: "bigint", + nullable: false, + defaultValue: 0L, + oldClrType: typeof(long), + oldType: "bigint", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Novels_ExternalId_SourceId", + table: "Novels", + columns: new[] { "ExternalId", "SourceId" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Chapter_Novels_NovelId", + table: "Chapter", + column: "NovelId", + principalTable: "Novels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Chapter_Novels_NovelId", + table: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_Novels_ExternalId_SourceId", + table: "Novels"); + + migrationBuilder.AlterColumn( + name: "NovelId", + table: "Chapter", + type: "bigint", + nullable: true, + oldClrType: typeof(long), + oldType: "bigint"); + + migrationBuilder.AddForeignKey( + name: "FK_Chapter_Novels_NovelId", + table: "Chapter", + column: "NovelId", + principalTable: "Novels", + principalColumn: "Id"); + } + } +} diff --git a/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs b/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs index 470f4a9..5f7b749 100644 --- a/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs +++ b/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs @@ -153,7 +153,7 @@ namespace FictionArchive.Service.NovelService.Migrations b.Property("NameId") .HasColumnType("uuid"); - b.Property("NovelId") + b.Property("NovelId") .HasColumnType("bigint"); b.Property("Order") @@ -234,6 +234,9 @@ namespace FictionArchive.Service.NovelService.Migrations b.HasIndex("SourceId"); + b.HasIndex("ExternalId", "SourceId") + .IsUnique(); + b.ToTable("Novels"); }); @@ -424,13 +427,17 @@ namespace FictionArchive.Service.NovelService.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null) + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel") .WithMany("Chapters") - .HasForeignKey("NovelId"); + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); b.Navigation("Body"); b.Navigation("Name"); + + b.Navigation("Novel"); }); modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => diff --git a/FictionArchive.Service.NovelService/Models/DTOs/BaseDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/BaseDto.cs new file mode 100644 index 0000000..cac9d71 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/DTOs/BaseDto.cs @@ -0,0 +1,10 @@ +using NodaTime; + +namespace FictionArchive.Service.NovelService.Models.DTOs; + +public abstract class BaseDto +{ + public TKey Id { get; init; } + public Instant CreatedTime { get; init; } + public Instant LastUpdatedTime { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/DTOs/ChapterDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/ChapterDto.cs new file mode 100644 index 0000000..c09fea9 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/DTOs/ChapterDto.cs @@ -0,0 +1,11 @@ +namespace FictionArchive.Service.NovelService.Models.DTOs; + +public class ChapterDto : BaseDto +{ + public uint Revision { get; init; } + public uint Order { get; init; } + public string? Url { get; init; } + public required string Name { get; init; } + public required string Body { get; init; } + public required List Images { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/DTOs/ChapterReaderDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/ChapterReaderDto.cs new file mode 100644 index 0000000..ceb9b1b --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/DTOs/ChapterReaderDto.cs @@ -0,0 +1,18 @@ +namespace FictionArchive.Service.NovelService.Models.DTOs; + +public class ChapterReaderDto : BaseDto +{ + public uint Revision { get; init; } + public uint Order { get; init; } + public string? Url { get; init; } + public required string Name { get; init; } + public required string Body { get; init; } + public required List Images { get; init; } + + // Navigation context + public uint NovelId { get; init; } + public required string NovelName { get; init; } + public int TotalChapters { get; init; } + public uint? PrevChapterOrder { get; init; } + public uint? NextChapterOrder { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/DTOs/ImageDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/ImageDto.cs new file mode 100644 index 0000000..00f009a --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/DTOs/ImageDto.cs @@ -0,0 +1,6 @@ +namespace FictionArchive.Service.NovelService.Models.DTOs; + +public class ImageDto : BaseDto +{ + public string? NewPath { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs new file mode 100644 index 0000000..637148a --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs @@ -0,0 +1,20 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.NovelService.Models.Enums; + +namespace FictionArchive.Service.NovelService.Models.DTOs; + +public class NovelDto : BaseDto +{ + public required PersonDto Author { get; init; } + public required string Url { get; init; } + public Language RawLanguage { get; init; } + public NovelStatus RawStatus { get; init; } + public NovelStatus? StatusOverride { get; init; } + public required SourceDto Source { get; init; } + public required string ExternalId { get; init; } + public required string Name { get; init; } + public required string Description { get; init; } + public required List Chapters { get; init; } + public required List Tags { get; init; } + public ImageDto? CoverImage { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/DTOs/NovelTagDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/NovelTagDto.cs new file mode 100644 index 0000000..baf15d1 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/DTOs/NovelTagDto.cs @@ -0,0 +1,11 @@ +using FictionArchive.Service.NovelService.Models.Enums; + +namespace FictionArchive.Service.NovelService.Models.DTOs; + +public class NovelTagDto : BaseDto +{ + public required string Key { get; init; } + public required string DisplayName { get; init; } + public TagType TagType { get; init; } + public SourceDto? Source { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/DTOs/PersonDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/PersonDto.cs new file mode 100644 index 0000000..6cd7e0d --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/DTOs/PersonDto.cs @@ -0,0 +1,7 @@ +namespace FictionArchive.Service.NovelService.Models.DTOs; + +public class PersonDto : BaseDto +{ + public required string Name { get; init; } + public string? ExternalUrl { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/DTOs/SourceDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/SourceDto.cs new file mode 100644 index 0000000..c0a2078 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/DTOs/SourceDto.cs @@ -0,0 +1,8 @@ +namespace FictionArchive.Service.NovelService.Models.DTOs; + +public class SourceDto : BaseDto +{ + public required string Name { get; init; } + public required string Key { get; init; } + public required string Url { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs b/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs index 05c8011..0f443e5 100644 --- a/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs +++ b/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs @@ -1,9 +1,11 @@ +using System.ComponentModel.DataAnnotations.Schema; using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.Shared.Models; namespace FictionArchive.Service.NovelService.Models.Novels; +[Table("Chapter")] public class Chapter : BaseEntity { public uint Revision { get; set; } @@ -15,4 +17,10 @@ public class Chapter : BaseEntity // Images appearing in this chapter. public List Images { get; set; } + + #region Navigation Properties + + public Novel Novel { get; set; } + + #endregion } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Program.cs b/FictionArchive.Service.NovelService/Program.cs index 0b1f860..6a45dba 100644 --- a/FictionArchive.Service.NovelService/Program.cs +++ b/FictionArchive.Service.NovelService/Program.cs @@ -44,6 +44,7 @@ public class Program #region GraphQL builder.Services.AddDefaultGraphQl() + .ModifyCostOptions(opt => opt.MaxFieldCost = 5000) .AddAuthorization(); #endregion diff --git a/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs b/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs index 6bcb7c7..008b60a 100644 --- a/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs +++ b/FictionArchive.Service.NovelService/Services/NovelServiceDbContext.cs @@ -10,10 +10,20 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger Novels { get; set; } + public DbSet Chapters { get; set; } public DbSet Sources { get; set; } public DbSet TranslationEngines { get; set; } public DbSet Tags { get; set; } public DbSet LocalizationKeys { get; set; } public DbSet LocalizationRequests { get; set; } public DbSet Images { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasIndex("ExternalId", "SourceId") + .IsUnique(); + } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs index 862dcbe..54bead9 100644 --- a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs +++ b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs @@ -1,3 +1,4 @@ +using FictionArchive.Common.Enums; using FictionArchive.Service.FileService.IntegrationEvents; using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Enums; @@ -31,14 +32,241 @@ public class NovelUpdateService _novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value; } + #region Helper Methods + + private async Task GetOrCreateSource(SourceDescriptor descriptor) + { + var existingSource = await _dbContext.Sources + .FirstOrDefaultAsync(s => s.Key == descriptor.Key); + + if (existingSource != null) + { + return existingSource; + } + + return new Source + { + Name = descriptor.Name, + Url = descriptor.Url, + Key = descriptor.Key + }; + } + + private Person GetOrCreateAuthor( + string authorName, + string? authorUrl, + Language rawLanguage, + Person? existingAuthor) + { + // Case 1: No existing author - create new + if (existingAuthor == null) + { + return new Person + { + Name = LocalizationKey.CreateFromText(authorName, rawLanguage), + ExternalUrl = authorUrl + }; + } + + // Case 2: ExternalUrl differs - create new Person + if (existingAuthor.ExternalUrl != authorUrl) + { + return new Person + { + Name = LocalizationKey.CreateFromText(authorName, rawLanguage), + ExternalUrl = authorUrl + }; + } + + // Case 3: Same URL - update name if different + UpdateLocalizationKey(existingAuthor.Name, authorName, rawLanguage); + return existingAuthor; + } + + private static void UpdateLocalizationKey(LocalizationKey key, string newText, Language language) + { + var existingText = key.Texts.FirstOrDefault(t => t.Language == language); + if (existingText != null) + { + existingText.Text = newText; + } + else + { + key.Texts.Add(new LocalizationText + { + Language = language, + Text = newText + }); + } + } + + private void UpdateNovelMetadata(Novel novel, NovelMetadata metadata) + { + UpdateLocalizationKey(novel.Name, metadata.Name, metadata.RawLanguage); + UpdateLocalizationKey(novel.Description, metadata.Description, metadata.RawLanguage); + novel.RawStatus = metadata.RawStatus; + novel.Url = metadata.Url; + } + + private async Task> SynchronizeTags( + List sourceTags, + List systemTags, + Language rawLanguage) + { + var allTagKeys = sourceTags.Concat(systemTags).ToHashSet(); + + // Query existing tags from DB by Key + var existingTagsInDb = await _dbContext.Tags + .Where(t => allTagKeys.Contains(t.Key)) + .ToListAsync(); + + var existingTagKeyMap = existingTagsInDb.ToDictionary(t => t.Key); + var result = new List(); + + // Process source tags + foreach (var tagKey in sourceTags) + { + if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag)) + { + result.Add(existingTag); + } + else + { + result.Add(new NovelTag + { + Key = tagKey, + DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage), + TagType = TagType.External + }); + } + } + + // Process system tags + foreach (var tagKey in systemTags) + { + if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag)) + { + result.Add(existingTag); + } + else + { + result.Add(new NovelTag + { + Key = tagKey, + DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage), + TagType = TagType.System + }); + } + } + + return result; + } + + private static List SynchronizeChapters( + List metadataChapters, + Language rawLanguage, + List? existingChapters) + { + existingChapters ??= new List(); + var existingOrderSet = existingChapters.Select(c => c.Order).ToHashSet(); + + // Only add chapters that don't already exist (by Order) + var newChapters = metadataChapters + .Where(mc => !existingOrderSet.Contains(mc.Order)) + .Select(mc => new Chapter + { + Order = mc.Order, + Url = mc.Url, + Revision = mc.Revision, + Name = LocalizationKey.CreateFromText(mc.Name, rawLanguage), + Body = new LocalizationKey + { + Texts = new List() + } + }) + .ToList(); + + // Combine existing chapters with new ones + return existingChapters.Concat(newChapters).ToList(); + } + + private static (Image? image, bool shouldPublishEvent) HandleCoverImage( + ImageData? newCoverData, + Image? existingCoverImage) + { + // Case 1: No new cover image - keep existing or null + if (newCoverData == null) + { + return (existingCoverImage, false); + } + + // Case 2: New cover, no existing + if (existingCoverImage == null) + { + var newImage = new Image { OriginalPath = newCoverData.Url }; + return (newImage, true); + } + + // Case 3: Both exist - check if URL changed + if (existingCoverImage.OriginalPath != newCoverData.Url) + { + existingCoverImage.OriginalPath = newCoverData.Url; + existingCoverImage.NewPath = null; // Reset uploaded path + return (existingCoverImage, true); + } + + // Case 4: Same cover URL - no change needed + return (existingCoverImage, false); + } + + private async Task CreateNewNovel(NovelMetadata metadata, Source source) + { + var author = new Person + { + Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage), + ExternalUrl = metadata.AuthorUrl + }; + + var tags = await SynchronizeTags( + metadata.SourceTags, + metadata.SystemTags, + metadata.RawLanguage); + + var chapters = SynchronizeChapters(metadata.Chapters, metadata.RawLanguage, null); + + var novel = new Novel + { + Author = author, + RawLanguage = metadata.RawLanguage, + Url = metadata.Url, + ExternalId = metadata.ExternalId, + CoverImage = metadata.CoverImage != null + ? new Image { OriginalPath = metadata.CoverImage.Url } + : null, + Chapters = chapters, + Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage), + Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage), + RawStatus = metadata.RawStatus, + Tags = tags, + Source = source + }; + + _dbContext.Novels.Add(novel); + return novel; + } + + #endregion + public async Task ImportNovel(string novelUrl) { + // Step 1: Get metadata from source adapter NovelMetadata? metadata = null; foreach (ISourceAdapter sourceAdapter in _sourceAdapters) { if (await sourceAdapter.CanProcessNovel(novelUrl)) { metadata = await sourceAdapter.GetMetadata(novelUrl); + break; // Stop after finding adapter } } @@ -47,72 +275,82 @@ public class NovelUpdateService throw new NotSupportedException("The provided novel url is currently unsupported."); } - var systemTags = metadata.SystemTags.Select(tag => new NovelTag() - { - Key = tag, - DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage), - TagType = TagType.System - }); - var sourceTags = metadata.SourceTags.Select(tag => new NovelTag() - { - Key = tag, - DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage), - TagType = TagType.External - }); + // Step 2: Resolve or create Source + var source = await GetOrCreateSource(metadata.SourceDescriptor); - var addedNovel = _dbContext.Novels.Add(new Novel() + // Step 3: Check for existing novel by ExternalId + Source.Key + var existingNovel = await _dbContext.Novels + .Include(n => n.Author) + .ThenInclude(a => a.Name) + .ThenInclude(lk => lk.Texts) + .Include(n => n.Source) + .Include(n => n.Name) + .ThenInclude(lk => lk.Texts) + .Include(n => n.Description) + .ThenInclude(lk => lk.Texts) + .Include(n => n.Tags) + .Include(n => n.Chapters) + .Include(n => n.CoverImage) + .FirstOrDefaultAsync(n => + n.ExternalId == metadata.ExternalId && + n.Source.Key == metadata.SourceDescriptor.Key); + + Novel novel; + bool shouldPublishCoverEvent; + + if (existingNovel == null) { - Author = new Person() - { - Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage), - ExternalUrl = metadata.AuthorUrl, - }, - RawLanguage = metadata.RawLanguage, - Url = metadata.Url, - ExternalId = metadata.ExternalId, - CoverImage = metadata.CoverImage != null ? new Image() - { - OriginalPath = metadata.CoverImage.Url, - } : null, - Chapters = metadata.Chapters.Select(chapter => - { - return new Chapter() - { - Order = chapter.Order, - Url = chapter.Url, - Revision = chapter.Revision, - Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage), - Body = new LocalizationKey() - { - Texts = new List() - } - }; - }).ToList(), - Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage), - Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage), - RawStatus = metadata.RawStatus, - Tags = sourceTags.Concat(systemTags).ToList(), - Source = new Source() - { - Name = metadata.SourceDescriptor.Name, - Url = metadata.SourceDescriptor.Url, - Key = metadata.SourceDescriptor.Key, - } - }); + // CREATE PATH: New novel + novel = await CreateNewNovel(metadata, source); + shouldPublishCoverEvent = novel.CoverImage != null; + } + else + { + // UPDATE PATH: Existing novel + novel = existingNovel; + + // Update author + novel.Author = GetOrCreateAuthor( + metadata.AuthorName, + metadata.AuthorUrl, + metadata.RawLanguage, + existingNovel.Author); + + // Update metadata (Name, Description, RawStatus) + UpdateNovelMetadata(novel, metadata); + + // Synchronize tags (remove old, add new, reuse existing) + novel.Tags = await SynchronizeTags( + metadata.SourceTags, + metadata.SystemTags, + metadata.RawLanguage); + + // Synchronize chapters (add only) + novel.Chapters = SynchronizeChapters( + metadata.Chapters, + metadata.RawLanguage, + existingNovel.Chapters); + + // Handle cover image + (novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage( + metadata.CoverImage, + existingNovel.CoverImage); + } + await _dbContext.SaveChangesAsync(); - - // Signal request for cover image if present - if (addedNovel.Entity.CoverImage != null) + + // Publish cover image event if needed + if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null) { - await _eventBus.Publish(new FileUploadRequestCreatedEvent() + await _eventBus.Publish(new FileUploadRequestCreatedEvent { - RequestId = addedNovel.Entity.CoverImage.Id, + RequestId = novel.CoverImage.Id, FileData = metadata.CoverImage.Data, - FilePath = $"Novels/{addedNovel.Entity.Id}/Images/cover.jpg" + FilePath = $"Novels/{novel.Id}/Images/cover.jpg" }); } - - return addedNovel.Entity; + + return novel; } public async Task PullChapterContents(uint novelId, uint chapterNumber) diff --git a/FictionArchive.Service.TranslationService/GraphQL/Query.cs b/FictionArchive.Service.TranslationService/GraphQL/Query.cs index e753d54..3cf247a 100644 --- a/FictionArchive.Service.TranslationService/GraphQL/Query.cs +++ b/FictionArchive.Service.TranslationService/GraphQL/Query.cs @@ -1,9 +1,9 @@ using FictionArchive.Service.TranslationService.Models; -using FictionArchive.Service.TranslationService.Models.Database; +using FictionArchive.Service.TranslationService.Models.DTOs; using FictionArchive.Service.TranslationService.Services.Database; using FictionArchive.Service.TranslationService.Services.TranslationEngines; using HotChocolate.Authorization; -using Microsoft.EntityFrameworkCore; +using HotChocolate.Data; namespace FictionArchive.Service.TranslationService.GraphQL; @@ -22,8 +22,20 @@ public class Query [UseProjection] [UseFiltering] [UseSorting] - public IQueryable GetTranslationRequests(TranslationServiceDbContext dbContext) + public IQueryable GetTranslationRequests(TranslationServiceDbContext dbContext) { - return dbContext.TranslationRequests.AsQueryable(); + return dbContext.TranslationRequests.Select(request => new TranslationRequestDto + { + Id = request.Id, + CreatedTime = request.CreatedTime, + LastUpdatedTime = request.LastUpdatedTime, + OriginalText = request.OriginalText, + TranslatedText = request.TranslatedText, + From = request.From, + To = request.To, + TranslationEngineKey = request.TranslationEngineKey, + Status = request.Status, + BilledCharacterCount = request.BilledCharacterCount + }); } -} \ No newline at end of file +} diff --git a/FictionArchive.Service.TranslationService/Models/DTOs/TranslationRequestDto.cs b/FictionArchive.Service.TranslationService/Models/DTOs/TranslationRequestDto.cs new file mode 100644 index 0000000..16a72ae --- /dev/null +++ b/FictionArchive.Service.TranslationService/Models/DTOs/TranslationRequestDto.cs @@ -0,0 +1,19 @@ +using FictionArchive.Common.Enums; +using FictionArchive.Service.TranslationService.Models.Enums; +using NodaTime; + +namespace FictionArchive.Service.TranslationService.Models.DTOs; + +public class TranslationRequestDto +{ + public Guid Id { get; init; } + public Instant CreatedTime { get; init; } + public Instant LastUpdatedTime { get; init; } + public required string OriginalText { get; init; } + public string? TranslatedText { get; init; } + public Language From { get; init; } + public Language To { get; init; } + public required string TranslationEngineKey { get; init; } + public TranslationRequestStatus Status { get; init; } + public uint BilledCharacterCount { get; init; } +} diff --git a/FictionArchive.Service.UserService/GraphQL/Mutation.cs b/FictionArchive.Service.UserService/GraphQL/Mutation.cs index 4553b44..0d6528d 100644 --- a/FictionArchive.Service.UserService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.UserService/GraphQL/Mutation.cs @@ -1,5 +1,5 @@ using FictionArchive.Service.Shared.Constants; -using FictionArchive.Service.UserService.Models.Database; +using FictionArchive.Service.UserService.Models.DTOs; using FictionArchive.Service.UserService.Services; using HotChocolate.Authorization; @@ -8,9 +8,31 @@ namespace FictionArchive.Service.UserService.GraphQL; public class Mutation { [Authorize(Roles = [AuthorizationConstants.Roles.Admin])] - public async Task RegisterUser(string username, string email, string oAuthProviderId, + public async Task RegisterUser(string username, string email, string oAuthProviderId, string? inviterOAuthProviderId, UserManagementService userManagementService) { - return await userManagementService.RegisterUser(username, email, oAuthProviderId, inviterOAuthProviderId); + var user = await userManagementService.RegisterUser(username, email, oAuthProviderId, inviterOAuthProviderId); + + return new UserDto + { + Id = user.Id, + CreatedTime = user.CreatedTime, + LastUpdatedTime = user.LastUpdatedTime, + Username = user.Username, + Email = user.Email, + Disabled = user.Disabled, + Inviter = user.Inviter != null + ? new UserDto + { + Id = user.Inviter.Id, + CreatedTime = user.Inviter.CreatedTime, + LastUpdatedTime = user.Inviter.LastUpdatedTime, + Username = user.Inviter.Username, + Email = user.Inviter.Email, + Disabled = user.Inviter.Disabled, + Inviter = null // Limit recursion to one level + } + : null + }; } -} \ No newline at end of file +} diff --git a/FictionArchive.Service.UserService/GraphQL/Query.cs b/FictionArchive.Service.UserService/GraphQL/Query.cs index 9049fa8..eac54b3 100644 --- a/FictionArchive.Service.UserService/GraphQL/Query.cs +++ b/FictionArchive.Service.UserService/GraphQL/Query.cs @@ -1,4 +1,4 @@ -using FictionArchive.Service.UserService.Models.Database; +using FictionArchive.Service.UserService.Models.DTOs; using FictionArchive.Service.UserService.Services; using HotChocolate.Authorization; @@ -7,8 +7,28 @@ namespace FictionArchive.Service.UserService.GraphQL; public class Query { [Authorize] - public async Task> GetUsers(UserManagementService userManagementService) + public IQueryable GetUsers(UserManagementService userManagementService) { - return userManagementService.GetUsers(); + return userManagementService.GetUsers().Select(user => new UserDto + { + Id = user.Id, + CreatedTime = user.CreatedTime, + LastUpdatedTime = user.LastUpdatedTime, + Username = user.Username, + Email = user.Email, + Disabled = user.Disabled, + Inviter = user.Inviter != null + ? new UserDto + { + Id = user.Inviter.Id, + CreatedTime = user.Inviter.CreatedTime, + LastUpdatedTime = user.Inviter.LastUpdatedTime, + Username = user.Inviter.Username, + Email = user.Inviter.Email, + Disabled = user.Inviter.Disabled, + Inviter = null // Limit recursion to one level + } + : null + }); } -} \ No newline at end of file +} diff --git a/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs b/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs new file mode 100644 index 0000000..787b27e --- /dev/null +++ b/FictionArchive.Service.UserService/Models/DTOs/UserDto.cs @@ -0,0 +1,15 @@ +using NodaTime; + +namespace FictionArchive.Service.UserService.Models.DTOs; + +public class UserDto +{ + public Guid Id { get; init; } + public Instant CreatedTime { get; init; } + public Instant LastUpdatedTime { get; init; } + public required string Username { get; init; } + public required string Email { get; init; } + // OAuthProviderId intentionally omitted for security + public bool Disabled { get; init; } + public UserDto? Inviter { get; init; } +} diff --git a/fictionarchive-web-astro/package-lock.json b/fictionarchive-web-astro/package-lock.json index ba4484a..a103b37 100644 --- a/fictionarchive-web-astro/package-lock.json +++ b/fictionarchive-web-astro/package-lock.json @@ -19,6 +19,7 @@ "date-fns": "^4.1.0", "dompurify": "^3.3.0", "graphql": "^16.12.0", + "isomorphic-dompurify": "^2.33.0", "oidc-client-ts": "^3.4.1", "svelte": "^5.45.2", "tailwind-merge": "^3.4.0", @@ -59,6 +60,12 @@ } } }, + "node_modules/@acemir/cssom": { + "version": "0.9.28", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.28.tgz", + "integrity": "sha512-LuS6IVEivI75vKN8S04qRD+YySP0RmU/cV8UNukhQZvprxF+76Z43TNo/a08eCodaGhT1Us8etqS1ZRY9/Or0A==", + "license": "MIT" + }, "node_modules/@ardatan/relay-compiler": { "version": "12.0.3", "resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-12.0.3.tgz", @@ -84,6 +91,56 @@ "graphql": "*" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", + "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.2" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@astrojs/compiler": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", @@ -474,6 +531,140 @@ "node": ">=18" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", + "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -4207,6 +4398,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4663,6 +4863,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bits-ui": { "version": "2.14.4", "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.14.4.tgz", @@ -5491,6 +5700,20 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/cssstyle": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.4.tgz", + "integrity": "sha512-KyOS/kJMEq5O9GdPnaf82noigg5X5DYn0kZPJTaAsCUaBizp6Xa1y9D4Qoqf/JazEXWuruErHgVXwjN5391ZJw==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.0", + "@csstools/css-syntax-patches-for-csstree": "1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -5501,6 +5724,53 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/dataloader": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", @@ -5548,6 +5818,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -7205,6 +7481,18 @@ "tslib": "^2.0.3" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -7247,6 +7535,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -7501,6 +7815,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -7591,6 +7911,19 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-dompurify": { + "version": "2.33.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.33.0.tgz", + "integrity": "sha512-pXGR3rAHAXb5Bvr56pc5aV0Lh8laubLo7/60F+soOzDFmwks6MtgDhL7p46VoxLnwgIsjgmVhQpUt4mUlL/XEw==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.3.0", + "jsdom": "^27.2.0" + }, + "engines": { + "node": ">=20.19.5" + } + }, "node_modules/isomorphic-ws": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", @@ -7645,6 +7978,91 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9940,7 +10358,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10207,6 +10624,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -10414,7 +10840,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -10423,6 +10848,18 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "license": "BlueOak-1.0.0" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scule": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", @@ -10945,6 +11382,12 @@ "tslib": "^2.0.3" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/sync-fetch": { "version": "0.6.0-2", "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.6.0-2.tgz", @@ -11104,6 +11547,24 @@ "tslib": "^2.0.3" } }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11126,6 +11587,18 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -11832,6 +12305,18 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -11859,11 +12344,34 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -12010,7 +12518,6 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "license": "MIT", "peer": true, "engines": { @@ -12029,6 +12536,21 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", diff --git a/fictionarchive-web-astro/package.json b/fictionarchive-web-astro/package.json index 7b6f097..0a35c55 100644 --- a/fictionarchive-web-astro/package.json +++ b/fictionarchive-web-astro/package.json @@ -23,6 +23,7 @@ "date-fns": "^4.1.0", "dompurify": "^3.3.0", "graphql": "^16.12.0", + "isomorphic-dompurify": "^2.33.0", "oidc-client-ts": "^3.4.1", "svelte": "^5.45.2", "tailwind-merge": "^3.4.0", diff --git a/fictionarchive-web-astro/src/layouts/GatedLayout.astro b/fictionarchive-web-astro/src/layouts/GatedLayout.astro new file mode 100644 index 0000000..be09193 --- /dev/null +++ b/fictionarchive-web-astro/src/layouts/GatedLayout.astro @@ -0,0 +1,38 @@ +--- +import AuthInit from '../lib/components/AuthInit.svelte'; +import GatedAuthDisplay from '../lib/components/GatedAuthDisplay.svelte'; +import '../styles/global.css'; + +interface Props { + title?: string; +} + +const { title = 'FictionArchive' } = Astro.props; +--- + + + + + + + + + {title} + + + +
+ +
+
+ +
+ + diff --git a/fictionarchive-web-astro/src/lib/auth/authStore.ts b/fictionarchive-web-astro/src/lib/auth/authStore.ts index 1d40d2e..238b0b5 100644 --- a/fictionarchive-web-astro/src/lib/auth/authStore.ts +++ b/fictionarchive-web-astro/src/lib/auth/authStore.ts @@ -52,6 +52,10 @@ export async function initAuth() { user.set(result ?? null); if (result) { setCookieFromUser(result); + // Reload to let server see the new cookie + const cleanUrl = `${url.origin}${url.pathname}`; + window.location.href = cleanUrl; + return; } } catch (e) { console.error('Failed to complete sign-in redirect', e); diff --git a/fictionarchive-web-astro/src/lib/components/AuthenticationDisplay.svelte b/fictionarchive-web-astro/src/lib/components/AuthenticationDisplay.svelte index 3c9d578..c65d882 100644 --- a/fictionarchive-web-astro/src/lib/components/AuthenticationDisplay.svelte +++ b/fictionarchive-web-astro/src/lib/components/AuthenticationDisplay.svelte @@ -4,10 +4,10 @@ let isOpen = $state(false); - const email = $derived( - $user?.profile?.email ?? + const name = $derived( + $user?.profile?.name ?? $user?.profile?.preferred_username ?? - $user?.profile?.name ?? + $user?.profile?.email ?? $user?.profile?.sub ?? 'User' ); @@ -38,7 +38,7 @@ {:else if $user}
{#if isOpen}
+ import { Button } from '$lib/components/ui/button'; + import ChevronLeft from '@lucide/svelte/icons/chevron-left'; + import ChevronRight from '@lucide/svelte/icons/chevron-right'; + import List from '@lucide/svelte/icons/list'; + + interface Props { + novelId: string; + prevChapterOrder: number | null | undefined; + nextChapterOrder: number | null | undefined; + showKeyboardHints?: boolean; + } + + let { novelId, prevChapterOrder, nextChapterOrder, showKeyboardHints = true }: Props = $props(); + + const hasPrev = $derived(prevChapterOrder != null); + const hasNext = $derived(nextChapterOrder != null); + + +
+
+ + + + + +
+ + {#if showKeyboardHints} + + {/if} +
diff --git a/fictionarchive-web-astro/src/lib/components/ChapterProgressBar.svelte b/fictionarchive-web-astro/src/lib/components/ChapterProgressBar.svelte new file mode 100644 index 0000000..d0a0b96 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/ChapterProgressBar.svelte @@ -0,0 +1,21 @@ + + +
+
+
diff --git a/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte b/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte new file mode 100644 index 0000000..bd22f39 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte @@ -0,0 +1,183 @@ + + + + + + +
+ + + + + {#if fetching} + + +
+
+
+
+
+ {/if} + + + {#if error && !fetching} + + +
+

+ {error === 'Chapter not found' ? 'Chapter Not Found' : 'Error Loading Chapter'} +

+

{error}

+ +
+
+
+ {/if} + + + {#if chapter && !fetching} + + + + + + +
+

+ {chapter.novelName} +

+

Chapter {chapter.order}: {chapter.name}

+
+
+
+ + + + +
+ {@html sanitizedBody} +
+
+
+ + + + {/if} +
diff --git a/fictionarchive-web-astro/src/lib/components/GatedAuthDisplay.svelte b/fictionarchive-web-astro/src/lib/components/GatedAuthDisplay.svelte new file mode 100644 index 0000000..124d094 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/GatedAuthDisplay.svelte @@ -0,0 +1,12 @@ + + +{#if $isLoading} + +{:else if !isConfigured} + Auth not configured +{:else} + +{/if} diff --git a/fictionarchive-web-astro/src/lib/components/NovelCard.svelte b/fictionarchive-web-astro/src/lib/components/NovelCard.svelte index 9fd8263..5050b8e 100644 --- a/fictionarchive-web-astro/src/lib/components/NovelCard.svelte +++ b/fictionarchive-web-astro/src/lib/components/NovelCard.svelte @@ -38,14 +38,8 @@ let { novel }: NovelCardProps = $props(); - function pickText(novelText?: NovelNode['name'] | NovelNode['description']) { - const texts = novelText?.texts ?? []; - const english = texts.find((t) => t.language === 'EN'); - return (english ?? texts[0])?.text ?? 'No description available.'; - } - - const title = $derived(pickText(novel.name)); - const descriptionRaw = $derived(pickText(novel.description)); + const title = $derived(novel.name || 'Untitled'); + const descriptionRaw = $derived(novel.description || 'No description available.'); const descriptionHtml = $derived(sanitizeHtml(descriptionRaw)); const coverSrc = $derived(novel.coverImage?.newPath ?? novel.coverImage?.originalPath); diff --git a/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte b/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte index 4e35272..6430b30 100644 --- a/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte @@ -1,25 +1,375 @@ + + - - - Novel Details - - -

- {#if novelId} - Viewing novel ID: {novelId} - {/if} -

-

- Detail view coming soon. Select a novel to explore chapters and metadata once implemented. -

-
-
+
+ + + + + {#if fetching} + + +
+
+
+
+
+ {/if} + + + {#if error && !fetching} + + +
+

+ {error === 'Novel not found' ? 'Novel Not Found' : 'Error Loading Novel'} +

+

{error}

+ +
+
+
+ {/if} + + + {#if novel && !fetching} + + + + +
+ +
+ {#if coverSrc} +
+ {novel.name} +
+ {:else} +
+ +
+ {/if} +
+ + +
+
+

{novel.name}

+ {#if novel.author} +

+ by + {#if novel.author.externalUrl} + + {novel.author.name} + + + {:else} + {novel.author.name} + {/if} +

+ {/if} +
+ + +
+ {statusLabel} + {languageLabel} +
+ + +
+ {#if novel.source} + + Source: + + {novel.source.name} + + + + {/if} + {#if relativeTime && absoluteTime} + + Updated: + + + + + + {absoluteTime} + + + + {/if} + {chapterCount} chapters +
+ + + {#if novel.tags && novel.tags.length > 0} +
+ {#each novel.tags as tag (tag.key)} + + {tag.displayName} + + {/each} +
+ {/if} +
+
+ + + {#if description} +
+
+ {@html truncatedDescriptionHtml} +
+ {#if isDescriptionLong} + + {/if} +
+ {/if} +
+
+ + + + + + + + Chapters + + + Comments + + + Recommendations + + + + + + + {#if sortedChapters.length === 0} +

+ No chapters available yet. +

+ {:else} +
+ {#each sortedChapters as chapter (chapter.id)} + {@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null} + +
+ + Ch. {chapter.order} + + + {chapter.name} + +
+ {#if chapterDate} + + {formatRelativeTime(chapterDate)} + + {/if} +
+ {/each} +
+ {/if} +
+ + +

+ Comments coming soon. +

+
+ + +

+ Recommendations coming soon. +

+
+
+
+
+ {/if} +
diff --git a/fictionarchive-web-astro/src/lib/components/NovelFilters.svelte b/fictionarchive-web-astro/src/lib/components/NovelFilters.svelte new file mode 100644 index 0000000..c6c4748 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/NovelFilters.svelte @@ -0,0 +1,222 @@ + + +
+ +
+ + handleSearchInput(e.currentTarget.value)} + class="pl-9" + /> +
+ + + handleStatusChange(v as NovelStatus[])} + > + + + {filters.statuses.length > 0 ? selectedStatusLabels : 'Status'} + + + + + {#each statusOptions as option (option.value)} + + {#snippet children({ selected })} +
+ {#if selected} + + {/if} +
+ {option.label} + {/snippet} +
+ {/each} +
+
+ + + {#if availableTags.length > 0} + handleTagChange(v as string[])} + > + + + {filters.tags.length > 0 ? selectedTagLabels : 'Tags'} + + + + + {#each availableTags as tag (tag.key)} + + {#snippet children({ selected })} +
+ {#if selected} + + {/if} +
+ {tag.displayName} + {/snippet} +
+ {/each} +
+
+ {/if} + + + {#if hasActiveFilters(filters)} + + {/if} +
+ + +{#if hasActiveFilters(filters)} +
+ {#if filters.search} + + Search: {filters.search} + + + {/if} + + {#each filters.statuses as status (status)} + + {statusOptions.find((o) => o.value === status)?.label ?? status} + + + {/each} + + {#each filters.tags as tag (tag)} + + {availableTags.find((t) => t.key === tag)?.displayName ?? tag} + + + {/each} +
+{/if} diff --git a/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte b/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte index a9e8cc7..c04ba87 100644 --- a/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte @@ -1,10 +1,20 @@
- Latest Novels -

Novels that have recently been updated.

+ Novels +

+ {#if hasActiveFilters(filters)} + Showing filtered results + {:else} + Browse all novels + {/if} +

+ + +
{#if fetching && initialLoad} @@ -73,7 +131,7 @@
@@ -84,7 +142,7 @@ {#if error} -

Could not load novels: {error}

+

Could not load novels: {error}

{/if} @@ -92,8 +150,12 @@ {#if !fetching && novels.length === 0 && !error && !initialLoad} -

- No novels found yet. Try adding content to the gateway. +

+ {#if hasActiveFilters(filters)} + No novels match your filters. Try adjusting your search criteria. + {:else} + No novels found yet. Try adding content to the gateway. + {/if}

diff --git a/fictionarchive-web-astro/src/lib/components/ui/tabs/index.ts b/fictionarchive-web-astro/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..86e7606 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,18 @@ +import { Tabs as TabsPrimitive } from 'bits-ui'; + +const Root = TabsPrimitive.Root; +const List = TabsPrimitive.List; +const Trigger = TabsPrimitive.Trigger; +const Content = TabsPrimitive.Content; + +export { + Root, + List, + Trigger, + Content, + // + Root as Tabs, + List as TabsList, + Trigger as TabsTrigger, + Content as TabsContent +}; diff --git a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts index 4eb8a76..8eba6de 100644 --- a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts +++ b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts @@ -29,27 +29,27 @@ export const ApplyPolicy = { } as const; export type ApplyPolicy = typeof ApplyPolicy[keyof typeof ApplyPolicy]; -export type Chapter = { - body: LocalizationKey; +export type ChapterDto = { + body: Scalars['String']['output']; createdTime: Scalars['Instant']['output']; id: Scalars['UnsignedInt']['output']; - images: Array; + images: Array; lastUpdatedTime: Scalars['Instant']['output']; - name: LocalizationKey; + name: Scalars['String']['output']; order: Scalars['UnsignedInt']['output']; revision: Scalars['UnsignedInt']['output']; url: Maybe; }; -export type ChapterFilterInput = { - and?: InputMaybe>; - body?: InputMaybe; +export type ChapterDtoFilterInput = { + and?: InputMaybe>; + body?: InputMaybe; createdTime?: InputMaybe; id?: InputMaybe; - images?: InputMaybe; + images?: InputMaybe; lastUpdatedTime?: InputMaybe; - name?: InputMaybe; - or?: InputMaybe>; + name?: InputMaybe; + or?: InputMaybe>; order?: InputMaybe; revision?: InputMaybe; url?: InputMaybe; @@ -60,15 +60,21 @@ export type ChapterPullRequestedEvent = { novelId: Scalars['UnsignedInt']['output']; }; -export type ChapterSortInput = { - body?: InputMaybe; - createdTime?: InputMaybe; - id?: InputMaybe; - lastUpdatedTime?: InputMaybe; - name?: InputMaybe; - order?: InputMaybe; - revision?: InputMaybe; - url?: InputMaybe; +export type ChapterReaderDto = { + body: Scalars['String']['output']; + createdTime: Scalars['Instant']['output']; + id: Scalars['UnsignedInt']['output']; + images: Array; + lastUpdatedTime: Scalars['Instant']['output']; + name: Scalars['String']['output']; + nextChapterOrder: Maybe; + novelId: Scalars['UnsignedInt']['output']; + novelName: Scalars['String']['output']; + order: Scalars['UnsignedInt']['output']; + prevChapterOrder: Maybe; + revision: Scalars['UnsignedInt']['output']; + totalChapters: Scalars['Int']['output']; + url: Maybe; }; export type DeleteJobError = KeyNotFoundError; @@ -103,8 +109,7 @@ export type FormatError = Error & { message: Scalars['String']['output']; }; -export type Image = { - chapter: Maybe; +export type ImageDto = { createdTime: Scalars['Instant']['output']; id: Scalars['UUID']['output']; lastUpdatedTime: Scalars['Instant']['output']; @@ -112,19 +117,17 @@ export type Image = { originalPath: Scalars['String']['output']; }; -export type ImageFilterInput = { - and?: InputMaybe>; - chapter?: InputMaybe; +export type ImageDtoFilterInput = { + and?: InputMaybe>; createdTime?: InputMaybe; id?: InputMaybe; lastUpdatedTime?: InputMaybe; newPath?: InputMaybe; - or?: InputMaybe>; + or?: InputMaybe>; originalPath?: InputMaybe; }; -export type ImageSortInput = { - chapter?: InputMaybe; +export type ImageDtoSortInput = { createdTime?: InputMaybe; id?: InputMaybe; lastUpdatedTime?: InputMaybe; @@ -178,81 +181,25 @@ export type LanguageOperationFilterInput = { nin?: InputMaybe>; }; -export type ListFilterInputTypeOfChapterFilterInput = { - all?: InputMaybe; +export type ListFilterInputTypeOfChapterDtoFilterInput = { + all?: InputMaybe; any?: InputMaybe; - none?: InputMaybe; - some?: InputMaybe; + none?: InputMaybe; + some?: InputMaybe; }; -export type ListFilterInputTypeOfImageFilterInput = { - all?: InputMaybe; +export type ListFilterInputTypeOfImageDtoFilterInput = { + all?: InputMaybe; any?: InputMaybe; - none?: InputMaybe; - some?: InputMaybe; + none?: InputMaybe; + some?: InputMaybe; }; -export type ListFilterInputTypeOfLocalizationTextFilterInput = { - all?: InputMaybe; +export type ListFilterInputTypeOfNovelTagDtoFilterInput = { + all?: InputMaybe; any?: InputMaybe; - none?: InputMaybe; - some?: InputMaybe; -}; - -export type ListFilterInputTypeOfNovelFilterInput = { - all?: InputMaybe; - any?: InputMaybe; - none?: InputMaybe; - some?: InputMaybe; -}; - -export type ListFilterInputTypeOfNovelTagFilterInput = { - all?: InputMaybe; - any?: InputMaybe; - none?: InputMaybe; - some?: InputMaybe; -}; - -export type LocalizationKey = { - createdTime: Scalars['Instant']['output']; - id: Scalars['UUID']['output']; - lastUpdatedTime: Scalars['Instant']['output']; - texts: Array; -}; - -export type LocalizationKeyFilterInput = { - and?: InputMaybe>; - createdTime?: InputMaybe; - id?: InputMaybe; - lastUpdatedTime?: InputMaybe; - or?: InputMaybe>; - texts?: InputMaybe; -}; - -export type LocalizationKeySortInput = { - createdTime?: InputMaybe; - id?: InputMaybe; - lastUpdatedTime?: InputMaybe; -}; - -export type LocalizationText = { - createdTime: Scalars['Instant']['output']; - id: Scalars['UUID']['output']; - language: Language; - lastUpdatedTime: Scalars['Instant']['output']; - text: Scalars['String']['output']; - translationEngine: Maybe; -}; - -export type LocalizationTextFilterInput = { - and?: InputMaybe>; - createdTime?: InputMaybe; - id?: InputMaybe; - language?: InputMaybe; - lastUpdatedTime?: InputMaybe; - or?: InputMaybe>; - text?: InputMaybe; - translationEngine?: InputMaybe; + none?: InputMaybe; + some?: InputMaybe; }; export type Mutation = { @@ -300,56 +247,56 @@ export type MutationTranslateTextArgs = { input: TranslateTextInput; }; -export type Novel = { - author: Person; - chapters: Array; - coverImage: Maybe; +export type NovelDto = { + author: PersonDto; + chapters: Array; + coverImage: Maybe; createdTime: Scalars['Instant']['output']; - description: LocalizationKey; + description: Scalars['String']['output']; externalId: Scalars['String']['output']; id: Scalars['UnsignedInt']['output']; lastUpdatedTime: Scalars['Instant']['output']; - name: LocalizationKey; + name: Scalars['String']['output']; rawLanguage: Language; rawStatus: NovelStatus; - source: Source; + source: SourceDto; statusOverride: Maybe; - tags: Array; + tags: Array; url: Scalars['String']['output']; }; -export type NovelFilterInput = { - and?: InputMaybe>; - author?: InputMaybe; - chapters?: InputMaybe; - coverImage?: InputMaybe; +export type NovelDtoFilterInput = { + and?: InputMaybe>; + author?: InputMaybe; + chapters?: InputMaybe; + coverImage?: InputMaybe; createdTime?: InputMaybe; - description?: InputMaybe; + description?: InputMaybe; externalId?: InputMaybe; id?: InputMaybe; lastUpdatedTime?: InputMaybe; - name?: InputMaybe; - or?: InputMaybe>; + name?: InputMaybe; + or?: InputMaybe>; rawLanguage?: InputMaybe; rawStatus?: InputMaybe; - source?: InputMaybe; + source?: InputMaybe; statusOverride?: InputMaybe; - tags?: InputMaybe; + tags?: InputMaybe; url?: InputMaybe; }; -export type NovelSortInput = { - author?: InputMaybe; - coverImage?: InputMaybe; +export type NovelDtoSortInput = { + author?: InputMaybe; + coverImage?: InputMaybe; createdTime?: InputMaybe; - description?: InputMaybe; + description?: InputMaybe; externalId?: InputMaybe; id?: InputMaybe; lastUpdatedTime?: InputMaybe; - name?: InputMaybe; + name?: InputMaybe; rawLanguage?: InputMaybe; rawStatus?: InputMaybe; - source?: InputMaybe; + source?: InputMaybe; statusOverride?: InputMaybe; url?: InputMaybe; }; @@ -370,27 +317,25 @@ export type NovelStatusOperationFilterInput = { nin?: InputMaybe>; }; -export type NovelTag = { +export type NovelTagDto = { createdTime: Scalars['Instant']['output']; - displayName: LocalizationKey; + displayName: Scalars['String']['output']; id: Scalars['UnsignedInt']['output']; key: Scalars['String']['output']; lastUpdatedTime: Scalars['Instant']['output']; - novels: Array; - source: Maybe; + source: Maybe; tagType: TagType; }; -export type NovelTagFilterInput = { - and?: InputMaybe>; +export type NovelTagDtoFilterInput = { + and?: InputMaybe>; createdTime?: InputMaybe; - displayName?: InputMaybe; + displayName?: InputMaybe; id?: InputMaybe; key?: InputMaybe; lastUpdatedTime?: InputMaybe; - novels?: InputMaybe; - or?: InputMaybe>; - source?: InputMaybe; + or?: InputMaybe>; + source?: InputMaybe; tagType?: InputMaybe; }; @@ -403,7 +348,7 @@ export type NovelsConnection = { /** A list of edges. */ edges: Maybe>; /** A flattened list of the nodes. */ - nodes: Maybe>; + nodes: Maybe>; /** Information to aid in pagination. */ pageInfo: PageInfo; }; @@ -413,7 +358,7 @@ export type NovelsEdge = { /** A cursor for use in pagination. */ cursor: Scalars['String']['output']; /** The item at the end of the edge. */ - node: Novel; + node: NovelDto; }; export type NullableOfNovelStatusOperationFilterInput = { @@ -435,38 +380,46 @@ export type PageInfo = { startCursor: Maybe; }; -export type Person = { +export type PersonDto = { createdTime: Scalars['Instant']['output']; externalUrl: Maybe; id: Scalars['UnsignedInt']['output']; lastUpdatedTime: Scalars['Instant']['output']; - name: LocalizationKey; + name: Scalars['String']['output']; }; -export type PersonFilterInput = { - and?: InputMaybe>; +export type PersonDtoFilterInput = { + and?: InputMaybe>; createdTime?: InputMaybe; externalUrl?: InputMaybe; id?: InputMaybe; lastUpdatedTime?: InputMaybe; - name?: InputMaybe; - or?: InputMaybe>; + name?: InputMaybe; + or?: InputMaybe>; }; -export type PersonSortInput = { +export type PersonDtoSortInput = { createdTime?: InputMaybe; externalUrl?: InputMaybe; id?: InputMaybe; lastUpdatedTime?: InputMaybe; - name?: InputMaybe; + name?: InputMaybe; }; export type Query = { + chapter: Maybe; jobs: Array; novels: Maybe; translationEngines: Array; translationRequests: Maybe; - users: Array; + users: Array; +}; + + +export type QueryChapterArgs = { + chapterOrder: Scalars['UnsignedInt']['input']; + novelId: Scalars['UnsignedInt']['input']; + preferredLanguage?: Language; }; @@ -475,8 +428,9 @@ export type QueryNovelsArgs = { before?: InputMaybe; first?: InputMaybe; last?: InputMaybe; - order?: InputMaybe>; - where?: InputMaybe; + order?: InputMaybe>; + preferredLanguage?: Language; + where?: InputMaybe; }; @@ -491,8 +445,8 @@ export type QueryTranslationRequestsArgs = { before?: InputMaybe; first?: InputMaybe; last?: InputMaybe; - order?: InputMaybe>; - where?: InputMaybe; + order?: InputMaybe>; + where?: InputMaybe; }; export type RegisterUserInput = { @@ -503,7 +457,7 @@ export type RegisterUserInput = { }; export type RegisterUserPayload = { - user: Maybe; + userDto: Maybe; }; export type RunJobError = JobPersistenceError; @@ -546,7 +500,7 @@ export const SortEnumType = { } as const; export type SortEnumType = typeof SortEnumType[keyof typeof SortEnumType]; -export type Source = { +export type SourceDto = { createdTime: Scalars['Instant']['output']; id: Scalars['UnsignedInt']['output']; key: Scalars['String']['output']; @@ -555,18 +509,18 @@ export type Source = { url: Scalars['String']['output']; }; -export type SourceFilterInput = { - and?: InputMaybe>; +export type SourceDtoFilterInput = { + and?: InputMaybe>; createdTime?: InputMaybe; id?: InputMaybe; key?: InputMaybe; lastUpdatedTime?: InputMaybe; name?: InputMaybe; - or?: InputMaybe>; + or?: InputMaybe>; url?: InputMaybe; }; -export type SourceSortInput = { +export type SourceDtoSortInput = { createdTime?: InputMaybe; id?: InputMaybe; key?: InputMaybe; @@ -616,13 +570,6 @@ export type TranslateTextPayload = { translationResult: Maybe; }; -export type TranslationEngine = { - createdTime: Scalars['Instant']['output']; - id: Scalars['UnsignedInt']['output']; - key: Scalars['String']['output']; - lastUpdatedTime: Scalars['Instant']['output']; -}; - export type TranslationEngineDescriptor = { displayName: Scalars['String']['output']; key: Scalars['String']['output']; @@ -640,16 +587,7 @@ export type TranslationEngineDescriptorSortInput = { key?: InputMaybe; }; -export type TranslationEngineFilterInput = { - and?: InputMaybe>; - createdTime?: InputMaybe; - id?: InputMaybe; - key?: InputMaybe; - lastUpdatedTime?: InputMaybe; - or?: InputMaybe>; -}; - -export type TranslationRequest = { +export type TranslationRequestDto = { billedCharacterCount: Scalars['UnsignedInt']['output']; createdTime: Scalars['Instant']['output']; from: Language; @@ -662,14 +600,14 @@ export type TranslationRequest = { translationEngineKey: Scalars['String']['output']; }; -export type TranslationRequestFilterInput = { - and?: InputMaybe>; +export type TranslationRequestDtoFilterInput = { + and?: InputMaybe>; billedCharacterCount?: InputMaybe; createdTime?: InputMaybe; from?: InputMaybe; id?: InputMaybe; lastUpdatedTime?: InputMaybe; - or?: InputMaybe>; + or?: InputMaybe>; originalText?: InputMaybe; status?: InputMaybe; to?: InputMaybe; @@ -677,7 +615,7 @@ export type TranslationRequestFilterInput = { translationEngineKey?: InputMaybe; }; -export type TranslationRequestSortInput = { +export type TranslationRequestDtoSortInput = { billedCharacterCount?: InputMaybe; createdTime?: InputMaybe; from?: InputMaybe; @@ -709,7 +647,7 @@ export type TranslationRequestsConnection = { /** A list of edges. */ edges: Maybe>; /** A flattened list of the nodes. */ - nodes: Maybe>; + nodes: Maybe>; /** Information to aid in pagination. */ pageInfo: PageInfo; }; @@ -719,7 +657,7 @@ export type TranslationRequestsEdge = { /** A cursor for use in pagination. */ cursor: Scalars['String']['output']; /** The item at the end of the edge. */ - node: TranslationRequest; + node: TranslationRequestDto; }; export type TranslationResult = { @@ -747,14 +685,13 @@ export type UnsignedIntOperationFilterInputType = { nlte?: InputMaybe; }; -export type User = { +export type UserDto = { createdTime: Scalars['Instant']['output']; disabled: Scalars['Boolean']['output']; email: Scalars['String']['output']; id: Scalars['UUID']['output']; - inviter: Maybe; + inviter: Maybe; lastUpdatedTime: Scalars['Instant']['output']; - oAuthProviderId: Scalars['String']['output']; username: Scalars['String']['output']; }; @@ -773,13 +710,31 @@ export type UuidOperationFilterInput = { nlte?: InputMaybe; }; -export type NovelsQueryVariables = Exact<{ - first?: InputMaybe; - after?: InputMaybe; +export type GetChapterQueryVariables = Exact<{ + novelId: Scalars['UnsignedInt']['input']; + chapterOrder: Scalars['UnsignedInt']['input']; }>; -export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, rawStatus: NovelStatus, lastUpdatedTime: any, name: { texts: Array<{ language: Language, text: string }> }, description: { texts: Array<{ language: Language, text: string }> }, coverImage: { originalPath: string, newPath: string | null } | null, chapters: Array<{ order: any, name: { texts: Array<{ language: Language, text: string }> } }> } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null }; +export type GetChapterQuery = { chapter: { id: any, order: any, name: string, body: string, url: string | null, revision: any, createdTime: any, lastUpdatedTime: any, novelId: any, novelName: string, totalChapters: number, prevChapterOrder: any | null, nextChapterOrder: any | null, images: Array<{ id: any, newPath: string | null }> } | null }; + +export type NovelQueryVariables = Exact<{ + id: Scalars['UnsignedInt']['input']; +}>; -export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"description"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"originalPath"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"texts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"text"}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export type NovelQuery = { novels: { nodes: Array<{ id: any, name: string, description: string, url: string, rawLanguage: Language, rawStatus: NovelStatus, statusOverride: NovelStatus | null, externalId: string, createdTime: any, lastUpdatedTime: any, author: { id: any, name: string, externalUrl: string | null }, source: { id: any, name: string, key: string, url: string }, coverImage: { newPath: string | null } | null, tags: Array<{ id: any, key: string, displayName: string, tagType: TagType }>, chapters: Array<{ id: any, order: any, name: string, lastUpdatedTime: any }> }> | null } | null }; + +export type NovelsQueryVariables = Exact<{ + first?: InputMaybe; + after?: InputMaybe; + where?: InputMaybe; +}>; + + +export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, name: string, description: string, rawStatus: NovelStatus, lastUpdatedTime: any, coverImage: { newPath: string | null } | null, chapters: Array<{ order: any, name: string }>, tags: Array<{ key: string, displayName: string }> } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null }; + + +export const GetChapterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"chapterOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"revision"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"novelName"}},{"kind":"Field","name":{"kind":"Name","value":"totalChapters"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterOrder"}}]}}]}}]} as unknown as DocumentNode; +export const NovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rawLanguage"}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"statusOverride"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"source"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/fictionarchive-web-astro/src/lib/graphql/queries/chapter.graphql b/fictionarchive-web-astro/src/lib/graphql/queries/chapter.graphql new file mode 100644 index 0000000..f1c092a --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/queries/chapter.graphql @@ -0,0 +1,23 @@ +query GetChapter($novelId: UnsignedInt!, $chapterOrder: UnsignedInt!) { + chapter(novelId: $novelId, chapterOrder: $chapterOrder) { + id + order + name + body + url + revision + createdTime + lastUpdatedTime + + images { + id + newPath + } + + novelId + novelName + totalChapters + prevChapterOrder + nextChapterOrder + } +} diff --git a/fictionarchive-web-astro/src/lib/graphql/queries/novel.graphql b/fictionarchive-web-astro/src/lib/graphql/queries/novel.graphql new file mode 100644 index 0000000..a9ca86b --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/queries/novel.graphql @@ -0,0 +1,47 @@ +query Novel($id: UnsignedInt!) { + novels(where: { id: { eq: $id } }, first: 1) { + nodes { + id + name + description + url + rawLanguage + rawStatus + statusOverride + externalId + createdTime + lastUpdatedTime + + author { + id + name + externalUrl + } + + source { + id + name + key + url + } + + coverImage { + newPath + } + + tags { + id + key + displayName + tagType + } + + chapters { + id + order + name + lastUpdatedTime + } + } + } +} diff --git a/fictionarchive-web-astro/src/lib/graphql/queries/novels.graphql b/fictionarchive-web-astro/src/lib/graphql/queries/novels.graphql index 74c5b20..5b5dab9 100644 --- a/fictionarchive-web-astro/src/lib/graphql/queries/novels.graphql +++ b/fictionarchive-web-astro/src/lib/graphql/queries/novels.graphql @@ -1,36 +1,24 @@ -query Novels($first: Int, $after: String) { - novels(first: $first, after: $after) { +query Novels($first: Int, $after: String, $where: NovelDtoFilterInput) { + novels(first: $first, after: $after, where: $where) { edges { cursor node { id url - name { - texts { - language - text - } - } - description { - texts { - language - text - } - } + name + description coverImage { - originalPath newPath } rawStatus lastUpdatedTime chapters { order - name { - texts { - language - text - } - } + name + } + tags { + key + displayName } } } diff --git a/fictionarchive-web-astro/src/lib/utils/filterParams.ts b/fictionarchive-web-astro/src/lib/utils/filterParams.ts new file mode 100644 index 0000000..e3319bc --- /dev/null +++ b/fictionarchive-web-astro/src/lib/utils/filterParams.ts @@ -0,0 +1,115 @@ +import type { NovelDtoFilterInput, NovelStatus } from '$lib/graphql/__generated__/graphql'; + +export interface NovelFilters { + search: string; + statuses: NovelStatus[]; + tags: string[]; +} + +export const EMPTY_FILTERS: NovelFilters = { + search: '', + statuses: [], + tags: [] +}; + +const VALID_STATUSES: NovelStatus[] = ['ABANDONED', 'COMPLETED', 'HIATUS', 'IN_PROGRESS', 'UNKNOWN']; + +/** + * Parse filter state from URL search parameters + */ +export function parseFiltersFromURL(searchParams?: URLSearchParams): NovelFilters { + const params = searchParams ?? new URLSearchParams(window.location.search); + + const search = params.get('search') ?? ''; + + const statusParam = params.get('status') ?? ''; + const statuses = statusParam + .split(',') + .filter((s) => s && VALID_STATUSES.includes(s as NovelStatus)) as NovelStatus[]; + + const tagsParam = params.get('tags') ?? ''; + const tags = tagsParam.split(',').filter((t) => t.length > 0); + + return { search, statuses, tags }; +} + +/** + * Convert filter state to URL search params string + */ +export function filtersToURLParams(filters: NovelFilters): string { + const params = new URLSearchParams(); + + if (filters.search.trim()) { + params.set('search', filters.search.trim()); + } + + if (filters.statuses.length > 0) { + params.set('status', filters.statuses.join(',')); + } + + if (filters.tags.length > 0) { + params.set('tags', filters.tags.join(',')); + } + + return params.toString(); +} + +/** + * Update browser URL with current filters (without page reload) + */ +export function syncFiltersToURL(filters: NovelFilters): void { + const params = filtersToURLParams(filters); + const newUrl = params ? `${window.location.pathname}?${params}` : window.location.pathname; + window.history.replaceState({}, '', newUrl); +} + +/** + * Convert filter state to GraphQL where input + * Returns null if no filters are active + */ +export function filtersToGraphQLWhere(filters: NovelFilters): NovelDtoFilterInput | null { + const conditions: NovelDtoFilterInput[] = []; + + // Text search on name (case-insensitive contains) + if (filters.search.trim()) { + conditions.push({ + name: { contains: filters.search.trim() } + }); + } + + // Status filter + if (filters.statuses.length > 0) { + conditions.push({ + rawStatus: { in: filters.statuses } + }); + } + + // Tag filter (match novels that have ANY of the selected tags) + if (filters.tags.length > 0) { + conditions.push({ + tags: { + some: { + key: { in: filters.tags } + } + } + }); + } + + // Return null if no filters, single condition if one filter, AND for multiple + if (conditions.length === 0) { + return null; + } + + if (conditions.length === 1) { + return conditions[0]; + } + + return { and: conditions }; +} + +/** + * Check if any filters are active + */ +export function hasActiveFilters(filters: NovelFilters): boolean { + return filters.search.trim().length > 0 || filters.statuses.length > 0 || filters.tags.length > 0; +} diff --git a/fictionarchive-web-astro/src/lib/utils/sanitize.ts b/fictionarchive-web-astro/src/lib/utils/sanitize.ts index 943a0be..77d417a 100644 --- a/fictionarchive-web-astro/src/lib/utils/sanitize.ts +++ b/fictionarchive-web-astro/src/lib/utils/sanitize.ts @@ -1,4 +1,4 @@ -import DOMPurify from 'dompurify'; +import DOMPurify from 'isomorphic-dompurify'; /** * Sanitizes HTML content, allowing only safe inline formatting elements. diff --git a/fictionarchive-web-astro/src/lib/utils/sanitizeChapter.ts b/fictionarchive-web-astro/src/lib/utils/sanitizeChapter.ts new file mode 100644 index 0000000..25d4ea7 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/utils/sanitizeChapter.ts @@ -0,0 +1,74 @@ +import DOMPurify from 'isomorphic-dompurify'; + +/** + * Sanitizes chapter HTML content with extended allowed tags. + * More permissive than the description sanitizer to support + * formatted novel content including headings, lists, and images. + */ +export function sanitizeChapterHtml(html: string): string { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: [ + // Basic formatting + 'b', + 'i', + 'em', + 'strong', + 'u', + 's', + 'strike', + 'del', + 'ins', + // Structure + 'p', + 'br', + 'hr', + 'div', + 'span', + // Headings + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + // Lists + 'ul', + 'ol', + 'li', + // Quotes + 'blockquote', + 'q', + 'cite', + // Preformatted + 'pre', + 'code', + // Ruby (for Asian language annotations) + 'ruby', + 'rt', + 'rp', + // Images + 'img', + // Tables + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td' + ], + ALLOWED_ATTR: [ + // Image attributes + 'src', + 'alt', + 'title', + 'width', + 'height', + // Table attributes + 'colspan', + 'rowspan', + // Generic styling (limited) + 'class' + ], + ALLOW_DATA_ATTR: false + }); +} diff --git a/fictionarchive-web-astro/src/middleware.ts b/fictionarchive-web-astro/src/middleware.ts new file mode 100644 index 0000000..653d190 --- /dev/null +++ b/fictionarchive-web-astro/src/middleware.ts @@ -0,0 +1,22 @@ +import { defineMiddleware } from 'astro:middleware'; + +const STATIC_PATHS = ['/_astro/', '/favicon.svg', '/favicon.ico']; + +export const onRequest = defineMiddleware(async (context, next) => { + const { request, url } = context; + + // Bypass auth for static assets + if (STATIC_PATHS.some((p) => url.pathname.startsWith(p))) { + return next(); + } + + // Simple presence check for fa_session cookie + const cookieHeader = request.headers.get('cookie') || ''; + const hasSession = /fa_session=[^;]+/.test(cookieHeader); + + if (hasSession) { + return next(); + } + + return context.rewrite('/gated-404'); +}); diff --git a/fictionarchive-web-astro/src/pages/404.astro b/fictionarchive-web-astro/src/pages/404.astro index b19615e..5347e55 100644 --- a/fictionarchive-web-astro/src/pages/404.astro +++ b/fictionarchive-web-astro/src/pages/404.astro @@ -1,6 +1,4 @@ --- -export const prerender = true; // Static page - import AppLayout from '../layouts/AppLayout.astro'; import { Card, CardContent, CardHeader, CardTitle } from '../lib/components/ui/card'; import { Button } from '../lib/components/ui/button'; diff --git a/fictionarchive-web-astro/src/pages/gated-404.astro b/fictionarchive-web-astro/src/pages/gated-404.astro new file mode 100644 index 0000000..e2062c1 --- /dev/null +++ b/fictionarchive-web-astro/src/pages/gated-404.astro @@ -0,0 +1,22 @@ +--- +// Internal route used by middleware for unauthenticated users +Astro.response.status = 404; + +import GatedLayout from '../layouts/GatedLayout.astro'; +import { Card, CardContent, CardHeader, CardTitle } from '../lib/components/ui/card'; +--- + + +
+ + + 404 + + +

+ The page you're looking for doesn't exist or has been moved. +

+
+
+
+
diff --git a/fictionarchive-web-astro/src/pages/index.astro b/fictionarchive-web-astro/src/pages/index.astro index f2cf1fc..df15813 100644 --- a/fictionarchive-web-astro/src/pages/index.astro +++ b/fictionarchive-web-astro/src/pages/index.astro @@ -1,6 +1,4 @@ --- -export const prerender = true; // Static page - import AppLayout from '../layouts/AppLayout.astro'; import HomePage from '../lib/components/HomePage.svelte'; --- diff --git a/fictionarchive-web-astro/src/pages/novels/[id]/chapters/[chapterNumber].astro b/fictionarchive-web-astro/src/pages/novels/[id]/chapters/[chapterNumber].astro new file mode 100644 index 0000000..66e71ba --- /dev/null +++ b/fictionarchive-web-astro/src/pages/novels/[id]/chapters/[chapterNumber].astro @@ -0,0 +1,10 @@ +--- +import AppLayout from '../../../../layouts/AppLayout.astro'; +import ChapterReaderPage from '../../../../lib/components/ChapterReaderPage.svelte'; + +const { id, chapterNumber } = Astro.params; +--- + + + + diff --git a/fictionarchive-web-astro/src/pages/novels/index.astro b/fictionarchive-web-astro/src/pages/novels/index.astro index 83dd387..a472baa 100644 --- a/fictionarchive-web-astro/src/pages/novels/index.astro +++ b/fictionarchive-web-astro/src/pages/novels/index.astro @@ -1,6 +1,4 @@ --- -export const prerender = true; // Static page - import AppLayout from '../../layouts/AppLayout.astro'; import NovelsPage from '../../lib/components/NovelsPage.svelte'; --- From e70c39ea75fb7b9e0df31bd144539b5389467563 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 9 Dec 2025 09:11:39 -0500 Subject: [PATCH 2/2] [FA-misc] Refresh button, UI mostly gold --- .../NovelUpdateServiceTests.cs | 134 +++++++++++++++++ .../Services/NovelUpdateService.cs | 15 ++ .../Implementations/RabbitMQEventBus.cs | 1 + .../lib/components/ChapterReaderPage.svelte | 7 - .../lib/components/ImportNovelModal.svelte | 141 ++++++++++++++++++ .../src/lib/components/NovelDetailPage.svelte | 93 ++++++++++-- .../src/lib/components/NovelFilters.svelte | 44 ++++++ .../src/lib/components/NovelsPage.svelte | 18 ++- .../src/lib/graphql/__generated__/graphql.ts | 8 + .../lib/graphql/mutations/importNovel.graphql | 7 + .../src/lib/utils/filterParams.ts | 28 +++- fictionarchive-web-astro/src/middleware.ts | 6 + 12 files changed, 476 insertions(+), 26 deletions(-) create mode 100644 fictionarchive-web-astro/src/lib/components/ImportNovelModal.svelte create mode 100644 fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql diff --git a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs index b82835b..dad1df3 100644 --- a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs +++ b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs @@ -162,4 +162,138 @@ public class NovelUpdateServiceTests } private record NovelCreateResult(Novel Novel, Chapter Chapter); + + #region UpdateImage Tests + + [Fact] + public async Task UpdateImage_sets_NewPath_on_image_without_chapter() + { + // Arrange + using var dbContext = CreateDbContext(); + var image = new Image + { + OriginalPath = "http://original/cover.jpg", + NewPath = null + }; + dbContext.Images.Add(image); + await dbContext.SaveChangesAsync(); + + var adapter = Substitute.For(); + var eventBus = Substitute.For(); + var service = CreateService(dbContext, adapter, eventBus); + + var newUrl = "https://cdn.example.com/uploaded/cover.jpg"; + + // Act + await service.UpdateImage(image.Id, newUrl); + + // Assert + var updatedImage = await dbContext.Images.FindAsync(image.Id); + updatedImage!.NewPath.Should().Be(newUrl); + updatedImage.OriginalPath.Should().Be("http://original/cover.jpg"); + } + + [Fact] + public async Task UpdateImage_updates_chapter_body_html_with_new_url() + { + // Arrange + using var dbContext = CreateDbContext(); + var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; + var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); + + var image = new Image + { + OriginalPath = "http://original/image.jpg", + NewPath = null, + Chapter = chapter + }; + chapter.Images.Add(image); + await dbContext.SaveChangesAsync(); + + // Set up the chapter body with an img tag referencing the image by ID (as PullChapterContents does) + var pendingUrl = "https://pending/placeholder.jpg"; + var bodyHtml = $"

Content

\"{image.Id}\""; + chapter.Body.Texts.Add(new LocalizationText + { + Language = Language.En, + Text = bodyHtml + }); + await dbContext.SaveChangesAsync(); + + var adapter = Substitute.For(); + var eventBus = Substitute.For(); + var service = CreateService(dbContext, adapter, eventBus, pendingUrl); + + var newUrl = "https://cdn.example.com/uploaded/image.jpg"; + + // Act + await service.UpdateImage(image.Id, newUrl); + + // Assert + var updatedImage = await dbContext.Images + .Include(i => i.Chapter) + .ThenInclude(c => c.Body) + .ThenInclude(b => b.Texts) + .FirstAsync(i => i.Id == image.Id); + + updatedImage.NewPath.Should().Be(newUrl); + + var updatedBodyText = updatedImage.Chapter!.Body.Texts.Single().Text; + var doc = new HtmlDocument(); + doc.LoadHtml(updatedBodyText); + var imgNode = doc.DocumentNode.SelectSingleNode("//img"); + imgNode.Should().NotBeNull(); + imgNode!.GetAttributeValue("src", string.Empty).Should().Be(newUrl); + } + + [Fact] + public async Task UpdateImage_does_not_modify_other_images_in_chapter_body() + { + // Arrange + using var dbContext = CreateDbContext(); + var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; + var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); + + var image1 = new Image { OriginalPath = "http://original/img1.jpg", Chapter = chapter }; + var image2 = new Image { OriginalPath = "http://original/img2.jpg", Chapter = chapter }; + chapter.Images.Add(image1); + chapter.Images.Add(image2); + await dbContext.SaveChangesAsync(); + + var pendingUrl = "https://pending/placeholder.jpg"; + var bodyHtml = $"

Content

\"{image1.Id}\"\"{image2.Id}\""; + chapter.Body.Texts.Add(new LocalizationText + { + Language = Language.En, + Text = bodyHtml + }); + await dbContext.SaveChangesAsync(); + + var adapter = Substitute.For(); + var eventBus = Substitute.For(); + var service = CreateService(dbContext, adapter, eventBus, pendingUrl); + + var newUrl = "https://cdn.example.com/uploaded/img1.jpg"; + + // Act - only update image1 + await service.UpdateImage(image1.Id, newUrl); + + // Assert + var updatedChapter = await dbContext.Chapters + .Include(c => c.Body) + .ThenInclude(b => b.Texts) + .FirstAsync(c => c.Id == chapter.Id); + + var updatedBodyText = updatedChapter.Body.Texts.Single().Text; + var doc = new HtmlDocument(); + doc.LoadHtml(updatedBodyText); + + var img1Node = doc.DocumentNode.SelectSingleNode($"//img[@alt='{image1.Id}']"); + var img2Node = doc.DocumentNode.SelectSingleNode($"//img[@alt='{image2.Id}']"); + + img1Node!.GetAttributeValue("src", string.Empty).Should().Be(newUrl); + img2Node!.GetAttributeValue("src", string.Empty).Should().Be(pendingUrl); + } + + #endregion } diff --git a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs index 54bead9..04ddfa1 100644 --- a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs +++ b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs @@ -350,6 +350,20 @@ public class NovelUpdateService }); } + // Publish chapter pull events for chapters without body content + var chaptersNeedingPull = novel.Chapters + .Where(c => c.Body?.Texts == null || !c.Body.Texts.Any()) + .ToList(); + + foreach (var chapter in chaptersNeedingPull) + { + await _eventBus.Publish(new ChapterPullRequestedEvent + { + NovelId = novel.Id, + ChapterNumber = chapter.Order + }); + } + return novel; } @@ -434,6 +448,7 @@ public class NovelUpdateService if (match != null) { match.Attributes["src"].Value = newUrl; + bodyText.Text = chapterDoc.DocumentNode.OuterHtml; } } } diff --git a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs index 7faa200..758f2d8 100644 --- a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs +++ b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs @@ -69,6 +69,7 @@ public class RabbitMQEventBus : IEventBus, IHostedService await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct, cancellationToken: cancellationToken); + await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: cancellationToken); await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false, cancellationToken: cancellationToken); var consumer = new AsyncEventingBasicConsumer(channel); diff --git a/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte b/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte index bd22f39..69572c4 100644 --- a/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/ChapterReaderPage.svelte @@ -13,7 +13,6 @@ import ChapterNavigation from './ChapterNavigation.svelte'; import ChapterProgressBar from './ChapterProgressBar.svelte'; import { sanitizeChapterHtml } from '$lib/utils/sanitizeChapter'; - import ArrowLeft from '@lucide/svelte/icons/arrow-left'; interface Props { novelId?: string; @@ -102,12 +101,6 @@
- - - {#if fetching} diff --git a/fictionarchive-web-astro/src/lib/components/ImportNovelModal.svelte b/fictionarchive-web-astro/src/lib/components/ImportNovelModal.svelte new file mode 100644 index 0000000..f1c1ca5 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/components/ImportNovelModal.svelte @@ -0,0 +1,141 @@ + + + + +{#if open} + +{/if} diff --git a/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte b/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte index 6430b30..314bc7a 100644 --- a/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/NovelDetailPage.svelte @@ -30,7 +30,8 @@
@@ -87,6 +106,16 @@ />
+ +
+ handleAuthorInput(e.currentTarget.value)} + /> +
+ {/if} + {#if filters.authorName} + + Author: {filters.authorName} + + + {/if} + {#each filters.statuses as status (status)} {statusOptions.find((o) => o.value === status)?.label ?? status} diff --git a/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte b/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte index c04ba87..21e7d80 100644 --- a/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte +++ b/fictionarchive-web-astro/src/lib/components/NovelsPage.svelte @@ -5,8 +5,10 @@ import { NovelsDocument, type NovelsQuery, type NovelTagDto } from '$lib/graphql/__generated__/graphql'; import NovelCard from './NovelCard.svelte'; import NovelFilters from './NovelFilters.svelte'; + import ImportNovelModal from './ImportNovelModal.svelte'; import { Button } from '$lib/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; + import { isAuthenticated } from '$lib/auth/authStore'; import { type NovelFilters as NovelFiltersType, parseFiltersFromURL, @@ -26,6 +28,7 @@ let error: string | null = $state(null); let initialLoad = $state(true); let filters: NovelFiltersType = $state({ ...EMPTY_FILTERS }); + let showImportModal = $state(false); const hasNextPage = $derived(pageInfo?.hasNextPage ?? false); const novels = $derived(edges.map((edge) => edge.node).filter(Boolean)); @@ -112,7 +115,14 @@
- Novels +
+ Novels + {#if $isAuthenticated} + + {/if} +

{#if hasActiveFilters(filters)} Showing filtered results @@ -177,3 +187,9 @@

{/if}
+ + (showImportModal = false)} + onSuccess={() => fetchNovels()} +/> diff --git a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts index 8eba6de..6a9a42c 100644 --- a/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts +++ b/fictionarchive-web-astro/src/lib/graphql/__generated__/graphql.ts @@ -710,6 +710,13 @@ export type UuidOperationFilterInput = { nlte?: InputMaybe; }; +export type ImportNovelMutationVariables = Exact<{ + input: ImportNovelInput; +}>; + + +export type ImportNovelMutation = { importNovel: { novelUpdateRequestedEvent: { novelUrl: string } | null } }; + export type GetChapterQueryVariables = Exact<{ novelId: Scalars['UnsignedInt']['input']; chapterOrder: Scalars['UnsignedInt']['input']; @@ -735,6 +742,7 @@ export type NovelsQueryVariables = Exact<{ export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, name: string, description: string, rawStatus: NovelStatus, lastUpdatedTime: any, coverImage: { newPath: string | null } | null, chapters: Array<{ order: any, name: string }>, tags: Array<{ key: string, displayName: string }> } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null }; +export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUpdateRequestedEvent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetChapterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"chapterOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"revision"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"novelName"}},{"kind":"Field","name":{"kind":"Name","value":"totalChapters"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterOrder"}}]}}]}}]} as unknown as DocumentNode; export const NovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rawLanguage"}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"statusOverride"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"source"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql b/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql new file mode 100644 index 0000000..887afa3 --- /dev/null +++ b/fictionarchive-web-astro/src/lib/graphql/mutations/importNovel.graphql @@ -0,0 +1,7 @@ +mutation ImportNovel($input: ImportNovelInput!) { + importNovel(input: $input) { + novelUpdateRequestedEvent { + novelUrl + } + } +} diff --git a/fictionarchive-web-astro/src/lib/utils/filterParams.ts b/fictionarchive-web-astro/src/lib/utils/filterParams.ts index e3319bc..ac4512e 100644 --- a/fictionarchive-web-astro/src/lib/utils/filterParams.ts +++ b/fictionarchive-web-astro/src/lib/utils/filterParams.ts @@ -4,12 +4,14 @@ export interface NovelFilters { search: string; statuses: NovelStatus[]; tags: string[]; + authorName: string; } export const EMPTY_FILTERS: NovelFilters = { search: '', statuses: [], - tags: [] + tags: [], + authorName: '' }; const VALID_STATUSES: NovelStatus[] = ['ABANDONED', 'COMPLETED', 'HIATUS', 'IN_PROGRESS', 'UNKNOWN']; @@ -30,7 +32,9 @@ export function parseFiltersFromURL(searchParams?: URLSearchParams): NovelFilter const tagsParam = params.get('tags') ?? ''; const tags = tagsParam.split(',').filter((t) => t.length > 0); - return { search, statuses, tags }; + const authorName = params.get('author') ?? ''; + + return { search, statuses, tags, authorName }; } /** @@ -51,6 +55,10 @@ export function filtersToURLParams(filters: NovelFilters): string { params.set('tags', filters.tags.join(',')); } + if (filters.authorName.trim()) { + params.set('author', filters.authorName.trim()); + } + return params.toString(); } @@ -95,6 +103,15 @@ export function filtersToGraphQLWhere(filters: NovelFilters): NovelDtoFilterInpu }); } + // Author filter (exact match on author name) + if (filters.authorName.trim()) { + conditions.push({ + author: { + name: { eq: filters.authorName.trim() } + } + }); + } + // Return null if no filters, single condition if one filter, AND for multiple if (conditions.length === 0) { return null; @@ -111,5 +128,10 @@ export function filtersToGraphQLWhere(filters: NovelFilters): NovelDtoFilterInpu * Check if any filters are active */ export function hasActiveFilters(filters: NovelFilters): boolean { - return filters.search.trim().length > 0 || filters.statuses.length > 0 || filters.tags.length > 0; + return ( + filters.search.trim().length > 0 || + filters.statuses.length > 0 || + filters.tags.length > 0 || + filters.authorName.trim().length > 0 + ); } diff --git a/fictionarchive-web-astro/src/middleware.ts b/fictionarchive-web-astro/src/middleware.ts index 653d190..0894821 100644 --- a/fictionarchive-web-astro/src/middleware.ts +++ b/fictionarchive-web-astro/src/middleware.ts @@ -1,6 +1,7 @@ import { defineMiddleware } from 'astro:middleware'; const STATIC_PATHS = ['/_astro/', '/favicon.svg', '/favicon.ico']; +const AUTH_BYPASS_PATHS = ['/gated-404']; export const onRequest = defineMiddleware(async (context, next) => { const { request, url } = context; @@ -10,6 +11,11 @@ export const onRequest = defineMiddleware(async (context, next) => { return next(); } + // Bypass auth for gated pages to prevent redirect loops + if (AUTH_BYPASS_PATHS.includes(url.pathname)) { + return next(); + } + // Simple presence check for fa_session cookie const cookieHeader = request.headers.get('cookie') || ''; const hasSession = /fa_session=[^;]+/.test(cookieHeader);