diff --git a/src/Novelly.Api/Common/AgentNotConfiguredException.cs b/src/Novelly.Api/Common/AgentNotConfiguredException.cs index 1ec2e05..8a8f1b1 100644 --- a/src/Novelly.Api/Common/AgentNotConfiguredException.cs +++ b/src/Novelly.Api/Common/AgentNotConfiguredException.cs @@ -1,8 +1,3 @@ namespace Novelly.Api.Common; -/// -/// Thrown when the agent is asked to run but has no model credentials. This is a -/// deployment problem rather than a bad request, so the API reports it as 503 — the rest -/// of the app works fine without a key. -/// public class AgentNotConfiguredException(string message) : Exception(message); diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs index 9f214a3..a991826 100644 --- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs +++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs @@ -6,6 +6,7 @@ using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common.Validation; using Novelly.Api.Data; +using Novelly.Api.Genres; using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; @@ -28,6 +29,7 @@ public static class NovellyServiceRegistration services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Novelly.Api/Data/INovelDbContext.cs b/src/Novelly.Api/Data/INovelDbContext.cs index 608220d..065aa71 100644 --- a/src/Novelly.Api/Data/INovelDbContext.cs +++ b/src/Novelly.Api/Data/INovelDbContext.cs @@ -3,6 +3,7 @@ using Novelly.Api.Agent; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; +using Novelly.Api.Genres; using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; @@ -23,6 +24,7 @@ public interface INovelDbContext DbSet Conversations { get; } DbSet AgentMessages { get; } DbSet ImportJobs { get; } + DbSet Genres { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Novelly.Api/Data/Migrations/20260813035743_AddGenres.Designer.cs b/src/Novelly.Api/Data/Migrations/20260813035743_AddGenres.Designer.cs new file mode 100644 index 0000000..a082f28 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260813035743_AddGenres.Designer.cs @@ -0,0 +1,879 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260813035743_AddGenres")] + partial class AddGenres + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatCharacter", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "CharactersId"); + + b.HasIndex("CharactersId"); + + b.ToTable("BeatCharacters", (string)null); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("Setting") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ProjectId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Genres.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + + b.HasData( + new + { + Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), + Name = "Adventure" + }, + new + { + Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), + Name = "Contemporary Fiction" + }, + new + { + Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), + Name = "Crime" + }, + new + { + Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), + Name = "Dystopian" + }, + new + { + Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), + Name = "Fantasy" + }, + new + { + Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), + Name = "Historical Fiction" + }, + new + { + Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), + Name = "Horror" + }, + new + { + Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), + Name = "Literary Fiction" + }, + new + { + Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), + Name = "Magical Realism" + }, + new + { + Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), + Name = "Memoir" + }, + new + { + Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), + Name = "Middle Grade" + }, + new + { + Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), + Name = "Mystery" + }, + new + { + Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), + Name = "Non-Fiction" + }, + new + { + Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), + Name = "Poetry" + }, + new + { + Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), + Name = "Romance" + }, + new + { + Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), + Name = "Science Fiction" + }, + new + { + Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), + Name = "Thriller" + }, + new + { + Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), + Name = "Young Adult" + }); + }); + + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ProjectId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("BeatCharacter", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Conversations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Characters.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Chapters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PovCharacter"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Characters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Tags") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260813035743_AddGenres.cs b/src/Novelly.Api/Data/Migrations/20260813035743_AddGenres.cs new file mode 100644 index 0000000..047a97c --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260813035743_AddGenres.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class AddGenres : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Genres", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Genres", x => x.Id); + }); + + migrationBuilder.InsertData( + table: "Genres", + columns: new[] { "Id", "Name" }, + values: new object[,] + { + { new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), "Middle Grade" }, + { new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), "Contemporary Fiction" }, + { new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), "Memoir" }, + { new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), "Young Adult" }, + { new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), "Horror" }, + { new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), "Science Fiction" }, + { new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), "Crime" }, + { new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), "Dystopian" }, + { new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), "Fantasy" }, + { new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), "Historical Fiction" }, + { new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), "Non-Fiction" }, + { new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), "Thriller" }, + { new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), "Romance" }, + { new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), "Adventure" }, + { new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), "Mystery" }, + { new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), "Literary Fiction" }, + { new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), "Magical Realism" }, + { new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), "Poetry" } + }); + + migrationBuilder.CreateIndex( + name: "IX_Genres_Name", + table: "Genres", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Genres"); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index 47577c6..c46254e 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -376,6 +376,117 @@ namespace Novelly.Api.Data.Migrations b.ToTable("CharacterRelationships"); }); + modelBuilder.Entity("Novelly.Api.Genres.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + + b.HasData( + new + { + Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), + Name = "Adventure" + }, + new + { + Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), + Name = "Contemporary Fiction" + }, + new + { + Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), + Name = "Crime" + }, + new + { + Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), + Name = "Dystopian" + }, + new + { + Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), + Name = "Fantasy" + }, + new + { + Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), + Name = "Historical Fiction" + }, + new + { + Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), + Name = "Horror" + }, + new + { + Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), + Name = "Literary Fiction" + }, + new + { + Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), + Name = "Magical Realism" + }, + new + { + Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), + Name = "Memoir" + }, + new + { + Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), + Name = "Middle Grade" + }, + new + { + Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), + Name = "Mystery" + }, + new + { + Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), + Name = "Non-Fiction" + }, + new + { + Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), + Name = "Poetry" + }, + new + { + Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), + Name = "Romance" + }, + new + { + Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), + Name = "Science Fiction" + }, + new + { + Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), + Name = "Thriller" + }, + new + { + Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), + Name = "Young Adult" + }); + }); + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => { b.Property("Id") diff --git a/src/Novelly.Api/Data/NovelDbContext.cs b/src/Novelly.Api/Data/NovelDbContext.cs index fc13211..841bd83 100644 --- a/src/Novelly.Api/Data/NovelDbContext.cs +++ b/src/Novelly.Api/Data/NovelDbContext.cs @@ -4,6 +4,7 @@ using Novelly.Api.Agent; using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; +using Novelly.Api.Genres; using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; @@ -30,6 +31,7 @@ public class NovelDbContext(DbContextOptions options) public DbSet Conversations => Set(); public DbSet AgentMessages => Set(); public DbSet ImportJobs => Set(); + public DbSet Genres => Set(); Task INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => base.SaveChangesAsync(cancellationToken); @@ -150,6 +152,13 @@ public class NovelDbContext(DbContextOptions options) entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique(); }); + builder.Entity(entity => + { + entity.Property(g => g.Name).IsRequired().HasMaxLength(100); + entity.HasIndex(g => g.Name).IsUnique(); + entity.HasData(SeededGenres.All); + }); + builder.Entity(entity => { entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000); diff --git a/src/Novelly.Api/Genres/Genre.cs b/src/Novelly.Api/Genres/Genre.cs new file mode 100644 index 0000000..77ea68e --- /dev/null +++ b/src/Novelly.Api/Genres/Genre.cs @@ -0,0 +1,8 @@ +namespace Novelly.Api.Genres; + +public class Genre +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string Name { get; set; } = string.Empty; +} diff --git a/src/Novelly.Api/Genres/GenreContracts.cs b/src/Novelly.Api/Genres/GenreContracts.cs new file mode 100644 index 0000000..9997346 --- /dev/null +++ b/src/Novelly.Api/Genres/GenreContracts.cs @@ -0,0 +1,8 @@ +namespace Novelly.Api.Genres; + +public record GenreResponse(Guid Id, string Name); + +public static class GenreMapping +{ + public static GenreResponse ToResponse(this Genre g) => new(g.Id, g.Name); +} diff --git a/src/Novelly.Api/Genres/GenreEndpoints.cs b/src/Novelly.Api/Genres/GenreEndpoints.cs new file mode 100644 index 0000000..0dda11e --- /dev/null +++ b/src/Novelly.Api/Genres/GenreEndpoints.cs @@ -0,0 +1,18 @@ +using Novelly.Api.Common; + +namespace Novelly.Api.Genres; + +public static class GenreEndpoints +{ + public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + { + var genres = app.MapGroup("/api/genres").WithTags("Genres") + .AddEndpointFilter(); + + genres.MapGet("/", async (GenreService service, CancellationToken ct) => + Results.Ok(await service.ListAsync(ct))) + .WithSummary("List the suggested genres a novel can be filed under."); + + return app; + } +} diff --git a/src/Novelly.Api/Genres/GenreService.cs b/src/Novelly.Api/Genres/GenreService.cs new file mode 100644 index 0000000..88db141 --- /dev/null +++ b/src/Novelly.Api/Genres/GenreService.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Novelly.Api.Data; + +namespace Novelly.Api.Genres; + +public class GenreService(INovelDbContext db, ILogger logger) +{ + public async Task> ListAsync(CancellationToken ct = default) + { + logger.LogInformation("Listing genres"); + + return await db.Genres + .OrderBy(g => g.Name) + .Select(g => new GenreResponse(g.Id, g.Name)) + .ToListAsync(ct); + } +} diff --git a/src/Novelly.Api/Genres/SeededGenres.cs b/src/Novelly.Api/Genres/SeededGenres.cs new file mode 100644 index 0000000..5ccf4c5 --- /dev/null +++ b/src/Novelly.Api/Genres/SeededGenres.cs @@ -0,0 +1,26 @@ +namespace Novelly.Api.Genres; + +public static class SeededGenres +{ + public static IReadOnlyList All { get; } = + [ + new Genre { Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), Name = "Adventure" }, + new Genre { Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), Name = "Contemporary Fiction" }, + new Genre { Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), Name = "Crime" }, + new Genre { Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), Name = "Dystopian" }, + new Genre { Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), Name = "Fantasy" }, + new Genre { Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), Name = "Historical Fiction" }, + new Genre { Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), Name = "Horror" }, + new Genre { Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), Name = "Literary Fiction" }, + new Genre { Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), Name = "Magical Realism" }, + new Genre { Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), Name = "Memoir" }, + new Genre { Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), Name = "Middle Grade" }, + new Genre { Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), Name = "Mystery" }, + new Genre { Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), Name = "Non-Fiction" }, + new Genre { Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), Name = "Poetry" }, + new Genre { Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), Name = "Romance" }, + new Genre { Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), Name = "Science Fiction" }, + new Genre { Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), Name = "Thriller" }, + new Genre { Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), Name = "Young Adult" } + ]; +} diff --git a/src/Novelly.Api/Program.cs b/src/Novelly.Api/Program.cs index 8ecb001..694e304 100644 --- a/src/Novelly.Api/Program.cs +++ b/src/Novelly.Api/Program.cs @@ -7,6 +7,7 @@ using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Data; +using Novelly.Api.Genres; using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; @@ -86,6 +87,7 @@ app.MapProjectEndpoints() .MapChapterEndpoints() .MapBeatEndpoints() .MapTagEndpoints() + .MapGenreEndpoints() .MapOpenQuestionEndpoints() .MapAgentEndpoints() .MapImportEndpoints(); diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index 94007af..3bbd927 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -10,6 +10,7 @@ import type { Conversation, ConversationSummary, Beat, + Genre, ImportInspection, ImportJob, ImportJobStatus, @@ -22,6 +23,7 @@ import type { export const keys = { projects: ['projects'] as const, + genres: ['genres'] as const, project: (id: string) => ['projects', id] as const, characters: (projectId: string) => ['projects', projectId, 'characters'] as const, tags: (projectId: string) => ['projects', projectId, 'tags'] as const, @@ -213,6 +215,9 @@ export function useDeleteQuestion(projectId: string) { }) } +export const useGenres = () => + useQuery({ queryKey: keys.genres, queryFn: () => api.get('/api/genres') }) + export const useTags = (projectId: string) => useQuery({ queryKey: keys.tags(projectId), diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index 8886ec3..9ff7d90 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -32,6 +32,11 @@ export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete'] +export interface Genre { + id: string + name: string +} + export interface ProjectSummary { id: string title: string diff --git a/src/Novelly.Web/src/components/ConfirmModal.tsx b/src/Novelly.Web/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..794c83c --- /dev/null +++ b/src/Novelly.Web/src/components/ConfirmModal.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' +import { Modal } from './ui' + +export function ConfirmModal({ + title, + message, + confirmLabel = 'Delete', + onConfirm, + onClose, +}: { + title: string + message: ReactNode + confirmLabel?: string + onConfirm: () => void + onClose: () => void +}) { + return ( + +

