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:
@@ -20,7 +20,8 @@ public record ChapterSummaryResponse(
|
|||||||
int BeatCount,
|
int BeatCount,
|
||||||
int SceneCount,
|
int SceneCount,
|
||||||
int WordCount,
|
int WordCount,
|
||||||
IReadOnlyList<TagResponse> Tags);
|
IReadOnlyList<TagResponse> Tags,
|
||||||
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
|
/// 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.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||||
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
|
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
|
||||||
c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount),
|
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")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Phase")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Synopsis")
|
b.Property<string>("Synopsis")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
|||||||
builder.Entity<Project>(entity =>
|
builder.Entity<Project>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(p => p.Title).IsRequired().HasMaxLength(300);
|
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!)
|
entity.HasMany(p => p.Characters).WithOne(c => c.Project!)
|
||||||
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||||
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!)
|
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ public class Project
|
|||||||
|
|
||||||
public int? TargetWordCount { get; set; }
|
public int? TargetWordCount { get; set; }
|
||||||
|
|
||||||
|
public ProjectPhase Phase { get; set; } = ProjectPhase.Brainstorming;
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public record ProjectSummaryResponse(
|
|||||||
string? Genre,
|
string? Genre,
|
||||||
string? Logline,
|
string? Logline,
|
||||||
int? TargetWordCount,
|
int? TargetWordCount,
|
||||||
|
ProjectPhase Phase,
|
||||||
int CharacterCount,
|
int CharacterCount,
|
||||||
int ChapterCount,
|
int ChapterCount,
|
||||||
int WordCount,
|
int WordCount,
|
||||||
@@ -23,6 +24,7 @@ public record ProjectResponse(
|
|||||||
string? Synopsis,
|
string? Synopsis,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
int? TargetWordCount,
|
int? TargetWordCount,
|
||||||
|
ProjectPhase Phase,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
@@ -59,7 +61,8 @@ public record UpdateProjectRequest(
|
|||||||
string? Logline = null,
|
string? Logline = null,
|
||||||
string? Synopsis = null,
|
string? Synopsis = null,
|
||||||
string? Notes = null,
|
string? Notes = null,
|
||||||
int? TargetWordCount = null);
|
int? TargetWordCount = null,
|
||||||
|
ProjectPhase? Phase = null);
|
||||||
|
|
||||||
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest>
|
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest>
|
||||||
{
|
{
|
||||||
@@ -118,5 +121,5 @@ public static class ProjectMapping
|
|||||||
{
|
{
|
||||||
public static ProjectResponse ToResponse(this Project p) => new(
|
public static ProjectResponse ToResponse(this Project p) => new(
|
||||||
p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes,
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Genre,
|
||||||
p.Logline,
|
p.Logline,
|
||||||
p.TargetWordCount,
|
p.TargetWordCount,
|
||||||
|
p.Phase,
|
||||||
p.Characters.Count,
|
p.Characters.Count,
|
||||||
p.Chapters.Count,
|
p.Chapters.Count,
|
||||||
p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0,
|
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.Synopsis = Patch.Apply(project.Synopsis, request.Synopsis);
|
||||||
project.Notes = Patch.Apply(project.Notes, request.Notes);
|
project.Notes = Patch.Apply(project.Notes, request.Notes);
|
||||||
project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
|
project.TargetWordCount = request.TargetWordCount ?? project.TargetWordCount;
|
||||||
|
project.Phase = request.Phase ?? project.Phase;
|
||||||
project.UpdatedAt = DateTimeOffset.UtcNow;
|
project.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
import { Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import ProjectsPage from './pages/ProjectsPage'
|
import ProjectsPage from './pages/ProjectsPage'
|
||||||
import ProjectLayout from './pages/ProjectLayout'
|
import ProjectLayout from './pages/ProjectLayout'
|
||||||
import OverviewPage from './pages/OverviewPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import CharactersPage from './pages/CharactersPage'
|
import CharactersPage from './pages/CharactersPage'
|
||||||
import TagsPage from './pages/TagsPage'
|
import TagsPage from './pages/TagsPage'
|
||||||
import ChaptersPage from './pages/ChaptersPage'
|
import ChaptersPage from './pages/ChaptersPage'
|
||||||
import ChapterPage from './pages/ChapterPage'
|
import ChapterPage from './pages/ChapterPage'
|
||||||
import AgentPage from './pages/AgentPage'
|
import AgentPage from './pages/AgentPage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ProjectsPage />} />
|
<Route path="/" element={<ProjectsPage />} />
|
||||||
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
||||||
<Route index element={<OverviewPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="characters" element={<CharactersPage />} />
|
<Route path="characters" element={<CharactersPage />} />
|
||||||
<Route path="chapters" element={<ChaptersPage />} />
|
<Route path="chapters" element={<ChaptersPage />} />
|
||||||
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
||||||
<Route path="tags" element={<TagsPage />} />
|
<Route path="tags" element={<TagsPage />} />
|
||||||
<Route path="agent" element={<AgentPage />} />
|
<Route path="agent" element={<AgentPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<ProjectsPage />} />
|
<Route path="*" element={<ProjectsPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
|
|||||||
|
|
||||||
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
|
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 {
|
export interface ProjectSummary {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -37,6 +42,7 @@ export interface ProjectSummary {
|
|||||||
genre: string | null
|
genre: string | null
|
||||||
logline: string | null
|
logline: string | null
|
||||||
targetWordCount: number | null
|
targetWordCount: number | null
|
||||||
|
phase: ProjectPhase
|
||||||
characterCount: number
|
characterCount: number
|
||||||
chapterCount: number
|
chapterCount: number
|
||||||
wordCount: number
|
wordCount: number
|
||||||
@@ -52,6 +58,7 @@ export interface Project {
|
|||||||
synopsis: string | null
|
synopsis: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
targetWordCount: number | null
|
targetWordCount: number | null
|
||||||
|
phase: ProjectPhase
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -194,6 +201,7 @@ export interface ChapterSummary {
|
|||||||
sceneCount: number
|
sceneCount: number
|
||||||
wordCount: number
|
wordCount: number
|
||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> {
|
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> {
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ body {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from '../api/hooks'
|
} from '../api/hooks'
|
||||||
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
|
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
|
||||||
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
|
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'
|
import { OpenQuestions } from '../components/OpenQuestions'
|
||||||
|
|
||||||
export default function ChapterPage() {
|
export default function ChapterPage() {
|
||||||
@@ -224,6 +224,7 @@ function BeatTable({
|
|||||||
const update = useUpdateBeat(chapter.id, projectId)
|
const update = useUpdateBeat(chapter.id, projectId)
|
||||||
const remove = useDeleteBeat(chapter.id)
|
const remove = useDeleteBeat(chapter.id)
|
||||||
const reorder = useReorderBeats(chapter.id)
|
const reorder = useReorderBeats(chapter.id)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
if (chapter.beats.length === 0) {
|
if (chapter.beats.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -247,26 +248,33 @@ function BeatTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card overflow-x-auto">
|
<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>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid var(--line)' }}>
|
<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-10 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-[14%] 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">
|
||||||
Character
|
Character
|
||||||
</th>
|
</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
|
What happened
|
||||||
</th>
|
</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-semibold uppercase muted">What's next</th>
|
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What's next</th>
|
||||||
<th className="w-32 px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
|
<th className="w-[12%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
|
||||||
<th className="w-8" />
|
<th className="w-8" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{chapter.beats.map((beat, index) => (
|
{chapter.beats.map((beat, index) =>
|
||||||
// Anchored so the character page's beat list can link straight to this row.
|
editingId === beat.id ? (
|
||||||
<tr key={beat.id} id={`beat-${beat.id}`} style={{ borderBottom: '1px solid var(--line)' }}>
|
<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">
|
<td className="px-2 py-2 align-top">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="w-4 text-xs muted">{index + 1}</span>
|
<span className="w-4 text-xs muted">{index + 1}</span>
|
||||||
@@ -359,17 +367,63 @@ function BeatTable({
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-2 py-2 align-top">
|
<td className="px-2 py-2 align-top">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
<button
|
<button
|
||||||
className="text-xs muted transition hover:opacity-100"
|
className="text-xs leading-none"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
aria-label="Done editing beat"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-xs muted leading-none transition hover:opacity-100"
|
||||||
style={{ color: 'var(--accent)' }}
|
style={{ color: 'var(--accent)' }}
|
||||||
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
|
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
|
||||||
aria-label={`Delete beat ${beat.title}`}
|
aria-label={`Delete beat ${beat.title}`}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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">
|
||||||
|
<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">{beat.characterName ?? <span className="muted">—</span>}</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 whitespace-pre-wrap break-words">
|
||||||
|
{beat.whatsNext ?? <span className="muted">—</span>}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-2 align-top">{beat.sceneTitle ?? <span className="muted">—</span>}</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-2 align-top" />
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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."
|
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) => (
|
{chapters?.map((chapter) => (
|
||||||
<li key={chapter.id}>
|
<li key={chapter.id}>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,48 +1,41 @@
|
|||||||
import { NavLink, Outlet, useParams, Link } from 'react-router-dom'
|
import { Outlet, useParams, Link } from 'react-router-dom'
|
||||||
import { useProject } from '../api/hooks'
|
import { useProject, useUpdateProject } from '../api/hooks'
|
||||||
|
import { projectPhases } from '../api/types'
|
||||||
import { ErrorNote, Spinner } from '../components/ui'
|
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() {
|
export default function ProjectLayout() {
|
||||||
const { projectId = '' } = useParams()
|
const { projectId = '' } = useParams()
|
||||||
const { data: project, isPending, error } = useProject(projectId)
|
const { data: project, isPending, error } = useProject(projectId)
|
||||||
|
const update = useUpdateProject(projectId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="min-h-full">
|
||||||
<header className="sticky top-0 z-10 border-b" style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}>
|
<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">
|
<Link to="/" className="text-sm muted hover:underline">
|
||||||
← Novels
|
← Novels
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="truncate text-base font-semibold">{project?.title ?? '…'}</h1>
|
<Link to={`/projects/${projectId}`} className="truncate text-base font-semibold hover:underline">
|
||||||
</div>
|
{project?.title ?? '…'}
|
||||||
<nav className="mx-auto flex max-w-6xl gap-1 px-4">
|
</Link>
|
||||||
{tabs.map((tab) => (
|
{project && (
|
||||||
<NavLink
|
<select
|
||||||
key={tab.label}
|
className="input ml-auto w-auto"
|
||||||
to={tab.to}
|
value={project.phase}
|
||||||
end={tab.end}
|
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
|
||||||
className={({ isActive }) =>
|
aria-label="Novel phase"
|
||||||
`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)' })}
|
|
||||||
>
|
>
|
||||||
{tab.label}
|
{projectPhases.map((phase) => (
|
||||||
</NavLink>
|
<option key={phase} value={phase}>
|
||||||
|
{phase}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</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} />}
|
{error && <ErrorNote error={error} />}
|
||||||
{isPending ? <Spinner label="Loading project" /> : <Outlet context={{ projectId }} />}
|
{isPending ? <Spinner label="Loading project" /> : <Outlet context={{ projectId }} />}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ import { useChapters, useCharacters, useDeleteProject, useProject, useUpdateProj
|
|||||||
import { ImportDialog } from '../components/ImportDialog'
|
import { ImportDialog } from '../components/ImportDialog'
|
||||||
import { AutoField, ErrorNote, Spinner } from '../components/ui'
|
import { AutoField, ErrorNote, Spinner } from '../components/ui'
|
||||||
|
|
||||||
export default function OverviewPage() {
|
export default function SettingsPage() {
|
||||||
const { projectId = '' } = useParams()
|
const { projectId = '' } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: project, isPending } = useProject(projectId)
|
const { data: project, isPending } = useProject(projectId)
|
||||||
@@ -138,7 +138,7 @@ export default function OverviewPage() {
|
|||||||
className="btn w-full"
|
className="btn w-full"
|
||||||
style={{ color: 'var(--accent)' }}
|
style={{ color: 'var(--accent)' }}
|
||||||
onClick={() => {
|
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('/') })
|
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]
|
[Test]
|
||||||
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
|
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user