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;
|
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>();
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.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();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
<input
|
||||||
className={className}
|
className={className}
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
list={suggestions?.length ? suggestionsId : undefined}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user