{message}

+
+ + +
+
+ ) +} diff --git a/src/Novelly.Web/src/components/ui.tsx b/src/Novelly.Web/src/components/ui.tsx index e8058c5..7577c6c 100644 --- a/src/Novelly.Web/src/components/ui.tsx +++ b/src/Novelly.Web/src/components/ui.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' +import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react' import type { DraftStatus } from '../api/types' export function Spinner({ label = 'Loading' }: { label?: string }) { @@ -67,6 +67,7 @@ export function AutoField({ rows = 3, placeholder, serif, + suggestions, onContextMenu, }: { label?: string @@ -76,10 +77,12 @@ export function AutoField({ rows?: number placeholder?: string serif?: boolean + suggestions?: readonly string[] onContextMenu?: (e: MouseEvent) => void }) { const [draft, setDraft] = useState(value ?? '') const committed = useRef(value ?? '') + const suggestionsId = useId() // Adopt changes that arrive from elsewhere (the agent, another tab) unless the user // is mid-edit, which would yank text out from under them. @@ -114,14 +117,24 @@ export function AutoField({ onContextMenu={onContextMenu} /> ) : ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} - /> + <> + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} + /> + {suggestions?.length ? ( + + {suggestions.map((suggestion) => ( + + ) : null} + )} ) @@ -152,6 +165,9 @@ export function Select({ ) } +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])' + export function Modal({ title, onClose, @@ -161,8 +177,33 @@ export function Modal({ onClose: () => void children: ReactNode }) { + const containerRef = useRef(null) + useEffect(() => { - const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose() + const triggerElement = document.activeElement as HTMLElement | null + containerRef.current?.querySelector(FOCUSABLE_SELECTOR)?.focus() + return () => triggerElement?.focus() + }, []) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + return + } + if (e.key !== 'Tab' || !containerRef.current) return + const focusable = containerRef.current.querySelectorAll(FOCUSABLE_SELECTOR) + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } + } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [onClose]) @@ -173,6 +214,7 @@ export function Modal({ onClick={onClose} >
e.stopPropagation()} role="dialog" diff --git a/src/Novelly.Web/src/index.css b/src/Novelly.Web/src/index.css index 50699d0..dd75e1a 100644 --- a/src/Novelly.Web/src/index.css +++ b/src/Novelly.Web/src/index.css @@ -102,6 +102,15 @@ body { background: var(--accent); } + .btn-danger { + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + color: var(--accent); + } + + .btn-danger:hover:not(:disabled) { + background: var(--accent-soft); + } + .input { @apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition; background: var(--surface); diff --git a/src/Novelly.Web/src/pages/ChapterPage.tsx b/src/Novelly.Web/src/pages/ChapterPage.tsx index be62286..ab3e670 100644 --- a/src/Novelly.Web/src/pages/ChapterPage.tsx +++ b/src/Novelly.Web/src/pages/ChapterPage.tsx @@ -13,6 +13,7 @@ import { } from '../api/hooks' import { draftStatuses, type Beat, type Chapter } from '../api/types' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui' +import { ConfirmModal } from '../components/ConfirmModal' import { TagChip, TagEditor } from '../components/TagEditor' import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect' import { useCharacterContextMenu } from '../components/CharacterContextMenu' @@ -32,6 +33,7 @@ export default function ChapterPage() { const remove = useDeleteChapter(projectId) const createBeat = useCreateBeat(chapterId, projectId) const [tab, setTab] = useState('outline') + const [confirmingDelete, setConfirmingDelete] = useState(false) const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId) useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' }) @@ -86,14 +88,14 @@ export default function ChapterPage() { POV character @@ -117,22 +119,25 @@ export default function ChapterPage() {
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
-
+ {confirmingDelete && ( + + remove.mutate(chapter.id, { + onSuccess: () => navigate(`/projects/${projectId}/chapters`), + }) + } + onClose={() => setConfirmingDelete(false)} + /> + )} +
{( [ @@ -246,6 +251,7 @@ function BeatTable({ const remove = useDeleteBeat(chapter.id) const reorder = useReorderBeats(chapter.id) const [editingId, setEditingId] = useState(null) + const [deletingBeat, setDeletingBeat] = useState(null) if (chapter.beats.length === 0) { return ( @@ -388,7 +394,7 @@ function BeatTable({
) } diff --git a/src/Novelly.Web/src/pages/ChaptersPage.tsx b/src/Novelly.Web/src/pages/ChaptersPage.tsx index d4ab20a..b171775 100644 --- a/src/Novelly.Web/src/pages/ChaptersPage.tsx +++ b/src/Novelly.Web/src/pages/ChaptersPage.tsx @@ -16,12 +16,6 @@ export default function ChaptersPage() { return (
-
- - ← Dashboard - -
-

Chapters

-
@@ -256,6 +252,15 @@ function CharacterSheet({ projectId, character }: { projectId: string; character /> + + {confirmingDelete && ( + remove.mutate(character.id)} + onClose={() => setConfirmingDelete(false)} + /> + )} ) } diff --git a/src/Novelly.Web/src/pages/DashboardPage.tsx b/src/Novelly.Web/src/pages/DashboardPage.tsx index ad11625..ccfae96 100644 --- a/src/Novelly.Web/src/pages/DashboardPage.tsx +++ b/src/Novelly.Web/src/pages/DashboardPage.tsx @@ -56,18 +56,6 @@ function OutliningDashboard({ projectId }: { projectId: string }) { return (
- -
diff --git a/src/Novelly.Web/src/pages/ProjectLayout.tsx b/src/Novelly.Web/src/pages/ProjectLayout.tsx index a7d4997..6a91b3d 100644 --- a/src/Novelly.Web/src/pages/ProjectLayout.tsx +++ b/src/Novelly.Web/src/pages/ProjectLayout.tsx @@ -1,9 +1,18 @@ -import { Outlet, useParams, Link, useNavigate } from 'react-router-dom' +import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom' import { useProject, useUpdateProject } from '../api/hooks' import { projectPhases } from '../api/types' import { ErrorNote, Spinner } from '../components/ui' import { useHotkey } from '../keyboard/HotkeysContext' +const sections: { to: string; label: string; end?: boolean }[] = [ + { to: '', label: 'Dashboard', end: true }, + { to: 'chapters', label: 'Chapters' }, + { to: 'characters', label: 'Characters' }, + { to: 'tags', label: 'Tags' }, + { to: 'agent', label: 'Agent' }, + { to: 'settings', label: 'Settings' }, +] + export default function ProjectLayout() { const { projectId = '' } = useParams() const navigate = useNavigate() @@ -44,6 +53,23 @@ export default function ProjectLayout() { )}
+
diff --git a/src/Novelly.Web/src/pages/SettingsPage.tsx b/src/Novelly.Web/src/pages/SettingsPage.tsx index 070fc11..4c61465 100644 --- a/src/Novelly.Web/src/pages/SettingsPage.tsx +++ b/src/Novelly.Web/src/pages/SettingsPage.tsx @@ -1,8 +1,16 @@ import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { useChapters, useCharacters, useDeleteProject, useProject, useUpdateProject } from '../api/hooks' +import { + useChapters, + useCharacters, + useDeleteProject, + useGenres, + useProject, + useUpdateProject, +} from '../api/hooks' import { ImportDialog } from '../components/ImportDialog' import { AutoField, ErrorNote, Spinner } from '../components/ui' +import { ConfirmModal } from '../components/ConfirmModal' export default function SettingsPage() { const { projectId = '' } = useParams() @@ -10,9 +18,11 @@ export default function SettingsPage() { const { data: project, isPending } = useProject(projectId) const { data: characters } = useCharacters(projectId) const { data: chapters } = useChapters(projectId) + const { data: genres } = useGenres() const update = useUpdateProject(projectId) const remove = useDeleteProject() const [importing, setImporting] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useState(false) if (isPending || !project) return @@ -36,7 +46,13 @@ export default function SettingsPage() { value={project.author} onCommit={(author) => update.mutate({ author })} /> - update.mutate({ genre })} /> + g.name)} + onCommit={(genre) => update.mutate({ genre })} + />
Deleting a novel removes its outline, characters, chapters and conversations.

-
@@ -154,6 +162,15 @@ export default function SettingsPage() { onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)} /> )} + + {confirmingDelete && ( + remove.mutate(projectId, { onSuccess: () => navigate('/') })} + onClose={() => setConfirmingDelete(false)} + /> + )} ) } diff --git a/src/Novelly.Web/src/pages/TagsPage.tsx b/src/Novelly.Web/src/pages/TagsPage.tsx index 41bfca3..19e632c 100644 --- a/src/Novelly.Web/src/pages/TagsPage.tsx +++ b/src/Novelly.Web/src/pages/TagsPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Link, useParams } from 'react-router-dom' import { useDeleteTag, useTagReferences, useTags, useUpdateTag } from '../api/hooks' import { EmptyState, ErrorNote, Spinner } from '../components/ui' +import { ConfirmModal } from '../components/ConfirmModal' import { TagChip } from '../components/TagEditor' export default function TagsPage() { @@ -65,6 +66,7 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str const { data, isPending, error } = useTagReferences(tagId) const update = useUpdateTag(projectId) const remove = useDeleteTag() + const [confirmingDelete, setConfirmingDelete] = useState(false) if (isPending) return if (error) return @@ -96,20 +98,22 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str onBlur={(e) => update.mutate({ id: tagId, color: e.target.value })} /> - {update.error && } + {confirmingDelete && ( + remove.mutate(tagId)} + onClose={() => setConfirmingDelete(false)} + /> + )} + {empty && ( + { + Assert.That(listed, Is.Not.Empty); + Assert.That(listed.Select(g => g.Name), Has.Member("Fantasy").And.Member("Literary Fiction")); + Assert.That(listed.Select(g => g.Name), Is.Ordered); + Assert.That(listed.Select(g => g.Id), Is.Unique); + }); + } + + [Test] + public async Task A_project_can_be_filed_under_a_genre_off_the_list() + { + var fantasy = (await Genres.ListAsync()).First(g => g.Name == "Fantasy"); + + var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road", Genre: fantasy.Name)); + + Assert.That(project.Genre, Is.EqualTo("Fantasy")); + } + + [Test] + public async Task A_project_can_still_carry_a_genre_that_is_not_on_the_list() + { + var project = await Projects.CreateAsync( + new CreateProjectRequest("The Salt Road", Genre: "Nautical Gothic")); + + Assert.Multiple(async () => + { + Assert.That(project.Genre, Is.EqualTo("Nautical Gothic")); + Assert.That((await Genres.ListAsync()).Select(g => g.Name), Has.No.Member("Nautical Gothic")); + }); + } +} diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index 07c3de9..6768c46 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -1,6 +1,7 @@ using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; +using Novelly.Api.Genres; using Novelly.Api.Projects; using Novelly.Api.Questions; using Novelly.Api.Tags; @@ -17,6 +18,7 @@ public abstract class ServiceTestFixture protected BeatService Beats { get; private set; } = null!; protected CharacterArcService Arcs { get; private set; } = null!; protected OpenQuestionService Questions { get; private set; } = null!; + protected GenreService Genres { get; private set; } = null!; protected CapturingLogger ProjectLogs { get; private set; } = null!; protected CapturingLogger CharacterLogs { get; private set; } = null!; @@ -25,6 +27,7 @@ public abstract class ServiceTestFixture protected CapturingLogger TagLogs { get; private set; } = null!; protected CapturingLogger ArcLogs { get; private set; } = null!; protected CapturingLogger QuestionLogs { get; private set; } = null!; + protected CapturingLogger GenreLogs { get; private set; } = null!; [SetUp] public void SetUpFixture() @@ -38,6 +41,7 @@ public abstract class ServiceTestFixture BeatLogs = new CapturingLogger(); ArcLogs = new CapturingLogger(); QuestionLogs = new CapturingLogger(); + GenreLogs = new CapturingLogger(); Tags = new TagService(Db.Context, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator()); Projects = new ProjectService(Db.Context, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator()); @@ -54,6 +58,7 @@ public abstract class ServiceTestFixture Questions = new OpenQuestionService( Db.Context, QuestionLogs, new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator()); + Genres = new GenreService(Db.Context, GenreLogs); OnSetUp(); }