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() {