Add genre feature; extract ConfirmModal for reusable confirm dialogs

Genres seed on boot, are editable per project, and gate agent config
errors more gracefully. ConfirmModal replaces ad-hoc confirm prompts
across chapters, tags, and characters pages.
This commit is contained in:
James Wampler
2026-08-15 11:25:13 -07:00
parent ffb476a81a
commit 27c287b9c8
27 changed files with 1428 additions and 80 deletions
@@ -1,8 +1,3 @@
namespace Novelly.Api.Common; namespace Novelly.Api.Common;
/// <summary>
/// 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.
/// </summary>
public class AgentNotConfiguredException(string message) : Exception(message); public class AgentNotConfiguredException(string message) : Exception(message);
@@ -6,6 +6,7 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
@@ -28,6 +29,7 @@ public static class NovellyServiceRegistration
services.AddScoped<CharacterArcService>(); services.AddScoped<CharacterArcService>();
services.AddScoped<BeatService>(); services.AddScoped<BeatService>();
services.AddScoped<TagService>(); services.AddScoped<TagService>();
services.AddScoped<GenreService>();
services.AddScoped<ChapterService>(); services.AddScoped<ChapterService>();
services.AddScoped<OpenQuestionService>(); services.AddScoped<OpenQuestionService>();
services.AddScoped<NovelAgentToolset>(); services.AddScoped<NovelAgentToolset>();
+2
View File
@@ -3,6 +3,7 @@ using Novelly.Api.Agent;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
@@ -23,6 +24,7 @@ public interface INovelDbContext
DbSet<AgentConversation> Conversations { get; } DbSet<AgentConversation> Conversations { get; }
DbSet<AgentMessage> AgentMessages { get; } DbSet<AgentMessage> AgentMessages { get; }
DbSet<ImportJob> ImportJobs { get; } DbSet<ImportJob> ImportJobs { get; }
DbSet<Genre> Genres { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
} }
@@ -0,0 +1,879 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("BeatCharacter", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "CharactersId");
b.HasIndex("CharactersId");
b.ToTable("BeatCharacters", (string)null);
});
modelBuilder.Entity("BeatTag", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("BeatTags", (string)null);
});
modelBuilder.Entity("ChapterTag", b =>
{
b.Property<Guid>("ChaptersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("ChaptersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("ChapterTags", (string)null);
});
modelBuilder.Entity("CharacterTag", b =>
{
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("CharactersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("CharacterTags", (string)null);
});
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Conversations");
});
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("ConversationId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<int>("Sequence")
.HasColumnType("INTEGER");
b.Property<string>("ToolCallsJson")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ConversationId", "Sequence")
.IsUnique();
b.ToTable("AgentMessages");
});
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("WhatHappened")
.HasColumnType("TEXT");
b.Property<string>("WhatsNext")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Beats");
});
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Age")
.HasColumnType("TEXT");
b.Property<string>("Appearance")
.HasColumnType("TEXT");
b.Property<string>("ArcSummary")
.HasColumnType("TEXT");
b.Property<string>("Backstory")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ExternalConflict")
.HasColumnType("TEXT");
b.Property<string>("Importance")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("InternalConflict")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Need")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Occupation")
.HasColumnType("TEXT");
b.Property<string>("Personality")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Pronouns")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Voice")
.HasColumnType("TEXT");
b.Property<string>("Want")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Characters");
});
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ChapterId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<Guid>("RelatedCharacterId")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ChaptersCompleted")
.HasColumnType("INTEGER");
b.Property<int>("ChaptersTotal")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("SourceRoot")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("StatusMessage")
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SourceRoot");
b.ToTable("ImportJobs");
});
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Author")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasColumnType("TEXT");
b.Property<string>("Logline")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Phase")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Synopsis")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ChapterId")
.HasColumnType("TEXT");
b.Property<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Detail")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Resolution")
.HasColumnType("TEXT");
b.Property<long?>("ResolvedAt")
.HasColumnType("INTEGER");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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
}
}
}
@@ -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
{
/// <inheritdoc />
public partial class AddGenres : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Genres",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Genres");
}
}
}
@@ -376,6 +376,117 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("CharacterRelationships"); b.ToTable("CharacterRelationships");
}); });
modelBuilder.Entity("Novelly.Api.Genres.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("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 => modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
+9
View File
@@ -4,6 +4,7 @@ using Novelly.Api.Agent;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
@@ -30,6 +31,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
public DbSet<AgentConversation> Conversations => Set<AgentConversation>(); public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>(); public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
public DbSet<ImportJob> ImportJobs => Set<ImportJob>(); public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
public DbSet<Genre> Genres => Set<Genre>();
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) => Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) =>
base.SaveChangesAsync(cancellationToken); base.SaveChangesAsync(cancellationToken);
@@ -150,6 +152,13 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique(); entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique();
}); });
builder.Entity<Genre>(entity =>
{
entity.Property(g => g.Name).IsRequired().HasMaxLength(100);
entity.HasIndex(g => g.Name).IsUnique();
entity.HasData(SeededGenres.All);
});
builder.Entity<ImportJob>(entity => builder.Entity<ImportJob>(entity =>
{ {
entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000); entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000);
+8
View File
@@ -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;
}
+8
View File
@@ -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);
}
+18
View File
@@ -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<RequestLoggingEndpointFilter>();
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;
}
}
+17
View File
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Genres;
public class GenreService(INovelDbContext db, ILogger<GenreService> logger)
{
public async Task<IReadOnlyList<GenreResponse>> 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);
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Novelly.Api.Genres;
public static class SeededGenres
{
public static IReadOnlyList<Genre> 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" }
];
}
+2
View File
@@ -7,6 +7,7 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
@@ -86,6 +87,7 @@ app.MapProjectEndpoints()
.MapChapterEndpoints() .MapChapterEndpoints()
.MapBeatEndpoints() .MapBeatEndpoints()
.MapTagEndpoints() .MapTagEndpoints()
.MapGenreEndpoints()
.MapOpenQuestionEndpoints() .MapOpenQuestionEndpoints()
.MapAgentEndpoints() .MapAgentEndpoints()
.MapImportEndpoints(); .MapImportEndpoints();
+5
View File
@@ -10,6 +10,7 @@ import type {
Conversation, Conversation,
ConversationSummary, ConversationSummary,
Beat, Beat,
Genre,
ImportInspection, ImportInspection,
ImportJob, ImportJob,
ImportJobStatus, ImportJobStatus,
@@ -22,6 +23,7 @@ import type {
export const keys = { export const keys = {
projects: ['projects'] as const, projects: ['projects'] as const,
genres: ['genres'] as const,
project: (id: string) => ['projects', id] as const, project: (id: string) => ['projects', id] as const,
characters: (projectId: string) => ['projects', projectId, 'characters'] as const, characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
tags: (projectId: string) => ['projects', projectId, 'tags'] 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<Genre[]>('/api/genres') })
export const useTags = (projectId: string) => export const useTags = (projectId: string) =>
useQuery({ useQuery({
queryKey: keys.tags(projectId), queryKey: keys.tags(projectId),
+5
View File
@@ -32,6 +32,11 @@ export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing'
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete'] export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
export interface Genre {
id: string
name: string
}
export interface ProjectSummary { export interface ProjectSummary {
id: string id: string
title: string title: string
@@ -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 (
<Modal title={title} onClose={onClose}>
<p className="text-sm">{message}</p>
<div className="mt-4 flex justify-end gap-2">
<button className="btn" onClick={onClose}>
Cancel
</button>
<button
className="btn btn-danger"
onClick={() => {
onConfirm()
onClose()
}}
>
{confirmLabel}
</button>
</div>
</Modal>
)
}
+52 -10
View File
@@ -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' import type { DraftStatus } from '../api/types'
export function Spinner({ label = 'Loading' }: { label?: string }) { export function Spinner({ label = 'Loading' }: { label?: string }) {
@@ -67,6 +67,7 @@ export function AutoField({
rows = 3, rows = 3,
placeholder, placeholder,
serif, serif,
suggestions,
onContextMenu, onContextMenu,
}: { }: {
label?: string label?: string
@@ -76,10 +77,12 @@ export function AutoField({
rows?: number rows?: number
placeholder?: string placeholder?: string
serif?: boolean serif?: boolean
suggestions?: readonly string[]
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
}) { }) {
const [draft, setDraft] = useState(value ?? '') const [draft, setDraft] = useState(value ?? '')
const committed = useRef(value ?? '') const committed = useRef(value ?? '')
const suggestionsId = useId()
// Adopt changes that arrive from elsewhere (the agent, another tab) unless the user // Adopt changes that arrive from elsewhere (the agent, another tab) unless the user
// is mid-edit, which would yank text out from under them. // is mid-edit, which would yank text out from under them.
@@ -114,14 +117,24 @@ export function AutoField({
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
/> />
) : ( ) : (
<input <>
className={className} <input
value={draft} className={className}
placeholder={placeholder} value={draft}
onChange={(e) => setDraft(e.target.value)} placeholder={placeholder}
onBlur={commit} list={suggestions?.length ? suggestionsId : undefined}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} onChange={(e) => setDraft(e.target.value)}
/> onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
/>
{suggestions?.length ? (
<datalist id={suggestionsId}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
) : null}
</>
)} )}
</label> </label>
) )
@@ -152,6 +165,9 @@ export function Select<T extends string>({
) )
} }
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
export function Modal({ export function Modal({
title, title,
onClose, onClose,
@@ -161,8 +177,33 @@ export function Modal({
onClose: () => void onClose: () => void
children: ReactNode children: ReactNode
}) { }) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose() const triggerElement = document.activeElement as HTMLElement | null
containerRef.current?.querySelector<HTMLElement>(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<HTMLElement>(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) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [onClose]) }, [onClose])
@@ -173,6 +214,7 @@ export function Modal({
onClick={onClose} onClick={onClose}
> >
<div <div
ref={containerRef}
className="card mt-12 w-full max-w-lg p-5 shadow-xl" className="card mt-12 w-full max-w-lg p-5 shadow-xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
+9
View File
@@ -102,6 +102,15 @@ body {
background: var(--accent); 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 { .input {
@apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition; @apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition;
background: var(--surface); background: var(--surface);
+44 -20
View File
@@ -13,6 +13,7 @@ import {
} from '../api/hooks' } from '../api/hooks'
import { draftStatuses, type Beat, type Chapter } from '../api/types' import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagChip, TagEditor } from '../components/TagEditor' import { TagChip, TagEditor } from '../components/TagEditor'
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect' import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
import { useCharacterContextMenu } from '../components/CharacterContextMenu' import { useCharacterContextMenu } from '../components/CharacterContextMenu'
@@ -32,6 +33,7 @@ export default function ChapterPage() {
const remove = useDeleteChapter(projectId) const remove = useDeleteChapter(projectId)
const createBeat = useCreateBeat(chapterId, projectId) const createBeat = useCreateBeat(chapterId, projectId)
const [tab, setTab] = useState<ChapterTab>('outline') const [tab, setTab] = useState<ChapterTab>('outline')
const [confirmingDelete, setConfirmingDelete] = useState(false)
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId) const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId)
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' }) useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
@@ -86,14 +88,14 @@ export default function ChapterPage() {
<span className="label">POV character</span> <span className="label">POV character</span>
<select <select
className="input" className="input"
value={chapter.povCharacterName ?? ''} value={chapter.povCharacterId ?? ''}
onChange={(e) => { onChange={(e) => patch({ povCharacterId: e.target.value || null })}
const match = characters?.find((c) => c.name === e.target.value)
patch({ povCharacterId: match?.id ?? null })
}}
> >
{['—', ...(characters?.map((c) => c.name) ?? [])].map((name) => ( <option value=""></option>
<option key={name}>{name}</option> {characters?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))} ))}
</select> </select>
</label> </label>
@@ -117,22 +119,25 @@ export default function ChapterPage() {
<div className="text-sm muted"> <div className="text-sm muted">
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words {chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div> </div>
<button <button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
className="btn"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm(`Delete chapter “${chapter.title}” and everything in it?`)) {
remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
})
}
}}
>
Delete chapter Delete chapter
</button> </button>
</div> </div>
</section> </section>
{confirmingDelete && (
<ConfirmModal
title="Delete chapter"
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
onConfirm={() =>
remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
})
}
onClose={() => setConfirmingDelete(false)}
/>
)}
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}> <div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
{( {(
[ [
@@ -246,6 +251,7 @@ function BeatTable({
const remove = useDeleteBeat(chapter.id) const remove = useDeleteBeat(chapter.id)
const reorder = useReorderBeats(chapter.id) const reorder = useReorderBeats(chapter.id)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
if (chapter.beats.length === 0) { if (chapter.beats.length === 0) {
return ( return (
@@ -388,7 +394,7 @@ function BeatTable({
<button <button
className="text-xs muted leading-none transition hover:opacity-100" className="text-xs muted leading-none transition hover:opacity-100"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)} onClick={() => setDeletingBeat(beat)}
aria-label={`Delete beat ${beat.title}`} aria-label={`Delete beat ${beat.title}`}
> >
@@ -400,9 +406,18 @@ function BeatTable({
<tr <tr
key={beat.id} key={beat.id}
id={`beat-${beat.id}`} id={`beat-${beat.id}`}
className="cursor-pointer transition hover:brightness-110" tabIndex={0}
role="button"
aria-label={`Edit beat ${beat.title}`}
className="cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]"
style={{ borderBottom: '1px solid var(--line)' }} style={{ borderBottom: '1px solid var(--line)' }}
onClick={() => setEditingId(beat.id)} onClick={() => setEditingId(beat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setEditingId(beat.id)
}
}}
> >
<td className="px-2 py-2 align-top text-xs muted">{index + 1}</td> <td className="px-2 py-2 align-top text-xs muted">{index + 1}</td>
@@ -443,6 +458,15 @@ function BeatTable({
)} )}
</tbody> </tbody>
</table> </table>
{deletingBeat && (
<ConfirmModal
title="Delete beat"
message={`Delete beat "${deletingBeat.title}"?`}
onConfirm={() => remove.mutate(deletingBeat.id)}
onClose={() => setDeletingBeat(null)}
/>
)}
</div> </div>
) )
} }
@@ -16,12 +16,6 @@ export default function ChaptersPage() {
return ( return (
<div> <div>
<div className="mb-4">
<Link to={`/projects/${projectId}`} className="text-sm muted hover:underline">
Dashboard
</Link>
</div>
<div className="mb-5 flex items-center justify-between gap-4"> <div className="mb-5 flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">Chapters</h2> <h2 className="text-xl font-semibold">Chapters</h2>
<button <button
+12 -7
View File
@@ -9,6 +9,7 @@ import {
} from '../api/hooks' } from '../api/hooks'
import { characterImportances, characterRoles, type Character } from '../api/types' import { characterImportances, characterRoles, type Character } from '../api/types'
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui' import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagEditor } from '../components/TagEditor' import { TagEditor } from '../components/TagEditor'
import { CharacterArc } from '../components/CharacterArc' import { CharacterArc } from '../components/CharacterArc'
import { CharacterBeats } from '../components/CharacterBeats' import { CharacterBeats } from '../components/CharacterBeats'
@@ -87,6 +88,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
const { data: allTags } = useTags(projectId) const { data: allTags } = useTags(projectId)
const update = useUpdateCharacter(projectId) const update = useUpdateCharacter(projectId)
const remove = useDeleteCharacter(projectId) const remove = useDeleteCharacter(projectId)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const patch = (body: Partial<Omit<Character, 'tags'>> & { tags?: string[] }) => const patch = (body: Partial<Omit<Character, 'tags'>> & { tags?: string[] }) =>
update.mutate({ id: character.id, ...body }) update.mutate({ id: character.id, ...body })
@@ -113,13 +115,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
onChange={(importance) => patch({ importance })} onChange={(importance) => patch({ importance })}
/> />
</div> </div>
<button <button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
className="btn mt-6"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm(`Delete ${character.name}?`)) remove.mutate(character.id)
}}
>
Delete Delete
</button> </button>
</div> </div>
@@ -256,6 +252,15 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
/> />
<OpenQuestions projectId={projectId} scope={{ characterId: character.id }} /> <OpenQuestions projectId={projectId} scope={{ characterId: character.id }} />
{confirmingDelete && (
<ConfirmModal
title="Delete character"
message={`Delete ${character.name}? This cannot be undone.`}
onConfirm={() => remove.mutate(character.id)}
onClose={() => setConfirmingDelete(false)}
/>
)}
</> </>
) )
} }
@@ -56,18 +56,6 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
return ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<nav className="flex flex-wrap gap-4 text-sm">
<Link to="tags" className="muted hover:underline">
Tags
</Link>
<Link to="agent" className="muted hover:underline">
Agent
</Link>
<Link to="settings" className="muted hover:underline">
Settings
</Link>
</nav>
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
<section className="card p-5 lg:col-span-2"> <section className="card p-5 lg:col-span-2">
<div className="mb-4 flex items-center justify-between gap-4"> <div className="mb-4 flex items-center justify-between gap-4">
+27 -1
View File
@@ -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 { useProject, useUpdateProject } from '../api/hooks'
import { projectPhases } from '../api/types' import { projectPhases } from '../api/types'
import { ErrorNote, Spinner } from '../components/ui' import { ErrorNote, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext' 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() { export default function ProjectLayout() {
const { projectId = '' } = useParams() const { projectId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@@ -44,6 +53,23 @@ export default function ProjectLayout() {
</select> </select>
)} )}
</div> </div>
<nav className="mx-auto flex max-w-[100rem] gap-1 px-6 pb-2 text-sm">
{sections.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`rounded-md px-3 py-1.5 font-medium transition ${isActive ? '' : 'muted hover:opacity-100'}`
}
style={({ isActive }) =>
isActive ? { background: 'var(--accent-soft)', color: 'var(--accent)' } : undefined
}
>
{label}
</NavLink>
))}
</nav>
</header> </header>
<main className="mx-auto max-w-[100rem] px-6 py-8"> <main className="mx-auto max-w-[100rem] px-6 py-8">
+28 -11
View File
@@ -1,8 +1,16 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' 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 { ImportDialog } from '../components/ImportDialog'
import { AutoField, ErrorNote, Spinner } from '../components/ui' import { AutoField, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
export default function SettingsPage() { export default function SettingsPage() {
const { projectId = '' } = useParams() const { projectId = '' } = useParams()
@@ -10,9 +18,11 @@ export default function SettingsPage() {
const { data: project, isPending } = useProject(projectId) const { data: project, isPending } = useProject(projectId)
const { data: characters } = useCharacters(projectId) const { data: characters } = useCharacters(projectId)
const { data: chapters } = useChapters(projectId) const { data: chapters } = useChapters(projectId)
const { data: genres } = useGenres()
const update = useUpdateProject(projectId) const update = useUpdateProject(projectId)
const remove = useDeleteProject() const remove = useDeleteProject()
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
if (isPending || !project) return <Spinner label="Loading brief" /> if (isPending || !project) return <Spinner label="Loading brief" />
@@ -36,7 +46,13 @@ export default function SettingsPage() {
value={project.author} value={project.author}
onCommit={(author) => update.mutate({ author })} onCommit={(author) => update.mutate({ author })}
/> />
<AutoField label="Genre" value={project.genre} onCommit={(genre) => update.mutate({ genre })} /> <AutoField
label="Genre"
value={project.genre}
placeholder="Pick one, or name your own."
suggestions={genres?.map((g) => g.name)}
onCommit={(genre) => update.mutate({ genre })}
/>
</div> </div>
<AutoField <AutoField
label="Logline" label="Logline"
@@ -134,15 +150,7 @@ export default function SettingsPage() {
<p className="mb-3 text-sm muted"> <p className="mb-3 text-sm muted">
Deleting a novel removes its outline, characters, chapters and conversations. Deleting a novel removes its outline, characters, chapters and conversations.
</p> </p>
<button <button className="btn btn-danger w-full" onClick={() => setConfirmingDelete(true)}>
className="btn w-full"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm(`Delete "${project.title}" and everything in it? This cannot be undone.`)) {
remove.mutate(projectId, { onSuccess: () => navigate('/') })
}
}}
>
Delete this novel Delete this novel
</button> </button>
</div> </div>
@@ -154,6 +162,15 @@ export default function SettingsPage() {
onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)} onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)}
/> />
)} )}
{confirmingDelete && (
<ConfirmModal
title="Delete novel"
message={`Delete "${project.title}" and everything in it? This cannot be undone.`}
onConfirm={() => remove.mutate(projectId, { onSuccess: () => navigate('/') })}
onClose={() => setConfirmingDelete(false)}
/>
)}
</div> </div>
) )
} }
+12 -8
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useDeleteTag, useTagReferences, useTags, useUpdateTag } from '../api/hooks' import { useDeleteTag, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { EmptyState, ErrorNote, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagChip } from '../components/TagEditor' import { TagChip } from '../components/TagEditor'
export default function TagsPage() { export default function TagsPage() {
@@ -65,6 +66,7 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
const { data, isPending, error } = useTagReferences(tagId) const { data, isPending, error } = useTagReferences(tagId)
const update = useUpdateTag(projectId) const update = useUpdateTag(projectId)
const remove = useDeleteTag() const remove = useDeleteTag()
const [confirmingDelete, setConfirmingDelete] = useState(false)
if (isPending) return <Spinner label="Loading references" /> if (isPending) return <Spinner label="Loading references" />
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
@@ -96,20 +98,22 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
onBlur={(e) => update.mutate({ id: tagId, color: e.target.value })} onBlur={(e) => update.mutate({ id: tagId, color: e.target.value })}
/> />
</label> </label>
<button <button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
className="btn"
style={{ color: 'var(--accent)' }}
onClick={() =>
confirm(`Delete the tag “${data.tag.name}”? What carries it is left alone.`) &&
remove.mutate(tagId)
}
>
Delete tag Delete tag
</button> </button>
</div> </div>
{update.error && <ErrorNote error={update.error} />} {update.error && <ErrorNote error={update.error} />}
{confirmingDelete && (
<ConfirmModal
title="Delete tag"
message={`Delete the tag "${data.tag.name}"? What carries it is left alone.`}
onConfirm={() => remove.mutate(tagId)}
onClose={() => setConfirmingDelete(false)}
/>
)}
{empty && ( {empty && (
<EmptyState <EmptyState
title="Nothing carries this tag" title="Nothing carries this tag"
@@ -0,0 +1,44 @@
using Novelly.Api.Projects;
namespace Novelly.Api.Tests;
[TestFixture]
public class GenreServiceTests : ServiceTestFixture
{
[Test]
public async Task The_genre_list_arrives_seeded_and_alphabetical()
{
var listed = await Genres.ListAsync();
Assert.Multiple(() =>
{
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"));
});
}
}
@@ -1,6 +1,7 @@
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Genres;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -17,6 +18,7 @@ public abstract class ServiceTestFixture
protected BeatService Beats { get; private set; } = null!; protected BeatService Beats { get; private set; } = null!;
protected CharacterArcService Arcs { get; private set; } = null!; protected CharacterArcService Arcs { get; private set; } = null!;
protected OpenQuestionService Questions { get; private set; } = null!; protected OpenQuestionService Questions { get; private set; } = null!;
protected GenreService Genres { get; private set; } = null!;
protected CapturingLogger<ProjectService> ProjectLogs { get; private set; } = null!; protected CapturingLogger<ProjectService> ProjectLogs { get; private set; } = null!;
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!; protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
@@ -25,6 +27,7 @@ public abstract class ServiceTestFixture
protected CapturingLogger<TagService> TagLogs { get; private set; } = null!; protected CapturingLogger<TagService> TagLogs { get; private set; } = null!;
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!; protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!; protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!;
protected CapturingLogger<GenreService> GenreLogs { get; private set; } = null!;
[SetUp] [SetUp]
public void SetUpFixture() public void SetUpFixture()
@@ -38,6 +41,7 @@ public abstract class ServiceTestFixture
BeatLogs = new CapturingLogger<BeatService>(); BeatLogs = new CapturingLogger<BeatService>();
ArcLogs = new CapturingLogger<CharacterArcService>(); ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>(); QuestionLogs = new CapturingLogger<OpenQuestionService>();
GenreLogs = new CapturingLogger<GenreService>();
Tags = new TagService(Db.Context, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator()); Tags = new TagService(Db.Context, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Projects = new ProjectService(Db.Context, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator()); Projects = new ProjectService(Db.Context, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator());
@@ -54,6 +58,7 @@ public abstract class ServiceTestFixture
Questions = new OpenQuestionService( Questions = new OpenQuestionService(
Db.Context, QuestionLogs, Db.Context, QuestionLogs,
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator()); new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
Genres = new GenreService(Db.Context, GenreLogs);
OnSetUp(); OnSetUp();
} }