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:
@@ -1,8 +1,3 @@
|
||||
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);
|
||||
|
||||
@@ -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<CharacterArcService>();
|
||||
services.AddScoped<BeatService>();
|
||||
services.AddScoped<TagService>();
|
||||
services.AddScoped<GenreService>();
|
||||
services.AddScoped<ChapterService>();
|
||||
services.AddScoped<OpenQuestionService>();
|
||||
services.AddScoped<NovelAgentToolset>();
|
||||
|
||||
@@ -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<AgentConversation> Conversations { get; }
|
||||
DbSet<AgentMessage> AgentMessages { get; }
|
||||
DbSet<ImportJob> ImportJobs { get; }
|
||||
DbSet<Genre> Genres { get; }
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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")
|
||||
|
||||
@@ -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<NovelDbContext> options)
|
||||
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
||||
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
|
||||
public DbSet<ImportJob> ImportJobs => Set<ImportJob>();
|
||||
public DbSet<Genre> Genres => Set<Genre>();
|
||||
|
||||
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) =>
|
||||
base.SaveChangesAsync(cancellationToken);
|
||||
@@ -150,6 +152,13 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
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 =>
|
||||
{
|
||||
entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Genre[]>('/api/genres') })
|
||||
|
||||
export const useTags = (projectId: string) =>
|
||||
useQuery({
|
||||
queryKey: keys.tags(projectId),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLTextAreaElement>) => 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}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={className}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
||||
/>
|
||||
<>
|
||||
<input
|
||||
className={className}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
list={suggestions?.length ? suggestionsId : undefined}
|
||||
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>
|
||||
)
|
||||
@@ -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({
|
||||
title,
|
||||
onClose,
|
||||
@@ -161,8 +177,33 @@ export function Modal({
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
@@ -173,6 +214,7 @@ export function Modal({
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="card mt-12 w-full max-w-lg p-5 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ChapterTab>('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() {
|
||||
<span className="label">POV character</span>
|
||||
<select
|
||||
className="input"
|
||||
value={chapter.povCharacterName ?? '—'}
|
||||
onChange={(e) => {
|
||||
const match = characters?.find((c) => c.name === e.target.value)
|
||||
patch({ povCharacterId: match?.id ?? null })
|
||||
}}
|
||||
value={chapter.povCharacterId ?? ''}
|
||||
onChange={(e) => patch({ povCharacterId: e.target.value || null })}
|
||||
>
|
||||
{['—', ...(characters?.map((c) => c.name) ?? [])].map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
<option value="">—</option>
|
||||
{characters?.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
@@ -117,22 +119,25 @@ export default function ChapterPage() {
|
||||
<div className="text-sm muted">
|
||||
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
|
||||
</div>
|
||||
<button
|
||||
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`),
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete chapter
|
||||
</button>
|
||||
</div>
|
||||
</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)' }}>
|
||||
{(
|
||||
[
|
||||
@@ -246,6 +251,7 @@ function BeatTable({
|
||||
const remove = useDeleteBeat(chapter.id)
|
||||
const reorder = useReorderBeats(chapter.id)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
|
||||
|
||||
if (chapter.beats.length === 0) {
|
||||
return (
|
||||
@@ -388,7 +394,7 @@ function BeatTable({
|
||||
<button
|
||||
className="text-xs muted leading-none transition hover:opacity-100"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
|
||||
onClick={() => setDeletingBeat(beat)}
|
||||
aria-label={`Delete beat ${beat.title}`}
|
||||
>
|
||||
✕
|
||||
@@ -400,9 +406,18 @@ function BeatTable({
|
||||
<tr
|
||||
key={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)' }}
|
||||
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>
|
||||
|
||||
@@ -443,6 +458,15 @@ function BeatTable({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{deletingBeat && (
|
||||
<ConfirmModal
|
||||
title="Delete beat"
|
||||
message={`Delete beat "${deletingBeat.title}"?`}
|
||||
onConfirm={() => remove.mutate(deletingBeat.id)}
|
||||
onClose={() => setDeletingBeat(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,6 @@ export default function ChaptersPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-xl font-semibold">Chapters</h2>
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../api/hooks'
|
||||
import { characterImportances, characterRoles, type Character } from '../api/types'
|
||||
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { TagEditor } from '../components/TagEditor'
|
||||
import { CharacterArc } from '../components/CharacterArc'
|
||||
import { CharacterBeats } from '../components/CharacterBeats'
|
||||
@@ -87,6 +88,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
const { data: allTags } = useTags(projectId)
|
||||
const update = useUpdateCharacter(projectId)
|
||||
const remove = useDeleteCharacter(projectId)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const patch = (body: Partial<Omit<Character, 'tags'>> & { tags?: string[] }) =>
|
||||
update.mutate({ id: character.id, ...body })
|
||||
|
||||
@@ -113,13 +115,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
onChange={(importance) => patch({ importance })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn mt-6"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete ${character.name}?`)) remove.mutate(character.id)
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -256,6 +252,15 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
/>
|
||||
|
||||
<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 (
|
||||
<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">
|
||||
<section className="card p-5 lg:col-span-2">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
|
||||
@@ -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() {
|
||||
</select>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<main className="mx-auto max-w-[100rem] px-6 py-8">
|
||||
|
||||
@@ -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 <Spinner label="Loading brief" />
|
||||
|
||||
@@ -36,7 +46,13 @@ export default function SettingsPage() {
|
||||
value={project.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>
|
||||
<AutoField
|
||||
label="Logline"
|
||||
@@ -134,15 +150,7 @@ export default function SettingsPage() {
|
||||
<p className="mb-3 text-sm muted">
|
||||
Deleting a novel removes its outline, characters, chapters and conversations.
|
||||
</p>
|
||||
<button
|
||||
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('/') })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-danger w-full" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete this novel
|
||||
</button>
|
||||
</div>
|
||||
@@ -154,6 +162,15 @@ export default function SettingsPage() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <Spinner label="Loading references" />
|
||||
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 })}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() =>
|
||||
confirm(`Delete the tag “${data.tag.name}”? What carries it is left alone.`) &&
|
||||
remove.mutate(tagId)
|
||||
}
|
||||
>
|
||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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 && (
|
||||
<EmptyState
|
||||
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.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<ProjectService> ProjectLogs { 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<CharacterArcService> ArcLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!;
|
||||
protected CapturingLogger<GenreService> GenreLogs { get; private set; } = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUpFixture()
|
||||
@@ -38,6 +41,7 @@ public abstract class ServiceTestFixture
|
||||
BeatLogs = new CapturingLogger<BeatService>();
|
||||
ArcLogs = new CapturingLogger<CharacterArcService>();
|
||||
QuestionLogs = new CapturingLogger<OpenQuestionService>();
|
||||
GenreLogs = new CapturingLogger<GenreService>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user