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

- Characters can carry aliases and be linked as the same underlying
  person (canonical SameCharacterAsId, optional reveal chapter/note),
  surfaced through the API, MCP tools, agent toolset, and web UI.
- Characters page redesigned as a filterable/sortable table (name+
  aliases, role, importance, occupation, tags) instead of a sidebar
  list, to stay usable as the cast grows.
- Beats can be moved between chapters (BeatService.MoveAsync + MCP/
  agent tool + endpoint).
- Add a keyboard-shortcuts help overlay (HelpButton/HelpOverlayContext)
  wired into the project layout.
- CLAUDE.md: require every frontend component to carry a unique id
  attribute; apply it to CharacterMultiSelect and MarkdownEditor.
This commit is contained in:
James Wampler
2026-08-17 17:26:50 -07:00
parent b4f4b35e3c
commit f124b9b4bb
32 changed files with 3002 additions and 356 deletions
+58 -1
View File
@@ -17,7 +17,8 @@ public class BeatService(
IModelValidator<CreateBeatRequest> createValidator,
IModelValidator<UpdateBeatRequest> updateValidator,
IModelValidator<ReorderBeatsRequest> reorderValidator,
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator)
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator,
IModelValidator<MoveBeatsRequest> moveValidator)
{
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
@@ -266,6 +267,62 @@ public class BeatService(
return await ListAsync(chapterId, ct);
}
public async Task<IReadOnlyList<Beat>?> MoveAsync(
Guid chapterId, MoveBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
moveValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation(
"Moving {Count} beats from chapter {ChapterId} to chapter {TargetChapterId}",
request.BeatIds.Count, chapterId, request.TargetChapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is null)
{
logger.LogWarning("Rejected beat move: chapter {ChapterId} not found", chapterId);
return null;
}
var targetChapter = await db.Chapters.FirstOrDefaultAsync(
c => c.Id == request.TargetChapterId && c.ProjectId == chapter.ProjectId, ct);
if (targetChapter is null)
{
logger.LogWarning(
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}",
request.TargetChapterId, chapter.ProjectId);
return null;
}
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0)
{
logger.LogWarning("Rejected beat move: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
return null;
}
if (chapterId == request.TargetChapterId)
{
return beats;
}
var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct);
foreach (var beatId in request.BeatIds)
{
var beat = beats.Single(b => b.Id == beatId);
beat.ChapterId = request.TargetChapterId;
beat.SortOrder = nextSortOrder++;
beat.UpdatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(ct);
return beats;
}
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
{
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId);