From e7d89a6d418b38023611b2dddd8e5e5dd62ceb14 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Mon, 10 Aug 2026 22:46:09 -0700 Subject: [PATCH] Add novel dashboard with project phases; fix beat/chapter overflow Dashboard replaces the tabbed overview: Outline + Characters cards (recent-first), gated by a new Project.Phase (Brainstorming -> Outlining -> Writing -> Editing -> Complete). Brainstorming shows just a notes editor; existing projects backfill to Outlining. Old brief editor moves to a Settings route. Beat table now renders as plain readable text, turning into editable fields only for the clicked row. Fixed horizontal overflow on the chapters list and beat table caused by unconstrained CSS grid/table tracks sizing to their widest unwrapped child. Widened the project layout's max width. --- src/Novelly.Api/Chapters/ChapterContracts.cs | 6 +- ...20260811004337_AddProjectPhase.Designer.cs | 837 ++++++++++++++++++ .../20260811004337_AddProjectPhase.cs | 30 + .../Migrations/NovelDbContextModelSnapshot.cs | 5 + src/Novelly.Api/Data/NovelDbContext.cs | 1 + src/Novelly.Api/Projects/Project.cs | 2 + src/Novelly.Api/Projects/ProjectContracts.cs | 7 +- src/Novelly.Api/Projects/ProjectPhase.cs | 11 + src/Novelly.Api/Projects/ProjectService.cs | 2 + src/Novelly.Web/src/App.tsx | 6 +- src/Novelly.Web/src/api/types.ts | 8 + src/Novelly.Web/src/index.css | 1 + src/Novelly.Web/src/pages/ChapterPage.tsx | 256 +++--- src/Novelly.Web/src/pages/ChaptersPage.tsx | 2 +- src/Novelly.Web/src/pages/DashboardPage.tsx | 149 ++++ src/Novelly.Web/src/pages/ProjectLayout.tsx | 53 +- .../{OverviewPage.tsx => SettingsPage.tsx} | 4 +- tests/Novelly.Api.Tests/ProjectDataTests.cs | 13 + 18 files changed, 1253 insertions(+), 140 deletions(-) create mode 100644 src/Novelly.Api/Data/Migrations/20260811004337_AddProjectPhase.Designer.cs create mode 100644 src/Novelly.Api/Data/Migrations/20260811004337_AddProjectPhase.cs create mode 100644 src/Novelly.Api/Projects/ProjectPhase.cs create mode 100644 src/Novelly.Web/src/pages/DashboardPage.tsx rename src/Novelly.Web/src/pages/{OverviewPage.tsx => SettingsPage.tsx} (97%) diff --git a/src/Novelly.Api/Chapters/ChapterContracts.cs b/src/Novelly.Api/Chapters/ChapterContracts.cs index f14dccc..b5ebbfc 100644 --- a/src/Novelly.Api/Chapters/ChapterContracts.cs +++ b/src/Novelly.Api/Chapters/ChapterContracts.cs @@ -20,7 +20,8 @@ public record ChapterSummaryResponse( int BeatCount, int SceneCount, int WordCount, - IReadOnlyList Tags); + IReadOnlyList Tags, + DateTimeOffset UpdatedAt); /// /// A chapter in full: the outline (a paragraph of summary plus an ordered beat table) @@ -146,5 +147,6 @@ public static class ChapterMapping c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount, c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount), - [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())]); + [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], + c.UpdatedAt); } diff --git a/src/Novelly.Api/Data/Migrations/20260811004337_AddProjectPhase.Designer.cs b/src/Novelly.Api/Data/Migrations/20260811004337_AddProjectPhase.Designer.cs new file mode 100644 index 0000000..6b823fc --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260811004337_AddProjectPhase.Designer.cs @@ -0,0 +1,837 @@ +// +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("20260811004337_AddProjectPhase")] + partial class AddProjectPhase + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SceneId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SceneId"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Setting") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ProjectId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ProjectId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Scenes.Scene", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("Conflict") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Goal") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Outcome") + .HasColumnType("TEXT"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Scenes"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("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.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Scenes.Scene", "Scene") + .WithMany() + .HasForeignKey("SceneId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Scene"); + }); + + 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.Scenes.Scene", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Scenes") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Chapter"); + + b.Navigation("PovCharacter"); + }); + + 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"); + + b.Navigation("Scenes"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260811004337_AddProjectPhase.cs b/src/Novelly.Api/Data/Migrations/20260811004337_AddProjectPhase.cs new file mode 100644 index 0000000..783e688 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260811004337_AddProjectPhase.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class AddProjectPhase : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Phase", + table: "Projects", + type: "TEXT", + maxLength: 32, + nullable: false, + defaultValue: "Outlining"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Phase", + table: "Projects"); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index 49fbec8..0769f4f 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -427,6 +427,11 @@ namespace Novelly.Api.Data.Migrations b.Property("Notes") .HasColumnType("TEXT"); + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + b.Property("Synopsis") .HasColumnType("TEXT"); diff --git a/src/Novelly.Api/Data/NovelDbContext.cs b/src/Novelly.Api/Data/NovelDbContext.cs index bcb980d..dc502bf 100644 --- a/src/Novelly.Api/Data/NovelDbContext.cs +++ b/src/Novelly.Api/Data/NovelDbContext.cs @@ -50,6 +50,7 @@ public class NovelDbContext(DbContextOptions options) builder.Entity(entity => { entity.Property(p => p.Title).IsRequired().HasMaxLength(300); + entity.Property(p => p.Phase).HasConversion().HasMaxLength(32); entity.HasMany(p => p.Characters).WithOne(c => c.Project!) .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); entity.HasMany(p => p.Chapters).WithOne(c => c.Project!) diff --git a/src/Novelly.Api/Projects/Project.cs b/src/Novelly.Api/Projects/Project.cs index d08f746..e27d3d5 100644 --- a/src/Novelly.Api/Projects/Project.cs +++ b/src/Novelly.Api/Projects/Project.cs @@ -25,6 +25,8 @@ public class Project public int? TargetWordCount { get; set; } + public ProjectPhase Phase { get; set; } = ProjectPhase.Brainstorming; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; diff --git a/src/Novelly.Api/Projects/ProjectContracts.cs b/src/Novelly.Api/Projects/ProjectContracts.cs index b1a8434..b7ac407 100644 --- a/src/Novelly.Api/Projects/ProjectContracts.cs +++ b/src/Novelly.Api/Projects/ProjectContracts.cs @@ -9,6 +9,7 @@ public record ProjectSummaryResponse( string? Genre, string? Logline, int? TargetWordCount, + ProjectPhase Phase, int CharacterCount, int ChapterCount, int WordCount, @@ -23,6 +24,7 @@ public record ProjectResponse( string? Synopsis, string? Notes, int? TargetWordCount, + ProjectPhase Phase, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); @@ -59,7 +61,8 @@ public record UpdateProjectRequest( string? Logline = null, string? Synopsis = null, string? Notes = null, - int? TargetWordCount = null); + int? TargetWordCount = null, + ProjectPhase? Phase = null); public class UpdateProjectRequestValidator : IModelValidator { @@ -118,5 +121,5 @@ public static class ProjectMapping { public static ProjectResponse ToResponse(this Project p) => new( p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes, - p.TargetWordCount, p.CreatedAt, p.UpdatedAt); + p.TargetWordCount, p.Phase, p.CreatedAt, p.UpdatedAt); } diff --git a/src/Novelly.Api/Projects/ProjectPhase.cs b/src/Novelly.Api/Projects/ProjectPhase.cs new file mode 100644 index 0000000..41f2732 --- /dev/null +++ b/src/Novelly.Api/Projects/ProjectPhase.cs @@ -0,0 +1,11 @@ +namespace Novelly.Api.Projects; + +/// Where a novel is in its lifecycle, from first notes to a finished manuscript. +public enum ProjectPhase +{ + Brainstorming, + Outlining, + Writing, + Editing, + Complete +} diff --git a/src/Novelly.Api/Projects/ProjectService.cs b/src/Novelly.Api/Projects/ProjectService.cs index 6aa0c72..8cd1b91 100644 --- a/src/Novelly.Api/Projects/ProjectService.cs +++ b/src/Novelly.Api/Projects/ProjectService.cs @@ -24,6 +24,7 @@ public class ProjectService( p.Genre, p.Logline, p.TargetWordCount, + p.Phase, p.Characters.Count, p.Chapters.Count, p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0, @@ -84,6 +85,7 @@ public class ProjectService( project.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis); project.Notes = Patch.Apply(project.Notes, request.Notes); project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount; + project.Phase = request.Phase ?? project.Phase; project.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); diff --git a/src/Novelly.Web/src/App.tsx b/src/Novelly.Web/src/App.tsx index 96b10f7..6b623c4 100644 --- a/src/Novelly.Web/src/App.tsx +++ b/src/Novelly.Web/src/App.tsx @@ -1,24 +1,26 @@ import { Route, Routes } from 'react-router-dom' import ProjectsPage from './pages/ProjectsPage' import ProjectLayout from './pages/ProjectLayout' -import OverviewPage from './pages/OverviewPage' +import DashboardPage from './pages/DashboardPage' import CharactersPage from './pages/CharactersPage' import TagsPage from './pages/TagsPage' import ChaptersPage from './pages/ChaptersPage' import ChapterPage from './pages/ChapterPage' import AgentPage from './pages/AgentPage' +import SettingsPage from './pages/SettingsPage' export default function App() { return ( } /> }> - } /> + } /> } /> } /> } /> } /> } /> + } /> } /> diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index 8f8885e..1ef0e7c 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -30,6 +30,11 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] +/** Where a novel is in its lifecycle, from first notes to a finished manuscript. */ +export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete' + +export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete'] + export interface ProjectSummary { id: string title: string @@ -37,6 +42,7 @@ export interface ProjectSummary { genre: string | null logline: string | null targetWordCount: number | null + phase: ProjectPhase characterCount: number chapterCount: number wordCount: number @@ -52,6 +58,7 @@ export interface Project { synopsis: string | null notes: string | null targetWordCount: number | null + phase: ProjectPhase createdAt: string updatedAt: string } @@ -194,6 +201,7 @@ export interface ChapterSummary { sceneCount: number wordCount: number tags: Tag[] + updatedAt: string } export interface Chapter extends Omit { diff --git a/src/Novelly.Web/src/index.css b/src/Novelly.Web/src/index.css index c595a02..9fd5eb2 100644 --- a/src/Novelly.Web/src/index.css +++ b/src/Novelly.Web/src/index.css @@ -111,6 +111,7 @@ body { background: var(--surface); border: 1px solid var(--line); color: var(--ink); + overflow-wrap: anywhere; } .input:focus { diff --git a/src/Novelly.Web/src/pages/ChapterPage.tsx b/src/Novelly.Web/src/pages/ChapterPage.tsx index c5b9ff3..08231e3 100644 --- a/src/Novelly.Web/src/pages/ChapterPage.tsx +++ b/src/Novelly.Web/src/pages/ChapterPage.tsx @@ -16,7 +16,7 @@ import { } from '../api/hooks' import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types' import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui' -import { TagEditor } from '../components/TagEditor' +import { TagChip, TagEditor } from '../components/TagEditor' import { OpenQuestions } from '../components/OpenQuestions' export default function ChapterPage() { @@ -224,6 +224,7 @@ function BeatTable({ const update = useUpdateBeat(chapter.id, projectId) const remove = useDeleteBeat(chapter.id) const reorder = useReorderBeats(chapter.id) + const [editingId, setEditingId] = useState(null) if (chapter.beats.length === 0) { return ( @@ -247,129 +248,182 @@ function BeatTable({ return (
- +
- - - + + - - - + + - {chapter.beats.map((beat, index) => ( - // Anchored so the character page's beat list can link straight to this row. - - { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setEditingId(null) + }} + > + + + + + + + + + + + + + + + + ) : ( + setEditingId(beat.id)} + > + - + - + - + - + - + - - - ))} + + ), + )}
#Beat + #Beat Character + What happened What's nextSceneWhat's nextScene
-
- {index + 1} -
+ {chapter.beats.map((beat, index) => + editingId === beat.id ? ( +
+
+ {index + 1} +
+ + +
+
+
+ title.trim() && patch(beat.id, { title })} + /> +
+ patch(beat.id, { tags })} + /> +
+
+ + + patch(beat.id, { whatHappened })} + /> + + patch(beat.id, { whatsNext })} + /> + + + +
- -
{index + 1} - title.trim() && patch(beat.id, { title })} - /> -
- patch(beat.id, { tags })} - /> -
-
+
{beat.title}
+ {beat.tags.length > 0 && ( +
+ {beat.tags.map((tag) => ( + + ))} +
+ )} +
- - {beat.characterName ?? } - patch(beat.id, { whatHappened })} - /> - + {beat.whatHappened ?? } + - patch(beat.id, { whatsNext })} - /> - + {beat.whatsNext ?? } + - - {beat.sceneTitle ?? } - -
+
diff --git a/src/Novelly.Web/src/pages/ChaptersPage.tsx b/src/Novelly.Web/src/pages/ChaptersPage.tsx index 0e612f0..86d9779 100644 --- a/src/Novelly.Web/src/pages/ChaptersPage.tsx +++ b/src/Novelly.Web/src/pages/ChaptersPage.tsx @@ -32,7 +32,7 @@ export default function ChaptersPage() { hint="A chapter is a summary paragraph plus a table of beats. Add one and start outlining." /> ) : ( -
    +
      {chapters?.map((chapter) => (
    • + if (isPending || !project) return + + return project.phase === 'Brainstorming' ? ( + + ) : ( + + ) +} + +/** The only thing the writer needs before there's a shape to the book: a place to dump notes. */ +function BrainstormingDashboard({ project }: { project: Project }) { + const update = useUpdateProject(project.id) + + return ( +
      +

      Notes

      +

      + Premise, voice, scraps of scene, whatever's rattling around. Move to Outlining once + there's a shape to work from. +

      + update.mutate({ notes })} + /> +
      + ) +} + +/** Chapter outlines and character development — where most of the outlining phase happens. */ +function OutliningDashboard({ projectId }: { projectId: string }) { + const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId) + const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId) + + const recentCharacters = [...(characters ?? [])].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ) + const recentChapters = [...(chapters ?? [])].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ) + + return ( +
      + + +
      +
      +
      +

      Outline

      + + View all → + +
      + + {chaptersError && } + {chaptersPending ? ( + + ) : recentChapters.length === 0 ? ( + + ) : ( +
        + {recentChapters.slice(0, RECENT_COUNT).map((chapter) => ( +
      • + + + {chapter.number} + +
        +
        {chapter.title}
        +
        + + + {new Date(chapter.updatedAt).toLocaleDateString()} + + +
      • + ))} +
      + )} +
      + +
      +
      +

      Characters

      + + View all → + +
      + + {charactersError && } + {charactersPending ? ( + + ) : recentCharacters.length === 0 ? ( + + ) : ( +
        + {recentCharacters.slice(0, RECENT_COUNT).map((character) => ( +
      • + +
        +
        {character.name}
        +
        + {character.role} · {character.importance} +
        +
        + + {new Date(character.updatedAt).toLocaleDateString()} + + +
      • + ))} +
      + )} +
      +
      +
      + ) +} diff --git a/src/Novelly.Web/src/pages/ProjectLayout.tsx b/src/Novelly.Web/src/pages/ProjectLayout.tsx index 9ce92de..244dc6d 100644 --- a/src/Novelly.Web/src/pages/ProjectLayout.tsx +++ b/src/Novelly.Web/src/pages/ProjectLayout.tsx @@ -1,48 +1,41 @@ -import { NavLink, Outlet, useParams, Link } from 'react-router-dom' -import { useProject } from '../api/hooks' +import { Outlet, useParams, Link } from 'react-router-dom' +import { useProject, useUpdateProject } from '../api/hooks' +import { projectPhases } from '../api/types' import { ErrorNote, Spinner } from '../components/ui' -const tabs = [ - { to: '', label: 'Overview', end: true }, - { to: 'characters', label: 'Characters' }, - { to: 'chapters', label: 'Chapters' }, - { to: 'tags', label: 'Tags' }, - { to: 'agent', label: 'Agent' }, -] - export default function ProjectLayout() { const { projectId = '' } = useParams() const { data: project, isPending, error } = useProject(projectId) + const update = useUpdateProject(projectId) return (
      -
      +
      ← Novels -

      {project?.title ?? '…'}

      -
      -
      -
      +
      {error && } {isPending ? : }
      diff --git a/src/Novelly.Web/src/pages/OverviewPage.tsx b/src/Novelly.Web/src/pages/SettingsPage.tsx similarity index 97% rename from src/Novelly.Web/src/pages/OverviewPage.tsx rename to src/Novelly.Web/src/pages/SettingsPage.tsx index a55c94d..070fc11 100644 --- a/src/Novelly.Web/src/pages/OverviewPage.tsx +++ b/src/Novelly.Web/src/pages/SettingsPage.tsx @@ -4,7 +4,7 @@ import { useChapters, useCharacters, useDeleteProject, useProject, useUpdateProj import { ImportDialog } from '../components/ImportDialog' import { AutoField, ErrorNote, Spinner } from '../components/ui' -export default function OverviewPage() { +export default function SettingsPage() { const { projectId = '' } = useParams() const navigate = useNavigate() const { data: project, isPending } = useProject(projectId) @@ -138,7 +138,7 @@ export default function OverviewPage() { className="btn w-full" style={{ color: 'var(--accent)' }} onClick={() => { - if (confirm(`Delete “${project.title}” and everything in it? This cannot be undone.`)) { + if (confirm(`Delete "${project.title}" and everything in it? This cannot be undone.`)) { remove.mutate(projectId, { onSuccess: () => navigate('/') }) } }} diff --git a/tests/Novelly.Api.Tests/ProjectDataTests.cs b/tests/Novelly.Api.Tests/ProjectDataTests.cs index 7eebe5f..e78c448 100644 --- a/tests/Novelly.Api.Tests/ProjectDataTests.cs +++ b/tests/Novelly.Api.Tests/ProjectDataTests.cs @@ -37,6 +37,19 @@ public class ProjectDataTests : ServiceTestFixture }); } + [Test] + public async Task New_projects_start_in_the_brainstorming_phase_and_can_be_advanced() + { + var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road")); + Assert.That(project.Phase, Is.EqualTo(ProjectPhase.Brainstorming)); + + var afterAdvance = (await Projects.UpdateAsync(project.Id, new UpdateProjectRequest(Phase: ProjectPhase.Outlining)))!; + Assert.That(afterAdvance.Phase, Is.EqualTo(ProjectPhase.Outlining)); + + var afterUnrelatedUpdate = (await Projects.UpdateAsync(project.Id, new UpdateProjectRequest(Genre: "Fantasy")))!; + Assert.That(afterUnrelatedUpdate.Phase, Is.EqualTo(ProjectPhase.Outlining)); + } + [Test] public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given() {