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
+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)