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:
@@ -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
|
||||
|
||||
|
||||
@@ -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() =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())))
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Generated
+1139
File diff suppressed because it is too large
Load Diff
+102
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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' }),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user