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