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.
This commit is contained in:
James Wampler
2026-08-10 22:46:09 -07:00
parent 2ccebb31eb
commit e7d89a6d41
18 changed files with 1253 additions and 140 deletions
+4 -2
View File
@@ -20,7 +20,8 @@ public record ChapterSummaryResponse(
int BeatCount,
int SceneCount,
int WordCount,
IReadOnlyList<TagResponse> Tags);
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
/// <summary>
/// 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);
}
@@ -0,0 +1,837 @@
// <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("20260811004337_AddProjectPhase")]
partial class AddProjectPhase
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
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<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("SceneId")
.HasColumnType("TEXT");
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("CharacterId");
b.HasIndex("SceneId");
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>("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.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.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.Scenes.Scene", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<string>("Conflict")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Goal")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Outcome")
.HasColumnType("TEXT");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
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("ChapterId", "SortOrder");
b.ToTable("Scenes");
});
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("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
}
}
}
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddProjectPhase : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Phase",
table: "Projects",
type: "TEXT",
maxLength: 32,
nullable: false,
defaultValue: "Outlining");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Phase",
table: "Projects");
}
}
}
@@ -427,6 +427,11 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Phase")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Synopsis")
.HasColumnType("TEXT");
+1
View File
@@ -50,6 +50,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
builder.Entity<Project>(entity =>
{
entity.Property(p => p.Title).IsRequired().HasMaxLength(300);
entity.Property(p => p.Phase).HasConversion<string>().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!)
+2
View File
@@ -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;
+5 -2
View File
@@ -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<UpdateProjectRequest>
{
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
namespace Novelly.Api.Projects;
/// <summary>Where a novel is in its lifecycle, from first notes to a finished manuscript.</summary>
public enum ProjectPhase
{
Brainstorming,
Outlining,
Writing,
Editing,
Complete
}
@@ -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);
+4 -2
View File
@@ -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 (
<Routes>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<OverviewPage />} />
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Routes>
+8
View File
@@ -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<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> {
+1
View File
@@ -111,6 +111,7 @@ body {
background: var(--surface);
border: 1px solid var(--line);
color: var(--ink);
overflow-wrap: anywhere;
}
.input:focus {
+155 -101
View File
@@ -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<string | null>(null)
if (chapter.beats.length === 0) {
return (
@@ -247,129 +248,182 @@ function BeatTable({
return (
<div className="card overflow-x-auto">
<table className="w-full min-w-[64rem] border-collapse text-sm">
<table className="w-full table-fixed border-collapse text-sm">
<thead>
<tr style={{ borderBottom: '1px solid var(--line)' }}>
<th className="w-16 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-56 px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
<th className="w-36 px-2 py-2 text-left text-xs font-semibold uppercase muted">
<th className="w-10 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-[14%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
<th className="w-[10%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
Character
</th>
<th className="px-2 py-2 text-left text-xs font-semibold uppercase muted">
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
What happened
</th>
<th className="px-2 py-2 text-left text-xs font-semibold uppercase muted">What&apos;s next</th>
<th className="w-32 px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What&apos;s next</th>
<th className="w-[12%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{chapter.beats.map((beat, index) => (
// Anchored so the character page's beat list can link straight to this row.
<tr key={beat.id} id={`beat-${beat.id}`} style={{ borderBottom: '1px solid var(--line)' }}>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1">
<span className="w-4 text-xs muted">{index + 1}</span>
<div className="flex flex-col">
{chapter.beats.map((beat, index) =>
editingId === beat.id ? (
<tr
key={beat.id}
id={`beat-${beat.id}`}
style={{ borderBottom: '1px solid var(--line)', background: 'var(--accent-soft)' }}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setEditingId(null)
}}
>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1">
<span className="w-4 text-xs muted">{index + 1}</span>
<div className="flex flex-col">
<button
className="text-xs leading-none muted disabled:opacity-25"
onClick={() => move(index, -1)}
disabled={index === 0 || reorder.isPending}
aria-label="Move beat up"
>
</button>
<button
className="text-xs leading-none muted disabled:opacity-25"
onClick={() => move(index, 1)}
disabled={index === chapter.beats.length - 1 || reorder.isPending}
aria-label="Move beat down"
>
</button>
</div>
</div>
</td>
<td className="px-2 py-2 align-top">
<AutoField
value={beat.title}
placeholder="Three to five words"
onCommit={(title) => title.trim() && patch(beat.id, { title })}
/>
<div className="mt-1.5">
<TagEditor
tags={beat.tags}
suggestions={suggestions}
onChange={(tags) => patch(beat.id, { tags })}
/>
</div>
</td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.characterName ?? '—'}
onChange={(e) => {
const match = characters.find((c) => c.name === e.target.value)
patch(beat.id, { characterId: match?.id ?? null })
}}
>
{['—', ...characters.map((c) => c.name)].map((name) => (
<option key={name}>{name}</option>
))}
</select>
</td>
<td className="px-2 py-2 align-top">
<AutoField
value={beat.whatHappened}
multiline
rows={3}
serif
placeholder="The event itself."
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
/>
</td>
<td className="px-2 py-2 align-top">
<AutoField
value={beat.whatsNext}
multiline
rows={3}
serif
placeholder="What it sets in motion."
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
/>
</td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.sceneTitle ?? '—'}
onChange={(e) => {
const match = chapter.scenes.find((s) => s.title === e.target.value)
patch(beat.id, { sceneId: match?.id ?? null })
}}
>
{['—', ...chapter.scenes.map((s) => s.title)].map((title) => (
<option key={title}>{title}</option>
))}
</select>
</td>
<td className="px-2 py-2 align-top">
<div className="flex flex-col items-center gap-2">
<button
className="text-xs leading-none muted disabled:opacity-25"
onClick={() => move(index, -1)}
disabled={index === 0 || reorder.isPending}
aria-label="Move beat up"
className="text-xs leading-none"
style={{ color: 'var(--accent)' }}
onClick={() => setEditingId(null)}
aria-label="Done editing beat"
>
</button>
<button
className="text-xs leading-none muted disabled:opacity-25"
onClick={() => move(index, 1)}
disabled={index === chapter.beats.length - 1 || reorder.isPending}
aria-label="Move beat down"
className="text-xs muted leading-none transition hover:opacity-100"
style={{ color: 'var(--accent)' }}
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
aria-label={`Delete beat ${beat.title}`}
>
</button>
</div>
</div>
</td>
</td>
</tr>
) : (
<tr
key={beat.id}
id={`beat-${beat.id}`}
className="cursor-pointer transition hover:brightness-110"
style={{ borderBottom: '1px solid var(--line)' }}
onClick={() => 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">
<AutoField
value={beat.title}
placeholder="Three to five words"
onCommit={(title) => title.trim() && patch(beat.id, { title })}
/>
<div className="mt-1.5">
<TagEditor
tags={beat.tags}
suggestions={suggestions}
onChange={(tags) => patch(beat.id, { tags })}
/>
</div>
</td>
<td className="px-2 py-2 align-top">
<div className="font-medium">{beat.title}</div>
{beat.tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{beat.tags.map((tag) => (
<TagChip key={tag.id} tag={tag} />
))}
</div>
)}
</td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.characterName ?? '—'}
onChange={(e) => {
const match = characters.find((c) => c.name === e.target.value)
patch(beat.id, { characterId: match?.id ?? null })
}}
>
{['—', ...characters.map((c) => c.name)].map((name) => (
<option key={name}>{name}</option>
))}
</select>
</td>
<td className="px-2 py-2 align-top">{beat.characterName ?? <span className="muted"></span>}</td>
<td className="px-2 py-2 align-top">
<AutoField
value={beat.whatHappened}
multiline
rows={3}
serif
placeholder="The event itself."
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
/>
</td>
<td className="px-2 py-2 align-top whitespace-pre-wrap break-words">
{beat.whatHappened ?? <span className="muted"></span>}
</td>
<td className="px-2 py-2 align-top">
<AutoField
value={beat.whatsNext}
multiline
rows={3}
serif
placeholder="What it sets in motion."
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
/>
</td>
<td className="px-2 py-2 align-top whitespace-pre-wrap break-words">
{beat.whatsNext ?? <span className="muted"></span>}
</td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.sceneTitle ?? '—'}
onChange={(e) => {
const match = chapter.scenes.find((s) => s.title === e.target.value)
patch(beat.id, { sceneId: match?.id ?? null })
}}
>
{['—', ...chapter.scenes.map((s) => s.title)].map((title) => (
<option key={title}>{title}</option>
))}
</select>
</td>
<td className="px-2 py-2 align-top">{beat.sceneTitle ?? <span className="muted"></span>}</td>
<td className="px-2 py-2 align-top">
<button
className="text-xs muted transition hover:opacity-100"
style={{ color: 'var(--accent)' }}
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
aria-label={`Delete beat ${beat.title}`}
>
</button>
</td>
</tr>
))}
<td className="px-2 py-2 align-top" />
</tr>
),
)}
</tbody>
</table>
</div>
+1 -1
View File
@@ -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."
/>
) : (
<ul className="grid gap-2">
<ul className="grid grid-cols-1 gap-2">
{chapters?.map((chapter) => (
<li key={chapter.id}>
<Link
+149
View File
@@ -0,0 +1,149 @@
import { Link, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useProject, useUpdateProject } from '../api/hooks'
import type { Project } from '../api/types'
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
const RECENT_COUNT = 5
export default function DashboardPage() {
const { projectId = '' } = useParams()
const { data: project, isPending, error } = useProject(projectId)
if (error) return <ErrorNote error={error} />
if (isPending || !project) return <Spinner label="Loading novel" />
return project.phase === 'Brainstorming' ? (
<BrainstormingDashboard project={project} />
) : (
<OutliningDashboard projectId={projectId} />
)
}
/** 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 (
<div className="card p-5">
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">Notes</h2>
<p className="mb-3 text-sm muted">
Premise, voice, scraps of scene, whatever's rattling around. Move to Outlining once
there's a shape to work from.
</p>
<AutoField
value={project.notes}
multiline
rows={20}
serif
placeholder="Start anywhere."
onCommit={(notes) => update.mutate({ notes })}
/>
</div>
)
}
/** 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 (
<div className="grid gap-6">
<nav className="flex flex-wrap gap-4 text-sm">
<Link to="tags" className="muted hover:underline">
Tags
</Link>
<Link to="agent" className="muted hover:underline">
Agent
</Link>
<Link to="settings" className="muted hover:underline">
Settings
</Link>
</nav>
<div className="grid gap-6 lg:grid-cols-3">
<section className="card p-5 lg:col-span-2">
<div className="mb-4 flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold">Outline</h2>
<Link to="chapters" className="text-sm muted hover:underline">
View all
</Link>
</div>
{chaptersError && <ErrorNote error={chaptersError} />}
{chaptersPending ? (
<Spinner label="Loading chapters" />
) : recentChapters.length === 0 ? (
<EmptyState title="No chapters yet" hint="Add the first chapter to start outlining." />
) : (
<ul className="grid grid-cols-1 gap-2">
{recentChapters.slice(0, RECENT_COUNT).map((chapter) => (
<li key={chapter.id}>
<Link
to={`chapters/${chapter.id}`}
className="card flex items-center gap-3 px-4 py-2.5 transition hover:shadow-sm"
>
<span className="w-6 shrink-0 text-right text-sm font-semibold muted">
{chapter.number}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{chapter.title}</div>
</div>
<StatusBadge status={chapter.status} />
<span className="shrink-0 text-xs muted">
{new Date(chapter.updatedAt).toLocaleDateString()}
</span>
</Link>
</li>
))}
</ul>
)}
</section>
<section className="card p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<h2 className="text-lg font-semibold">Characters</h2>
<Link to="characters" className="text-sm muted hover:underline">
View all
</Link>
</div>
{charactersError && <ErrorNote error={charactersError} />}
{charactersPending ? (
<Spinner label="Loading characters" />
) : recentCharacters.length === 0 ? (
<EmptyState title="No characters yet" hint="Add the protagonist first." />
) : (
<ul className="grid grid-cols-1 gap-2">
{recentCharacters.slice(0, RECENT_COUNT).map((character) => (
<li key={character.id}>
<Link
to="characters"
className="card flex items-center justify-between gap-3 px-4 py-2.5 transition hover:shadow-sm"
>
<div className="min-w-0">
<div className="truncate font-medium">{character.name}</div>
<div className="text-xs muted">
{character.role} · {character.importance}
</div>
</div>
<span className="shrink-0 text-xs muted">
{new Date(character.updatedAt).toLocaleDateString()}
</span>
</Link>
</li>
))}
</ul>
)}
</section>
</div>
</div>
)
}
+23 -30
View File
@@ -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 (
<div className="min-h-full">
<header className="sticky top-0 z-10 border-b" style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}>
<div className="mx-auto flex max-w-6xl items-center gap-4 px-6 py-3">
<div className="mx-auto flex max-w-[100rem] items-center gap-4 px-6 py-3">
<Link to="/" className="text-sm muted hover:underline">
Novels
</Link>
<h1 className="truncate text-base font-semibold">{project?.title ?? '…'}</h1>
</div>
<nav className="mx-auto flex max-w-6xl gap-1 px-4">
{tabs.map((tab) => (
<NavLink
key={tab.label}
to={tab.to}
end={tab.end}
className={({ isActive }) =>
`border-b-2 px-3 py-2 text-sm font-medium transition ${
isActive ? 'border-current' : 'border-transparent'
}`
}
style={({ isActive }) => ({ color: isActive ? 'var(--accent)' : 'var(--ink-muted)' })}
<Link to={`/projects/${projectId}`} className="truncate text-base font-semibold hover:underline">
{project?.title ?? '…'}
</Link>
{project && (
<select
className="input ml-auto w-auto"
value={project.phase}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
aria-label="Novel phase"
>
{tab.label}
</NavLink>
))}
</nav>
{projectPhases.map((phase) => (
<option key={phase} value={phase}>
{phase}
</option>
))}
</select>
)}
</div>
</header>
<main className="mx-auto max-w-6xl px-6 py-8">
<main className="mx-auto max-w-[100rem] px-6 py-8">
{error && <ErrorNote error={error} />}
{isPending ? <Spinner label="Loading project" /> : <Outlet context={{ projectId }} />}
</main>
@@ -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('/') })
}
}}
@@ -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()
{