Add soft delete + trash, keyboard-first web overhaul, move chapter tags to bottom
CI / build-and-push (push) Failing after 31s
CI / deploy (push) Has been skipped

Adds SoftDelete/Trash across characters, chapters, locations, beats with a
purge schedule and Trash page. Reworks the web client for keyboard-driven
navigation (focus helpers, help overlay, keyboard.md doc). Moves the
ChapterPage tag editor to the bottom of the page to match CharacterDetailPage.
This commit is contained in:
James Wampler
2026-08-20 16:39:09 -07:00
parent 7df1fffdca
commit aca26588f9
59 changed files with 2913 additions and 170 deletions
+1
View File
@@ -59,6 +59,7 @@ Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for A
- `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`.
- Enums cross wire as names, never ordinals
- All frontend components should have an id attribute that identifies them uniquely.
- Web client is keyboard-first: read `docs/keyboard.md` before adding any interactive UI (forms, editable rows, create flows).
## Testing
+60
View File
@@ -0,0 +1,60 @@
# Keyboard conventions
Novelly's web client is built to be driven entirely from the keyboard. New interactive
components should follow these rules so the app stays consistent as it grows.
## Escape cancels or closes — it never destroys already-saved work
In an editor that commits per field (a beat row, a chapter's title), Escape reverts only the
field you're currently in and then closes the editor. Fields you already tabbed past and
committed stay saved — Escape is honest about this, not a full undo. Anywhere a component *can*
offer a true "discard everything" cancel (a create form that hasn't saved anything yet), do
that instead.
## Enter commits a single-line field and advances
Pressing Enter in a single-line field is equivalent to Tab: it commits the field's value and
moves focus to the next field. Shift+Enter moves to the previous field. This is what
`AutoField` (`src/components/ui.tsx`) does by default — reuse it rather than hand-rolling a
text input's key handling.
## mod+Enter commits a multiline field or completes a record
A `<textarea>` needs plain Enter to insert a newline, so multiline fields commit on
`mod+Enter` (Cmd or Ctrl) instead. The same combo, handled at the row/form level, means "I'm
done with this record" — closing a beat row, submitting a question. This mirrors the app's
original convention in `AgentPanel.tsx` (`mod+Enter` sends a message).
## Bare single letters create the primary thing on the page
`n` is the default create-hotkey across the app (new character, new chapter, new location). A
page with a second creatable thing uses a mnemonic instead (`b` for beat, `q` for question, `a`
for arc stage). Register these with `useHotkey` from the component that owns the create action,
so the shortcut is scoped to that page/section and unregisters when it unmounts — never
register a bare letter globally.
## Creating something puts focus in its first editable field
A create action that leaves the user hunting for the thing they just made is a bug. Land focus
in the new item's first field (or, when a mutation's response id isn't the field's DOM node
yet, request focus for that id and let it land once the row/page actually renders — see the
`focusRequestId`/`onAutoFocused` pattern used for beats and arc stages).
## Chip inputs commit on Enter, comma, or blur
`TagEditor`, `LocationEditor`, `CharacterMultiSelect`, and `AliasEditor` all add their draft
value to the list on Enter, comma, or losing focus. Follow the same shape for any new
chip-style input.
## Destructive confirmations use `ConfirmModal`
Never use the native `confirm()`/`alert()` dialogs — they're not stylable, not consistent with
the rest of the app, and (depending on browser) can be genuinely awkward to dismiss from the
keyboard. Use `ConfirmModal` (`src/components/ConfirmModal.tsx`), which wraps `Modal` and gets
focus-trapping and Escape-to-close for free.
## The exception, not the rule: `allowInInputs`
`useHotkey` shortcuts don't fire while a text field is focused, unless registered with
`allowInInputs: true`. Reserve that for shortcuts that make sense mid-typing (`mod+Enter` to
submit, `Escape` to close) — never a bare letter.
+2 -1
View File
@@ -21,7 +21,8 @@ public enum ActivityAction
{
Created,
Updated,
Deleted
Deleted,
Restored
}
public class ActivityEvent
+1
View File
@@ -37,6 +37,7 @@ public class BeatEntityTypeConfiguration : IEntityTypeConfiguration<Beat>
{
entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
entity.HasQueryFilter(b => b.Chapter!.DeletedAt == null);
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
+1 -1
View File
@@ -146,7 +146,7 @@ public static class BeatMapping
b.ChapterId,
b.SortOrder,
b.Title,
[.. b.Characters.OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
[.. b.Characters.Where(c => c.DeletedAt is null).OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
b.WhatHappened,
b.WhatsNext,
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
+2 -2
View File
@@ -70,7 +70,7 @@ public class BeatService(
var beats = await db.Beats
.Include(b => b.Chapter)
.Include(b => b.ArcStages)
.Include(b => b.ArcStages.Where(s => s.Character!.DeletedAt == null))
.Where(b => b.Characters.Any(c => c.Id == characterId))
.ToListAsync(ct);
@@ -395,7 +395,7 @@ public class BeatService(
private IQueryable<Beat> Query() =>
db.Beats
.Include(b => b.Characters)
.Include(b => b.Characters.Where(c => c.DeletedAt == null))
.Include(b => b.Tags);
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
+3 -1
View File
@@ -8,7 +8,7 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
public class Chapter
public class Chapter : ISoftDeletable
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; }
@@ -33,6 +33,7 @@ public class Chapter
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DeletedAt { get; set; }
public List<Beat> Beats { get; set; } = [];
@@ -48,5 +49,6 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.NovelId, c.Number });
entity.HasQueryFilter(c => c.DeletedAt == null);
}
}
+2 -2
View File
@@ -123,7 +123,7 @@ public static class ChapterMapping
{
public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
[.. c.Locations.Where(l => l.DeletedAt is null).OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Notes,
c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
@@ -133,7 +133,7 @@ public static class ChapterMapping
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
[.. c.Locations.Where(l => l.DeletedAt is null).OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
+1 -1
View File
@@ -68,7 +68,7 @@ public static class ChapterEndpoints
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a chapter.");
.WithSummary("Move a chapter to the trash.");
return app;
}
+8 -6
View File
@@ -31,7 +31,7 @@ public class ChapterService(
return await db.Chapters
.Include(c => c.Beats)
.Include(c => c.Tags)
.Include(c => c.Locations)
.Include(c => c.Locations.Where(l => l.DeletedAt == null))
.Where(c => c.NovelId == novelId)
.OrderBy(c => c.Number)
.ToListAsync(ct);
@@ -153,17 +153,18 @@ public class ChapterService(
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting chapter {ChapterId}", id);
logger.LogInformation("Moving chapter {ChapterId} to trash", id);
var chapter = await FindAsync(id, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", id);
return false;
}
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
db.Chapters.Remove(chapter);
chapter.DeletedAt = DateTimeOffset.UtcNow;
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount);
await db.SaveChangesAsync(ct);
return true;
@@ -177,6 +178,7 @@ public class ChapterService(
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
var max = await db.Chapters
.IgnoreQueryFilters()
.Where(c => c.NovelId == novelId)
.MaxAsync(c => (int?)c.Number, ct);
@@ -190,10 +192,10 @@ public class ChapterService(
logger.LogDebug("Finding chapter {ChapterId}", id);
var chapter = await db.Chapters
.Include(c => c.Beats).ThenInclude(b => b.Characters)
.Include(c => c.Beats).ThenInclude(b => b.Characters.Where(ch => ch.DeletedAt == null))
.Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Tags)
.Include(c => c.Locations)
.Include(c => c.Locations.Where(l => l.DeletedAt == null))
.FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null)
+6 -1
View File
@@ -2,12 +2,13 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Novels;
using Novelly.Api.Tags;
namespace Novelly.Api.Characters;
public class Character
public class Character : ISoftDeletable
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; }
@@ -46,6 +47,7 @@ public class Character
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DeletedAt { get; set; }
public List<CharacterRelationship> Relationships { get; set; } = [];
public List<Tag> Tags { get; set; } = [];
@@ -79,6 +81,7 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Charact
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => c.NovelId);
entity.HasIndex(c => c.SameCharacterAsId);
entity.HasQueryFilter(c => c.DeletedAt == null);
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
@@ -102,5 +105,7 @@ public class CharacterRelationshipEntityTypeConfiguration : IEntityTypeConfigura
entity.HasOne(r => r.RelatedCharacter).WithMany()
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
entity.HasQueryFilter(r => r.Character!.DeletedAt == null && r.RelatedCharacter!.DeletedAt == null);
}
}
@@ -283,7 +283,7 @@ public class CharacterArcService(
private IQueryable<CharacterArcStage> Query() =>
db.CharacterArcStages
.Include(s => s.Chapter)
.Include(s => s.Beats).ThenInclude(b => b.Chapter);
.Include(s => s.Beats.Where(b => b.Chapter!.DeletedAt == null)).ThenInclude(b => b.Chapter);
private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
{
@@ -33,6 +33,7 @@ public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration
{
entity.Property(s => s.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
entity.HasQueryFilter(s => s.Character!.DeletedAt == null);
entity.HasOne(s => s.Chapter).WithMany()
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
@@ -294,37 +294,45 @@ public static class CharacterMapping
c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
[.. c.Aliases],
c.SameCharacterAsId,
c.SameCharacterAs?.Name,
c.SameCharacterAs is { DeletedAt: null } canonical ? canonical.Name : null,
c.RevealedInChapterId,
c.RevealedInChapter?.Number,
c.RevealedInChapter is { } revealedInChapter ? ChapterLabel(revealedInChapter, displayNumbers) : null,
c.RevealedInChapter is { DeletedAt: null } revealedInChapter ? revealedInChapter.Number : null,
c.RevealedInChapter is { DeletedAt: null } revealedInChapter2 ? ChapterLabel(revealedInChapter2, displayNumbers) : null,
c.IdentityNote,
[.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
[.. c.Relationships.Select(r => new RelationshipResponse(
[.. c.OtherIdentities.Where(o => o.DeletedAt is null).OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
[.. c.Relationships
.Where(r => r.RelatedCharacter is { DeletedAt: null })
.Select(r => new RelationshipResponse(
r.Id,
r.RelatedCharacterId,
r.RelatedCharacter?.Name ?? "(unknown)",
r.RelatedCharacter!.Name,
r.RelationshipType,
r.Description))],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse(displayNumbers))],
c.UpdatedAt);
public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary<Guid, int>? displayNumbers = null)
{
var chapter = s.Chapter is { DeletedAt: null } ? s.Chapter : null;
return new(
s.Id,
s.CharacterId,
s.SortOrder,
s.Title,
s.Result,
s.ChapterId,
s.Chapter?.Number,
s.Chapter?.Title,
s.Chapter is { } chapter ? ChapterLabel(chapter, displayNumbers) : null,
chapter?.Number,
chapter?.Title,
chapter is not null ? ChapterLabel(chapter, displayNumbers) : null,
[.. s.Beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.Where(b => b.Chapter is { DeletedAt: null })
.OrderBy(b => b.Chapter!.Number)
.ThenBy(b => b.SortOrder)
.Select(b => b.ToCharacterBeatResponse(s.CharacterId, b.Chapter is { } beatChapter ? ChapterLabel(beatChapter, displayNumbers) : null))],
.Select(b => b.ToCharacterBeatResponse(s.CharacterId, ChapterLabel(b.Chapter!, displayNumbers)))],
s.UpdatedAt);
}
private static string ChapterLabel(Chapter chapter, IReadOnlyDictionary<Guid, int>? displayNumbers) =>
ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
@@ -103,7 +103,7 @@ public static class CharacterEndpoints
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a character.");
.WithSummary("Move a character to the trash.");
characters.MapPost("/{id:guid}/relationships", async (
Guid id, CreateRelationshipRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
@@ -159,7 +159,7 @@ public class CharacterService(
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting character {CharacterId}", id);
logger.LogInformation("Moving character {CharacterId} to trash", id);
var character = await FindAsync(id, ct);
if (character is null)
@@ -169,7 +169,7 @@ public class CharacterService(
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
db.Characters.Remove(character);
character.DeletedAt = DateTimeOffset.UtcNow;
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Deleted, character.Id);
await db.SaveChangesAsync(ct);
return true;
@@ -351,7 +351,7 @@ public class CharacterService(
.Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter)
.Include(c => c.ArcStages)
.ThenInclude(s => s.Beats)
.ThenInclude(s => s.Beats.Where(b => b.Chapter!.DeletedAt == null))
.ThenInclude(b => b.Chapter)
.Include(c => c.SameCharacterAs)
.Include(c => c.OtherIdentities)
@@ -18,6 +18,7 @@ using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
using Novelly.Api.Trash;
using Novelly.Api.Users;
namespace Novelly.Api.Common;
@@ -103,6 +104,11 @@ public static class NovellyServiceRegistration
services.AddScoped<ImportAgentService>();
services.AddHostedService<ImportJobRunner>();
services.Configure<TrashOptions>(configuration.GetSection(TrashOptions.SectionName));
services.AddScoped<TrashService>();
services.AddSingleton(TimeProvider.System);
services.AddHostedService<TrashPurgeRunner>();
services.AddModelValidatorsFromAssemblyContaining<Program>();
return services;
+6
View File
@@ -0,0 +1,6 @@
namespace Novelly.Api.Common;
public interface ISoftDeletable
{
DateTimeOffset? DeletedAt { get; set; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations");
migrationBuilder.AddColumn<long>(
name: "DeletedAt",
table: "Locations",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "DeletedAt",
table: "Characters",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "DeletedAt",
table: "Chapters",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations",
columns: new[] { "NovelId", "Name" },
unique: true,
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Locations");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Characters");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Chapters");
migrationBuilder.CreateIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations",
columns: new[] { "NovelId", "Name" },
unique: true);
}
}
}
@@ -319,6 +319,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long?>("DeletedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
@@ -390,6 +393,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long?>("DeletedAt")
.HasColumnType("INTEGER");
b.Property<string>("IdentityNote")
.HasColumnType("TEXT");
@@ -680,6 +686,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long?>("DeletedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
@@ -691,7 +700,8 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id");
b.HasIndex("NovelId", "Name")
.IsUnique();
.IsUnique()
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Locations");
});
+5 -2
View File
@@ -1,11 +1,12 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Novels;
namespace Novelly.Api.Locations;
public class Location
public class Location : ISoftDeletable
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -17,6 +18,7 @@ public class Location
public List<Chapter> Chapters { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DeletedAt { get; set; }
}
public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location>
@@ -25,7 +27,8 @@ public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location
{
entity.Property(l => l.Name).IsRequired().HasMaxLength(120);
entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique();
entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique().HasFilter("\"DeletedAt\" IS NULL");
entity.HasQueryFilter(l => l.DeletedAt == null);
entity.HasMany(l => l.Chapters).WithMany(c => c.Locations)
.UsingEntity(join => join.ToTable("ChapterLocations"));
@@ -54,7 +54,7 @@ public static class LocationEndpoints
locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a location. Whatever carried it is left alone.");
.WithSummary("Move a location to the trash. Whatever carried it is left alone.");
return app;
}
+2 -2
View File
@@ -122,7 +122,7 @@ public class LocationService(
{
Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Deleting location {LocationId}", locationId);
logger.LogInformation("Moving location {LocationId} to trash", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
@@ -133,7 +133,7 @@ public class LocationService(
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
db.Locations.Remove(location);
location.DeletedAt = DateTimeOffset.UtcNow;
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Deleted, location.Id);
await db.SaveChangesAsync(ct);
return true;
+3 -1
View File
@@ -15,6 +15,7 @@ using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
using Novelly.Api.Trash;
using Novelly.Api.Users;
using Serilog;
@@ -128,7 +129,8 @@ app.MapNovelEndpoints()
.MapOpenQuestionEndpoints()
.MapAgentEndpoints()
.MapImportEndpoints()
.MapActivityEndpoints();
.MapActivityEndpoints()
.MapTrashEndpoints();
app.Run();
@@ -76,22 +76,28 @@ public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQu
public static class OpenQuestionMapping
{
public static OpenQuestionResponse ToResponse(this OpenQuestion q, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
public static OpenQuestionResponse ToResponse(this OpenQuestion q, IReadOnlyDictionary<Guid, int>? displayNumbers = null)
{
var chapter = q.Chapter is { DeletedAt: null } ? q.Chapter : null;
var character = q.Character is { DeletedAt: null } ? q.Character : null;
return new(
q.Id,
q.NovelId,
q.Question,
q.Detail,
q.ChapterId,
q.Chapter?.Number,
q.Chapter?.Title,
q.Chapter is { } chapter
chapter?.Number,
chapter?.Title,
chapter is not null
? ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title)
: null,
q.CharacterId,
q.Character?.Name,
character?.Name,
q.Resolution,
q.IsResolved,
q.ResolvedAt,
q.CreatedAt,
q.UpdatedAt);
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace Novelly.Api.Trash;
public record TrashedItemResponse(
Guid Id,
TrashEntityKind Kind,
string Label,
string? Detail,
DateTimeOffset DeletedAt,
DateTimeOffset PurgeAfter);
+38
View File
@@ -0,0 +1,38 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Trash;
public static class TrashEndpoints
{
public static IEndpointRouteBuilder MapTrashEndpoints(this IEndpointRouteBuilder app)
{
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/trash").WithTags("Trash")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, TrashService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(novelId, ct)))
.WithSummary("List everything in a novel's trash.");
novelScoped.MapDelete("/", async (Guid novelId, TrashService service, CancellationToken ct) =>
Results.Ok(new { purged = await service.EmptyAsync(novelId, ct) }))
.WithSummary("Empty a novel's trash, permanently deleting everything in it.");
var trash = app.MapGroup("/api/trash").WithTags("Trash")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
trash.MapPost("/{kind}/{id:guid}/restore", async (
TrashEntityKind kind, Guid id, TrashService service, CancellationToken ct) =>
await service.RestoreAsync(kind, id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Restore a trashed item.");
trash.MapDelete("/{kind}/{id:guid}", async (
TrashEntityKind kind, Guid id, TrashService service, CancellationToken ct) =>
await service.PurgeAsync(kind, id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Permanently delete a trashed item.");
return app;
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Novelly.Api.Trash;
public enum TrashEntityKind
{
Character,
Chapter,
Location
}
+10
View File
@@ -0,0 +1,10 @@
namespace Novelly.Api.Trash;
public class TrashOptions
{
public const string SectionName = "Trash";
public bool Enabled { get; set; } = true;
public int RetentionDays { get; set; } = 30;
public TimeOnly PurgeAtLocalTime { get; set; } = new(2, 0);
}
+57
View File
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Trash;
internal static class TrashPurge
{
public static async Task<bool> RemoveAsync(INovelDbContext db, TrashEntityKind kind, Guid id, CancellationToken ct) => kind switch
{
TrashEntityKind.Character => await RemoveCharacterAsync(db, id, ct),
TrashEntityKind.Chapter => await RemoveChapterAsync(db, id, ct),
TrashEntityKind.Location => await RemoveLocationAsync(db, id, ct),
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
private static async Task<bool> RemoveCharacterAsync(INovelDbContext db, Guid id, CancellationToken ct)
{
var character = await db.Characters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id, ct);
if (character is null)
{
return false;
}
var inboundRelationships = await db.CharacterRelationships
.IgnoreQueryFilters()
.Where(r => r.RelatedCharacterId == id)
.ToListAsync(ct);
db.CharacterRelationships.RemoveRange(inboundRelationships);
db.Characters.Remove(character);
return true;
}
private static async Task<bool> RemoveChapterAsync(INovelDbContext db, Guid id, CancellationToken ct)
{
var chapter = await db.Chapters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null)
{
return false;
}
db.Chapters.Remove(chapter);
return true;
}
private static async Task<bool> RemoveLocationAsync(INovelDbContext db, Guid id, CancellationToken ct)
{
var location = await db.Locations.IgnoreQueryFilters().FirstOrDefaultAsync(l => l.Id == id, ct);
if (location is null)
{
return false;
}
db.Locations.Remove(location);
return true;
}
}
+84
View File
@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Novelly.Api.Data;
namespace Novelly.Api.Trash;
public class TrashPurgeRunner(
IServiceScopeFactory scopeFactory,
IOptions<TrashOptions> options,
TimeProvider clock,
ILogger<TrashPurgeRunner> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!options.Value.Enabled)
{
logger.LogInformation("Trash purge is disabled");
return;
}
await RunPurgePassAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
var now = clock.GetLocalNow();
var delay = TrashPurgeSchedule.NextRunAfter(now, options.Value.PurgeAtLocalTime) - now;
try
{
await Task.Delay(delay, clock, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
await RunPurgePassAsync(stoppingToken);
}
}
private async Task RunPurgePassAsync(CancellationToken ct)
{
try
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<INovelDbContext>();
var cutoff = clock.GetUtcNow().AddDays(-options.Value.RetentionDays);
var (characterCount, chapterCount, locationCount) = await SweepAsync(db, cutoff, ct);
logger.LogInformation(
"Trash purge removed {CharacterCount} characters, {ChapterCount} chapters, {LocationCount} locations",
characterCount, chapterCount, locationCount);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Trash purge pass failed");
}
}
public static async Task<(int Characters, int Chapters, int Locations)> SweepAsync(
INovelDbContext db, DateTimeOffset cutoff, CancellationToken ct)
{
var characterIds = await db.Characters.IgnoreQueryFilters()
.Where(c => c.DeletedAt != null && c.DeletedAt < cutoff).Select(c => c.Id).ToListAsync(ct);
var chapterIds = await db.Chapters.IgnoreQueryFilters()
.Where(c => c.DeletedAt != null && c.DeletedAt < cutoff).Select(c => c.Id).ToListAsync(ct);
var locationIds = await db.Locations.IgnoreQueryFilters()
.Where(l => l.DeletedAt != null && l.DeletedAt < cutoff).Select(l => l.Id).ToListAsync(ct);
foreach (var id in characterIds)
await TrashPurge.RemoveAsync(db, TrashEntityKind.Character, id, ct);
foreach (var id in chapterIds)
await TrashPurge.RemoveAsync(db, TrashEntityKind.Chapter, id, ct);
foreach (var id in locationIds)
await TrashPurge.RemoveAsync(db, TrashEntityKind.Location, id, ct);
await db.SaveChangesAsync(ct);
return (characterIds.Count, chapterIds.Count, locationIds.Count);
}
}
@@ -0,0 +1,11 @@
namespace Novelly.Api.Trash;
public static class TrashPurgeSchedule
{
public static DateTimeOffset NextRunAfter(DateTimeOffset now, TimeOnly runAt)
{
var candidate = new DateTimeOffset(now.Year, now.Month, now.Day, runAt.Hour, runAt.Minute, runAt.Second, now.Offset);
return candidate > now ? candidate : candidate.AddDays(1);
}
}
+186
View File
@@ -0,0 +1,186 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Trash;
public class TrashService(
INovelDbContext db,
NovelAccessService access,
ActivityLog activity,
IOptions<TrashOptions> options,
ILogger<TrashService> logger)
{
public async Task<IReadOnlyList<TrashedItemResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing trash for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.Read, ct);
var retentionDays = options.Value.RetentionDays;
var characters = await db.Characters
.IgnoreQueryFilters()
.Where(c => c.NovelId == novelId && c.DeletedAt != null)
.Select(c => new TrashedItemResponse(
c.Id, TrashEntityKind.Character, c.Name,
c.ArcStages.Count > 0 ? $"{c.ArcStages.Count} arc stages" : null,
c.DeletedAt!.Value, c.DeletedAt!.Value.AddDays(retentionDays)))
.ToListAsync(ct);
var chapters = await db.Chapters
.IgnoreQueryFilters()
.Where(c => c.NovelId == novelId && c.DeletedAt != null)
.Select(c => new TrashedItemResponse(
c.Id, TrashEntityKind.Chapter, c.Title,
c.Beats.Count > 0 ? $"{c.Beats.Count} beats" : null,
c.DeletedAt!.Value, c.DeletedAt!.Value.AddDays(retentionDays)))
.ToListAsync(ct);
var locations = await db.Locations
.IgnoreQueryFilters()
.Where(l => l.NovelId == novelId && l.DeletedAt != null)
.Select(l => new TrashedItemResponse(
l.Id, TrashEntityKind.Location, l.Name,
l.Chapters.Count > 0 ? $"used by {l.Chapters.Count} chapters" : null,
l.DeletedAt!.Value, l.DeletedAt!.Value.AddDays(retentionDays)))
.ToListAsync(ct);
return [.. characters.Concat(chapters).Concat(locations).OrderByDescending(i => i.DeletedAt)];
}
public async Task<bool> RestoreAsync(TrashEntityKind kind, Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Restoring {Kind} {ItemId} from trash", kind, id);
return kind switch
{
TrashEntityKind.Character => await RestoreCharacterAsync(id, ct),
TrashEntityKind.Chapter => await RestoreChapterAsync(id, ct),
TrashEntityKind.Location => await RestoreLocationAsync(id, ct),
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
public async Task<bool> PurgeAsync(TrashEntityKind kind, Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Purging {Kind} {ItemId} from trash", kind, id);
var novelId = await NovelIdOfTrashedAsync(kind, id, ct);
if (novelId is null)
{
return false;
}
await access.RequireAsync(novelId.Value, NovelPermission.DeleteContent, ct);
var removed = await TrashPurge.RemoveAsync(db, kind, id, ct);
if (!removed)
{
return false;
}
await db.SaveChangesAsync(ct);
return true;
}
public async Task<int> EmptyAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Emptying trash for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.DeleteContent, ct);
var items = await ListAsync(novelId, ct);
var purged = 0;
foreach (var item in items)
{
if (await TrashPurge.RemoveAsync(db, item.Kind, item.Id, ct))
{
purged++;
}
}
await db.SaveChangesAsync(ct);
logger.LogInformation("Emptied {Count} items from trash for novel {NovelId}", purged, novelId);
return purged;
}
private async Task<bool> RestoreCharacterAsync(Guid id, CancellationToken ct)
{
var character = await db.Characters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt != null, ct);
if (character is null)
{
logger.LogWarning("Trashed character {CharacterId} not found", id);
return false;
}
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
character.DeletedAt = null;
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Restored, character.Id);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<bool> RestoreChapterAsync(Guid id, CancellationToken ct)
{
var chapter = await db.Chapters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt != null, ct);
if (chapter is null)
{
logger.LogWarning("Trashed chapter {ChapterId} not found", id);
return false;
}
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
chapter.DeletedAt = null;
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Restored, chapter.Id, chapter.WordCount);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<bool> RestoreLocationAsync(Guid id, CancellationToken ct)
{
var location = await db.Locations.IgnoreQueryFilters().FirstOrDefaultAsync(l => l.Id == id && l.DeletedAt != null, ct);
if (location is null)
{
logger.LogWarning("Trashed location {LocationId} not found", id);
return false;
}
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
var clash = await db.Locations.FirstOrDefaultAsync(
l => l.NovelId == location.NovelId && EF.Functions.Like(l.Name, location.Name), ct);
if (clash is not null)
{
logger.LogWarning("Rejected restore of location {LocationId}: '{Name}' already exists as {ClashLocationId}", id, location.Name, clash.Id);
throw new InvalidOperationException($"The novel already has a location called '{clash.Name}'.");
}
location.DeletedAt = null;
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Restored, location.Id);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Guid?> NovelIdOfTrashedAsync(TrashEntityKind kind, Guid id, CancellationToken ct) => kind switch
{
TrashEntityKind.Character => (await db.Characters.IgnoreQueryFilters().Where(c => c.Id == id && c.DeletedAt != null).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct)),
TrashEntityKind.Chapter => (await db.Chapters.IgnoreQueryFilters().Where(c => c.Id == id && c.DeletedAt != null).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct)),
TrashEntityKind.Location => (await db.Locations.IgnoreQueryFilters().Where(l => l.Id == id && l.DeletedAt != null).Select(l => (Guid?)l.NovelId).FirstOrDefaultAsync(ct)),
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
+5
View File
@@ -38,5 +38,10 @@
},
"Imports": {
"RootPath": null
},
"Trash": {
"Enabled": true,
"RetentionDays": 30,
"PurgeAtLocalTime": "02:00"
}
}
+2 -1
View File
@@ -44,7 +44,8 @@ public static class LocationTools
api.PatchAsync($"/api/locations/{locationId}", new { name }, ct);
[McpServerTool(Name = "delete_location")]
[Description("Delete a location. Whatever carried it is left alone — only the label goes.")]
[Description("Move a location to the trash. Whatever carried it is left alone — only the label goes. "
+ "It can be restored from the Trash page within the retention window.")]
public static Task<CallToolResult> DeleteLocation(
NovelApiClient api,
[Description("The location's id.")] Guid locationId,
@@ -21,17 +21,12 @@ public static class Extensions
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
+2
View File
@@ -8,6 +8,7 @@ import TagsPage from './pages/TagsPage'
import LocationsPage from './pages/LocationsPage'
import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage'
import TrashPage from './pages/TrashPage'
import SettingsPage from './pages/SettingsPage'
import LoginPage from './pages/LoginPage'
import { AuthProvider, useAuth } from './auth/AuthContext'
@@ -43,6 +44,7 @@ export default function App() {
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="trash" element={<TrashPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<NovelsPage />} />
+54
View File
@@ -18,6 +18,7 @@ import type {
ImportJobStatus,
ImportUpload,
OpenQuestion,
Location,
LocationReferences,
LocationSummary,
Novel,
@@ -26,6 +27,8 @@ import type {
NovelSummary,
TagReferences,
TagSummary,
TrashedItem,
TrashEntityKind,
UiSettings,
User,
} from './types'
@@ -52,6 +55,7 @@ export const keys = {
importBrowse: (path: string) => ['imports', 'browse', path] as const,
novelActivity: (novelId: string) => ['novels', novelId, 'activity'] as const,
myActivity: ['activity'] as const,
trash: (novelId: string) => ['novels', novelId, 'trash'] as const,
}
export const useUiSettings = () =>
@@ -202,6 +206,7 @@ export function useDeleteCharacter(novelId: string) {
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
qc.invalidateQueries({ queryKey: keys.trash(novelId) })
},
})
}
@@ -433,6 +438,14 @@ export const useLocationReferences = (locationId: string | undefined) =>
enabled: Boolean(locationId),
})
export function useCreateLocation(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (name: string) => api.post<Location>(`/api/novels/${novelId}/locations`, { name }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.locations(novelId) }),
})
}
export function useUpdateLocation(novelId: string) {
const qc = useQueryClient()
return useMutation({
@@ -579,6 +592,7 @@ export function useDeleteChapter(novelId: string) {
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
qc.invalidateQueries({ queryKey: keys.trash(novelId) })
},
})
}
@@ -625,6 +639,46 @@ export const useMyActivity = () =>
queryFn: () => api.get<ActivityCalendar>('/api/activity'),
})
export const useTrash = (novelId: string) =>
useQuery({
queryKey: keys.trash(novelId),
queryFn: () => api.get<TrashedItem[]>(`/api/novels/${novelId}/trash`),
})
function invalidateAfterTrashChange(qc: ReturnType<typeof useQueryClient>, novelId: string) {
qc.invalidateQueries({ queryKey: keys.trash(novelId) })
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
}
export function useRestoreTrashed(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ kind, id }: { kind: TrashEntityKind; id: string }) =>
api.post(`/api/trash/${kind}/${id}/restore`, {}),
onSuccess: () => invalidateAfterTrashChange(qc, novelId),
})
}
export function usePurgeTrashed(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ kind, id }: { kind: TrashEntityKind; id: string }) => api.delete(`/api/trash/${kind}/${id}`),
onSuccess: () => invalidateAfterTrashChange(qc, novelId),
})
}
export function useEmptyTrash(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.delete(`/api/novels/${novelId}/trash`),
onSuccess: () => invalidateAfterTrashChange(qc, novelId),
})
}
export function useInspectImport() {
return useMutation({
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
+13
View File
@@ -350,6 +350,19 @@ export interface ImportUpload {
markdownFileCount: number
}
export type TrashEntityKind = 'Character' | 'Chapter' | 'Location'
export const trashEntityKinds: TrashEntityKind[] = ['Character', 'Chapter', 'Location']
export interface TrashedItem {
id: string
kind: TrashEntityKind
label: string
detail: string | null
deletedAt: string
purgeAfter: string
}
export interface ActivityDay {
date: string
words: number
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import {
useCharacterBeats,
@@ -12,6 +12,8 @@ import {
import { chapterLabel } from '../api/chapterLabel'
import type { ArcStage, Character, ChapterKind } from '../api/types'
import { AutoField, ErrorNote } from './ui'
import { ConfirmModal } from './ConfirmModal'
import { useHotkey } from '../keyboard/HotkeysContext'
export function CharacterArc({
novelId,
@@ -32,6 +34,8 @@ export function CharacterArc({
const reorder = useReorderArcStages(novelId)
const [title, setTitle] = useState('')
const [pendingStageId, setPendingStageId] = useState<string | null>(null)
const titleInputRef = useRef<HTMLInputElement>(null)
const stages = character.arcStages
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
@@ -41,10 +45,15 @@ export function CharacterArc({
if (!title.trim()) return
create.mutate(
{ characterId: character.id, title: title.trim() },
{ onSuccess: () => setTitle('') },
{ onSuccess: (stage) => { setTitle(''); setPendingStageId(stage.id) } },
)
}
useHotkey('a', 'Add arc stage', () => titleInputRef.current?.focus(), {
group: 'Character',
enabled: canCreate,
})
const move = (index: number, delta: number) => {
const next = [...stages]
const [moved] = next.splice(index, 1)
@@ -80,6 +89,8 @@ export function CharacterArc({
onMove={(delta) => move(index, delta)}
canWrite={canWrite}
canDelete={canDelete}
autoFocusTitle={pendingStageId === stage.id}
onTitleAutoFocused={() => setPendingStageId(null)}
/>
))}
</ol>
@@ -95,10 +106,14 @@ export function CharacterArc({
{canCreate && (
<form onSubmit={submit} className="mt-3 flex gap-2">
<input
ref={titleInputRef}
className="input flex-1"
placeholder="Add a section — a short title, e.g. “spoiled noble”"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setTitle('')
}}
/>
<button className="btn btn-primary shrink-0" disabled={!title.trim() || create.isPending}>
Add
@@ -125,6 +140,8 @@ function ArcStageRow({
onMove,
canWrite,
canDelete,
autoFocusTitle,
onTitleAutoFocused,
}: {
novelId: string
stage: ArcStage
@@ -135,10 +152,13 @@ function ArcStageRow({
onMove: (delta: number) => void
canWrite: boolean
canDelete: boolean
autoFocusTitle: boolean
onTitleAutoFocused: () => void
}) {
const update = useUpdateArcStage(novelId)
const remove = useDeleteArcStage(novelId)
const setBeats = useSetArcStageBeats(novelId, stage.characterId)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const addBeat = (beatId: string) => {
if (!beatId) return
@@ -162,6 +182,9 @@ function ArcStageRow({
value={stage.title}
onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })}
readOnly={!canWrite}
autoFocus={autoFocusTitle}
selectOnFocus
onAutoFocused={onTitleAutoFocused}
/>
<AutoField
value={stage.result}
@@ -266,9 +289,7 @@ function ArcStageRow({
<button
className="btn px-2 py-0.5 text-xs"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm(`Delete “${stage.title}” from the arc?`)) remove.mutate(stage.id)
}}
onClick={() => setConfirmingDelete(true)}
aria-label="Delete stage"
>
@@ -277,6 +298,15 @@ function ArcStageRow({
</div>
)}
</div>
{confirmingDelete && (
<ConfirmModal
title="Delete stage"
message={`Delete "${stage.title}" from the arc?`}
onConfirm={() => remove.mutate(stage.id)}
onClose={() => setConfirmingDelete(false)}
/>
)}
</li>
)
}
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, type KeyboardEvent } from 'react'
import {
useDeleteQuestion,
useOpenQuestions,
@@ -8,6 +8,10 @@ import {
} from '../api/hooks'
import type { OpenQuestion } from '../api/types'
import { ErrorNote, Spinner } from './ui'
import { ConfirmModal } from './ConfirmModal'
import { useHotkey } from '../keyboard/HotkeysContext'
const isSubmitCombo = (e: KeyboardEvent) => (e.metaKey || e.ctrlKey) && e.key === 'Enter'
export function OpenQuestions({
novelId,
@@ -34,8 +38,7 @@ export function OpenQuestions({
const [question, setQuestion] = useState('')
const [detail, setDetail] = useState('')
const submit = (e: React.FormEvent) => {
e.preventDefault()
const raiseQuestion = () => {
if (!question.trim()) return
raise.mutate(
{ question: question.trim(), detail: detail.trim() || undefined, ...scope },
@@ -49,6 +52,14 @@ export function OpenQuestions({
)
}
useHotkey('q', 'Raise a question', () => setAsking(true), { group: 'Questions', enabled: canCreate })
const cancelAsking = () => {
setAsking(false)
setQuestion('')
setDetail('')
}
const openCount = questions?.filter((q) => !q.isResolved).length ?? 0
return (
@@ -68,7 +79,7 @@ export function OpenQuestions({
Show resolved
</label>
{canCreate && (
<button className="btn" onClick={() => setAsking((open) => !open)}>
<button className="btn" onClick={() => (asking ? cancelAsking() : setAsking(true))}>
{asking ? 'Cancel' : 'Ask'}
</button>
)}
@@ -76,7 +87,23 @@ export function OpenQuestions({
</div>
{asking && (
<form onSubmit={submit} className="mb-4 grid gap-2">
<form
onSubmit={(e) => {
e.preventDefault()
raiseQuestion()
}}
className="mb-4 grid gap-2"
onKeyDown={(e) => {
if (e.key === 'Escape') {
cancelAsking()
return
}
if (isSubmitCombo(e)) {
e.preventDefault()
raiseQuestion()
}
}}
>
<input
className="input"
autoFocus
@@ -147,9 +174,14 @@ function QuestionRow({
const [resolving, setResolving] = useState(false)
const [resolution, setResolution] = useState('')
const [appendToNotes, setAppendToNotes] = useState(true)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const submit = (e: React.FormEvent) => {
e.preventDefault()
const cancelResolving = () => {
setResolving(false)
setResolution('')
}
const submitResolution = () => {
if (!resolution.trim()) return
resolve.mutate(
{ id: question.id, resolution: resolution.trim(), appendToNotes },
@@ -204,7 +236,7 @@ function QuestionRow({
) : (
<button
className="btn px-2 py-1 text-xs"
onClick={() => setResolving((open) => !open)}
onClick={() => (resolving ? cancelResolving() : setResolving(true))}
>
{resolving ? 'Cancel' : 'Resolve'}
</button>
@@ -214,11 +246,7 @@ function QuestionRow({
<button
className="btn px-2 py-1 text-xs"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm('Delete this question? Resolving keeps the decision; deleting does not.')) {
remove.mutate(question.id)
}
}}
onClick={() => setConfirmingDelete(true)}
>
Delete
</button>
@@ -227,7 +255,23 @@ function QuestionRow({
</div>
{resolving && (
<form onSubmit={submit} className="mt-2 grid gap-2">
<form
onSubmit={(e) => {
e.preventDefault()
submitResolution()
}}
className="mt-2 grid gap-2"
onKeyDown={(e) => {
if (e.key === 'Escape') {
cancelResolving()
return
}
if (isSubmitCombo(e)) {
e.preventDefault()
submitResolution()
}
}}
>
<textarea
className="input"
rows={2}
@@ -252,6 +296,15 @@ function QuestionRow({
{resolve.error && <ErrorNote error={resolve.error} />}
</form>
)}
{confirmingDelete && (
<ConfirmModal
title="Delete question"
message="Delete this question? Resolving keeps the decision; deleting does not."
onConfirm={() => remove.mutate(question.id)}
onClose={() => setConfirmingDelete(false)}
/>
)}
</li>
)
}
+11
View File
@@ -75,6 +75,17 @@ export function IconAgent(props: SVGProps<SVGSVGElement>) {
)
}
export function IconTrash(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M4.5 7h15" />
<path d="M9.5 7V4.8a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1V7" />
<path d="M6.5 7l1 12.2a1 1 0 0 0 1 .8h7a1 1 0 0 0 1-.8L17.5 7" />
<path d="M10 11v6M14 11v6" />
</Icon>
)
}
export function IconSettings(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
+58 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import { useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { draftStatusColor } from '../api/stage'
import type { DraftStatus } from '../api/types'
import { focusNextTabbable } from '../keyboard/focus'
export function Spinner({ label = 'Loading' }: { label?: string }) {
return (
@@ -59,6 +60,9 @@ export function AutoField({
suggestions,
onContextMenu,
readOnly,
autoFocus,
selectOnFocus,
onAutoFocused,
}: {
label?: string
value: string | null | undefined
@@ -70,10 +74,17 @@ export function AutoField({
suggestions?: readonly string[]
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
readOnly?: boolean
autoFocus?: boolean
selectOnFocus?: boolean
onAutoFocused?: () => void
}) {
const [draft, setDraft] = useState(value ?? '')
const committed = useRef(value ?? '')
const suggestionsId = useId()
const inputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const onAutoFocusedRef = useRef(onAutoFocused)
onAutoFocusedRef.current = onAutoFocused
useEffect(() => {
const incoming = value ?? ''
@@ -83,6 +94,14 @@ export function AutoField({
}
}, [value])
useEffect(() => {
if (!autoFocus) return
const field = multiline ? textareaRef.current : inputRef.current
field?.focus()
if (selectOnFocus) field?.select()
onAutoFocusedRef.current?.()
}, [autoFocus, multiline, selectOnFocus])
const commit = () => {
if (draft !== committed.current) {
committed.current = draft
@@ -90,6 +109,31 @@ export function AutoField({
}
}
const revert = () => setDraft(committed.current)
const onSingleLineKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
revert()
return
}
if (e.key !== 'Enter') return
e.preventDefault()
commit()
if (e.metaKey || e.ctrlKey) return
focusNextTabbable(e.currentTarget, e.shiftKey ? -1 : 1)
}
const onMultilineKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
revert()
return
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
commit()
}
}
const className = `input ${serif ? 'prose-serif' : ''}`
return (
@@ -97,25 +141,28 @@ export function AutoField({
{label && <span className="label">{label}</span>}
{multiline ? (
<textarea
ref={textareaRef}
className={className}
rows={rows}
value={draft}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={onMultilineKeyDown}
onContextMenu={onContextMenu}
readOnly={readOnly}
/>
) : (
<>
<input
ref={inputRef}
className={className}
value={draft}
placeholder={placeholder}
list={suggestions?.length ? suggestionsId : undefined}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
onKeyDown={onSingleLineKeyDown}
readOnly={readOnly}
/>
{suggestions?.length ? (
@@ -137,17 +184,25 @@ export function Select<T extends string>({
value,
options,
onChange,
autoFocus,
}: {
id?: string
label?: string
value: T
options: readonly T[]
onChange: (next: T) => void
autoFocus?: boolean
}) {
const selectRef = useRef<HTMLSelectElement>(null)
useEffect(() => {
if (autoFocus) selectRef.current?.focus()
}, [autoFocus])
return (
<label className="block">
{label && <span className="label">{label}</span>}
<select id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
<select ref={selectRef} id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
{options.map((option) => (
<option key={option} value={option}>
{option}
+20 -1
View File
@@ -1,6 +1,13 @@
import { useHelpOverlay } from './HelpOverlayContext'
import { useHotkeysList } from './HotkeysContext'
const CONVENTIONS = [
'Escape cancels or closes. It reverts the field youre in; fields you already tabbed past stay saved.',
'Enter commits a single-line field and moves to the next one, like Tab.',
'mod+Enter commits a multiline field, or finishes the record youre editing.',
'Destructive actions always confirm through a dialog you can dismiss with Escape — never a browser popup.',
]
const formatToken = (token: string) => {
if (token === 'mod') return '⌘/Ctrl'
if (token.length === 1) return token.toUpperCase()
@@ -80,13 +87,25 @@ export function HelpOverlay() {
<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>
<span>
{shortcut.description}
{shortcut.allowInInputs && <span className="muted"> · works while typing</span>}
</span>
<KeySequence keys={shortcut.keys} />
</li>
))}
</ul>
</div>
))}
<div>
<h3 className="label mb-2">Conventions</h3>
<ul className="grid gap-2 text-sm muted">
{CONVENTIONS.map((convention) => (
<li key={convention}>{convention}</li>
))}
</ul>
</div>
</div>
</aside>
</div>
+21
View File
@@ -0,0 +1,21 @@
const TABBABLE_SELECTOR = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
export function focusNextTabbable(from: HTMLElement, direction: 1 | -1, within?: HTMLElement | null) {
const scope = within ?? document.body
const candidates = [...scope.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)]
const index = candidates.indexOf(from)
if (index === -1) return
const target = candidates[index + direction]
if (!target) return
target.focus()
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
target.select()
}
}
export function isWithin(container: HTMLElement | null, node: Node | null): boolean {
if (!container || !node) return false
return container.contains(node)
}
+104 -30
View File
@@ -1,5 +1,5 @@
import { useState, type MouseEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useEffect, useRef, useState, type MouseEvent } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import {
useAssignCharacterToBeats,
useChapter,
@@ -29,12 +29,14 @@ import { useCharacterContextMenu } from '../components/CharacterContextMenu'
import { MarkdownEditor } from '../components/MarkdownEditor'
import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext'
import { isWithin } from '../keyboard/focus'
type ChapterTab = 'outline' | 'prose'
export default function ChapterPage() {
const { novelId = '', chapterId = '' } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { data: chapter, isPending, error } = useChapter(chapterId)
const { data: novel } = useNovel(novelId)
const { data: characters } = useCharacters(novelId)
@@ -47,13 +49,32 @@ export default function ChapterPage() {
const createBeat = useCreateBeat(chapterId, novelId)
const [tab, setTab] = useState<ChapterTab>('outline')
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [editingBeatId, setEditingBeatId] = useState<string | null>(null)
const [focusBeatId, setFocusBeatId] = useState<string | null>(null)
const { handleContextMenu, menuElement } = useCharacterContextMenu(novelId)
const { can } = useAuth()
const canWrite = can('Write', novel)
const canCreate = can('CreateContent', novel)
const canDelete = can('DeleteContent', novel)
useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
const focusTitleOnArrival = Boolean((location.state as { focusTitle?: boolean } | null)?.focusTitle)
const clearFocusTitleState = () => navigate(location.pathname, { replace: true, state: null })
const addBeat = () => {
if (!canCreate) return
createBeat.mutate(
{ title: 'New beat' },
{
onSuccess: (beat) => {
setEditingBeatId(beat.id)
setFocusBeatId(beat.id)
},
},
)
}
useHotkey('b', 'Add beat', addBeat, { group: 'Chapter' })
const currentIndex = chapters?.findIndex((c) => c.id === chapterId) ?? -1
const prevChapter = currentIndex > 0 ? chapters?.[currentIndex - 1] : undefined
@@ -168,6 +189,9 @@ export default function ChapterPage() {
value={chapter.title}
onCommit={(title) => title.trim() && patch({ title })}
readOnly={!canWrite}
autoFocus={focusTitleOnArrival}
selectOnFocus
onAutoFocused={clearFocusTitleState}
/>
<Select
id="chapter-kind-select"
@@ -197,22 +221,13 @@ export default function ChapterPage() {
/>
</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
Move to trash
</button>
)}
</div>
@@ -235,8 +250,9 @@ export default function ChapterPage() {
{confirmingDelete && (
<ConfirmModal
title="Delete chapter"
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
title="Move to trash"
message={`Move chapter "${chapter.title}" and its beats to the trash? You can restore it from the Trash page.`}
confirmLabel="Move to trash"
onConfirm={() =>
remove.mutate(chapter.id, {
onSuccess: () => navigate(`/novels/${novelId}/chapters`),
@@ -274,14 +290,14 @@ export default function ChapterPage() {
onCharacterContextMenu={handleContextMenu}
canWrite={canWrite}
canDelete={canDelete}
editingId={editingBeatId}
setEditingId={setEditingBeatId}
focusBeatId={focusBeatId}
setFocusBeatId={setFocusBeatId}
/>
{canCreate && (
<button
className="btn btn-primary mt-3"
onClick={() => createBeat.mutate({ title: 'New beat' })}
disabled={createBeat.isPending}
>
<button className="btn btn-primary mt-3" onClick={addBeat} disabled={createBeat.isPending}>
Add beat
</button>
)}
@@ -328,6 +344,15 @@ export default function ChapterPage() {
</section>
)}
<section id="chapter-tags" className="card mt-6 p-5">
<TagEditor
label="Tags"
tags={chapter.tags}
suggestions={suggestions}
onChange={(tags) => canWrite && patch({ tags })}
/>
</section>
{menuElement}
</div>
)
@@ -345,6 +370,10 @@ function BeatTable({
onCharacterContextMenu,
canWrite,
canDelete,
editingId,
setEditingId,
focusBeatId,
setFocusBeatId,
}: {
chapter: Chapter
novelId: string
@@ -358,13 +387,16 @@ function BeatTable({
) => void
canWrite: boolean
canDelete: boolean
editingId: string | null
setEditingId: (id: string | null) => void
focusBeatId: string | null
setFocusBeatId: (id: string | null) => void
}) {
const update = useUpdateBeat(chapter.id, novelId)
const remove = useDeleteBeat(chapter.id, novelId)
const reorder = useReorderBeats(chapter.id)
const assignCharacter = useAssignCharacterToBeats(chapter.id)
const moveBeats = useMoveBeats(chapter.id)
const [editingId, setEditingId] = useState<string | null>(null)
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [assignCharacterId, setAssignCharacterId] = useState('')
@@ -372,6 +404,25 @@ function BeatTable({
const [focusedBeatId, setFocusedBeatId] = useState<string | null>(null)
const [dragBeatId, setDragBeatId] = useState<string | null>(null)
const [dragOverBeatId, setDragOverBeatId] = useState<string | null>(null)
const [returnFocusBeatId, setReturnFocusBeatId] = useState<string | null>(null)
const rowRefs = useRef(new Map<string, HTMLTableRowElement>())
useEffect(() => {
if (!returnFocusBeatId) return
document.getElementById(`beat-${returnFocusBeatId}`)?.focus()
setReturnFocusBeatId(null)
}, [returnFocusBeatId])
const openEdit = (beatId: string) => {
if (!canWrite) return
setEditingId(beatId)
setFocusBeatId(beatId)
}
const closeEdit = (beatId: string) => {
setEditingId(null)
setReturnFocusBeatId(beatId)
}
const toggleSelected = (id: string) =>
setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]))
@@ -449,7 +500,7 @@ function BeatTable({
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) => (
const renderMoveButtons = (beat: Beat, index: number, focusable: boolean) => (
<div className="flex items-center gap-1">
<span
className={canWrite ? 'cursor-grab text-base muted' : 'text-base muted'}
@@ -463,6 +514,7 @@ function BeatTable({
<button
id={`move-beat-up-${beat.id}`}
className={moveButtonClass}
tabIndex={focusable ? undefined : -1}
onClick={(e) => {
e.stopPropagation()
move(index, -1)
@@ -476,6 +528,7 @@ function BeatTable({
<button
id={`move-beat-down-${beat.id}`}
className={moveButtonClass}
tabIndex={focusable ? undefined : -1}
onClick={(e) => {
e.stopPropagation()
move(index, 1)
@@ -575,28 +628,48 @@ function BeatTable({
<tr
key={beat.id}
id={`beat-${beat.id}`}
ref={(el) => {
if (el) rowRefs.current.set(beat.id, el)
else rowRefs.current.delete(beat.id)
}}
style={{ borderBottom: '1px solid var(--line)', background: 'var(--accent-soft)' }}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setEditingId(null)
onBlur={() => {
window.setTimeout(() => {
if (!isWithin(rowRefs.current.get(beat.id) ?? null, document.activeElement)) {
setEditingId(null)
}
}, 0)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
closeEdit(beat.id)
return
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
closeEdit(beat.id)
}
}}
onKeyDown={(e) => e.key === 'Escape' && setEditingId(null)}
>
<td className="px-2 py-2 align-top">
<input
type="checkbox"
tabIndex={-1}
aria-label={`Select beat ${beat.title}`}
checked={selectedIds.includes(beat.id)}
onChange={() => toggleSelected(beat.id)}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td>
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index, false)}</td>
<td className="px-2 py-2 align-top">
<AutoField
value={beat.title}
placeholder="Three to five words"
onCommit={(title) => title.trim() && patch(beat.id, { title })}
autoFocus={focusBeatId === beat.id}
selectOnFocus
onAutoFocused={() => setFocusBeatId(null)}
/>
<div className="mt-1.5">
<TagEditor
@@ -651,8 +724,9 @@ function BeatTable({
<button
className="text-xs leading-none"
style={{ color: 'var(--accent)' }}
onClick={() => setEditingId(null)}
onClick={() => closeEdit(beat.id)}
aria-label="Done editing beat"
title="Done (mod+Enter)"
>
</button>
@@ -687,14 +761,14 @@ function BeatTable({
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 ? () => openEdit(beat.id) : undefined}
onFocus={canWrite ? () => setFocusedBeatId(beat.id) : undefined}
onKeyDown={
canWrite
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setEditingId(beat.id)
openEdit(beat.id)
}
}
: undefined
@@ -737,7 +811,7 @@ function BeatTable({
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td>
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index, true)}</td>
<td className="px-2 py-2 align-top">
<div className="font-medium">{beat.title}</div>
+15 -7
View File
@@ -1,4 +1,4 @@
import { Link, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useChapters, useCreateChapter, useNovel } from '../api/hooks'
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
import { TagChip } from '../components/TagEditor'
@@ -7,13 +7,25 @@ import { useHotkey } from '../keyboard/HotkeysContext'
export default function ChaptersPage() {
const { novelId = '' } = useParams()
const navigate = useNavigate()
const { data: chapters, isPending, error } = useChapters(novelId)
const { data: novel } = useNovel(novelId)
const { can } = useAuth()
const canCreate = can('CreateContent', novel)
const create = useCreateChapter(novelId)
useHotkey('n', 'Add chapter', () => canCreate && create.mutate({ title: 'Untitled chapter' }), { group: 'Chapters' })
const addChapter = () => {
if (!canCreate) return
create.mutate(
{ title: 'Untitled chapter' },
{
onSuccess: (chapter) =>
navigate(`/novels/${novelId}/chapters/${chapter.id}`, { state: { focusTitle: true } }),
},
)
}
useHotkey('n', 'Add chapter', addChapter, { group: 'Chapters' })
if (isPending) return <Spinner label="Loading chapters" />
if (error) return <ErrorNote error={error} />
@@ -23,11 +35,7 @@ export default function ChaptersPage() {
<div className="mb-5 flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">Chapters</h2>
{canCreate && (
<button
className="btn btn-primary"
onClick={() => create.mutate({ title: 'Untitled chapter' })}
disabled={create.isPending}
>
<button className="btn btn-primary" onClick={addChapter} disabled={create.isPending}>
Add chapter
</button>
)}
@@ -292,8 +292,9 @@ function CharacterSheet({
{confirmingDelete && (
<ConfirmModal
title="Delete character"
message={`Delete ${character.name}? This cannot be undone.`}
title="Move to trash"
message={`Move ${character.name} to the trash? You can restore it from the Trash page.`}
confirmLabel="Move to trash"
onConfirm={() =>
remove.mutate(character.id, { onSuccess: () => navigate(`/novels/${novelId}/characters`) })
}
+72 -23
View File
@@ -1,10 +1,18 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
import {
useCreateLocation,
useDeleteLocation,
useLocationReferences,
useLocations,
useNovel,
useUpdateLocation,
} from '../api/hooks'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { AutoField, EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function LocationsPage() {
const { novelId = '' } = useParams()
@@ -12,9 +20,16 @@ export default function LocationsPage() {
const { data: novel } = useNovel(novelId)
const { can } = useAuth()
const canWrite = can('Write', novel)
const canCreate = can('CreateContent', novel)
const canDelete = can('DeleteContent', novel)
const [searchParams, setSearchParams] = useSearchParams()
const selectedId = searchParams.get('location') ?? undefined
const [name, setName] = useState('')
const [justCreatedId, setJustCreatedId] = useState<string | null>(null)
const nameInputRef = useRef<HTMLInputElement>(null)
const create = useCreateLocation(novelId)
useHotkey('n', 'Add location', () => canCreate && nameInputRef.current?.focus(), { group: 'Locations' })
if (isPending) return <Spinner label="Loading locations" />
if (error) return <ErrorNote error={error} />
@@ -27,20 +42,48 @@ export default function LocationsPage() {
return params
})
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
create.mutate(name.trim(), {
onSuccess: (location) => {
setName('')
setJustCreatedId(location.id)
select(location.id)
},
})
}
return (
<div id="locations-page" className="grid gap-6 lg:grid-cols-[18rem_1fr]">
<aside className="grid content-start gap-2">
<div>
<h2 className="text-lg font-semibold">Locations</h2>
<p className="text-sm muted">
Applied from a chapter. Pick one to see every chapter set there.
</p>
<p className="text-sm muted">Pick one to see every chapter set there.</p>
</div>
{canCreate && (
<form id="location-create-form" onSubmit={submit} className="flex gap-2">
<input
id="location-create-input"
ref={nameInputRef}
className="input flex-1"
placeholder="Add a location…"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setName('')
}}
/>
<button className="btn btn-primary shrink-0" disabled={!name.trim() || create.isPending}>
Add
</button>
</form>
)}
{create.error && <ErrorNote error={create.error} />}
{locations?.length === 0 && (
<p className="mt-2 text-sm muted">
No locations yet. Add one from a chapter and it will appear here.
</p>
<p className="mt-2 text-sm muted">No locations yet.</p>
)}
{locations?.map((location) => (
@@ -64,7 +107,7 @@ export default function LocationsPage() {
{!selected ? (
<EmptyState
title="No locations yet"
hint="Locations cross-reference the book: attach one to a chapter, then trace it from here."
hint="Add one on the left, or apply a new name to a chapter and it will appear here."
/>
) : (
<LocationReferencePanel
@@ -73,6 +116,8 @@ export default function LocationsPage() {
locationId={selected.id}
canWrite={canWrite}
canDelete={canDelete}
autoFocusName={justCreatedId === selected.id}
onNameAutoFocused={() => setJustCreatedId(null)}
/>
)}
</section>
@@ -85,11 +130,15 @@ function LocationReferencePanel({
locationId,
canWrite,
canDelete,
autoFocusName,
onNameAutoFocused,
}: {
novelId: string
locationId: string
canWrite: boolean
canDelete: boolean
autoFocusName: boolean
onNameAutoFocused: () => void
}) {
const { data, isPending, error } = useLocationReferences(locationId)
const update = useUpdateLocation(novelId)
@@ -105,21 +154,20 @@ function LocationReferencePanel({
return (
<div className="grid gap-4">
<div className="card flex flex-wrap items-end justify-between gap-3 p-4">
<label className="block">
<span className="label">Location name</span>
<input
className="input w-64"
defaultValue={data.location.name}
<div className="w-64">
<AutoField
label="Location name"
value={data.location.name}
readOnly={!canWrite}
onBlur={(e) => {
const name = e.target.value.trim()
if (name && name !== data.location.name) update.mutate({ id: locationId, name })
}}
onCommit={(name) => name.trim() && update.mutate({ id: locationId, name: name.trim() })}
autoFocus={autoFocusName}
selectOnFocus
onAutoFocused={onNameAutoFocused}
/>
</label>
</div>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete location
Move to trash
</button>
)}
</div>
@@ -128,8 +176,9 @@ function LocationReferencePanel({
{confirmingDelete && (
<ConfirmModal
title="Delete location"
message={`Delete the location "${data.location.name}"? What carries it is left alone.`}
title="Move to trash"
message={`Move the location "${data.location.name}" to the trash? What carries it is left alone. You can restore it from the Trash page.`}
confirmLabel="Move to trash"
onConfirm={() => remove.mutate(locationId)}
onClose={() => setConfirmingDelete(false)}
/>
+4 -1
View File
@@ -13,6 +13,7 @@ import {
IconLocations,
IconSettings,
IconTags,
IconTrash,
} from '../components/icons'
import { AgentPanel, type AgentContext } from '../components/AgentPanel'
import { HelpButton } from '../keyboard/HelpButton'
@@ -23,9 +24,10 @@ const sections: { to: string; label: string; end?: boolean; icon: ComponentType<
{ to: '', label: 'Dashboard', end: true, icon: IconDashboard },
{ to: 'chapters', label: 'Chapters', icon: IconChapters },
{ to: 'characters', label: 'Characters', icon: IconCharacters },
{ to: 'tags', label: 'Tags', icon: IconTags },
{ to: 'locations', label: 'Locations', icon: IconLocations },
{ to: 'tags', label: 'Tags', icon: IconTags },
{ to: 'settings', label: 'Settings', icon: IconSettings },
{ to: 'trash', label: 'Trash', icon: IconTrash },
]
export default function NovelLayout() {
@@ -46,6 +48,7 @@ export default function NovelLayout() {
useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' })
useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' })
useHotkey('g l', 'Go to locations', () => goTo('locations'), { group: 'Navigate' })
useHotkey('g r', 'Go to trash', () => goTo('trash'), { group: 'Navigate' })
useHotkey('g a', 'Toggle agent', () => setAgentOpen((o) => !o), { group: 'Navigate' })
useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' })
+127
View File
@@ -0,0 +1,127 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import { useEmptyTrash, useNovel, usePurgeTrashed, useRestoreTrashed, useTrash } from '../api/hooks'
import type { TrashedItem } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ConfirmModal } from '../components/ConfirmModal'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
export default function TrashPage() {
const { novelId = '' } = useParams()
const { data: items, isPending, error } = useTrash(novelId)
const { data: novel } = useNovel(novelId)
const { can } = useAuth()
const canDelete = can('DeleteContent', novel)
const restore = useRestoreTrashed(novelId)
const purge = usePurgeTrashed(novelId)
const emptyTrash = useEmptyTrash(novelId)
const [purging, setPurging] = useState<TrashedItem | null>(null)
const [emptying, setEmptying] = useState(false)
if (isPending) return <Spinner label="Loading trash" />
if (error) return <ErrorNote error={error} />
return (
<div id="trash-page" className="grid gap-6">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Trash</h1>
<p className="text-sm muted">
Trashed characters, chapters, and locations. Restore them or delete them for good.
</p>
</div>
{canDelete && items && items.length > 0 && (
<button id="trash-empty" className="btn btn-danger" onClick={() => setEmptying(true)}>
Empty trash
</button>
)}
</div>
{(restore.error || purge.error || emptyTrash.error) && (
<ErrorNote error={restore.error ?? purge.error ?? emptyTrash.error} />
)}
{!items || items.length === 0 ? (
<EmptyState title="Trash is empty" hint="Deleted characters, chapters, and locations show up here." />
) : (
<div className="card overflow-x-auto" id="trash-table">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase muted">
<th className="py-2 px-3 font-semibold">Item</th>
<th className="py-2 px-3 font-semibold">Kind</th>
<th className="py-2 px-3 font-semibold">Deleted</th>
<th className="py-2 px-3 font-semibold">Purges in</th>
{canDelete && <th className="py-2 px-3 font-semibold" />}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={`${item.kind}-${item.id}`} className="align-top" style={{ borderTop: '1px solid var(--line)' }}>
<td className="py-2 px-3">
<div className="font-medium">{item.label}</div>
{item.detail && <div className="text-xs muted">{item.detail}</div>}
</td>
<td className="py-2 px-3">{item.kind}</td>
<td className="py-2 px-3 muted">{new Date(item.deletedAt).toLocaleDateString()}</td>
<td className="py-2 px-3 muted">{daysUntil(item.purgeAfter)}</td>
{canDelete && (
<td className="py-2 px-3">
<div className="flex justify-end gap-2">
<button
id={`trash-restore-${item.id}`}
className="btn"
disabled={restore.isPending}
onClick={() => restore.mutate({ kind: item.kind, id: item.id })}
>
Restore
</button>
<button
id={`trash-purge-${item.id}`}
className="btn btn-danger"
onClick={() => setPurging(item)}
>
Delete forever
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{purging && (
<ConfirmModal
title="Delete forever"
message={`Permanently delete "${purging.label}"? This cannot be undone.`}
confirmLabel="Delete forever"
onConfirm={() => purge.mutate({ kind: purging.kind, id: purging.id })}
onClose={() => setPurging(null)}
/>
)}
{emptying && (
<ConfirmModal
title="Empty trash"
message="Permanently delete everything in the trash? This cannot be undone."
confirmLabel="Empty trash"
onConfirm={() => emptyTrash.mutate()}
onClose={() => setEmptying(false)}
/>
)}
</div>
)
}
function daysUntil(isoDate: string): string {
const ms = new Date(isoDate).getTime() - Date.now()
const days = Math.ceil(ms / (1000 * 60 * 60 * 24))
if (days <= 0) return 'any time now'
if (days === 1) return '1 day'
return `${days} days`
}
+6 -3
View File
@@ -152,7 +152,7 @@ public class CharacterArcTests : ServiceTestFixture
}
[Test]
public async Task Deleting_a_chapter_unpins_an_arc_stage_rather_than_deleting_it()
public async Task Trashing_a_chapter_hides_it_from_an_arc_stage_without_unpinning_it()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var stage = await Arcs.CreateAsync(
@@ -161,11 +161,14 @@ public class CharacterArcTests : ServiceTestFixture
await Chapters.DeleteAsync(chapter.Id);
var survivor = (await Arcs.GetAsync(stage.Id))!;
var response = survivor.ToResponse();
Assert.Multiple(() =>
{
Assert.That(survivor.ChapterId, Is.Null);
Assert.That(survivor.Title, Is.EqualTo("The map is wrong"));
Assert.That(survivor.ChapterId, Is.EqualTo(chapter.Id), "the pin survives so restoring the chapter restores the link");
Assert.That(response.ChapterNumber, Is.Null);
Assert.That(response.ChapterTitle, Is.Null);
Assert.That(response.Title, Is.EqualTo("The map is wrong"));
});
}
@@ -258,7 +258,7 @@ public class CharacterServiceTests : ServiceTestFixture
}
[Test]
public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive()
public async Task Trashing_the_canonical_character_keeps_the_link_so_restoring_brings_it_back()
{
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
@@ -268,7 +268,13 @@ public class CharacterServiceTests : ServiceTestFixture
using var verification = Db.CreateContext();
var survivor = await verification.Characters.FirstAsync(c => c.Id == stranger.Id);
Assert.That(survivor.SameCharacterAsId, Is.Null);
var response = (await Characters.GetAsync(stranger.Id))!.ToResponse();
Assert.Multiple(() =>
{
Assert.That(survivor.SameCharacterAsId, Is.EqualTo(kael.Id), "the pin survives so restoring Kael restores the identity link");
Assert.That(response.SameCharacterAsName, Is.Null, "a trashed canonical identity does not show up while it's in the trash");
});
}
[Test]
@@ -151,7 +151,7 @@ public class LocationServiceTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(survivor.Title, Is.EqualTo("Landfall"));
Assert.That(survivor.Locations, Is.Empty);
Assert.That(survivor.ToResponse().Locations, Is.Empty);
});
}
+4 -2
View File
@@ -182,7 +182,7 @@ public class OpenQuestionTests : ServiceTestFixture
}
[Test]
public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them()
public async Task Trashing_a_chapter_leaves_its_questions_open_and_pinned_but_hides_the_chapter()
{
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Does she know about the letter?", ChapterId: _chapterId));
@@ -190,10 +190,12 @@ public class OpenQuestionTests : ServiceTestFixture
await Chapters.DeleteAsync(_chapterId);
var survivor = (await Questions.GetAsync(question.Id))!;
var response = survivor.ToResponse();
Assert.Multiple(() =>
{
Assert.That(survivor.ChapterId, Is.Null);
Assert.That(survivor.ChapterId, Is.EqualTo(_chapterId), "the pin survives so restoring the chapter restores the link");
Assert.That(response.ChapterTitle, Is.Null);
Assert.That(survivor.Question, Is.EqualTo("Does she know about the letter?"));
});
}
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Options;
using Novelly.Api.Activity;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
@@ -7,6 +8,7 @@ using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
using Novelly.Api.Trash;
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
@@ -28,6 +30,8 @@ public abstract class ServiceTestFixture
protected CharacterArcService Arcs { get; private set; } = null!;
protected OpenQuestionService Questions { get; private set; } = null!;
protected GenreService Genres { get; private set; } = null!;
protected TrashService Trash { get; private set; } = null!;
protected TrashOptions TrashOptions { get; private set; } = null!;
protected CapturingLogger<NovelService> NovelLogs { get; private set; } = null!;
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
@@ -38,6 +42,7 @@ public abstract class ServiceTestFixture
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!;
protected CapturingLogger<GenreService> GenreLogs { get; private set; } = null!;
protected CapturingLogger<TrashService> TrashLogs { get; private set; } = null!;
[SetUp]
public void SetUpFixture()
@@ -67,6 +72,7 @@ public abstract class ServiceTestFixture
ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>();
GenreLogs = new CapturingLogger<GenreService>();
TrashLogs = new CapturingLogger<TrashService>();
Tags = new TagService(Db.Context, Access, ActivityLog, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Locations = new LocationService(Db.Context, Access, ActivityLog, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator());
@@ -90,6 +96,8 @@ public abstract class ServiceTestFixture
Db.Context, Access, ActivityLog, QuestionLogs,
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
Genres = new GenreService(Db.Context, GenreLogs);
TrashOptions = new TrashOptions();
Trash = new TrashService(Db.Context, Access, ActivityLog, Options.Create(TrashOptions), TrashLogs);
OnSetUp();
}
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Novels;
using Novelly.Api.Trash;
namespace Novelly.Api.Tests;
[TestFixture]
public class TrashPurgeScheduleTests
{
[Test]
public void Before_the_run_time_the_next_run_is_today() =>
Assert.That(
TrashPurgeSchedule.NextRunAfter(new DateTimeOffset(2026, 8, 20, 1, 0, 0, TimeSpan.Zero), new TimeOnly(2, 0)),
Is.EqualTo(new DateTimeOffset(2026, 8, 20, 2, 0, 0, TimeSpan.Zero)));
[Test]
public void After_the_run_time_the_next_run_is_tomorrow() =>
Assert.That(
TrashPurgeSchedule.NextRunAfter(new DateTimeOffset(2026, 8, 20, 3, 0, 0, TimeSpan.Zero), new TimeOnly(2, 0)),
Is.EqualTo(new DateTimeOffset(2026, 8, 21, 2, 0, 0, TimeSpan.Zero)));
[Test]
public void Exactly_at_the_run_time_the_next_run_is_tomorrow() =>
Assert.That(
TrashPurgeSchedule.NextRunAfter(new DateTimeOffset(2026, 8, 20, 2, 0, 0, TimeSpan.Zero), new TimeOnly(2, 0)),
Is.EqualTo(new DateTimeOffset(2026, 8, 21, 2, 0, 0, TimeSpan.Zero)));
}
[TestFixture]
public class TrashPurgeSweepTests : ServiceTestFixture
{
private Guid _novelId;
protected override void OnSetUp() =>
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
[Test]
public async Task A_sweep_removes_only_items_older_than_the_retention_window()
{
var old = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Old News"));
var recent = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Fresh Off The Press"));
await Chapters.DeleteAsync(old!.Id);
await Chapters.DeleteAsync(recent!.Id);
using (var context = Db.CreateContext())
{
var oldRow = await context.Chapters.IgnoreQueryFilters().SingleAsync(c => c.Id == old.Id);
oldRow.DeletedAt = DateTimeOffset.UtcNow.AddDays(-31);
await context.SaveChangesAsync();
}
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
var (characters, chapters, locations) = await TrashPurgeRunner.SweepAsync(Db.Context, cutoff, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That((characters, chapters, locations), Is.EqualTo((0, 1, 0)));
});
using var verification = Db.CreateContext();
Assert.Multiple(async () =>
{
Assert.That(await verification.Chapters.IgnoreQueryFilters().AnyAsync(c => c.Id == old.Id), Is.False);
Assert.That(await verification.Chapters.IgnoreQueryFilters().AnyAsync(c => c.Id == recent.Id), Is.True);
});
}
}
@@ -0,0 +1,249 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Trash;
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
[TestFixture]
public class TrashServiceTests : ServiceTestFixture
{
private Guid _novelId;
protected override void OnSetUp() =>
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
[Test]
public async Task Trashing_a_chapter_hides_it_from_the_chapter_list_but_keeps_its_beats()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("They spot the wreck"));
await Chapters.DeleteAsync(chapter.Id);
var listed = await Chapters.ListAsync(_novelId);
Assert.That(listed, Is.Empty);
using var verification = Db.CreateContext();
var survivingBeats = await verification.Beats.IgnoreQueryFilters().Where(b => b.ChapterId == chapter.Id).ToListAsync();
Assert.That(survivingBeats, Has.Count.EqualTo(1));
}
[Test]
public async Task Restoring_a_chapter_brings_its_beats_back()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("They spot the wreck"));
await Chapters.DeleteAsync(chapter.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Chapter).Id;
var restored = await Trash.RestoreAsync(TrashEntityKind.Chapter, trashedId);
Assert.That(restored, Is.True);
var survivor = (await Chapters.GetAsync(chapter.Id))!;
Assert.That(survivor.Beats.Select(b => b.Title), Is.EquivalentTo(new[] { "They spot the wreck" }));
}
[Test]
public async Task Restoring_a_chapter_restores_its_word_count_to_the_activity_feed()
{
var chapter = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Prose: "The tide came in slow and cold."));
await Chapters.DeleteAsync(chapter!.Id);
var afterDelete = await Activity.GetForNovelAsync(_novelId, days: 1);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Chapter).Id;
await Trash.RestoreAsync(TrashEntityKind.Chapter, trashedId);
var afterRestore = await Activity.GetForNovelAsync(_novelId, days: 1);
Assert.Multiple(() =>
{
Assert.That(afterDelete.TotalWords, Is.EqualTo(0), "the create and the trash deltas cancel out");
Assert.That(afterRestore.TotalWords, Is.EqualTo(chapter.WordCount), "restoring adds the word count back");
});
}
[Test]
public async Task A_trashed_location_name_can_be_used_by_a_new_location()
{
var chapter = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
var locationId = (await Locations.ListAsync(_novelId)).Single().Id;
await Locations.DeleteAsync(locationId);
var recreated = await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour"));
Assert.That(recreated, Is.Not.Null);
Assert.That(chapter, Is.Not.Null);
}
[Test]
public async Task Restoring_a_location_whose_name_was_taken_reports_a_clash()
{
var locationId = (await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour")))!.Id;
await Locations.DeleteAsync(locationId);
await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour"));
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Location).Id;
Assert.That(
() => Trash.RestoreAsync(TrashEntityKind.Location, trashedId),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a location"));
}
[Test]
public async Task Trashed_characters_disappear_from_beat_listings()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var beat = await Beats.CreateAsync(
chapter!.Id, new CreateBeatRequest("Kael spots the wreck", CharacterIds: [character!.Id]));
await Characters.DeleteAsync(character.Id);
var survivor = (await Beats.GetAsync(beat!.Id))!;
Assert.That(survivor.ToResponse().Characters, Is.Empty);
}
[Test]
public async Task Restoring_a_trashed_character_brings_it_back()
{
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
await Characters.DeleteAsync(character!.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Character).Id;
var restored = await Trash.RestoreAsync(TrashEntityKind.Character, trashedId);
Assert.Multiple(async () =>
{
Assert.That(restored, Is.True);
Assert.That(await Characters.GetAsync(character.Id), Is.Not.Null);
Assert.That(await Trash.ListAsync(_novelId), Is.Empty);
});
}
[Test]
public async Task Emptying_trash_purges_everything_in_the_novel_but_leaves_other_novels_alone()
{
var otherNovelId = (await Novels.CreateAsync(new CreateNovelRequest("Elsewhere"))).Id;
var elsewhereChapter = await Chapters.CreateAsync(otherNovelId, new CreateChapterRequest("A Different Book"));
await Chapters.DeleteAsync(elsewhereChapter!.Id);
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
await Chapters.DeleteAsync(chapter!.Id);
await Characters.DeleteAsync(character!.Id);
var purged = await Trash.EmptyAsync(_novelId);
Assert.Multiple(async () =>
{
Assert.That(purged, Is.EqualTo(2));
Assert.That(await Trash.ListAsync(_novelId), Is.Empty);
Assert.That((await Trash.ListAsync(otherNovelId)).Select(i => i.Id), Is.EquivalentTo(new[] { elsewhereChapter.Id }));
});
using var verification = Db.CreateContext();
Assert.Multiple(async () =>
{
Assert.That(await verification.Chapters.IgnoreQueryFilters().AnyAsync(c => c.Id == chapter.Id), Is.False);
Assert.That(await verification.Characters.IgnoreQueryFilters().AnyAsync(c => c.Id == character.Id), Is.False);
});
}
[Test]
public async Task Purging_a_character_that_others_relate_to_succeeds()
{
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var mira = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mira"));
await Characters.AddRelationshipAsync(mira!.Id, new CreateRelationshipRequest(kael!.Id, "sibling"));
await Characters.DeleteAsync(kael.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Character).Id;
var purged = await Trash.PurgeAsync(TrashEntityKind.Character, trashedId);
Assert.That(purged, Is.True);
using var verification = Db.CreateContext();
Assert.That(await verification.Characters.IgnoreQueryFilters().AnyAsync(c => c.Id == kael.Id), Is.False);
}
[Test]
public async Task Purging_a_chapter_deletes_its_beats_for_good()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("They spot the wreck"));
await Chapters.DeleteAsync(chapter.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Chapter).Id;
await Trash.PurgeAsync(TrashEntityKind.Chapter, trashedId);
using var verification = Db.CreateContext();
Assert.That(await verification.Beats.IgnoreQueryFilters().AnyAsync(b => b.ChapterId == chapter.Id), Is.False);
}
[Test]
public async Task An_editor_cannot_restore_or_purge()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Chapters.DeleteAsync(chapter!.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single().Id;
var editorId = AsNewUser(GlobalRole.Reviewer);
GrantNovelRole(_novelId, editorId, NovelRole.Editor);
Assert.Multiple(() =>
{
Assert.That(() => Trash.RestoreAsync(TrashEntityKind.Chapter, trashedId), Throws.TypeOf<NotAuthorizedException>());
Assert.That(() => Trash.PurgeAsync(TrashEntityKind.Chapter, trashedId), Throws.TypeOf<NotAuthorizedException>());
});
}
[Test]
public async Task Trash_lists_only_the_novels_own_items()
{
var otherNovelId = (await Novels.CreateAsync(new CreateNovelRequest("Elsewhere"))).Id;
var otherChapter = await Chapters.CreateAsync(otherNovelId, new CreateChapterRequest("A Different Book"));
await Chapters.DeleteAsync(otherChapter!.Id);
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Chapters.DeleteAsync(chapter!.Id);
var listed = await Trash.ListAsync(_novelId);
Assert.That(listed.Select(i => i.Id), Is.EquivalentTo(new[] { chapter.Id }));
}
private Guid AsNewUser(GlobalRole globalRole)
{
var user = new NovellyUser
{
Id = Guid.NewGuid(),
UserName = $"{Guid.NewGuid()}@novelly.test",
Email = $"{Guid.NewGuid()}@novelly.test",
DisplayName = "Test User",
GlobalRole = globalRole
};
Db.Context.Users.Add(user);
Db.Context.SaveChanges();
UserContext.UserId = user.Id;
UserContext.GlobalRole = globalRole;
return user.Id;
}
private void GrantNovelRole(Guid novelId, Guid userId, NovelRole role)
{
Db.Context.NovelMembers.Add(new NovelMember { NovelId = novelId, UserId = userId, NovelRole = role, GrantedByUserId = userId });
Db.Context.SaveChanges();
}
}