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