Add soft delete + trash, keyboard-first web overhaul, move chapter tags to bottom
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:
@@ -21,7 +21,8 @@ public enum ActivityAction
|
||||
{
|
||||
Created,
|
||||
Updated,
|
||||
Deleted
|
||||
Deleted,
|
||||
Restored
|
||||
}
|
||||
|
||||
public class ActivityEvent
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
r.Id,
|
||||
r.RelatedCharacterId,
|
||||
r.RelatedCharacter?.Name ?? "(unknown)",
|
||||
r.RelationshipType,
|
||||
r.Description))],
|
||||
[.. 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,
|
||||
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(
|
||||
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,
|
||||
[.. s.Beats
|
||||
.OrderBy(b => b.Chapter?.Number ?? 0)
|
||||
.ThenBy(b => b.SortOrder)
|
||||
.Select(b => b.ToCharacterBeatResponse(s.CharacterId, b.Chapter is { } beatChapter ? ChapterLabel(beatChapter, displayNumbers) : null))],
|
||||
s.UpdatedAt);
|
||||
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,
|
||||
chapter?.Number,
|
||||
chapter?.Title,
|
||||
chapter is not null ? ChapterLabel(chapter, displayNumbers) : null,
|
||||
[.. s.Beats
|
||||
.Where(b => b.Chapter is { DeletedAt: null })
|
||||
.OrderBy(b => b.Chapter!.Number)
|
||||
.ThenBy(b => b.SortOrder)
|
||||
.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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
q.Id,
|
||||
q.NovelId,
|
||||
q.Question,
|
||||
q.Detail,
|
||||
q.ChapterId,
|
||||
q.Chapter?.Number,
|
||||
q.Chapter?.Title,
|
||||
q.Chapter is { } chapter
|
||||
? 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,
|
||||
q.Resolution,
|
||||
q.IsResolved,
|
||||
q.ResolvedAt,
|
||||
q.CreatedAt,
|
||||
q.UpdatedAt);
|
||||
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,
|
||||
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,
|
||||
character?.Name,
|
||||
q.Resolution,
|
||||
q.IsResolved,
|
||||
q.ResolvedAt,
|
||||
q.CreatedAt,
|
||||
q.UpdatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Novelly.Api.Trash;
|
||||
|
||||
public record TrashedItemResponse(
|
||||
Guid Id,
|
||||
TrashEntityKind Kind,
|
||||
string Label,
|
||||
string? Detail,
|
||||
DateTimeOffset DeletedAt,
|
||||
DateTimeOffset PurgeAfter);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Novelly.Api.Trash;
|
||||
|
||||
public enum TrashEntityKind
|
||||
{
|
||||
Character,
|
||||
Chapter,
|
||||
Location
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -38,5 +38,10 @@
|
||||
},
|
||||
"Imports": {
|
||||
"RootPath": null
|
||||
},
|
||||
"Trash": {
|
||||
"Enabled": true,
|
||||
"RetentionDays": 30,
|
||||
"PurgeAtLocalTime": "02:00"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useHelpOverlay } from './HelpOverlayContext'
|
||||
import { useHotkeysList } from './HotkeysContext'
|
||||
|
||||
const CONVENTIONS = [
|
||||
'Escape cancels or closes. It reverts the field you’re 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 you’re 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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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`) })
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user