Add character aliases/identity links, move-beats, keyboard help overlay

- Characters can carry aliases and be linked as the same underlying
  person (canonical SameCharacterAsId, optional reveal chapter/note),
  surfaced through the API, MCP tools, agent toolset, and web UI.
- Characters page redesigned as a filterable/sortable table (name+
  aliases, role, importance, occupation, tags) instead of a sidebar
  list, to stay usable as the cast grows.
- Beats can be moved between chapters (BeatService.MoveAsync + MCP/
  agent tool + endpoint).
- Add a keyboard-shortcuts help overlay (HelpButton/HelpOverlayContext)
  wired into the project layout.
- CLAUDE.md: require every frontend component to carry a unique id
  attribute; apply it to CharacterMultiSelect and MarkdownEditor.
This commit is contained in:
James Wampler
2026-08-17 17:26:50 -07:00
parent b4f4b35e3c
commit f124b9b4bb
32 changed files with 3002 additions and 356 deletions
+1
View File
@@ -58,6 +58,7 @@ Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for A
- DTOs are records; entities are classes. DO NOT use Dto in names.
- `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`.
- Enums cross wire as names, never ordinals
- All frontend components should have an id attribute that identifies them uniquely.
## Testing
+61 -3
View File
@@ -151,7 +151,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
JsonInput.Strings(input, "tags"),
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Project", projectId));
yield return new AgentTool(
"update_character",
@@ -181,7 +182,42 @@ public class NovelAgentToolset(
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Character", characterId);
JsonInput.Strings(input, "tags"),
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId);
});
yield return new AgentTool(
"link_character_identity",
"Record that a character is really another character — e.g. one introduced under one name "
+ "who is later revealed to be a character already in the project under another name. Both "
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.",
new JsonSchemaBuilder()
.Str("character_id", "Id of the character being revealed as someone else.", required: true)
.Str("same_character_as_id", "Id of the character this one really is.", required: true)
.Str("revealed_in_chapter_id", "Id of the chapter where the reveal happens, if any.")
.Str("note", "Context on the reveal, e.g. how and why the disguise held.")
.Build(),
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(characters.LinkIdentityAsync(
characterId,
new LinkCharacterIdentityRequest(
JsonInput.RequiredGuid(input, "same_character_as_id"),
JsonInput.Guid(input, "revealed_in_chapter_id"),
JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId);
});
yield return new AgentTool(
"unlink_character_identity",
"Remove a character's identity link, restoring it to its own separate identity.",
new JsonSchemaBuilder()
.Str("character_id", "Id of the character to unlink.", required: true)
.Build(),
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId);
});
yield return new AgentTool(
@@ -289,6 +325,27 @@ public class NovelAgentToolset(
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
});
yield return new AgentTool(
"move_beats",
"Move one or more beats from one chapter to another, appending them to the target "
+ "chapter's end in the order given.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the beats' current chapter.", required: true)
.Str("target_chapter_id", "Id of the chapter to move the beats into.", required: true)
.StringArray("beat_ids", "Ids of the beats to move.", required: true)
.Build(),
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(beats.MoveAsync(
chapterId,
new MoveBeatsRequest(
JsonInput.RequiredGuid(input, "target_chapter_id"),
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
});
yield return new AgentTool(
"list_tags",
"List the project's tags with how many characters, chapters and beats carry each. "
@@ -596,7 +653,8 @@ public class NovelAgentToolset(
.Str("arc_summary", "How they change over the course of the book.")
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
.Str("notes", "Anything else worth recording.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.StringArray("aliases", "Other names this character is known by. Replaces the existing aliases.");
}
private static JsonSchemaBuilder BeatSchema() =>
+18
View File
@@ -119,6 +119,24 @@ public class AssignCharacterToBeatsRequestValidator : IModelValidator<AssignChar
}
}
public record MoveBeatsRequest(Guid TargetChapterId, IReadOnlyList<Guid> BeatIds);
public class MoveBeatsRequestValidator : IModelValidator<MoveBeatsRequest>
{
public ValidationResult Validate(MoveBeatsRequest model)
{
var result = new ValidationResult();
if (model.TargetChapterId == Guid.Empty)
result.AddError("TargetChapterId", "'Target Chapter Id' must not be empty.");
if (model.BeatIds is null || model.BeatIds.Count == 0)
result.AddError("BeatIds", "'Beat Ids' must not be empty.");
return result;
}
}
public static class BeatMapping
{
public static BeatResponse ToResponse(this Beat b) => new(
+5
View File
@@ -39,6 +39,11 @@ public static class BeatEndpoints
(await service.AssignCharacterAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult())
.WithSummary("Add a character to several beats at once, leaving each beat's existing characters alone.");
chapterScoped.MapPost("/move", async (
Guid chapterId, MoveBeatsRequest request, BeatService service, CancellationToken ct) =>
(await service.MoveAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult())
.WithSummary("Move one or more beats to another chapter, appending them to its end.");
app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) =>
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult())
+58 -1
View File
@@ -17,7 +17,8 @@ public class BeatService(
IModelValidator<CreateBeatRequest> createValidator,
IModelValidator<UpdateBeatRequest> updateValidator,
IModelValidator<ReorderBeatsRequest> reorderValidator,
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator)
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator,
IModelValidator<MoveBeatsRequest> moveValidator)
{
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
@@ -266,6 +267,62 @@ public class BeatService(
return await ListAsync(chapterId, ct);
}
public async Task<IReadOnlyList<Beat>?> MoveAsync(
Guid chapterId, MoveBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
moveValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation(
"Moving {Count} beats from chapter {ChapterId} to chapter {TargetChapterId}",
request.BeatIds.Count, chapterId, request.TargetChapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is null)
{
logger.LogWarning("Rejected beat move: chapter {ChapterId} not found", chapterId);
return null;
}
var targetChapter = await db.Chapters.FirstOrDefaultAsync(
c => c.Id == request.TargetChapterId && c.ProjectId == chapter.ProjectId, ct);
if (targetChapter is null)
{
logger.LogWarning(
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}",
request.TargetChapterId, chapter.ProjectId);
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0)
{
logger.LogWarning("Rejected beat move: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
return null;
}
if (chapterId == request.TargetChapterId)
{
return beats;
}
var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct);
foreach (var beatId in request.BeatIds)
{
var beat = beats.Single(b => b.Id == beatId);
beat.ChapterId = request.TargetChapterId;
beat.SortOrder = nextSortOrder++;
beat.UpdatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(ct);
return beats;
}
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
{
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId);
+18
View File
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Projects;
using Novelly.Api.Tags;
@@ -38,6 +39,16 @@ public class Character
public string? Notes { get; set; }
public List<string> Aliases { get; set; } = [];
public Guid? SameCharacterAsId { get; set; }
public Character? SameCharacterAs { get; set; }
public List<Character> OtherIdentities { get; set; } = [];
public Guid? RevealedInChapterId { get; set; }
public Chapter? RevealedInChapter { get; set; }
public string? IdentityNote { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
@@ -72,12 +83,19 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Charact
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => c.ProjectId);
entity.HasIndex(c => c.SameCharacterAsId);
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(c => c.ArcStages).WithOne(s => s.Character!)
.HasForeignKey(s => s.CharacterId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.SameCharacterAs).WithMany(c => c.OtherIdentities)
.HasForeignKey(c => c.SameCharacterAsId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(c => c.RevealedInChapter).WithMany()
.HasForeignKey(c => c.RevealedInChapterId).OnDelete(DeleteBehavior.SetNull);
}
}
@@ -22,11 +22,20 @@ public record CharacterResponse(
string? ArcSummary,
string? Voice,
string? Notes,
IReadOnlyList<string> Aliases,
Guid? SameCharacterAsId,
string? SameCharacterAsName,
Guid? RevealedInChapterId,
int? RevealedInChapterNumber,
string? IdentityNote,
IReadOnlyList<CharacterIdentityResponse> OtherIdentities,
IReadOnlyList<RelationshipResponse> Relationships,
IReadOnlyList<TagResponse> Tags,
IReadOnlyList<ArcStageResponse> ArcStages,
DateTimeOffset UpdatedAt);
public record CharacterIdentityResponse(Guid Id, string Name);
public record RelationshipResponse(
Guid Id,
Guid RelatedCharacterId,
@@ -51,7 +60,8 @@ public record CreateCharacterRequest(
string? ArcSummary = null,
string? Voice = null,
string? Notes = null,
IReadOnlyList<string>? Tags = null);
IReadOnlyList<string>? Tags = null,
IReadOnlyList<string>? Aliases = null);
public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRequest>
{
@@ -63,7 +73,7 @@ public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRe
CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
model.Notes, model.Tags, result);
model.Notes, model.Tags, model.Aliases, result);
return result;
}
@@ -86,7 +96,8 @@ public record UpdateCharacterRequest(
string? ArcSummary = null,
string? Voice = null,
string? Notes = null,
IReadOnlyList<string>? Tags = null);
IReadOnlyList<string>? Tags = null,
IReadOnlyList<string>? Aliases = null);
public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRequest>
{
@@ -98,7 +109,7 @@ public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRe
CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
model.Notes, model.Tags, result);
model.Notes, model.Tags, model.Aliases, result);
return result;
}
@@ -109,7 +120,7 @@ file static class CharacterValidation
public static void OptionalFields(
string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory,
string? want, string? need, string? internalConflict, string? externalConflict, string? arcSummary, string? voice,
string? notes, IReadOnlyList<string>? tags, ValidationResult result)
string? notes, IReadOnlyList<string>? tags, IReadOnlyList<string>? aliases, ValidationResult result)
{
Cap(age, "Age", 100, result);
Cap(pronouns, "Pronouns", 100, result);
@@ -127,6 +138,18 @@ file static class CharacterValidation
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
result.AddError("Tags", "'Tags' must not contain blank entries.");
if (aliases is not null)
{
if (aliases.Any(string.IsNullOrWhiteSpace))
result.AddError("Aliases", "'Aliases' must not contain blank entries.");
if (aliases.Any(a => a.Length > 200))
result.AddError("Aliases", "'Aliases' entries must be 200 characters or fewer.");
if (aliases.Count > 25)
result.AddError("Aliases", "'Aliases' must contain 25 entries or fewer.");
}
}
private static void Cap(string? value, string field, int max, ValidationResult result)
@@ -157,6 +180,26 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation
}
}
public record LinkCharacterIdentityRequest(
Guid SameCharacterAsId,
Guid? RevealedInChapterId = null,
string? Note = null);
public class LinkCharacterIdentityRequestValidator : IModelValidator<LinkCharacterIdentityRequest>
{
public ValidationResult Validate(LinkCharacterIdentityRequest model)
{
var result = new ValidationResult();
if (model.SameCharacterAsId == Guid.Empty)
result.AddError("SameCharacterAsId", "'Same Character As Id' must not be empty.");
result.AddOptionalTextErrors("Note", "Note", model.Note, 2000);
return result;
}
}
public record ArcStageResponse(
Guid Id,
Guid CharacterId,
@@ -240,6 +283,13 @@ public static class CharacterMapping
c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
[.. c.Aliases],
c.SameCharacterAsId,
c.SameCharacterAs?.Name,
c.RevealedInChapterId,
c.RevealedInChapter?.Number,
c.IdentityNote,
[.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
[.. c.Relationships.Select(r => new RelationshipResponse(
r.Id,
r.RelatedCharacterId,
@@ -56,6 +56,16 @@ public static class CharacterEndpoints
await service.RemoveRelationshipAsync(relationshipId, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Remove a relationship.");
characters.MapPut("/{id:guid}/identity", async (
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) =>
(await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Link this character as another identity of a character in the same project.");
characters.MapDelete("/{id:guid}/identity", async (
Guid id, CharacterService service, CancellationToken ct) =>
await service.UnlinkIdentityAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Remove this character's identity link.");
characters.MapGet("/{id:guid}/arc", async (
Guid id, CharacterArcService service, CancellationToken ct) =>
Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse())))
+107 -2
View File
@@ -14,7 +14,8 @@ public class CharacterService(
ILogger<CharacterService> logger,
IModelValidator<CreateCharacterRequest> createValidator,
IModelValidator<UpdateCharacterRequest> updateValidator,
IModelValidator<CreateRelationshipRequest> relationshipValidator)
IModelValidator<CreateRelationshipRequest> relationshipValidator,
IModelValidator<LinkCharacterIdentityRequest> identityValidator)
{
public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default)
{
@@ -97,6 +98,11 @@ public class CharacterService(
character.Tags = await tags.ResolveAsync(projectId, names, ct);
}
if (request.Aliases is { } aliases)
{
character.Aliases = [.. aliases];
}
db.Characters.Add(character);
await db.SaveChangesAsync(ct);
@@ -142,6 +148,11 @@ public class CharacterService(
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct);
}
if (request.Aliases is { } aliases)
{
character.Aliases = [.. aliases];
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
@@ -229,13 +240,107 @@ public class CharacterService(
return true;
}
public async Task<Character?> LinkIdentityAsync(
Guid characterId, LinkCharacterIdentityRequest request, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
Guard.Null(request, nameof(request));
identityValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation(
"Linking character {CharacterId} to identity {SameCharacterAsId}", characterId, request.SameCharacterAsId);
var character = await FindAsync(characterId, ct);
if (character is null)
{
return null;
}
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
if (request.SameCharacterAsId == characterId)
{
logger.LogWarning("Rejected identity link: character {CharacterId} cannot be linked to itself", characterId);
throw new InvalidOperationException("A character cannot be linked to itself.");
}
var target = await db.Characters
.Include(c => c.SameCharacterAs)
.FirstOrDefaultAsync(c => c.Id == request.SameCharacterAsId, ct);
if (target is null)
{
logger.LogWarning("Rejected identity link: target character {SameCharacterAsId} not found", request.SameCharacterAsId);
return null;
}
if (target.ProjectId != character.ProjectId)
{
logger.LogWarning(
"Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different projects",
characterId, request.SameCharacterAsId);
throw new InvalidOperationException("Characters must belong to the same project to be linked.");
}
if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct))
{
logger.LogWarning("Rejected identity link: character {CharacterId} already has other identities pointing to it", characterId);
throw new InvalidOperationException("This character already has other identities linked to it and cannot itself be an alias.");
}
var canonical = target.SameCharacterAsId ?? target.Id;
if (request.RevealedInChapterId is { } chapterId)
{
var chapterInProject = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.ProjectId == character.ProjectId, ct);
if (!chapterInProject)
{
logger.LogWarning("Rejected identity link: chapter {ChapterId} not in project {ProjectId}", chapterId, character.ProjectId);
throw new InvalidOperationException("The reveal chapter must belong to the same project.");
}
}
character.SameCharacterAsId = canonical;
character.RevealedInChapterId = request.RevealedInChapterId;
character.IdentityNote = request.Note;
character.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(characterId, ct))!;
}
public async Task<bool> UnlinkIdentityAsync(Guid characterId, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
logger.LogInformation("Unlinking identity for character {CharacterId}", characterId);
var character = await FindAsync(characterId, ct);
if (character is null)
{
return false;
}
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
character.SameCharacterAsId = null;
character.RevealedInChapterId = null;
character.IdentityNote = null;
character.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return true;
}
private IQueryable<Character> Query() =>
db.Characters
.Include(c => c.Relationships)
.ThenInclude(r => r.RelatedCharacter)
.Include(c => c.Tags)
.Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter);
.ThenInclude(s => s.Chapter)
.Include(c => c.SameCharacterAs)
.Include(c => c.OtherIdentities)
.Include(c => c.RevealedInChapter);
private async Task<Character?> FindAsync(Guid id, CancellationToken ct)
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddCharacterAliasesAndIdentityLinks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Aliases",
table: "Characters",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "IdentityNote",
table: "Characters",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "RevealedInChapterId",
table: "Characters",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "SameCharacterAsId",
table: "Characters",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Characters_RevealedInChapterId",
table: "Characters",
column: "RevealedInChapterId");
migrationBuilder.CreateIndex(
name: "IX_Characters_SameCharacterAsId",
table: "Characters",
column: "SameCharacterAsId");
migrationBuilder.AddForeignKey(
name: "FK_Characters_Chapters_RevealedInChapterId",
table: "Characters",
column: "RevealedInChapterId",
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_Characters_Characters_SameCharacterAsId",
table: "Characters",
column: "SameCharacterAsId",
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Characters_Chapters_RevealedInChapterId",
table: "Characters");
migrationBuilder.DropForeignKey(
name: "FK_Characters_Characters_SameCharacterAsId",
table: "Characters");
migrationBuilder.DropIndex(
name: "IX_Characters_RevealedInChapterId",
table: "Characters");
migrationBuilder.DropIndex(
name: "IX_Characters_SameCharacterAsId",
table: "Characters");
migrationBuilder.DropColumn(
name: "Aliases",
table: "Characters");
migrationBuilder.DropColumn(
name: "IdentityNote",
table: "Characters");
migrationBuilder.DropColumn(
name: "RevealedInChapterId",
table: "Characters");
migrationBuilder.DropColumn(
name: "SameCharacterAsId",
table: "Characters");
}
}
}
@@ -299,6 +299,10 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Age")
.HasColumnType("TEXT");
b.PrimitiveCollection<string>("Aliases")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Appearance")
.HasColumnType("TEXT");
@@ -314,6 +318,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("ExternalConflict")
.HasColumnType("TEXT");
b.Property<string>("IdentityNote")
.HasColumnType("TEXT");
b.Property<string>("Importance")
.IsRequired()
.HasMaxLength(32)
@@ -345,11 +352,17 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Pronouns")
.HasColumnType("TEXT");
b.Property<Guid?>("RevealedInChapterId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid?>("SameCharacterAsId")
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
@@ -363,6 +376,10 @@ namespace Novelly.Api.Data.Migrations
b.HasIndex("ProjectId");
b.HasIndex("RevealedInChapterId");
b.HasIndex("SameCharacterAsId");
b.ToTable("Characters");
});
@@ -963,7 +980,21 @@ namespace Novelly.Api.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Chapters.Chapter", "RevealedInChapter")
.WithMany()
.HasForeignKey("RevealedInChapterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Novelly.Api.Characters.Character", "SameCharacterAs")
.WithMany("OtherIdentities")
.HasForeignKey("SameCharacterAsId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Project");
b.Navigation("RevealedInChapter");
b.Navigation("SameCharacterAs");
});
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
@@ -1082,6 +1113,8 @@ namespace Novelly.Api.Data.Migrations
{
b.Navigation("ArcStages");
b.Navigation("OtherIdentities");
b.Navigation("Relationships");
});
+6
View File
@@ -27,6 +27,12 @@ public class NovelApiClient(HttpClient http, ILogger<NovelApiClient> logger)
Content = JsonContent.Create(body, options: Options)
}, ct);
public Task<CallToolResult> PutAsync(string path, object body, CancellationToken ct = default) =>
SendAsync(new HttpRequestMessage(HttpMethod.Put, path)
{
Content = JsonContent.Create(body, options: Options)
}, ct);
public Task<CallToolResult> DeleteAsync(string path, CancellationToken ct = default) => SendAsync(new HttpRequestMessage(HttpMethod.Delete, path), ct);
private async Task<CallToolResult> SendAsync(HttpRequestMessage request, CancellationToken ct)
+11
View File
@@ -78,4 +78,15 @@ public static class BeatTools
[Description("Beat ids in their new order.")] Guid[] beatIds,
CancellationToken ct) =>
api.PostAsync($"/api/chapters/{chapterId}/beats/reorder", new { beatIds }, ct);
[McpServerTool(Name = "move_beats")]
[Description("Move one or more beats from one chapter to another, appending them to the "
+ "target chapter's end in the order given.")]
public static Task<CallToolResult> MoveBeats(
NovelApiClient api,
[Description("The beats' current chapter id.")] Guid chapterId,
[Description("Id of the chapter to move the beats into.")] Guid targetChapterId,
[Description("Ids of the beats to move.")] Guid[] beatIds,
CancellationToken ct) =>
api.PostAsync($"/api/chapters/{chapterId}/beats/move", new { targetChapterId, beatIds }, ct);
}
+31 -4
View File
@@ -48,7 +48,8 @@ public static class CharacterTools
[Description("How they change over the course of the book.")] string? arcSummary = null,
[Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null,
[Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null,
[Description("Other names this character is known by.")] string[]? aliases = null) =>
api.PostAsync($"/api/projects/{projectId}/characters", new
{
name,
@@ -67,7 +68,8 @@ public static class CharacterTools
arcSummary,
voice,
notes,
tags
tags,
aliases
}, ct);
[McpServerTool(Name = "update_character")]
@@ -94,7 +96,8 @@ public static class CharacterTools
[Description("How they change over the course of the book.")] string? arcSummary = null,
[Description("Speech patterns and register.")] string? voice = null,
[Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null,
[Description("Other names this character is known by. Replaces the existing aliases.")] string[]? aliases = null) =>
api.PatchAsync($"/api/characters/{characterId}", new
{
name,
@@ -113,7 +116,8 @@ public static class CharacterTools
arcSummary,
voice,
notes,
tags
tags,
aliases
}, ct);
[McpServerTool(Name = "get_character_beats")]
@@ -191,4 +195,27 @@ public static class CharacterTools
[Description("What the relationship is like, and where it is headed.")] string? description = null) =>
api.PostAsync($"/api/characters/{characterId}/relationships",
new { relatedCharacterId, relationshipType, description }, ct);
[McpServerTool(Name = "link_character_identity")]
[Description("Record that this character is really another character — e.g. a character introduced "
+ "under one name who is later revealed to be a character already in the project under "
+ "another name. Both characters keep their own dossier and beats; the canonical identity "
+ "is whichever character you link to.")]
public static Task<CallToolResult> LinkCharacterIdentity(
NovelApiClient api,
[Description("Id of the character being revealed as someone else.")] Guid characterId,
[Description("Id of the character this one really is.")] Guid sameCharacterAsId,
CancellationToken ct,
[Description("Id of the chapter where the reveal happens, if any.")] Guid? revealedInChapterId = null,
[Description("Context on the reveal, e.g. how and why the disguise held.")] string? note = null) =>
api.PutAsync($"/api/characters/{characterId}/identity",
new { sameCharacterAsId, revealedInChapterId, note }, ct);
[McpServerTool(Name = "unlink_character_identity")]
[Description("Remove a character's identity link, restoring it to its own separate identity.")]
public static Task<CallToolResult> UnlinkCharacterIdentity(
NovelApiClient api,
[Description("The character's id.")] Guid characterId,
CancellationToken ct) =>
api.DeleteAsync($"/api/characters/{characterId}/identity", ct);
}
+19 -16
View File
@@ -13,6 +13,7 @@ import { AuthProvider, useAuth } from './auth/AuthContext'
import { Spinner } from './components/ui'
import { HotkeysProvider } from './keyboard/HotkeysContext'
import { HelpOverlay } from './keyboard/HelpOverlay'
import { HelpOverlayProvider } from './keyboard/HelpOverlayContext'
function RequireAuth() {
const { user, isPending } = useAuth()
@@ -27,23 +28,25 @@ export default function App() {
return (
<AuthProvider>
<HotkeysProvider>
<HelpOverlay />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<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 />} />
<HelpOverlayProvider>
<HelpOverlay />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<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 />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Route>
</Routes>
</Routes>
</HelpOverlayProvider>
</HotkeysProvider>
</AuthProvider>
)
+2
View File
@@ -39,5 +39,7 @@ export const api = {
request<T>(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
}
+38
View File
@@ -178,6 +178,32 @@ export function useDeleteCharacter(projectId: string) {
})
}
export function useLinkCharacterIdentity(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
id,
sameCharacterAsId,
revealedInChapterId,
note,
}: {
id: string
sameCharacterAsId: string
revealedInChapterId?: string | null
note?: string | null
}) => api.put<Character>(`/api/characters/${id}/identity`, { sameCharacterAsId, revealedInChapterId, note }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export function useUnlinkCharacterIdentity(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/characters/${id}/identity`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export const useCharacterBeats = (characterId: string | undefined) =>
useQuery({
queryKey: keys.characterBeats(characterId ?? ''),
@@ -371,6 +397,18 @@ export function useAssignCharacterToBeats(chapterId: string) {
})
}
export function useMoveBeats(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ targetChapterId, beatIds }: { targetChapterId: string; beatIds: string[] }) =>
api.post<Beat[]>(`/api/chapters/${chapterId}/beats/move`, { targetChapterId, beatIds }),
onSuccess: (_, { targetChapterId }) => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.chapter(targetChapterId) })
},
})
}
export const useChapters = (projectId: string) =>
useQuery({
queryKey: keys.chapters(projectId),
+12
View File
@@ -188,12 +188,24 @@ export interface Character {
arcSummary: string | null
voice: string | null
notes: string | null
aliases: string[]
sameCharacterAsId: string | null
sameCharacterAsName: string | null
revealedInChapterId: string | null
revealedInChapterNumber: number | null
identityNote: string | null
otherIdentities: CharacterIdentity[]
relationships: Relationship[]
tags: Tag[]
arcStages: ArcStage[]
updatedAt: string
}
export interface CharacterIdentity {
id: string
name: string
}
export interface ChapterSummary {
id: string
projectId: string
@@ -0,0 +1,69 @@
import { useState } from 'react'
export function AliasEditor({
aliases,
onChange,
label,
readOnly,
}: {
aliases: string[]
onChange: (aliases: string[]) => void
label?: string
readOnly?: boolean
}) {
const [draft, setDraft] = useState('')
const add = () => {
const name = draft.trim()
if (!name) return
if (!aliases.some((a) => a.toLowerCase() === name.toLowerCase())) {
onChange([...aliases, name])
}
setDraft('')
}
const remove = (name: string) => onChange(aliases.filter((a) => a !== name))
return (
<div id="alias-editor">
{label && <span className="label">{label}</span>}
<div className="flex flex-wrap items-center gap-1.5">
{aliases.map((alias) => (
<span
key={alias}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
>
{alias}
{!readOnly && (
<button
type="button"
onClick={() => remove(alias)}
className="opacity-60 transition hover:opacity-100"
aria-label={`Remove alias ${alias}`}
>
</button>
)}
</span>
))}
{!readOnly && (
<input
id="alias-editor-input"
className="input w-32 flex-1 px-2 py-0.5 text-xs"
value={draft}
placeholder="Add alias…"
onChange={(e) => setDraft(e.target.value)}
onBlur={add}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add()
}
}}
/>
)}
</div>
</div>
)
}
@@ -1,13 +1,33 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useCreateCharacter } from '../api/hooks'
import type { BeatCharacter } from '../api/types'
export function CharacterChip({ character, onRemove }: { character: BeatCharacter; onRemove?: () => void }) {
export function CharacterChip({
character,
projectId,
onRemove,
}: {
character: BeatCharacter
projectId?: string
onRemove?: () => void
}) {
return (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
>
{character.name}
{projectId ? (
<Link
to={`/projects/${projectId}/characters?character=${character.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{character.name}
</Link>
) : (
character.name
)}
{onRemove && (
<button
type="button"
@@ -23,16 +43,19 @@ export function CharacterChip({ character, onRemove }: { character: BeatCharacte
}
export function CharacterMultiSelect({
projectId,
selected,
options,
onChange,
}: {
projectId: string
selected: BeatCharacter[]
options: { id: string; name: string }[]
onChange: (ids: string[]) => void
}) {
const [draft, setDraft] = useState('')
const listId = 'character-multiselect-options'
const createCharacter = useCreateCharacter(projectId)
const add = () => {
const name = draft.trim()
@@ -40,9 +63,17 @@ export function CharacterMultiSelect({
if (!name) return
const match = options.find((o) => o.name.toLowerCase() === name.toLowerCase())
if (match && !selected.some((c) => c.id === match.id)) {
onChange([...selected.map((c) => c.id), match.id])
if (match) {
if (!selected.some((c) => c.id === match.id)) {
onChange([...selected.map((c) => c.id), match.id])
}
return
}
createCharacter.mutate(
{ name },
{ onSuccess: (character) => onChange([...selected.map((c) => c.id), character.id]) },
)
}
const remove = (id: string) => onChange(selected.filter((c) => c.id !== id).map((c) => c.id))
@@ -52,7 +83,7 @@ export function CharacterMultiSelect({
return (
<div className="flex flex-wrap items-center gap-1.5">
{selected.map((character) => (
<CharacterChip key={character.id} character={character} onRemove={() => remove(character.id)} />
<CharacterChip key={character.id} character={character} projectId={projectId} onRemove={() => remove(character.id)} />
))}
<input
className="input w-28 flex-1 px-2 py-0.5 text-xs"
@@ -16,7 +16,9 @@ export function MarkdownEditor({
}) {
const [draft, setDraft] = useState(value ?? '')
const [mode, setMode] = useState<'write' | 'preview'>('write')
const [isFullscreen, setIsFullscreen] = useState(false)
const committed = useRef(value ?? '')
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const incoming = value ?? ''
@@ -26,6 +28,12 @@ export function MarkdownEditor({
}
}, [value])
useEffect(() => {
const onFullscreenChange = () => setIsFullscreen(document.fullscreenElement === containerRef.current)
document.addEventListener('fullscreenchange', onFullscreenChange)
return () => document.removeEventListener('fullscreenchange', onFullscreenChange)
}, [])
const commit = () => {
if (draft !== committed.current) {
committed.current = draft
@@ -33,8 +41,20 @@ export function MarkdownEditor({
}
}
const toggleFullscreen = () => {
if (document.fullscreenElement === containerRef.current) {
document.exitFullscreen()
return
}
containerRef.current?.requestFullscreen()
}
return (
<div>
<div
ref={containerRef}
className={isFullscreen ? 'flex h-screen flex-col p-4' : ''}
style={isFullscreen ? { background: 'var(--surface)' } : undefined}
>
<div className="mb-2 flex justify-end gap-1">
<button
type="button"
@@ -53,11 +73,14 @@ export function MarkdownEditor({
>
Preview
</button>
<button type="button" className="btn px-2 py-1 text-xs" onClick={toggleFullscreen}>
{isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
</button>
</div>
{mode === 'write' ? (
<textarea
className="input font-mono text-sm"
className={`input font-mono text-sm ${isFullscreen ? 'flex-1 resize-none' : ''}`}
rows={rows}
value={draft}
placeholder={placeholder}
@@ -66,7 +89,9 @@ export function MarkdownEditor({
readOnly={readOnly}
/>
) : (
<div className="markdown-preview card min-h-[20rem] p-4">
<div
className={`markdown-preview card min-h-[20rem] p-4 ${isFullscreen ? 'flex-1 overflow-y-auto' : ''}`}
>
{draft.trim() ? <ReactMarkdown>{draft}</ReactMarkdown> : <p className="muted">Nothing written yet.</p>}
</div>
)}
@@ -0,0 +1,17 @@
import { useHelpOverlay } from './HelpOverlayContext'
export function HelpButton() {
const { setOpen } = useHelpOverlay()
return (
<button
className="flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold shadow-sm transition hover:shadow-md"
style={{ background: 'var(--surface)', border: '1px solid var(--line)' }}
onClick={() => setOpen(true)}
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
>
?
</button>
)
}
+40 -55
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { useHotkey, useHotkeysList } from './HotkeysContext'
import { useHelpOverlay } from './HelpOverlayContext'
import { useHotkeysList } from './HotkeysContext'
const formatToken = (token: string) => {
if (token === 'mod') return '⌘/Ctrl'
@@ -38,12 +38,9 @@ function KeySequence({ keys }: { keys: string }) {
}
export function HelpOverlay() {
const [open, setOpen] = useState(false)
const { open, setOpen } = useHelpOverlay()
const shortcuts = useHotkeysList()
useHotkey('?', 'Toggle this help', () => setOpen((v) => !v), { group: 'Global' })
useHotkey('Escape', 'Close help', () => setOpen(false), { group: 'Global', enabled: open })
const groups = new Map<string, typeof shortcuts>()
for (const shortcut of shortcuts) {
if (!groups.has(shortcut.group)) groups.set(shortcut.group, [])
@@ -53,57 +50,45 @@ export function HelpOverlay() {
a === 'Global' ? -1 : b === 'Global' ? 1 : a.localeCompare(b),
)
if (!open) return null
return (
<>
<button
className="fixed top-3 right-4 z-40 flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold shadow-sm transition hover:shadow-md"
style={{ background: 'var(--surface)', border: '1px solid var(--line)' }}
onClick={() => setOpen(true)}
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={() => setOpen(false)}>
<aside
className="flex h-full w-full max-w-sm flex-col overflow-y-auto p-5 shadow-xl"
style={{ background: 'var(--surface)', borderLeft: '1px solid var(--line)' }}
onClick={(e) => e.stopPropagation()}
>
?
</button>
{open && (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={() => setOpen(false)}>
<aside
className="flex h-full w-full max-w-sm flex-col overflow-y-auto p-5 shadow-xl"
style={{ background: 'var(--surface)', borderLeft: '1px solid var(--line)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
<button className="btn px-2 py-1" onClick={() => setOpen(false)} aria-label="Close">
</button>
</div>
<p className="mb-4 text-sm muted">
Shown here are the shortcuts available on the screen you're on. Shortcuts don't fire
while a text field is focused, except where noted.
</p>
{orderedGroups.length === 0 && <p className="text-sm muted">No shortcuts registered.</p>}
<div className="grid gap-5">
{orderedGroups.map(([group, groupShortcuts]) => (
<div key={group}>
<h3 className="label mb-2">{group}</h3>
<ul className="grid gap-2">
{groupShortcuts.map((shortcut) => (
<li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm">
<span>{shortcut.description}</span>
<KeySequence keys={shortcut.keys} />
</li>
))}
</ul>
</div>
))}
</div>
</aside>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
<button className="btn px-2 py-1" onClick={() => setOpen(false)} aria-label="Close">
</button>
</div>
)}
</>
<p className="mb-4 text-sm muted">
Shown here are the shortcuts available on the screen you're on. Shortcuts don't fire
while a text field is focused, except where noted.
</p>
{orderedGroups.length === 0 && <p className="text-sm muted">No shortcuts registered.</p>}
<div className="grid gap-5">
{orderedGroups.map(([group, groupShortcuts]) => (
<div key={group}>
<h3 className="label mb-2">{group}</h3>
<ul className="grid gap-2">
{groupShortcuts.map((shortcut) => (
<li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm">
<span>{shortcut.description}</span>
<KeySequence keys={shortcut.keys} />
</li>
))}
</ul>
</div>
))}
</div>
</aside>
</div>
)
}
@@ -0,0 +1,24 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import { useHotkey } from './HotkeysContext'
interface HelpOverlayState {
open: boolean
setOpen: (open: boolean) => void
}
const HelpOverlayStateContext = createContext<HelpOverlayState | null>(null)
export function HelpOverlayProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false)
useHotkey('?', 'Toggle this help', () => setOpen((v) => !v), { group: 'Global' })
useHotkey('Escape', 'Close help', () => setOpen(false), { group: 'Global', enabled: open })
return <HelpOverlayStateContext.Provider value={{ open, setOpen }}>{children}</HelpOverlayStateContext.Provider>
}
export function useHelpOverlay() {
const context = useContext(HelpOverlayStateContext)
if (!context) throw new Error('useHelpOverlay must be used within a HelpOverlayProvider')
return context
}
+320 -131
View File
@@ -3,17 +3,20 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import {
useAssignCharacterToBeats,
useChapter,
useChapters,
useCharacters,
useCreateBeat,
useCreateChapter,
useDeleteBeat,
useDeleteChapter,
useMoveBeats,
useProject,
useReorderBeats,
useTags,
useUpdateBeat,
useUpdateChapter,
} from '../api/hooks'
import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -33,6 +36,8 @@ export default function ChapterPage() {
const { data: project } = useProject(projectId)
const { data: characters } = useCharacters(projectId)
const { data: allTags } = useTags(projectId)
const { data: chapters } = useChapters(projectId)
const createChapter = useCreateChapter(projectId)
const update = useUpdateChapter(projectId)
const remove = useDeleteChapter(projectId)
const createBeat = useCreateBeat(chapterId, projectId)
@@ -46,6 +51,24 @@ export default function ChapterPage() {
useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
const currentIndex = chapters?.findIndex((c) => c.id === chapterId) ?? -1
const prevChapter = currentIndex > 0 ? chapters?.[currentIndex - 1] : undefined
const nextChapter =
currentIndex >= 0 && chapters && currentIndex < chapters.length - 1 ? chapters[currentIndex + 1] : undefined
useHotkey(
'[',
'Previous chapter',
() => prevChapter && navigate(`/projects/${projectId}/chapters/${prevChapter.id}`),
{ group: 'Chapter', enabled: Boolean(prevChapter) },
)
useHotkey(
']',
'Next chapter',
() => nextChapter && navigate(`/projects/${projectId}/chapters/${nextChapter.id}`),
{ group: 'Chapter', enabled: Boolean(nextChapter) },
)
if (isPending) return <Spinner label="Loading chapter" />
if (error) return <ErrorNote error={error} />
if (!chapter) return null
@@ -54,87 +77,45 @@ export default function ChapterPage() {
update.mutate({ id: chapter.id, ...body })
const suggestions = allTags?.map((t) => t.name) ?? []
const settingSuggestions = [
...new Set((chapters ?? []).map((c) => c.setting).filter((s): s is string => Boolean(s?.trim()))),
].sort()
return (
<div>
<div className="mb-4">
<div className="mb-4 flex items-center justify-between gap-4">
<Link to={`/projects/${projectId}/chapters`} className="text-sm muted hover:underline">
All chapters
</Link>
</div>
<section className="card mb-6 p-5">
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]">
<label className="block">
<span className="label">No.</span>
<input
className="input"
type="number"
min={1}
defaultValue={chapter.number}
readOnly={!canWrite}
onBlur={(e) => {
const number = Number(e.target.value)
if (number > 0 && number !== chapter.number) patch({ number })
}}
/>
</label>
<AutoField
label="Title"
value={chapter.title}
onCommit={(title) => title.trim() && patch({ title })}
readOnly={!canWrite}
/>
<Select
label="Status"
value={chapter.status}
options={draftStatuses}
onChange={(status) => canWrite && patch({ status })}
/>
</div>
<div className="mt-4">
<AutoField
label="Setting"
value={chapter.setting}
onCommit={(setting) => patch({ setting })}
readOnly={!canWrite}
/>
</div>
<div className="mt-4">
<TagEditor
label="Tags"
tags={chapter.tags}
suggestions={suggestions}
onChange={(tags) => canWrite && patch({ tags })}
/>
</div>
<div className="mt-4 flex items-end justify-between gap-4">
<div className="text-sm muted">
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete chapter
</button>
<div className="flex items-center gap-3 text-sm">
{prevChapter ? (
<Link
to={`/projects/${projectId}/chapters/${prevChapter.id}`}
className="muted hover:underline"
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`}
>
Ch. {prevChapter.number}
</Link>
) : (
<span className="muted" style={{ opacity: 0.4 }}>
Ch.
</span>
)}
{nextChapter ? (
<Link
to={`/projects/${projectId}/chapters/${nextChapter.id}`}
className="muted hover:underline"
title={`Chapter ${nextChapter.number}: ${nextChapter.title}`}
>
Ch. {nextChapter.number}
</Link>
) : (
<span className="muted" style={{ opacity: 0.4 }}>
Ch.
</span>
)}
</div>
</section>
{confirmingDelete && (
<ConfirmModal
title="Delete chapter"
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
onConfirm={() =>
remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
})
}
onClose={() => setConfirmingDelete(false)}
/>
)}
</div>
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
{(
@@ -157,6 +138,83 @@ export default function ChapterPage() {
))}
</div>
{tab === 'outline' && (
<section className="card mb-6 p-5">
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]">
<label className="block">
<span className="label">No.</span>
<input
key={chapter.id}
className="input"
type="number"
min={1}
defaultValue={chapter.number}
readOnly={!canWrite}
onBlur={(e) => {
const number = Number(e.target.value)
if (number > 0 && number !== chapter.number) patch({ number })
}}
/>
</label>
<AutoField
label="Title"
value={chapter.title}
onCommit={(title) => title.trim() && patch({ title })}
readOnly={!canWrite}
/>
<Select
label="Status"
value={chapter.status}
options={draftStatuses}
onChange={(status) => canWrite && patch({ status })}
/>
</div>
<div className="mt-4">
<AutoField
label="Setting"
value={chapter.setting}
onCommit={(setting) => patch({ setting })}
suggestions={settingSuggestions}
readOnly={!canWrite}
/>
</div>
<div className="mt-4">
<TagEditor
label="Tags"
tags={chapter.tags}
suggestions={suggestions}
onChange={(tags) => canWrite && patch({ tags })}
/>
</div>
<div className="mt-4 flex items-end justify-between gap-4">
<div className="text-sm muted">
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete chapter
</button>
)}
</div>
</section>
)}
{confirmingDelete && (
<ConfirmModal
title="Delete chapter"
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
onConfirm={() =>
remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
})
}
onClose={() => setConfirmingDelete(false)}
/>
)}
{tab === 'outline' ? (
<section className="mb-8">
<p className="mb-3 text-sm muted">
@@ -180,6 +238,8 @@ export default function ChapterPage() {
chapter={chapter}
projectId={projectId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
otherChapters={chapters?.filter((c) => c.id !== chapter.id) ?? []}
createChapter={createChapter}
suggestions={suggestions}
onCharacterContextMenu={handleContextMenu}
canWrite={canWrite}
@@ -200,6 +260,30 @@ export default function ChapterPage() {
<ErrorNote error={createBeat.error} />
</div>
)}
<div className="card mt-6 p-5">
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
<p className="mb-3 text-xs muted">
Anything that does not belong in the outline itself continuity to watch, research
to do, decisions already made. Resolved questions land here too.
</p>
<AutoField
value={chapter.notes}
multiline
rows={5}
placeholder="Notes on this chapter."
onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/>
</div>
<OpenQuestions
projectId={projectId}
scope={{ chapterId: chapter.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
</section>
) : (
<section className="mb-8">
@@ -207,45 +291,26 @@ export default function ChapterPage() {
<MarkdownEditor
value={chapter.prose}
placeholder="Start writing the chapter."
rows={36}
onCommit={(prose) => patch({ prose })}
readOnly={!canWrite}
/>
</section>
)}
<section className="card mt-6 p-5">
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
<p className="mb-3 text-xs muted">
Anything that does not belong in the outline itself — continuity to watch, research
to do, decisions already made. Resolved questions land here too.
</p>
<AutoField
value={chapter.notes}
multiline
rows={5}
placeholder="Notes on this chapter."
onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/>
</section>
<OpenQuestions
projectId={projectId}
scope={{ chapterId: chapter.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
{menuElement}
</div>
)
}
const MOVE_TO_NEW_CHAPTER = '__new__'
function BeatTable({
chapter,
projectId,
characters,
otherChapters,
createChapter,
suggestions,
onCharacterContextMenu,
canWrite,
@@ -254,6 +319,8 @@ function BeatTable({
chapter: Chapter
projectId: string
characters: { id: string; name: string }[]
otherChapters: ChapterSummary[]
createChapter: ReturnType<typeof useCreateChapter>
suggestions: string[]
onCharacterContextMenu: (
e: MouseEvent<HTMLTextAreaElement>,
@@ -266,10 +333,15 @@ function BeatTable({
const remove = useDeleteBeat(chapter.id)
const reorder = useReorderBeats(chapter.id)
const assignCharacter = useAssignCharacterToBeats(chapter.id)
const moveBeats = useMoveBeats(chapter.id)
const [editingId, setEditingId] = useState<string | null>(null)
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [assignCharacterId, setAssignCharacterId] = useState('')
const [moveTargetId, setMoveTargetId] = useState('')
const [focusedBeatId, setFocusedBeatId] = useState<string | null>(null)
const [dragBeatId, setDragBeatId] = useState<string | null>(null)
const [dragOverBeatId, setDragOverBeatId] = useState<string | null>(null)
const toggleSelected = (id: string) =>
setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]))
@@ -285,12 +357,15 @@ function BeatTable({
)
}
if (chapter.beats.length === 0) {
return (
<div className="card px-6 py-8 text-center text-sm muted">
No beats yet. Each one is a short handle — “she burns the atlas” — plus what happened and
what it sets up.
</div>
const moveSelected = async () => {
if (!moveTargetId || selectedIds.length === 0) return
const targetChapterId =
moveTargetId === MOVE_TO_NEW_CHAPTER
? (await createChapter.mutateAsync({ title: 'New chapter' })).id
: moveTargetId
moveBeats.mutate(
{ targetChapterId, beatIds: selectedIds },
{ onSuccess: () => { setSelectedIds([]); setMoveTargetId('') } },
)
}
@@ -303,9 +378,88 @@ function BeatTable({
reorder.mutate(ids)
}
const moveFocused = (delta: number) => {
if (!canWrite || focusedBeatId === null) return
const index = chapter.beats.findIndex((b) => b.id === focusedBeatId)
if (index === -1) return
move(index, delta)
}
useHotkey('mod+ArrowUp', 'Move focused beat up', () => moveFocused(-1), {
group: 'Chapter',
enabled: canWrite && focusedBeatId !== null,
})
useHotkey('mod+ArrowDown', 'Move focused beat down', () => moveFocused(1), {
group: 'Chapter',
enabled: canWrite && focusedBeatId !== null,
})
if (chapter.beats.length === 0) {
return (
<div className="card px-6 py-8 text-center text-sm muted">
No beats yet. Each one is a short handle — “she burns the atlas” — plus what happened and
what it sets up.
</div>
)
}
const reorderByDrag = (targetBeatId: string) => {
if (!canWrite || dragBeatId === null || dragBeatId === targetBeatId) return
const ids = chapter.beats.map((b) => b.id)
const from = ids.indexOf(dragBeatId)
const to = ids.indexOf(targetBeatId)
if (from === -1 || to === -1) return
ids.splice(to, 0, ...ids.splice(from, 1))
reorder.mutate(ids)
}
const patch = (id: string, body: Partial<Omit<Beat, 'tags' | 'characters'>> & { tags?: string[]; characterIds?: string[] }) =>
update.mutate({ id, ...body })
const moveButtonClass =
'flex h-6 w-6 items-center justify-center rounded text-base leading-none transition hover:bg-[var(--accent-soft)] disabled:opacity-25 disabled:hover:bg-transparent'
const renderMoveButtons = (beat: Beat, index: number) => (
<div className="flex items-center gap-1">
<span
className={canWrite ? 'cursor-grab text-base muted' : 'text-base muted'}
title={canWrite ? 'Drag to reorder' : undefined}
aria-hidden="true"
>
</span>
<span className="w-4 text-xs muted">{index + 1}</span>
<div className="flex flex-col">
<button
id={`move-beat-up-${beat.id}`}
className={moveButtonClass}
onClick={(e) => {
e.stopPropagation()
move(index, -1)
}}
disabled={index === 0 || reorder.isPending}
aria-label="Move beat up"
title="Move beat up"
>
</button>
<button
id={`move-beat-down-${beat.id}`}
className={moveButtonClass}
onClick={(e) => {
e.stopPropagation()
move(index, 1)
}}
disabled={index === chapter.beats.length - 1 || reorder.isPending}
aria-label="Move beat down"
title="Move beat down"
>
</button>
</div>
</div>
)
return (
<div>
{selectedIds.length > 0 && canWrite && (
@@ -332,10 +486,32 @@ function BeatTable({
>
Assign
</button>
<select
className="input w-48"
value={moveTargetId}
onChange={(e) => setMoveTargetId(e.target.value)}
>
<option value="">Move to chapter…</option>
{otherChapters.map((c) => (
<option key={c.id} value={c.id}>
{c.number}. {c.title}
</option>
))}
<option value={MOVE_TO_NEW_CHAPTER}>New chapter…</option>
</select>
<button
className="btn btn-primary"
onClick={moveSelected}
disabled={!moveTargetId || moveBeats.isPending || createChapter.isPending}
>
Move
</button>
<button className="btn" onClick={() => setSelectedIds([])}>
Clear selection
</button>
{assignCharacter.error && <ErrorNote error={assignCharacter.error} />}
{moveBeats.error && <ErrorNote error={moveBeats.error} />}
{createChapter.error && <ErrorNote error={createChapter.error} />}
</div>
)}
@@ -351,7 +527,7 @@ function BeatTable({
onChange={toggleSelectAll}
/>
</th>
<th className="w-10 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-16 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
Characters
@@ -384,29 +560,7 @@ function BeatTable({
onClick={(e) => e.stopPropagation()}
/>
</td>
<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">{renderMoveButtons(beat, index)}</td>
<td className="px-2 py-2 align-top">
<AutoField
@@ -425,6 +579,7 @@ function BeatTable({
<td className="px-2 py-2 align-top">
<CharacterMultiSelect
projectId={projectId}
selected={beat.characters}
options={characters}
onChange={(characterIds) => patch(beat.id, { characterIds })}
@@ -493,13 +648,19 @@ function BeatTable({
tabIndex={canWrite ? 0 : undefined}
role={canWrite ? 'button' : undefined}
aria-label={canWrite ? `Edit beat ${beat.title}` : undefined}
draggable={canWrite}
className={
canWrite
? 'cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]'
: ''
}
style={{ borderBottom: '1px solid var(--line)' }}
style={{
borderBottom: '1px solid var(--line)',
opacity: dragBeatId === beat.id ? 0.4 : 1,
boxShadow: dragOverBeatId === beat.id && dragBeatId !== beat.id ? 'inset 0 2px 0 0 var(--accent)' : undefined,
}}
onClick={canWrite ? () => setEditingId(beat.id) : undefined}
onFocus={canWrite ? () => setFocusedBeatId(beat.id) : undefined}
onKeyDown={
canWrite
? (e) => {
@@ -510,6 +671,34 @@ function BeatTable({
}
: undefined
}
onDragStart={canWrite ? () => setDragBeatId(beat.id) : undefined}
onDragOver={
canWrite
? (e) => {
e.preventDefault()
setDragOverBeatId(beat.id)
}
: undefined
}
onDragLeave={canWrite ? () => setDragOverBeatId((id) => (id === beat.id ? null : id)) : undefined}
onDrop={
canWrite
? (e) => {
e.preventDefault()
reorderByDrag(beat.id)
setDragBeatId(null)
setDragOverBeatId(null)
}
: undefined
}
onDragEnd={
canWrite
? () => {
setDragBeatId(null)
setDragOverBeatId(null)
}
: undefined
}
>
<td className="px-2 py-2 align-top">
<input
@@ -520,7 +709,7 @@ function BeatTable({
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="px-2 py-2 align-top text-xs muted">{index + 1}</td>
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td>
<td className="px-2 py-2 align-top">
<div className="font-medium">{beat.title}</div>
@@ -537,7 +726,7 @@ function BeatTable({
{beat.characters.length > 0 ? (
<div className="flex flex-wrap gap-1">
{beat.characters.map((character) => (
<CharacterChip key={character.id} character={character} />
<CharacterChip key={character.id} character={character} projectId={projectId} />
))}
</div>
) : (
+484 -53
View File
@@ -1,93 +1,156 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import { useMemo, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import {
useChapters,
useCharacters,
useCreateCharacter,
useDeleteCharacter,
useLinkCharacterIdentity,
useProject,
useTags,
useUnlinkCharacterIdentity,
useUpdateCharacter,
} from '../api/hooks'
import { characterImportances, characterRoles, type Character } from '../api/types'
import { characterImportances, characterRoles, type Character, type CharacterImportance, type CharacterRole } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagEditor } from '../components/TagEditor'
import { TagChip, TagEditor } from '../components/TagEditor'
import { AliasEditor } from '../components/AliasEditor'
import { CharacterArc } from '../components/CharacterArc'
import { CharacterBeats } from '../components/CharacterBeats'
import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext'
type SortKey = 'name' | 'updatedAt'
export default function CharactersPage() {
const { projectId = '' } = useParams()
const { data: characters, isPending, error } = useCharacters(projectId)
const { data: project } = useProject(projectId)
const { data: allTags } = useTags(projectId)
const { can } = useAuth()
const canCreate = can('CreateContent', project)
const canWrite = can('Write', project)
const canDelete = can('DeleteContent', project)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [selectedId, setSelectedId] = useState<string | null>(searchParams.get('character'))
const [adding, setAdding] = useState(false)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<CharacterRole | ''>('')
const [importanceFilter, setImportanceFilter] = useState<CharacterImportance | ''>('')
const [occupationFilter, setOccupationFilter] = useState('')
const [tagFilter, setTagFilter] = useState('')
const [sortKey, setSortKey] = useState<SortKey>('name')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
useHotkey('n', 'Add character', () => canCreate && setAdding(true), { group: 'Characters' })
const occupations = useMemo(
() => [...new Set((characters ?? []).map((c) => c.occupation).filter((o): o is string => Boolean(o)))].sort(),
[characters],
)
const filtered = useMemo(() => {
const term = search.trim().toLowerCase()
return (characters ?? []).filter((c) => {
if (term && !c.name.toLowerCase().includes(term) && !c.aliases.some((a) => a.toLowerCase().includes(term)))
return false
if (roleFilter && c.role !== roleFilter) return false
if (importanceFilter && c.importance !== importanceFilter) return false
if (occupationFilter && c.occupation !== occupationFilter) return false
if (tagFilter && !c.tags.some((t) => t.name === tagFilter)) return false
return true
})
}, [characters, search, roleFilter, importanceFilter, occupationFilter, tagFilter])
const sorted = useMemo(() => {
const list = [...filtered]
list.sort((a, b) => {
const cmp =
sortKey === 'name' ? a.name.localeCompare(b.name) : Date.parse(a.updatedAt) - Date.parse(b.updatedAt)
return sortDir === 'asc' ? cmp : -cmp
})
return list
}, [filtered, sortKey, sortDir])
if (isPending) return <Spinner label="Loading characters" />
if (error) return <ErrorNote error={error} />
const selected = characters?.find((c) => c.id === selectedId) ?? characters?.[0]
const selected = characters?.find((c) => c.id === selectedId)
const select = (id: string) => {
setSelectedId(id)
setSearchParams((params) => {
params.set('character', id)
return params
})
}
if (!characters || characters.length === 0) {
return (
<div className="grid gap-4">
{canCreate && (
<div className="flex justify-end">
<button className="btn btn-primary" onClick={() => setAdding(true)}>
Add character
</button>
</div>
)}
<EmptyState
title="No characters yet"
hint="Add the protagonist first — most outline questions resolve once you know what they want."
/>
{adding && (
<AddCharacterModal projectId={projectId} onClose={() => setAdding(false)} onCreated={setSelectedId} />
)}
</div>
)
}
return (
<div className="grid gap-6 lg:grid-cols-[16rem_1fr]">
<aside className="grid content-start gap-2">
<div className="grid gap-6">
<div className="flex items-center justify-between gap-3">
<h1 className="text-lg font-semibold">Characters</h1>
{canCreate && (
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
Add character
</button>
)}
{(['Main', 'Supporting'] as const).map((importance) => {
const group = characters?.filter((c) => c.importance === importance) ?? []
if (group.length === 0) return null
</div>
return (
<div key={importance} className="grid gap-2">
<h2 className="label mt-2 mb-0">{importance}</h2>
{group.map((character) => (
<button
key={character.id}
onClick={() => setSelectedId(character.id)}
className="card px-3 py-2 text-left transition hover:shadow-sm"
style={
character.id === selected?.id
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
: undefined
}
>
<div className="truncate font-medium">{character.name}</div>
<div className="text-xs muted">{character.role}</div>
</button>
))}
</div>
)
})}
</aside>
<CharacterFilterBar
search={search}
onSearch={setSearch}
roleFilter={roleFilter}
onRoleFilter={setRoleFilter}
importanceFilter={importanceFilter}
onImportanceFilter={setImportanceFilter}
occupationFilter={occupationFilter}
onOccupationFilter={setOccupationFilter}
occupations={occupations}
tagFilter={tagFilter}
onTagFilter={setTagFilter}
tags={allTags ?? []}
sortKey={sortKey}
onSortKey={setSortKey}
sortDir={sortDir}
onSortDir={setSortDir}
/>
<section>
{!selected ? (
<EmptyState
title="No characters yet"
hint="Add the protagonist first — most outline questions resolve once you know what they want."
/>
) : (
<CharacterSheet
key={selected.id}
projectId={projectId}
character={selected}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)}
</section>
<CharacterTable characters={sorted} selectedId={selected?.id} onSelect={select} />
{selected && (
<CharacterSheet
key={selected.id}
projectId={projectId}
character={selected}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)}
{adding && (
<AddCharacterModal
@@ -100,6 +163,213 @@ export default function CharactersPage() {
)
}
function CharacterFilterBar({
search,
onSearch,
roleFilter,
onRoleFilter,
importanceFilter,
onImportanceFilter,
occupationFilter,
onOccupationFilter,
occupations,
tagFilter,
onTagFilter,
tags,
sortKey,
onSortKey,
sortDir,
onSortDir,
}: {
search: string
onSearch: (value: string) => void
roleFilter: CharacterRole | ''
onRoleFilter: (value: CharacterRole | '') => void
importanceFilter: CharacterImportance | ''
onImportanceFilter: (value: CharacterImportance | '') => void
occupationFilter: string
onOccupationFilter: (value: string) => void
occupations: string[]
tagFilter: string
onTagFilter: (value: string) => void
tags: { id: string; name: string }[]
sortKey: SortKey
onSortKey: (value: SortKey) => void
sortDir: 'asc' | 'desc'
onSortDir: (value: 'asc' | 'desc') => void
}) {
return (
<div id="character-filter-bar" className="card flex flex-wrap items-end gap-3 p-3">
<label className="block">
<span className="label">Search</span>
<input
id="character-filter-search"
className="input w-48"
value={search}
placeholder="Name or alias…"
onChange={(e) => onSearch(e.target.value)}
/>
</label>
<label className="block">
<span className="label">Role</span>
<select
id="character-filter-role"
className="input w-40"
value={roleFilter}
onChange={(e) => onRoleFilter(e.target.value as CharacterRole | '')}
>
<option value="">All roles</option>
{characterRoles.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Importance</span>
<select
id="character-filter-importance"
className="input w-36"
value={importanceFilter}
onChange={(e) => onImportanceFilter(e.target.value as CharacterImportance | '')}
>
<option value="">All</option>
{characterImportances.map((importance) => (
<option key={importance} value={importance}>
{importance}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Occupation</span>
<select
id="character-filter-occupation"
className="input w-40"
value={occupationFilter}
onChange={(e) => onOccupationFilter(e.target.value)}
>
<option value="">All</option>
{occupations.map((occupation) => (
<option key={occupation} value={occupation}>
{occupation}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Tag</span>
<select
id="character-filter-tag"
className="input w-36"
value={tagFilter}
onChange={(e) => onTagFilter(e.target.value)}
>
<option value="">All</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.name}>
{tag.name}
</option>
))}
</select>
</label>
<div className="ml-auto flex items-end gap-2">
<label className="block">
<span className="label">Sort by</span>
<select
id="character-sort-key"
className="input w-36"
value={sortKey}
onChange={(e) => onSortKey(e.target.value as SortKey)}
>
<option value="name">Name</option>
<option value="updatedAt">Last modified</option>
</select>
</label>
<button
id="character-sort-direction"
type="button"
className="btn"
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
onClick={() => onSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
>
{sortDir === 'asc' ? '↑' : '↓'}
</button>
</div>
</div>
)
}
function CharacterTable({
characters,
selectedId,
onSelect,
}: {
characters: Character[]
selectedId: string | undefined
onSelect: (id: string) => void
}) {
if (characters.length === 0) {
return (
<div className="card p-5 text-sm muted" id="character-table-empty">
No characters match these filters.
</div>
)
}
return (
<div className="card overflow-x-auto" id="character-table">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase muted">
<th className="py-2 px-3 font-semibold">Name</th>
<th className="py-2 px-3 font-semibold">Role</th>
<th className="py-2 px-3 font-semibold">Importance</th>
<th className="py-2 px-3 font-semibold">Occupation</th>
<th className="py-2 px-3 font-semibold">Tags</th>
</tr>
</thead>
<tbody>
{characters.map((character) => (
<tr
key={character.id}
onClick={() => onSelect(character.id)}
className="cursor-pointer align-top transition"
style={{
borderTop: '1px solid var(--line)',
background: character.id === selectedId ? 'var(--accent-soft)' : undefined,
}}
>
<td className="py-2 px-3">
<div className="font-medium">{character.name}</div>
{character.aliases.length > 0 && (
<div className="text-xs muted">aka {character.aliases.join(', ')}</div>
)}
</td>
<td className="py-2 px-3">{character.role}</td>
<td className="py-2 px-3">{character.importance}</td>
<td className="py-2 px-3">{character.occupation ?? <span className="muted"></span>}</td>
<td className="py-2 px-3">
<div className="flex flex-wrap gap-1">
{character.tags.map((tag) => (
<TagChip key={tag.id} tag={tag} />
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function CharacterSheet({
projectId,
character,
@@ -114,10 +384,14 @@ function CharacterSheet({
canDelete: boolean
}) {
const { data: allTags } = useTags(projectId)
const { data: allCharacters } = useCharacters(projectId)
const { data: chapters } = useChapters(projectId)
const update = useUpdateCharacter(projectId)
const remove = useDeleteCharacter(projectId)
const linkIdentity = useLinkCharacterIdentity(projectId)
const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const patch = (body: Partial<Omit<Character, 'tags'>> & { tags?: string[] }) =>
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
update.mutate({ id: character.id, ...body })
return (
@@ -167,13 +441,19 @@ function CharacterSheet({
/>
</div>
<div className="mt-5">
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<TagEditor
label="Tags"
tags={character.tags}
suggestions={allTags?.map((t) => t.name) ?? []}
onChange={(tags) => canWrite && patch({ tags })}
/>
<AliasEditor
label="Also known as"
aliases={character.aliases}
readOnly={!canWrite}
onChange={(aliases) => canWrite && patch({ aliases })}
/>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
@@ -274,11 +554,29 @@ function CharacterSheet({
</div>
)}
<div className="mt-6">
<IdentitySection
character={character}
allCharacters={allCharacters ?? []}
chapters={chapters ?? []}
canWrite={canWrite}
onLink={(sameCharacterAsId, revealedInChapterId, note) =>
linkIdentity.mutate({ id: character.id, sameCharacterAsId, revealedInChapterId, note })
}
onUnlink={() => unlinkIdentity.mutate(character.id)}
/>
</div>
{update.error && (
<div className="mt-4">
<ErrorNote error={update.error} />
</div>
)}
{linkIdentity.error && (
<div className="mt-4">
<ErrorNote error={linkIdentity.error} />
</div>
)}
</div>
{(character.importance === 'Main' || character.arcStages.length > 0) && (
@@ -317,6 +615,139 @@ function CharacterSheet({
)
}
function IdentitySection({
character,
allCharacters,
chapters,
canWrite,
onLink,
onUnlink,
}: {
character: Character
allCharacters: Character[]
chapters: { id: string; number: number; title: string }[]
canWrite: boolean
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
onUnlink: () => void
}) {
const [picking, setPicking] = useState(false)
const [targetId, setTargetId] = useState('')
const [chapterId, setChapterId] = useState('')
const [note, setNote] = useState('')
const candidates = allCharacters.filter(
(c) => c.id !== character.id && c.otherIdentities.length === 0,
)
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!targetId) return
onLink(targetId, chapterId || null, note.trim() || null)
setPicking(false)
setTargetId('')
setChapterId('')
setNote('')
}
if (character.sameCharacterAsId) {
return (
<div id="character-identity-linked">
<h3 className="label">Identity</h3>
<p className="text-sm">
Really <span className="font-medium">{character.sameCharacterAsName}</span>
{character.revealedInChapterNumber != null && (
<span className="muted"> revealed in Chapter {character.revealedInChapterNumber}</span>
)}
</p>
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
{canWrite && (
<button className="btn mt-2" id="unlink-identity-button" onClick={onUnlink}>
Unlink identity
</button>
)}
</div>
)
}
return (
<div id="character-identity-section">
{character.otherIdentities.length > 0 && (
<div className="mb-3">
<h3 className="label">Also appears as</h3>
<ul className="grid gap-1 text-sm">
{character.otherIdentities.map((identity) => (
<li key={identity.id}>{identity.name}</li>
))}
</ul>
</div>
)}
{canWrite && candidates.length > 0 && (
<>
{!picking ? (
<button className="btn" id="link-identity-button" onClick={() => setPicking(true)}>
Link as another identity
</button>
) : (
<form onSubmit={submit} className="card grid gap-2 p-3">
<label className="block">
<span className="label">This character is really</span>
<select
id="link-identity-character-select"
className="input"
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
autoFocus
>
<option value="">Select a character</option>
{candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Revealed in chapter (optional)</span>
<select
id="link-identity-chapter-select"
className="input"
value={chapterId}
onChange={(e) => setChapterId(e.target.value)}
>
<option value=""></option>
{chapters.map((ch) => (
<option key={ch.id} value={ch.id}>
Ch. {ch.number} {ch.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Note (optional)</span>
<input
id="link-identity-note-input"
className="input"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" className="btn" onClick={() => setPicking(false)}>
Cancel
</button>
<button className="btn btn-primary" disabled={!targetId}>
Link
</button>
</div>
</form>
)}
</>
)}
</div>
)
}
function AddCharacterModal({
projectId,
onClose,
@@ -3,6 +3,7 @@ import { useLogout, useProject, useUpdateProject } from '../api/hooks'
import { projectPhases } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui'
import { HelpButton } from '../keyboard/HelpButton'
import { useHotkey } from '../keyboard/HotkeysContext'
const sections: { to: string; label: string; end?: boolean }[] = [
@@ -58,6 +59,7 @@ export default function ProjectLayout() {
))}
</select>
)}
<HelpButton />
{user && (
<>
<span className="truncate text-sm muted" title={user.email}>
+88 -76
View File
@@ -4,6 +4,7 @@ import { useCreateProject, useGenres, useLogout, useProjects } from '../api/hook
import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
import { HelpButton } from '../keyboard/HelpButton'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ProjectsPage() {
@@ -19,87 +20,94 @@ export default function ProjectsPage() {
useHotkey('i', 'Import from outline', () => canCreate && setImporting(true), { group: 'Novels' })
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<header className="mb-8 flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Your novels</h1>
<p className="mt-1 text-sm muted">
Outlines, character dossiers, and a writing partner that knows the book.
</p>
</div>
<div className="flex shrink-0 items-center gap-2 whitespace-nowrap">
{canCreate && (
<>
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
</>
)}
{user && (
<>
<span className="ml-2 text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
)}
<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-4xl items-center gap-4 px-6 py-3">
<span className="truncate text-base font-semibold">Your novels</span>
<div className="ml-auto flex items-center gap-2 whitespace-nowrap">
{canCreate && (
<>
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
</>
)}
<HelpButton />
{user && (
<>
<span className="truncate text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
)}
</div>
</div>
</header>
{error && <ErrorNote error={error} />}
{isPending && <Spinner label="Loading projects" />}
<main className="mx-auto max-w-4xl px-6 py-12">
<p className="mb-8 -mt-4 text-sm muted">
Outlines, character dossiers, and a writing partner that knows the book.
</p>
{projects?.length === 0 && (
<EmptyState
title="Nothing here yet"
hint="Start with a title and a one-sentence logline. Everything else can come later."
/>
)}
{error && <ErrorNote error={error} />}
{isPending && <Spinner label="Loading projects" />}
<div className="grid gap-3">
{projects?.map((project) => (
<Link
key={project.id}
to={`/projects/${project.id}`}
className="card block px-5 py-4 transition hover:shadow-md"
>
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-lg font-semibold">{project.title}</h2>
<span className="text-xs muted">
{project.genre ?? 'Uncategorised'}
{project.author && ` · ${project.author}`}
</span>
</div>
{project.logline && <p className="mt-1 text-sm muted">{project.logline}</p>}
<div className="mt-3 flex gap-4 text-xs muted">
<span>{project.characterCount} characters</span>
<span>{project.chapterCount} chapters</span>
<span>
{project.wordCount.toLocaleString()}
{project.targetWordCount
? ` / ${project.targetWordCount.toLocaleString()} words`
: ' words'}
</span>
</div>
</Link>
))}
</div>
{projects?.length === 0 && (
<EmptyState
title="Nothing here yet"
hint="Start with a title and a one-sentence logline. Everything else can come later."
/>
)}
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
onImported={(projectId) => navigate(`/projects/${projectId}`)}
/>
)}
<div className="grid gap-3">
{projects?.map((project) => (
<Link
key={project.id}
to={`/projects/${project.id}`}
className="card block px-5 py-4 transition hover:shadow-md"
>
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-lg font-semibold">{project.title}</h2>
<span className="text-xs muted">
{project.genre ?? 'Uncategorised'}
{project.author && ` · ${project.author}`}
</span>
</div>
{project.logline && <p className="mt-1 text-sm muted">{project.logline}</p>}
<div className="mt-3 flex gap-4 text-xs muted">
<span>{project.characterCount} characters</span>
<span>{project.chapterCount} chapters</span>
<span>
{project.wordCount.toLocaleString()}
{project.targetWordCount
? ` / ${project.targetWordCount.toLocaleString()} words`
: ' words'}
</span>
</div>
</Link>
))}
</div>
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
onImported={(projectId) => navigate(`/projects/${projectId}`)}
/>
)}
</main>
</div>
)
}
@@ -179,7 +187,11 @@ function CreateProjectModal({ onClose }: { onClose: () => void }) {
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={!title.trim() || create.isPending}>
<button
type="submit"
className="btn btn-primary"
disabled={!title.trim() || create.isPending}
>
{create.isPending ? 'Creating…' : 'Create'}
</button>
</div>
@@ -162,6 +162,56 @@ public class BeatServiceTests : ServiceTestFixture
Assert.That((await Beats.GetAsync(beat.Id))!.Characters, Is.Empty);
}
[Test]
public async Task Moving_beats_appends_them_to_the_end_of_the_target_chapter()
{
var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall"));
await Beats.CreateAsync(other.Id, new CreateBeatRequest("Already there"));
var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var moved = await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(other.Id, [second.Id, first.Id]));
var listed = await Beats.ListAsync(other.Id);
var remaining = await Beats.ListAsync(_chapterId);
Assert.Multiple(() =>
{
Assert.That(moved!.Select(b => b.ChapterId), Is.All.EqualTo(other.Id));
Assert.That(listed.Select(b => b.Title), Is.EqualTo(new[] { "Already there", "Second", "First" }));
Assert.That(remaining, Is.Empty);
});
}
[Test]
public async Task Moving_to_an_unknown_chapter_returns_null()
{
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
Assert.That(await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(Guid.NewGuid(), [beat.Id])), Is.Null);
}
[Test]
public async Task Moving_to_a_chapter_in_another_project_returns_null()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var otherChapter = await Chapters.CreateAsync(other.Id, new CreateChapterRequest("Elsewhere"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
Assert.That(await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(otherChapter.Id, [beat.Id])), Is.Null);
}
[Test]
public async Task Moving_an_unknown_beat_returns_null_rather_than_partially_applying()
{
var other = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Second landfall"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var result = await Beats.MoveAsync(_chapterId, new MoveBeatsRequest(other.Id, [beat.Id, Guid.NewGuid()]));
Assert.That(result, Is.Null);
Assert.That((await Beats.GetAsync(beat.Id))!.ChapterId, Is.EqualTo(_chapterId));
}
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
@@ -124,4 +124,119 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Reading_a_missing_character_returns_null_rather_than_throwing() =>
Assert.That(await Characters.GetAsync(Guid.NewGuid()), Is.Null);
[Test]
public async Task Aliases_round_trip_on_create_and_update()
{
var created = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"]));
Assert.That(created.Aliases, Is.EqualTo(new[] { "The Grey Man", "Kael" }));
var updated = (await Characters.UpdateAsync(
created.Id, new UpdateCharacterRequest(Aliases: ["The Stranger"])))!;
Assert.That(updated.Aliases, Is.EqualTo(new[] { "The Stranger" }));
}
[Test]
public async Task Clearing_aliases_with_an_empty_list_empties_them()
{
var created = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"]));
var cleared = (await Characters.UpdateAsync(created.Id, new UpdateCharacterRequest(Aliases: [])))!;
Assert.That(cleared.Aliases, Is.Empty);
}
[Test]
public async Task Linking_a_character_to_its_true_identity_records_it_on_both_sides()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
var linked = (await Characters.LinkIdentityAsync(
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, Note: "Same man, after the exile.")))!;
Assert.Multiple(() =>
{
Assert.That(linked.SameCharacterAsId, Is.EqualTo(kael.Id));
Assert.That(linked.SameCharacterAs?.Name, Is.EqualTo("Kael"));
Assert.That(linked.IdentityNote, Is.EqualTo("Same man, after the exile."));
});
var canonical = (await Characters.GetAsync(kael.Id))!;
Assert.That(canonical.OtherIdentities.Select(o => o.Id), Is.EqualTo(new[] { stranger.Id }));
}
[Test]
public async Task Linking_to_a_character_that_is_itself_an_alias_flattens_to_the_canonical()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
var exile = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Exile"));
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
var linked = (await Characters.LinkIdentityAsync(exile.Id, new LinkCharacterIdentityRequest(stranger.Id)))!;
Assert.That(linked.SameCharacterAsId, Is.EqualTo(kael.Id));
}
[Test]
public async Task Linking_a_character_to_itself_is_rejected()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
Assert.That(
async () => await Characters.LinkIdentityAsync(kael.Id, new LinkCharacterIdentityRequest(kael.Id)),
Throws.TypeOf<InvalidOperationException>());
}
[Test]
public async Task Linking_identities_across_projects_is_refused()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That(
async () => await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
}
[Test]
public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
await Characters.DeleteAsync(kael.Id);
using var verification = Db.CreateContext();
var survivor = await verification.Characters.FirstAsync(c => c.Id == stranger.Id);
Assert.That(survivor.SameCharacterAsId, Is.Null);
}
[Test]
public async Task Unlinking_an_identity_clears_the_reveal_chapter_and_note()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("The Reveal"));
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
await Characters.LinkIdentityAsync(
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, chapter.Id, "Same man."));
var removed = await Characters.UnlinkIdentityAsync(stranger.Id);
var after = (await Characters.GetAsync(stranger.Id))!;
Assert.Multiple(() =>
{
Assert.That(removed, Is.True);
Assert.That(after.SameCharacterAsId, Is.Null);
Assert.That(after.RevealedInChapterId, Is.Null);
Assert.That(after.IdentityNote, Is.Null);
});
}
}
@@ -63,12 +63,13 @@ public abstract class ServiceTestFixture
Db.Context, Access, UserContext, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator());
Characters = new CharacterService(
Db.Context, Access, Tags, CharacterLogs,
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(),
new LinkCharacterIdentityRequestValidator());
Chapters = new ChapterService(Db.Context, Access, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Beats = new BeatService(
Db.Context, Access, Tags, BeatLogs,
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
new AssignCharacterToBeatsRequestValidator());
new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator());
Arcs = new CharacterArcService(
Db.Context, Access, ArcLogs,
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());