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.
|
- 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`.
|
- `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
|
- Enums cross wire as names, never ordinals
|
||||||
|
- All frontend components should have an id attribute that identifies them uniquely.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,8 @@ public class NovelAgentToolset(
|
|||||||
JsonInput.String(input, "arc_summary"),
|
JsonInput.String(input, "arc_summary"),
|
||||||
JsonInput.String(input, "voice"),
|
JsonInput.String(input, "voice"),
|
||||||
JsonInput.String(input, "notes"),
|
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(
|
yield return new AgentTool(
|
||||||
"update_character",
|
"update_character",
|
||||||
@@ -181,7 +182,42 @@ public class NovelAgentToolset(
|
|||||||
JsonInput.String(input, "arc_summary"),
|
JsonInput.String(input, "arc_summary"),
|
||||||
JsonInput.String(input, "voice"),
|
JsonInput.String(input, "voice"),
|
||||||
JsonInput.String(input, "notes"),
|
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(
|
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);
|
.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(
|
yield return new AgentTool(
|
||||||
"list_tags",
|
"list_tags",
|
||||||
"List the project's tags with how many characters, chapters and beats carry each. "
|
"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("arc_summary", "How they change over the course of the book.")
|
||||||
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
|
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
|
||||||
.Str("notes", "Anything else worth recording.")
|
.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() =>
|
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 class BeatMapping
|
||||||
{
|
{
|
||||||
public static BeatResponse ToResponse(this Beat b) => new(
|
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())
|
(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.");
|
.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 (
|
app.MapGet("/api/characters/{characterId:guid}/beats", async (
|
||||||
Guid characterId, BeatService service, CancellationToken ct) =>
|
Guid characterId, BeatService service, CancellationToken ct) =>
|
||||||
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult())
|
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult())
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ public class BeatService(
|
|||||||
IModelValidator<CreateBeatRequest> createValidator,
|
IModelValidator<CreateBeatRequest> createValidator,
|
||||||
IModelValidator<UpdateBeatRequest> updateValidator,
|
IModelValidator<UpdateBeatRequest> updateValidator,
|
||||||
IModelValidator<ReorderBeatsRequest> reorderValidator,
|
IModelValidator<ReorderBeatsRequest> reorderValidator,
|
||||||
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator)
|
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator,
|
||||||
|
IModelValidator<MoveBeatsRequest> moveValidator)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -266,6 +267,62 @@ public class BeatService(
|
|||||||
return await ListAsync(chapterId, ct);
|
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)
|
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
|
||||||
{
|
{
|
||||||
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId);
|
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
using Novelly.Api.Beats;
|
using Novelly.Api.Beats;
|
||||||
|
using Novelly.Api.Chapters;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
@@ -38,6 +39,16 @@ public class Character
|
|||||||
|
|
||||||
public string? Notes { get; set; }
|
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 CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAt { 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.Role).HasConversion<string>().HasMaxLength(32);
|
||||||
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
|
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
|
||||||
entity.HasIndex(c => c.ProjectId);
|
entity.HasIndex(c => c.ProjectId);
|
||||||
|
entity.HasIndex(c => c.SameCharacterAsId);
|
||||||
|
|
||||||
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
||||||
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
entity.HasMany(c => c.ArcStages).WithOne(s => s.Character!)
|
entity.HasMany(c => c.ArcStages).WithOne(s => s.Character!)
|
||||||
.HasForeignKey(s => s.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
.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? ArcSummary,
|
||||||
string? Voice,
|
string? Voice,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
|
IReadOnlyList<string> Aliases,
|
||||||
|
Guid? SameCharacterAsId,
|
||||||
|
string? SameCharacterAsName,
|
||||||
|
Guid? RevealedInChapterId,
|
||||||
|
int? RevealedInChapterNumber,
|
||||||
|
string? IdentityNote,
|
||||||
|
IReadOnlyList<CharacterIdentityResponse> OtherIdentities,
|
||||||
IReadOnlyList<RelationshipResponse> Relationships,
|
IReadOnlyList<RelationshipResponse> Relationships,
|
||||||
IReadOnlyList<TagResponse> Tags,
|
IReadOnlyList<TagResponse> Tags,
|
||||||
IReadOnlyList<ArcStageResponse> ArcStages,
|
IReadOnlyList<ArcStageResponse> ArcStages,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
public record CharacterIdentityResponse(Guid Id, string Name);
|
||||||
|
|
||||||
public record RelationshipResponse(
|
public record RelationshipResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid RelatedCharacterId,
|
Guid RelatedCharacterId,
|
||||||
@@ -51,7 +60,8 @@ public record CreateCharacterRequest(
|
|||||||
string? ArcSummary = null,
|
string? ArcSummary = null,
|
||||||
string? Voice = null,
|
string? Voice = null,
|
||||||
string? Notes = null,
|
string? Notes = null,
|
||||||
IReadOnlyList<string>? Tags = null);
|
IReadOnlyList<string>? Tags = null,
|
||||||
|
IReadOnlyList<string>? Aliases = null);
|
||||||
|
|
||||||
public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRequest>
|
public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRequest>
|
||||||
{
|
{
|
||||||
@@ -63,7 +73,7 @@ public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRe
|
|||||||
CharacterValidation.OptionalFields(
|
CharacterValidation.OptionalFields(
|
||||||
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
|
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.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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -86,7 +96,8 @@ public record UpdateCharacterRequest(
|
|||||||
string? ArcSummary = null,
|
string? ArcSummary = null,
|
||||||
string? Voice = null,
|
string? Voice = null,
|
||||||
string? Notes = null,
|
string? Notes = null,
|
||||||
IReadOnlyList<string>? Tags = null);
|
IReadOnlyList<string>? Tags = null,
|
||||||
|
IReadOnlyList<string>? Aliases = null);
|
||||||
|
|
||||||
public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRequest>
|
public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRequest>
|
||||||
{
|
{
|
||||||
@@ -98,7 +109,7 @@ public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRe
|
|||||||
CharacterValidation.OptionalFields(
|
CharacterValidation.OptionalFields(
|
||||||
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
|
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.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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -109,7 +120,7 @@ file static class CharacterValidation
|
|||||||
public static void OptionalFields(
|
public static void OptionalFields(
|
||||||
string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory,
|
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? 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(age, "Age", 100, result);
|
||||||
Cap(pronouns, "Pronouns", 100, result);
|
Cap(pronouns, "Pronouns", 100, result);
|
||||||
@@ -127,6 +138,18 @@ file static class CharacterValidation
|
|||||||
|
|
||||||
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
|
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
|
||||||
result.AddError("Tags", "'Tags' must not contain blank entries.");
|
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)
|
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(
|
public record ArcStageResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid CharacterId,
|
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.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.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
|
||||||
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
|
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(
|
[.. c.Relationships.Select(r => new RelationshipResponse(
|
||||||
r.Id,
|
r.Id,
|
||||||
r.RelatedCharacterId,
|
r.RelatedCharacterId,
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ public static class CharacterEndpoints
|
|||||||
await service.RemoveRelationshipAsync(relationshipId, ct) ? Results.NoContent() : Results.NotFound())
|
await service.RemoveRelationshipAsync(relationshipId, ct) ? Results.NoContent() : Results.NotFound())
|
||||||
.WithSummary("Remove a relationship.");
|
.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 (
|
characters.MapGet("/{id:guid}/arc", async (
|
||||||
Guid id, CharacterArcService service, CancellationToken ct) =>
|
Guid id, CharacterArcService service, CancellationToken ct) =>
|
||||||
Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse())))
|
Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse())))
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ public class CharacterService(
|
|||||||
ILogger<CharacterService> logger,
|
ILogger<CharacterService> logger,
|
||||||
IModelValidator<CreateCharacterRequest> createValidator,
|
IModelValidator<CreateCharacterRequest> createValidator,
|
||||||
IModelValidator<UpdateCharacterRequest> updateValidator,
|
IModelValidator<UpdateCharacterRequest> updateValidator,
|
||||||
IModelValidator<CreateRelationshipRequest> relationshipValidator)
|
IModelValidator<CreateRelationshipRequest> relationshipValidator,
|
||||||
|
IModelValidator<LinkCharacterIdentityRequest> identityValidator)
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default)
|
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);
|
character.Tags = await tags.ResolveAsync(projectId, names, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.Aliases is { } aliases)
|
||||||
|
{
|
||||||
|
character.Aliases = [.. aliases];
|
||||||
|
}
|
||||||
|
|
||||||
db.Characters.Add(character);
|
db.Characters.Add(character);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
@@ -142,6 +148,11 @@ public class CharacterService(
|
|||||||
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct);
|
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.Aliases is { } aliases)
|
||||||
|
{
|
||||||
|
character.Aliases = [.. aliases];
|
||||||
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return (await FindAsync(id, ct))!;
|
return (await FindAsync(id, ct))!;
|
||||||
}
|
}
|
||||||
@@ -229,13 +240,107 @@ public class CharacterService(
|
|||||||
return true;
|
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() =>
|
private IQueryable<Character> Query() =>
|
||||||
db.Characters
|
db.Characters
|
||||||
.Include(c => c.Relationships)
|
.Include(c => c.Relationships)
|
||||||
.ThenInclude(r => r.RelatedCharacter)
|
.ThenInclude(r => r.RelatedCharacter)
|
||||||
.Include(c => c.Tags)
|
.Include(c => c.Tags)
|
||||||
.Include(c => c.ArcStages)
|
.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)
|
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")
|
b.Property<string>("Age")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string>("Aliases")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Appearance")
|
b.Property<string>("Appearance")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -314,6 +318,9 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Property<string>("ExternalConflict")
|
b.Property<string>("ExternalConflict")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("IdentityNote")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Importance")
|
b.Property<string>("Importance")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
@@ -345,11 +352,17 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Property<string>("Pronouns")
|
b.Property<string>("Pronouns")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RevealedInChapterId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Role")
|
b.Property<string>("Role")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("SameCharacterAsId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<long>("UpdatedAt")
|
b.Property<long>("UpdatedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -363,6 +376,10 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ProjectId");
|
b.HasIndex("ProjectId");
|
||||||
|
|
||||||
|
b.HasIndex("RevealedInChapterId");
|
||||||
|
|
||||||
|
b.HasIndex("SameCharacterAsId");
|
||||||
|
|
||||||
b.ToTable("Characters");
|
b.ToTable("Characters");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -963,7 +980,21 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.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("Project");
|
||||||
|
|
||||||
|
b.Navigation("RevealedInChapter");
|
||||||
|
|
||||||
|
b.Navigation("SameCharacterAs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
|
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
|
||||||
@@ -1082,6 +1113,8 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("ArcStages");
|
b.Navigation("ArcStages");
|
||||||
|
|
||||||
|
b.Navigation("OtherIdentities");
|
||||||
|
|
||||||
b.Navigation("Relationships");
|
b.Navigation("Relationships");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ public class NovelApiClient(HttpClient http, ILogger<NovelApiClient> logger)
|
|||||||
Content = JsonContent.Create(body, options: Options)
|
Content = JsonContent.Create(body, options: Options)
|
||||||
}, ct);
|
}, 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);
|
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)
|
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,
|
[Description("Beat ids in their new order.")] Guid[] beatIds,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
api.PostAsync($"/api/chapters/{chapterId}/beats/reorder", new { beatIds }, 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("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("Speech patterns and register that make their dialogue theirs.")] string? voice = null,
|
||||||
[Description("Anything else worth recording.")] string? notes = 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
|
api.PostAsync($"/api/projects/{projectId}/characters", new
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
@@ -67,7 +68,8 @@ public static class CharacterTools
|
|||||||
arcSummary,
|
arcSummary,
|
||||||
voice,
|
voice,
|
||||||
notes,
|
notes,
|
||||||
tags
|
tags,
|
||||||
|
aliases
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
[McpServerTool(Name = "update_character")]
|
[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("How they change over the course of the book.")] string? arcSummary = null,
|
||||||
[Description("Speech patterns and register.")] string? voice = null,
|
[Description("Speech patterns and register.")] string? voice = null,
|
||||||
[Description("Anything else worth recording.")] string? notes = 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
|
api.PatchAsync($"/api/characters/{characterId}", new
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
@@ -113,7 +116,8 @@ public static class CharacterTools
|
|||||||
arcSummary,
|
arcSummary,
|
||||||
voice,
|
voice,
|
||||||
notes,
|
notes,
|
||||||
tags
|
tags,
|
||||||
|
aliases
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
[McpServerTool(Name = "get_character_beats")]
|
[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) =>
|
[Description("What the relationship is like, and where it is headed.")] string? description = null) =>
|
||||||
api.PostAsync($"/api/characters/{characterId}/relationships",
|
api.PostAsync($"/api/characters/{characterId}/relationships",
|
||||||
new { relatedCharacterId, relationshipType, description }, ct);
|
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 { Spinner } from './components/ui'
|
||||||
import { HotkeysProvider } from './keyboard/HotkeysContext'
|
import { HotkeysProvider } from './keyboard/HotkeysContext'
|
||||||
import { HelpOverlay } from './keyboard/HelpOverlay'
|
import { HelpOverlay } from './keyboard/HelpOverlay'
|
||||||
|
import { HelpOverlayProvider } from './keyboard/HelpOverlayContext'
|
||||||
|
|
||||||
function RequireAuth() {
|
function RequireAuth() {
|
||||||
const { user, isPending } = useAuth()
|
const { user, isPending } = useAuth()
|
||||||
@@ -27,23 +28,25 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<HotkeysProvider>
|
<HotkeysProvider>
|
||||||
<HelpOverlay />
|
<HelpOverlayProvider>
|
||||||
<Routes>
|
<HelpOverlay />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Routes>
|
||||||
<Route element={<RequireAuth />}>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/" element={<ProjectsPage />} />
|
<Route element={<RequireAuth />}>
|
||||||
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
<Route path="/" element={<ProjectsPage />} />
|
||||||
<Route index element={<DashboardPage />} />
|
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
||||||
<Route path="characters" element={<CharactersPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="chapters" element={<ChaptersPage />} />
|
<Route path="characters" element={<CharactersPage />} />
|
||||||
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
<Route path="chapters" element={<ChaptersPage />} />
|
||||||
<Route path="tags" element={<TagsPage />} />
|
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
||||||
<Route path="agent" element={<AgentPage />} />
|
<Route path="tags" element={<TagsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="agent" element={<AgentPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<ProjectsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<ProjectsPage />} />
|
</Routes>
|
||||||
</Route>
|
</HelpOverlayProvider>
|
||||||
</Routes>
|
|
||||||
</HotkeysProvider>
|
</HotkeysProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,5 +39,7 @@ export const api = {
|
|||||||
request<T>(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
|
request<T>(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
|
||||||
patch: <T>(path: string, body: unknown) =>
|
patch: <T>(path: string, body: unknown) =>
|
||||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
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' }),
|
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) =>
|
export const useCharacterBeats = (characterId: string | undefined) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: keys.characterBeats(characterId ?? ''),
|
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) =>
|
export const useChapters = (projectId: string) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: keys.chapters(projectId),
|
queryKey: keys.chapters(projectId),
|
||||||
|
|||||||
@@ -188,12 +188,24 @@ export interface Character {
|
|||||||
arcSummary: string | null
|
arcSummary: string | null
|
||||||
voice: string | null
|
voice: string | null
|
||||||
notes: 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[]
|
relationships: Relationship[]
|
||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
arcStages: ArcStage[]
|
arcStages: ArcStage[]
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CharacterIdentity {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChapterSummary {
|
export interface ChapterSummary {
|
||||||
id: string
|
id: string
|
||||||
projectId: 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 { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useCreateCharacter } from '../api/hooks'
|
||||||
import type { BeatCharacter } from '../api/types'
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
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)' }}
|
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 && (
|
{onRemove && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -23,16 +43,19 @@ export function CharacterChip({ character, onRemove }: { character: BeatCharacte
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CharacterMultiSelect({
|
export function CharacterMultiSelect({
|
||||||
|
projectId,
|
||||||
selected,
|
selected,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
projectId: string
|
||||||
selected: BeatCharacter[]
|
selected: BeatCharacter[]
|
||||||
options: { id: string; name: string }[]
|
options: { id: string; name: string }[]
|
||||||
onChange: (ids: string[]) => void
|
onChange: (ids: string[]) => void
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const listId = 'character-multiselect-options'
|
const listId = 'character-multiselect-options'
|
||||||
|
const createCharacter = useCreateCharacter(projectId)
|
||||||
|
|
||||||
const add = () => {
|
const add = () => {
|
||||||
const name = draft.trim()
|
const name = draft.trim()
|
||||||
@@ -40,9 +63,17 @@ export function CharacterMultiSelect({
|
|||||||
if (!name) return
|
if (!name) return
|
||||||
|
|
||||||
const match = options.find((o) => o.name.toLowerCase() === name.toLowerCase())
|
const match = options.find((o) => o.name.toLowerCase() === name.toLowerCase())
|
||||||
if (match && !selected.some((c) => c.id === match.id)) {
|
if (match) {
|
||||||
onChange([...selected.map((c) => c.id), match.id])
|
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))
|
const remove = (id: string) => onChange(selected.filter((c) => c.id !== id).map((c) => c.id))
|
||||||
@@ -52,7 +83,7 @@ export function CharacterMultiSelect({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{selected.map((character) => (
|
{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
|
<input
|
||||||
className="input w-28 flex-1 px-2 py-0.5 text-xs"
|
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 [draft, setDraft] = useState(value ?? '')
|
||||||
const [mode, setMode] = useState<'write' | 'preview'>('write')
|
const [mode, setMode] = useState<'write' | 'preview'>('write')
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
const committed = useRef(value ?? '')
|
const committed = useRef(value ?? '')
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const incoming = value ?? ''
|
const incoming = value ?? ''
|
||||||
@@ -26,6 +28,12 @@ export function MarkdownEditor({
|
|||||||
}
|
}
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFullscreenChange = () => setIsFullscreen(document.fullscreenElement === containerRef.current)
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (draft !== committed.current) {
|
if (draft !== committed.current) {
|
||||||
committed.current = draft
|
committed.current = draft
|
||||||
@@ -33,8 +41,20 @@ export function MarkdownEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (document.fullscreenElement === containerRef.current) {
|
||||||
|
document.exitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
containerRef.current?.requestFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mb-2 flex justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -53,11 +73,14 @@ export function MarkdownEditor({
|
|||||||
>
|
>
|
||||||
Preview
|
Preview
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn px-2 py-1 text-xs" onClick={toggleFullscreen}>
|
||||||
|
{isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'write' ? (
|
{mode === 'write' ? (
|
||||||
<textarea
|
<textarea
|
||||||
className="input font-mono text-sm"
|
className={`input font-mono text-sm ${isFullscreen ? 'flex-1 resize-none' : ''}`}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -66,7 +89,9 @@ export function MarkdownEditor({
|
|||||||
readOnly={readOnly}
|
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>}
|
{draft.trim() ? <ReactMarkdown>{draft}</ReactMarkdown> : <p className="muted">Nothing written yet.</p>}
|
||||||
</div>
|
</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 { useHelpOverlay } from './HelpOverlayContext'
|
||||||
import { useHotkey, useHotkeysList } from './HotkeysContext'
|
import { useHotkeysList } from './HotkeysContext'
|
||||||
|
|
||||||
const formatToken = (token: string) => {
|
const formatToken = (token: string) => {
|
||||||
if (token === 'mod') return '⌘/Ctrl'
|
if (token === 'mod') return '⌘/Ctrl'
|
||||||
@@ -38,12 +38,9 @@ function KeySequence({ keys }: { keys: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function HelpOverlay() {
|
export function HelpOverlay() {
|
||||||
const [open, setOpen] = useState(false)
|
const { open, setOpen } = useHelpOverlay()
|
||||||
const shortcuts = useHotkeysList()
|
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>()
|
const groups = new Map<string, typeof shortcuts>()
|
||||||
for (const shortcut of shortcuts) {
|
for (const shortcut of shortcuts) {
|
||||||
if (!groups.has(shortcut.group)) groups.set(shortcut.group, [])
|
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),
|
a === 'Global' ? -1 : b === 'Global' ? 1 : a.localeCompare(b),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={() => setOpen(false)}>
|
||||||
<button
|
<aside
|
||||||
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"
|
className="flex h-full w-full max-w-sm flex-col overflow-y-auto p-5 shadow-xl"
|
||||||
style={{ background: 'var(--surface)', border: '1px solid var(--line)' }}
|
style={{ background: 'var(--surface)', borderLeft: '1px solid var(--line)' }}
|
||||||
onClick={() => setOpen(true)}
|
onClick={(e) => e.stopPropagation()}
|
||||||
aria-label="Keyboard shortcuts"
|
|
||||||
title="Keyboard shortcuts (?)"
|
|
||||||
>
|
>
|
||||||
?
|
<div className="mb-4 flex items-center justify-between">
|
||||||
</button>
|
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
|
||||||
|
<button className="btn px-2 py-1" onClick={() => setOpen(false)} aria-label="Close">
|
||||||
{open && (
|
✕
|
||||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={() => setOpen(false)}>
|
</button>
|
||||||
<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>
|
</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 {
|
import {
|
||||||
useAssignCharacterToBeats,
|
useAssignCharacterToBeats,
|
||||||
useChapter,
|
useChapter,
|
||||||
|
useChapters,
|
||||||
useCharacters,
|
useCharacters,
|
||||||
useCreateBeat,
|
useCreateBeat,
|
||||||
|
useCreateChapter,
|
||||||
useDeleteBeat,
|
useDeleteBeat,
|
||||||
useDeleteChapter,
|
useDeleteChapter,
|
||||||
|
useMoveBeats,
|
||||||
useProject,
|
useProject,
|
||||||
useReorderBeats,
|
useReorderBeats,
|
||||||
useTags,
|
useTags,
|
||||||
useUpdateBeat,
|
useUpdateBeat,
|
||||||
useUpdateChapter,
|
useUpdateChapter,
|
||||||
} from '../api/hooks'
|
} 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 { useAuth } from '../auth/AuthContext'
|
||||||
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
@@ -33,6 +36,8 @@ export default function ChapterPage() {
|
|||||||
const { data: project } = useProject(projectId)
|
const { data: project } = useProject(projectId)
|
||||||
const { data: characters } = useCharacters(projectId)
|
const { data: characters } = useCharacters(projectId)
|
||||||
const { data: allTags } = useTags(projectId)
|
const { data: allTags } = useTags(projectId)
|
||||||
|
const { data: chapters } = useChapters(projectId)
|
||||||
|
const createChapter = useCreateChapter(projectId)
|
||||||
const update = useUpdateChapter(projectId)
|
const update = useUpdateChapter(projectId)
|
||||||
const remove = useDeleteChapter(projectId)
|
const remove = useDeleteChapter(projectId)
|
||||||
const createBeat = useCreateBeat(chapterId, 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' })
|
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 (isPending) return <Spinner label="Loading chapter" />
|
||||||
if (error) return <ErrorNote error={error} />
|
if (error) return <ErrorNote error={error} />
|
||||||
if (!chapter) return null
|
if (!chapter) return null
|
||||||
@@ -54,87 +77,45 @@ export default function ChapterPage() {
|
|||||||
update.mutate({ id: chapter.id, ...body })
|
update.mutate({ id: chapter.id, ...body })
|
||||||
|
|
||||||
const suggestions = allTags?.map((t) => t.name) ?? []
|
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 (
|
return (
|
||||||
<div>
|
<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">
|
<Link to={`/projects/${projectId}/chapters`} className="text-sm muted hover:underline">
|
||||||
← All chapters
|
← All chapters
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
{prevChapter ? (
|
||||||
<section className="card mb-6 p-5">
|
<Link
|
||||||
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]">
|
to={`/projects/${projectId}/chapters/${prevChapter.id}`}
|
||||||
<label className="block">
|
className="muted hover:underline"
|
||||||
<span className="label">No.</span>
|
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`}
|
||||||
<input
|
>
|
||||||
className="input"
|
← Ch. {prevChapter.number}
|
||||||
type="number"
|
</Link>
|
||||||
min={1}
|
) : (
|
||||||
defaultValue={chapter.number}
|
<span className="muted" style={{ opacity: 0.4 }}>
|
||||||
readOnly={!canWrite}
|
← Ch.
|
||||||
onBlur={(e) => {
|
</span>
|
||||||
const number = Number(e.target.value)
|
)}
|
||||||
if (number > 0 && number !== chapter.number) patch({ number })
|
{nextChapter ? (
|
||||||
}}
|
<Link
|
||||||
/>
|
to={`/projects/${projectId}/chapters/${nextChapter.id}`}
|
||||||
</label>
|
className="muted hover:underline"
|
||||||
<AutoField
|
title={`Chapter ${nextChapter.number}: ${nextChapter.title}`}
|
||||||
label="Title"
|
>
|
||||||
value={chapter.title}
|
Ch. {nextChapter.number} →
|
||||||
onCommit={(title) => title.trim() && patch({ title })}
|
</Link>
|
||||||
readOnly={!canWrite}
|
) : (
|
||||||
/>
|
<span className="muted" style={{ opacity: 0.4 }}>
|
||||||
<Select
|
Ch. →
|
||||||
label="Status"
|
</span>
|
||||||
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>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{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 className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
|
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||||
{(
|
{(
|
||||||
@@ -157,6 +138,83 @@ export default function ChapterPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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' ? (
|
{tab === 'outline' ? (
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<p className="mb-3 text-sm muted">
|
<p className="mb-3 text-sm muted">
|
||||||
@@ -180,6 +238,8 @@ export default function ChapterPage() {
|
|||||||
chapter={chapter}
|
chapter={chapter}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
|
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
|
||||||
|
otherChapters={chapters?.filter((c) => c.id !== chapter.id) ?? []}
|
||||||
|
createChapter={createChapter}
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
onCharacterContextMenu={handleContextMenu}
|
onCharacterContextMenu={handleContextMenu}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
@@ -200,6 +260,30 @@ export default function ChapterPage() {
|
|||||||
<ErrorNote error={createBeat.error} />
|
<ErrorNote error={createBeat.error} />
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
@@ -207,45 +291,26 @@ export default function ChapterPage() {
|
|||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
value={chapter.prose}
|
value={chapter.prose}
|
||||||
placeholder="Start writing the chapter."
|
placeholder="Start writing the chapter."
|
||||||
|
rows={36}
|
||||||
onCommit={(prose) => patch({ prose })}
|
onCommit={(prose) => patch({ prose })}
|
||||||
readOnly={!canWrite}
|
readOnly={!canWrite}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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}
|
{menuElement}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MOVE_TO_NEW_CHAPTER = '__new__'
|
||||||
|
|
||||||
function BeatTable({
|
function BeatTable({
|
||||||
chapter,
|
chapter,
|
||||||
projectId,
|
projectId,
|
||||||
characters,
|
characters,
|
||||||
|
otherChapters,
|
||||||
|
createChapter,
|
||||||
suggestions,
|
suggestions,
|
||||||
onCharacterContextMenu,
|
onCharacterContextMenu,
|
||||||
canWrite,
|
canWrite,
|
||||||
@@ -254,6 +319,8 @@ function BeatTable({
|
|||||||
chapter: Chapter
|
chapter: Chapter
|
||||||
projectId: string
|
projectId: string
|
||||||
characters: { id: string; name: string }[]
|
characters: { id: string; name: string }[]
|
||||||
|
otherChapters: ChapterSummary[]
|
||||||
|
createChapter: ReturnType<typeof useCreateChapter>
|
||||||
suggestions: string[]
|
suggestions: string[]
|
||||||
onCharacterContextMenu: (
|
onCharacterContextMenu: (
|
||||||
e: MouseEvent<HTMLTextAreaElement>,
|
e: MouseEvent<HTMLTextAreaElement>,
|
||||||
@@ -266,10 +333,15 @@ function BeatTable({
|
|||||||
const remove = useDeleteBeat(chapter.id)
|
const remove = useDeleteBeat(chapter.id)
|
||||||
const reorder = useReorderBeats(chapter.id)
|
const reorder = useReorderBeats(chapter.id)
|
||||||
const assignCharacter = useAssignCharacterToBeats(chapter.id)
|
const assignCharacter = useAssignCharacterToBeats(chapter.id)
|
||||||
|
const moveBeats = useMoveBeats(chapter.id)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
|
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
const [assignCharacterId, setAssignCharacterId] = useState('')
|
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) =>
|
const toggleSelected = (id: string) =>
|
||||||
setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]))
|
setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]))
|
||||||
@@ -285,12 +357,15 @@ function BeatTable({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chapter.beats.length === 0) {
|
const moveSelected = async () => {
|
||||||
return (
|
if (!moveTargetId || selectedIds.length === 0) return
|
||||||
<div className="card px-6 py-8 text-center text-sm muted">
|
const targetChapterId =
|
||||||
No beats yet. Each one is a short handle — “she burns the atlas” — plus what happened and
|
moveTargetId === MOVE_TO_NEW_CHAPTER
|
||||||
what it sets up.
|
? (await createChapter.mutateAsync({ title: 'New chapter' })).id
|
||||||
</div>
|
: moveTargetId
|
||||||
|
moveBeats.mutate(
|
||||||
|
{ targetChapterId, beatIds: selectedIds },
|
||||||
|
{ onSuccess: () => { setSelectedIds([]); setMoveTargetId('') } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,9 +378,88 @@ function BeatTable({
|
|||||||
reorder.mutate(ids)
|
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[] }) =>
|
const patch = (id: string, body: Partial<Omit<Beat, 'tags' | 'characters'>> & { tags?: string[]; characterIds?: string[] }) =>
|
||||||
update.mutate({ id, ...body })
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{selectedIds.length > 0 && canWrite && (
|
{selectedIds.length > 0 && canWrite && (
|
||||||
@@ -332,10 +486,32 @@ function BeatTable({
|
|||||||
>
|
>
|
||||||
Assign
|
Assign
|
||||||
</button>
|
</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([])}>
|
<button className="btn" onClick={() => setSelectedIds([])}>
|
||||||
Clear selection
|
Clear selection
|
||||||
</button>
|
</button>
|
||||||
{assignCharacter.error && <ErrorNote error={assignCharacter.error} />}
|
{assignCharacter.error && <ErrorNote error={assignCharacter.error} />}
|
||||||
|
{moveBeats.error && <ErrorNote error={moveBeats.error} />}
|
||||||
|
{createChapter.error && <ErrorNote error={createChapter.error} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -351,7 +527,7 @@ function BeatTable({
|
|||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
/>
|
/>
|
||||||
</th>
|
</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">Beat</th>
|
||||||
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||||
Characters
|
Characters
|
||||||
@@ -384,29 +560,7 @@ function BeatTable({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 align-top">
|
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td>
|
||||||
<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">
|
<td className="px-2 py-2 align-top">
|
||||||
<AutoField
|
<AutoField
|
||||||
@@ -425,6 +579,7 @@ function BeatTable({
|
|||||||
|
|
||||||
<td className="px-2 py-2 align-top">
|
<td className="px-2 py-2 align-top">
|
||||||
<CharacterMultiSelect
|
<CharacterMultiSelect
|
||||||
|
projectId={projectId}
|
||||||
selected={beat.characters}
|
selected={beat.characters}
|
||||||
options={characters}
|
options={characters}
|
||||||
onChange={(characterIds) => patch(beat.id, { characterIds })}
|
onChange={(characterIds) => patch(beat.id, { characterIds })}
|
||||||
@@ -493,13 +648,19 @@ function BeatTable({
|
|||||||
tabIndex={canWrite ? 0 : undefined}
|
tabIndex={canWrite ? 0 : undefined}
|
||||||
role={canWrite ? 'button' : undefined}
|
role={canWrite ? 'button' : undefined}
|
||||||
aria-label={canWrite ? `Edit beat ${beat.title}` : undefined}
|
aria-label={canWrite ? `Edit beat ${beat.title}` : undefined}
|
||||||
|
draggable={canWrite}
|
||||||
className={
|
className={
|
||||||
canWrite
|
canWrite
|
||||||
? 'cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]'
|
? '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}
|
onClick={canWrite ? () => setEditingId(beat.id) : undefined}
|
||||||
|
onFocus={canWrite ? () => setFocusedBeatId(beat.id) : undefined}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
canWrite
|
canWrite
|
||||||
? (e) => {
|
? (e) => {
|
||||||
@@ -510,6 +671,34 @@ function BeatTable({
|
|||||||
}
|
}
|
||||||
: undefined
|
: 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">
|
<td className="px-2 py-2 align-top">
|
||||||
<input
|
<input
|
||||||
@@ -520,7 +709,7 @@ function BeatTable({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</td>
|
</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">
|
<td className="px-2 py-2 align-top">
|
||||||
<div className="font-medium">{beat.title}</div>
|
<div className="font-medium">{beat.title}</div>
|
||||||
@@ -537,7 +726,7 @@ function BeatTable({
|
|||||||
{beat.characters.length > 0 ? (
|
{beat.characters.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{beat.characters.map((character) => (
|
{beat.characters.map((character) => (
|
||||||
<CharacterChip key={character.id} character={character} />
|
<CharacterChip key={character.id} character={character} projectId={projectId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,93 +1,156 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
|
useChapters,
|
||||||
useCharacters,
|
useCharacters,
|
||||||
useCreateCharacter,
|
useCreateCharacter,
|
||||||
useDeleteCharacter,
|
useDeleteCharacter,
|
||||||
|
useLinkCharacterIdentity,
|
||||||
useProject,
|
useProject,
|
||||||
useTags,
|
useTags,
|
||||||
|
useUnlinkCharacterIdentity,
|
||||||
useUpdateCharacter,
|
useUpdateCharacter,
|
||||||
} from '../api/hooks'
|
} 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 { useAuth } from '../auth/AuthContext'
|
||||||
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
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 { CharacterArc } from '../components/CharacterArc'
|
||||||
import { CharacterBeats } from '../components/CharacterBeats'
|
import { CharacterBeats } from '../components/CharacterBeats'
|
||||||
import { OpenQuestions } from '../components/OpenQuestions'
|
import { OpenQuestions } from '../components/OpenQuestions'
|
||||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
|
|
||||||
|
type SortKey = 'name' | 'updatedAt'
|
||||||
|
|
||||||
export default function CharactersPage() {
|
export default function CharactersPage() {
|
||||||
const { projectId = '' } = useParams()
|
const { projectId = '' } = useParams()
|
||||||
const { data: characters, isPending, error } = useCharacters(projectId)
|
const { data: characters, isPending, error } = useCharacters(projectId)
|
||||||
const { data: project } = useProject(projectId)
|
const { data: project } = useProject(projectId)
|
||||||
|
const { data: allTags } = useTags(projectId)
|
||||||
const { can } = useAuth()
|
const { can } = useAuth()
|
||||||
const canCreate = can('CreateContent', project)
|
const canCreate = can('CreateContent', project)
|
||||||
const canWrite = can('Write', project)
|
const canWrite = can('Write', project)
|
||||||
const canDelete = can('DeleteContent', 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 [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' })
|
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 (isPending) return <Spinner label="Loading characters" />
|
||||||
if (error) return <ErrorNote error={error} />
|
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 (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[16rem_1fr]">
|
<div className="grid gap-6">
|
||||||
<aside className="grid content-start gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h1 className="text-lg font-semibold">Characters</h1>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
|
<button className="btn btn-primary" onClick={() => setAdding(true)}>
|
||||||
Add character
|
Add character
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(['Main', 'Supporting'] as const).map((importance) => {
|
</div>
|
||||||
const group = characters?.filter((c) => c.importance === importance) ?? []
|
|
||||||
if (group.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
<CharacterFilterBar
|
||||||
<div key={importance} className="grid gap-2">
|
search={search}
|
||||||
<h2 className="label mt-2 mb-0">{importance}</h2>
|
onSearch={setSearch}
|
||||||
{group.map((character) => (
|
roleFilter={roleFilter}
|
||||||
<button
|
onRoleFilter={setRoleFilter}
|
||||||
key={character.id}
|
importanceFilter={importanceFilter}
|
||||||
onClick={() => setSelectedId(character.id)}
|
onImportanceFilter={setImportanceFilter}
|
||||||
className="card px-3 py-2 text-left transition hover:shadow-sm"
|
occupationFilter={occupationFilter}
|
||||||
style={
|
onOccupationFilter={setOccupationFilter}
|
||||||
character.id === selected?.id
|
occupations={occupations}
|
||||||
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
|
tagFilter={tagFilter}
|
||||||
: undefined
|
onTagFilter={setTagFilter}
|
||||||
}
|
tags={allTags ?? []}
|
||||||
>
|
sortKey={sortKey}
|
||||||
<div className="truncate font-medium">{character.name}</div>
|
onSortKey={setSortKey}
|
||||||
<div className="text-xs muted">{character.role}</div>
|
sortDir={sortDir}
|
||||||
</button>
|
onSortDir={setSortDir}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section>
|
<CharacterTable characters={sorted} selectedId={selected?.id} onSelect={select} />
|
||||||
{!selected ? (
|
|
||||||
<EmptyState
|
{selected && (
|
||||||
title="No characters yet"
|
<CharacterSheet
|
||||||
hint="Add the protagonist first — most outline questions resolve once you know what they want."
|
key={selected.id}
|
||||||
/>
|
projectId={projectId}
|
||||||
) : (
|
character={selected}
|
||||||
<CharacterSheet
|
canWrite={canWrite}
|
||||||
key={selected.id}
|
canCreate={canCreate}
|
||||||
projectId={projectId}
|
canDelete={canDelete}
|
||||||
character={selected}
|
/>
|
||||||
canWrite={canWrite}
|
)}
|
||||||
canCreate={canCreate}
|
|
||||||
canDelete={canDelete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{adding && (
|
{adding && (
|
||||||
<AddCharacterModal
|
<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({
|
function CharacterSheet({
|
||||||
projectId,
|
projectId,
|
||||||
character,
|
character,
|
||||||
@@ -114,10 +384,14 @@ function CharacterSheet({
|
|||||||
canDelete: boolean
|
canDelete: boolean
|
||||||
}) {
|
}) {
|
||||||
const { data: allTags } = useTags(projectId)
|
const { data: allTags } = useTags(projectId)
|
||||||
|
const { data: allCharacters } = useCharacters(projectId)
|
||||||
|
const { data: chapters } = useChapters(projectId)
|
||||||
const update = useUpdateCharacter(projectId)
|
const update = useUpdateCharacter(projectId)
|
||||||
const remove = useDeleteCharacter(projectId)
|
const remove = useDeleteCharacter(projectId)
|
||||||
|
const linkIdentity = useLinkCharacterIdentity(projectId)
|
||||||
|
const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
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 })
|
update.mutate({ id: character.id, ...body })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,13 +441,19 @@ function CharacterSheet({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||||
<TagEditor
|
<TagEditor
|
||||||
label="Tags"
|
label="Tags"
|
||||||
tags={character.tags}
|
tags={character.tags}
|
||||||
suggestions={allTags?.map((t) => t.name) ?? []}
|
suggestions={allTags?.map((t) => t.name) ?? []}
|
||||||
onChange={(tags) => canWrite && patch({ tags })}
|
onChange={(tags) => canWrite && patch({ tags })}
|
||||||
/>
|
/>
|
||||||
|
<AliasEditor
|
||||||
|
label="Also known as"
|
||||||
|
aliases={character.aliases}
|
||||||
|
readOnly={!canWrite}
|
||||||
|
onChange={(aliases) => canWrite && patch({ aliases })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||||
@@ -274,11 +554,29 @@ function CharacterSheet({
|
|||||||
</div>
|
</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 && (
|
{update.error && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ErrorNote error={update.error} />
|
<ErrorNote error={update.error} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{linkIdentity.error && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<ErrorNote error={linkIdentity.error} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(character.importance === 'Main' || character.arcStages.length > 0) && (
|
{(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({
|
function AddCharacterModal({
|
||||||
projectId,
|
projectId,
|
||||||
onClose,
|
onClose,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLogout, useProject, useUpdateProject } from '../api/hooks'
|
|||||||
import { projectPhases } from '../api/types'
|
import { projectPhases } from '../api/types'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { ErrorNote, Spinner } from '../components/ui'
|
import { ErrorNote, Spinner } from '../components/ui'
|
||||||
|
import { HelpButton } from '../keyboard/HelpButton'
|
||||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
|
|
||||||
const sections: { to: string; label: string; end?: boolean }[] = [
|
const sections: { to: string; label: string; end?: boolean }[] = [
|
||||||
@@ -58,6 +59,7 @@ export default function ProjectLayout() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
<HelpButton />
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<span className="truncate text-sm muted" title={user.email}>
|
<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 { useAuth } from '../auth/AuthContext'
|
||||||
import { ImportDialog } from '../components/ImportDialog'
|
import { ImportDialog } from '../components/ImportDialog'
|
||||||
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
|
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
|
||||||
|
import { HelpButton } from '../keyboard/HelpButton'
|
||||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
@@ -19,87 +20,94 @@ export default function ProjectsPage() {
|
|||||||
useHotkey('i', 'Import from outline', () => canCreate && setImporting(true), { group: 'Novels' })
|
useHotkey('i', 'Import from outline', () => canCreate && setImporting(true), { group: 'Novels' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
<div className="min-h-full">
|
||||||
<header className="mb-8 flex items-end justify-between gap-4">
|
<header
|
||||||
<div>
|
className="sticky top-0 z-10 border-b"
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">Your novels</h1>
|
style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}
|
||||||
<p className="mt-1 text-sm muted">
|
>
|
||||||
Outlines, character dossiers, and a writing partner that knows the book.
|
<div className="mx-auto flex max-w-4xl items-center gap-4 px-6 py-3">
|
||||||
</p>
|
<span className="truncate text-base font-semibold">Your novels</span>
|
||||||
</div>
|
<div className="ml-auto flex items-center gap-2 whitespace-nowrap">
|
||||||
<div className="flex shrink-0 items-center gap-2 whitespace-nowrap">
|
{canCreate && (
|
||||||
{canCreate && (
|
<>
|
||||||
<>
|
<button className="btn" onClick={() => setImporting(true)}>
|
||||||
<button className="btn" onClick={() => setImporting(true)}>
|
Import from outline
|
||||||
Import from outline
|
</button>
|
||||||
</button>
|
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>
|
New novel
|
||||||
New novel
|
</button>
|
||||||
</button>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
<HelpButton />
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<span className="ml-2 text-sm muted" title={user.email}>
|
<span className="truncate text-sm muted" title={user.email}>
|
||||||
{user.displayName} · {user.globalRole}
|
{user.displayName} · {user.globalRole}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
|
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <ErrorNote error={error} />}
|
<main className="mx-auto max-w-4xl px-6 py-12">
|
||||||
{isPending && <Spinner label="Loading projects" />}
|
<p className="mb-8 -mt-4 text-sm muted">
|
||||||
|
Outlines, character dossiers, and a writing partner that knows the book.
|
||||||
|
</p>
|
||||||
|
|
||||||
{projects?.length === 0 && (
|
{error && <ErrorNote error={error} />}
|
||||||
<EmptyState
|
{isPending && <Spinner label="Loading projects" />}
|
||||||
title="Nothing here yet"
|
|
||||||
hint="Start with a title and a one-sentence logline. Everything else can come later."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
{projects?.length === 0 && (
|
||||||
{projects?.map((project) => (
|
<EmptyState
|
||||||
<Link
|
title="Nothing here yet"
|
||||||
key={project.id}
|
hint="Start with a title and a one-sentence logline. Everything else can come later."
|
||||||
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)} />}
|
<div className="grid gap-3">
|
||||||
{importing && (
|
{projects?.map((project) => (
|
||||||
<ImportDialog
|
<Link
|
||||||
onClose={() => setImporting(false)}
|
key={project.id}
|
||||||
onImported={(projectId) => navigate(`/projects/${projectId}`)}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,7 +187,11 @@ function CreateProjectModal({ onClose }: { onClose: () => void }) {
|
|||||||
<button type="button" className="btn" onClick={onClose}>
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</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'}
|
{create.isPending ? 'Creating…' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,6 +162,56 @@ public class BeatServiceTests : ServiceTestFixture
|
|||||||
Assert.That((await Beats.GetAsync(beat.Id))!.Characters, Is.Empty);
|
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]
|
[Test]
|
||||||
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
|
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -124,4 +124,119 @@ public class CharacterServiceTests : ServiceTestFixture
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task Reading_a_missing_character_returns_null_rather_than_throwing() =>
|
public async Task Reading_a_missing_character_returns_null_rather_than_throwing() =>
|
||||||
Assert.That(await Characters.GetAsync(Guid.NewGuid()), Is.Null);
|
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());
|
Db.Context, Access, UserContext, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator());
|
||||||
Characters = new CharacterService(
|
Characters = new CharacterService(
|
||||||
Db.Context, Access, Tags, CharacterLogs,
|
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());
|
Chapters = new ChapterService(Db.Context, Access, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
||||||
Beats = new BeatService(
|
Beats = new BeatService(
|
||||||
Db.Context, Access, Tags, BeatLogs,
|
Db.Context, Access, Tags, BeatLogs,
|
||||||
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
|
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
|
||||||
new AssignCharacterToBeatsRequestValidator());
|
new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator());
|
||||||
Arcs = new CharacterArcService(
|
Arcs = new CharacterArcService(
|
||||||
Db.Context, Access, ArcLogs,
|
Db.Context, Access, ArcLogs,
|
||||||
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());
|
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());
|
||||||
|
|||||||
Reference in New Issue
Block a user