Simplify character dossier fields, add ShowPronouns setting
CI / build-and-push (push) Successful in 51s
CI / deploy (push) Successful in 9s

Collapse Want/Need into Motivation and Internal/External Conflict into
Conflict, drop Arc summary field, and move tags below open questions on
the character page. Gate Pronouns display behind new UiSettings:ShowPronouns
config (default off).
This commit is contained in:
James Wampler
2026-08-19 15:33:14 -07:00
parent bbd5e66777
commit 45afc980d3
18 changed files with 1425 additions and 147 deletions
+6 -15
View File
@@ -146,11 +146,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "appearance"), JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"), JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"), JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"), JsonInput.String(input, "motivation"),
JsonInput.String(input, "need"), JsonInput.String(input, "conflict"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"), JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags"), JsonInput.Strings(input, "tags"),
@@ -177,11 +174,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "appearance"), JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"), JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"), JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"), JsonInput.String(input, "motivation"),
JsonInput.String(input, "need"), JsonInput.String(input, "conflict"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"), JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags"), JsonInput.Strings(input, "tags"),
@@ -667,11 +661,8 @@ public class NovelAgentToolset(
.Str("appearance", "How they look.") .Str("appearance", "How they look.")
.Str("personality", "Temperament, habits, how they treat people.") .Str("personality", "Temperament, habits, how they treat people.")
.Str("backstory", "History that shapes who they are now.") .Str("backstory", "History that shapes who they are now.")
.Str("want", "What they consciously pursue.") .Str("motivation", "What they consciously pursue, weighed against what they actually need.")
.Str("need", "What they actually need, usually at odds with what they want.") .Str("conflict", "The war inside them and what in the world opposes them.")
.Str("internal_conflict", "The war inside them.")
.Str("external_conflict", "What in the world opposes them.")
.Str("arc_summary", "How they change over the course of the book.")
.Str("voice", "Speech patterns and register that make their dialogue theirs.") .Str("voice", "Speech patterns and register that make their dialogue theirs.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
+2 -7
View File
@@ -26,14 +26,9 @@ public class Character
public string? Personality { get; set; } public string? Personality { get; set; }
public string? Backstory { get; set; } public string? Backstory { get; set; }
public string? Want { get; set; } public string? Motivation { get; set; }
public string? Need { get; set; } public string? Conflict { get; set; }
public string? InternalConflict { get; set; }
public string? ExternalConflict { get; set; }
public string? ArcSummary { get; set; }
public string? Voice { get; set; } public string? Voice { get; set; }
@@ -16,11 +16,8 @@ public record CharacterResponse(
string? Appearance, string? Appearance,
string? Personality, string? Personality,
string? Backstory, string? Backstory,
string? Want, string? Motivation,
string? Need, string? Conflict,
string? InternalConflict,
string? ExternalConflict,
string? ArcSummary,
string? Voice, string? Voice,
string? Notes, string? Notes,
IReadOnlyList<string> Aliases, IReadOnlyList<string> Aliases,
@@ -54,11 +51,8 @@ public record CreateCharacterRequest(
string? Appearance = null, string? Appearance = null,
string? Personality = null, string? Personality = null,
string? Backstory = null, string? Backstory = null,
string? Want = null, string? Motivation = null,
string? Need = null, string? Conflict = null,
string? InternalConflict = null,
string? ExternalConflict = null,
string? ArcSummary = null,
string? Voice = null, string? Voice = null,
string? Notes = null, string? Notes = null,
IReadOnlyList<string>? Tags = null, IReadOnlyList<string>? Tags = null,
@@ -73,7 +67,7 @@ public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRe
result.AddRequiredTextErrors("Name", "Name", model.Name, 200); result.AddRequiredTextErrors("Name", "Name", model.Name, 200);
CharacterValidation.OptionalFields( CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory, model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice, model.Motivation, model.Conflict, model.Voice,
model.Notes, model.Tags, model.Aliases, result); model.Notes, model.Tags, model.Aliases, result);
return result; return result;
@@ -90,11 +84,8 @@ public record UpdateCharacterRequest(
string? Appearance = null, string? Appearance = null,
string? Personality = null, string? Personality = null,
string? Backstory = null, string? Backstory = null,
string? Want = null, string? Motivation = null,
string? Need = null, string? Conflict = null,
string? InternalConflict = null,
string? ExternalConflict = null,
string? ArcSummary = null,
string? Voice = null, string? Voice = null,
string? Notes = null, string? Notes = null,
IReadOnlyList<string>? Tags = null, IReadOnlyList<string>? Tags = null,
@@ -109,7 +100,7 @@ public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRe
result.AddUnclearableTextErrors("Name", "Name", model.Name, "a character", 200); result.AddUnclearableTextErrors("Name", "Name", model.Name, "a character", 200);
CharacterValidation.OptionalFields( CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory, model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice, model.Motivation, model.Conflict, model.Voice,
model.Notes, model.Tags, model.Aliases, result); model.Notes, model.Tags, model.Aliases, result);
return result; return result;
@@ -120,7 +111,7 @@ file static class CharacterValidation
{ {
public static void OptionalFields( public static void OptionalFields(
string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory, string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory,
string? want, string? need, string? internalConflict, string? externalConflict, string? arcSummary, string? voice, string? motivation, string? conflict, string? voice,
string? notes, IReadOnlyList<string>? tags, IReadOnlyList<string>? aliases, ValidationResult result) string? notes, IReadOnlyList<string>? tags, IReadOnlyList<string>? aliases, ValidationResult result)
{ {
Cap(age, "Age", 100, result); Cap(age, "Age", 100, result);
@@ -129,11 +120,8 @@ file static class CharacterValidation
Cap(appearance, "Appearance", 20000, result); Cap(appearance, "Appearance", 20000, result);
Cap(personality, "Personality", 20000, result); Cap(personality, "Personality", 20000, result);
Cap(backstory, "Backstory", 20000, result); Cap(backstory, "Backstory", 20000, result);
Cap(want, "Want", 2000, result); Cap(motivation, "Motivation", 2000, result);
Cap(need, "Need", 2000, result); Cap(conflict, "Conflict", 2000, result);
Cap(internalConflict, "InternalConflict", 2000, result);
Cap(externalConflict, "ExternalConflict", 2000, result);
Cap(arcSummary, "ArcSummary", 20000, result);
Cap(voice, "Voice", 2000, result); Cap(voice, "Voice", 2000, result);
Cap(notes, "Notes", 20000, result); Cap(notes, "Notes", 20000, result);
@@ -300,8 +288,7 @@ public static class CharacterMapping
{ {
public static CharacterResponse ToResponse(this Character c) => new( public static CharacterResponse ToResponse(this Character c) => new(
c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation, c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need, c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
[.. c.Aliases], [.. c.Aliases],
c.SameCharacterAsId, c.SameCharacterAsId,
c.SameCharacterAs?.Name, c.SameCharacterAs?.Name,
+4 -10
View File
@@ -84,11 +84,8 @@ public class CharacterService(
Appearance = request.Appearance, Appearance = request.Appearance,
Personality = request.Personality, Personality = request.Personality,
Backstory = request.Backstory, Backstory = request.Backstory,
Want = request.Want, Motivation = request.Motivation,
Need = request.Need, Conflict = request.Conflict,
InternalConflict = request.InternalConflict,
ExternalConflict = request.ExternalConflict,
ArcSummary = request.ArcSummary,
Voice = request.Voice, Voice = request.Voice,
Notes = request.Notes Notes = request.Notes
}; };
@@ -134,11 +131,8 @@ public class CharacterService(
character.Appearance = Patch.Apply(character.Appearance, request.Appearance); character.Appearance = Patch.Apply(character.Appearance, request.Appearance);
character.Personality = Patch.Apply(character.Personality, request.Personality); character.Personality = Patch.Apply(character.Personality, request.Personality);
character.Backstory = Patch.Apply(character.Backstory, request.Backstory); character.Backstory = Patch.Apply(character.Backstory, request.Backstory);
character.Want = Patch.Apply(character.Want, request.Want); character.Motivation = Patch.Apply(character.Motivation, request.Motivation);
character.Need = Patch.Apply(character.Need, request.Need); character.Conflict = Patch.Apply(character.Conflict, request.Conflict);
character.InternalConflict = Patch.Apply(character.InternalConflict, request.InternalConflict);
character.ExternalConflict = Patch.Apply(character.ExternalConflict, request.ExternalConflict);
character.ArcSummary = Patch.Apply(character.ArcSummary, request.ArcSummary);
character.Voice = Patch.Apply(character.Voice, request.Voice); character.Voice = Patch.Apply(character.Voice, request.Voice);
character.Notes = Patch.Apply(character.Notes, request.Notes); character.Notes = Patch.Apply(character.Notes, request.Notes);
character.UpdatedAt = DateTimeOffset.UtcNow; character.UpdatedAt = DateTimeOffset.UtcNow;
@@ -26,6 +26,8 @@ public static class NovellyServiceRegistration
{ {
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<UiSettingsOptions>(configuration.GetSection(UiSettingsOptions.SectionName));
var connectionString = configuration.GetConnectionString("Novel") var connectionString = configuration.GetConnectionString("Novel")
?? "Data Source=novel.db"; ?? "Data Source=novel.db";
@@ -0,0 +1,18 @@
using Microsoft.Extensions.Options;
namespace Novelly.Api.Common;
public static class UiSettingsEndpoints
{
public static IEndpointRouteBuilder MapUiSettingsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/ui-settings", (IOptions<UiSettingsOptions> options) =>
Results.Ok(new UiSettingsResponse(options.Value.ShowPronouns)))
.WithTags("UiSettings")
.AllowAnonymous();
return app;
}
}
public record UiSettingsResponse(bool ShowPronouns);
@@ -0,0 +1,8 @@
namespace Novelly.Api.Common;
public class UiSettingsOptions
{
public const string SectionName = "UiSettings";
public bool ShowPronouns { get; set; }
}
@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class CollapseCharacterMotivationAndConflict : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE Characters SET Want = CASE
WHEN Want IS NULL OR Want = '' THEN Need
WHEN Need IS NULL OR Need = '' THEN Want
ELSE Want || char(10) || char(10) || Need
END;
""");
migrationBuilder.Sql(
"""
UPDATE Characters SET InternalConflict = CASE
WHEN InternalConflict IS NULL OR InternalConflict = '' THEN ExternalConflict
WHEN ExternalConflict IS NULL OR ExternalConflict = '' THEN InternalConflict
ELSE InternalConflict || char(10) || char(10) || ExternalConflict
END;
""");
migrationBuilder.DropColumn(
name: "ArcSummary",
table: "Characters");
migrationBuilder.DropColumn(
name: "ExternalConflict",
table: "Characters");
migrationBuilder.DropColumn(
name: "Need",
table: "Characters");
migrationBuilder.RenameColumn(
name: "Want",
table: "Characters",
newName: "Motivation");
migrationBuilder.RenameColumn(
name: "InternalConflict",
table: "Characters",
newName: "Conflict");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Motivation",
table: "Characters",
newName: "Want");
migrationBuilder.RenameColumn(
name: "Conflict",
table: "Characters",
newName: "InternalConflict");
migrationBuilder.AddColumn<string>(
name: "ArcSummary",
table: "Characters",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalConflict",
table: "Characters",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Need",
table: "Characters",
type: "TEXT",
nullable: true);
}
}
}
@@ -333,18 +333,15 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Appearance") b.Property<string>("Appearance")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ArcSummary") b.Property<string>("Backstory")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Backstory") b.Property<string>("Conflict")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("ExternalConflict")
.HasColumnType("TEXT");
b.Property<string>("IdentityNote") b.Property<string>("IdentityNote")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -353,7 +350,7 @@ namespace Novelly.Api.Data.Migrations
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("InternalConflict") b.Property<string>("Motivation")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Name") b.Property<string>("Name")
@@ -361,9 +358,6 @@ namespace Novelly.Api.Data.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Need")
.HasColumnType("TEXT");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -396,9 +390,6 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Voice") b.Property<string>("Voice")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Want")
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("NovelId"); b.HasIndex("NovelId");
@@ -241,7 +241,7 @@ public class ImportAgentToolset(
Occupation: JsonInput.String(input, "occupation"), Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"), Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"), Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"), Motivation: JsonInput.String(input, "motivation"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return created is null return created is null
@@ -264,7 +264,7 @@ public class ImportAgentToolset(
Occupation: JsonInput.String(input, "occupation"), Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"), Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"), Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"), Motivation: JsonInput.String(input, "motivation"),
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
return updated is null return updated is null
@@ -379,6 +379,6 @@ public class ImportAgentToolset(
.Str("occupation", "The italic tagline under the heading.") .Str("occupation", "The italic tagline under the heading.")
.Str("appearance", "The ## Appearance section.") .Str("appearance", "The ## Appearance section.")
.Str("backstory", "The ## Background section.") .Str("backstory", "The ## Background section.")
.Str("want", "The ## Motivation section.") .Str("motivation", "The ## Motivation section.")
.Str("notes", "The ## Notes section, if present."); .Str("notes", "The ## Notes section, if present.");
} }
+1
View File
@@ -105,6 +105,7 @@ if (app.Environment.IsDevelopment())
app.MapDefaultEndpoints(); app.MapDefaultEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous(); app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
app.MapUiSettingsEndpoints();
app.MapUserEndpoints(); app.MapUserEndpoints();
app.MapNovelMemberEndpoints(); app.MapNovelMemberEndpoints();
+4 -1
View File
@@ -28,9 +28,12 @@
"Origins": [ "http://localhost:5173" ] "Origins": [ "http://localhost:5173" ]
}, },
"Agent": { "Agent": {
"Model": "claude-opus-5", "Model": "claude-sonnet-5",
"MaxTokens": 16000, "MaxTokens": 16000,
"Effort": "high", "Effort": "high",
"MaxIterations": 12 "MaxIterations": 12
},
"UiSettings": {
"ShowPronouns": false
} }
} }
+8 -20
View File
@@ -41,11 +41,8 @@ public static class CharacterTools
[Description("How they look.")] string? appearance = null, [Description("How they look.")] string? appearance = null,
[Description("Temperament, habits, how they treat people.")] string? personality = null, [Description("Temperament, habits, how they treat people.")] string? personality = null,
[Description("History that shapes who they are now.")] string? backstory = null, [Description("History that shapes who they are now.")] string? backstory = null,
[Description("What they consciously pursue.")] string? want = null, [Description("What they consciously pursue, weighed against what they actually need.")] string? motivation = null,
[Description("What they actually need, usually at odds with what they want.")] string? need = null, [Description("The war inside them and what in the world opposes them.")] string? conflict = null,
[Description("The war inside them.")] string? internalConflict = null,
[Description("What in the world opposes them.")] string? externalConflict = null,
[Description("How they change over the course of the book.")] string? arcSummary = null,
[Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null, [Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null,
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null, [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null,
@@ -61,11 +58,8 @@ public static class CharacterTools
appearance, appearance,
personality, personality,
backstory, backstory,
want, motivation,
need, conflict,
internalConflict,
externalConflict,
arcSummary,
voice, voice,
notes, notes,
tags, tags,
@@ -89,11 +83,8 @@ public static class CharacterTools
[Description("How they look.")] string? appearance = null, [Description("How they look.")] string? appearance = null,
[Description("Temperament, habits, how they treat people.")] string? personality = null, [Description("Temperament, habits, how they treat people.")] string? personality = null,
[Description("History that shapes who they are now.")] string? backstory = null, [Description("History that shapes who they are now.")] string? backstory = null,
[Description("What they consciously pursue.")] string? want = null, [Description("What they consciously pursue, weighed against what they actually need.")] string? motivation = null,
[Description("What they actually need.")] string? need = null, [Description("The war inside them and what in the world opposes them.")] string? conflict = null,
[Description("The war inside them.")] string? internalConflict = null,
[Description("What in the world opposes them.")] string? externalConflict = null,
[Description("How they change over the course of the book.")] string? arcSummary = null,
[Description("Speech patterns and register.")] string? voice = null, [Description("Speech patterns and register.")] string? voice = null,
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null, [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null,
@@ -109,11 +100,8 @@ public static class CharacterTools
appearance, appearance,
personality, personality,
backstory, backstory,
want, motivation,
need, conflict,
internalConflict,
externalConflict,
arcSummary,
voice, voice,
notes, notes,
tags, tags,
+5
View File
@@ -23,11 +23,13 @@ import type {
NovelSummary, NovelSummary,
TagReferences, TagReferences,
TagSummary, TagSummary,
UiSettings,
User, User,
} from './types' } from './types'
export const keys = { export const keys = {
me: ['me'] as const, me: ['me'] as const,
uiSettings: ['ui-settings'] as const,
members: (novelId: string) => ['novels', novelId, 'members'] as const, members: (novelId: string) => ['novels', novelId, 'members'] as const,
novels: ['novels'] as const, novels: ['novels'] as const,
genres: ['genres'] as const, genres: ['genres'] as const,
@@ -46,6 +48,9 @@ export const keys = {
importJob: (id: string) => ['imports', id] as const, importJob: (id: string) => ['imports', id] as const,
} }
export const useUiSettings = () =>
useQuery({ queryKey: keys.uiSettings, queryFn: () => api.get<UiSettings>('/api/ui-settings') })
export const useMe = () => export const useMe = () =>
useQuery({ useQuery({
queryKey: keys.me, queryKey: keys.me,
+6 -5
View File
@@ -49,6 +49,10 @@ export interface User {
globalRole: GlobalRole globalRole: GlobalRole
} }
export interface UiSettings {
showPronouns: boolean
}
export interface NovelMember { export interface NovelMember {
userId: string userId: string
email: string email: string
@@ -197,11 +201,8 @@ export interface Character {
appearance: string | null appearance: string | null
personality: string | null personality: string | null
backstory: string | null backstory: string | null
want: string | null motivation: string | null
need: string | null conflict: string | null
internalConflict: string | null
externalConflict: string | null
arcSummary: string | null
voice: string | null voice: string | null
notes: string | null notes: string | null
aliases: string[] aliases: string[]
@@ -9,6 +9,7 @@ import {
useNovel, useNovel,
useRemoveRelationship, useRemoveRelationship,
useTags, useTags,
useUiSettings,
useUnlinkCharacterIdentity, useUnlinkCharacterIdentity,
useUpdateCharacter, useUpdateCharacter,
} from '../api/hooks' } from '../api/hooks'
@@ -79,6 +80,7 @@ function CharacterSheet({
canDelete: boolean canDelete: boolean
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const { data: uiSettings } = useUiSettings()
const { data: allTags } = useTags(novelId) const { data: allTags } = useTags(novelId)
const { data: allCharacters } = useCharacters(novelId) const { data: allCharacters } = useCharacters(novelId)
const { data: chapters } = useChapters(novelId) const { data: chapters } = useChapters(novelId)
@@ -125,12 +127,14 @@ function CharacterSheet({
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} readOnly={!canWrite} /> <AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} readOnly={!canWrite} />
{uiSettings?.showPronouns && (
<AutoField <AutoField
label="Pronouns" label="Pronouns"
value={character.pronouns} value={character.pronouns}
onCommit={(pronouns) => patch({ pronouns })} onCommit={(pronouns) => patch({ pronouns })}
readOnly={!canWrite} readOnly={!canWrite}
/> />
)}
<AutoField <AutoField
label="Occupation" label="Occupation"
value={character.occupation} value={character.occupation}
@@ -139,13 +143,7 @@ function CharacterSheet({
/> />
</div> </div>
<div className="mt-5 grid gap-4 sm:grid-cols-2"> <div className="mt-5">
<TagEditor
label="Tags"
tags={character.tags}
suggestions={allTags?.map((t) => t.name) ?? []}
onChange={(tags) => canWrite && patch({ tags })}
/>
<AliasEditor <AliasEditor
label="Also known as" label="Also known as"
aliases={character.aliases} aliases={character.aliases}
@@ -156,42 +154,20 @@ function CharacterSheet({
<div className="mt-6 grid gap-4 lg:grid-cols-2"> <div className="mt-6 grid gap-4 lg:grid-cols-2">
<AutoField <AutoField
label="Wants" label="Motivation"
value={character.want} value={character.motivation}
multiline multiline
rows={3} rows={3}
placeholder="What they are consciously chasing." placeholder="What they consciously want, and what they need instead."
onCommit={(want) => patch({ want })} onCommit={(motivation) => patch({ motivation })}
readOnly={!canWrite} readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Needs" label="Conflict"
value={character.need} value={character.conflict}
multiline multiline
rows={3} placeholder="The war inside them, and what in the world opposes them."
placeholder="What the story will make them face instead." onCommit={(conflict) => patch({ conflict })}
onCommit={(need) => patch({ need })}
readOnly={!canWrite}
/>
<AutoField
label="Internal conflict"
value={character.internalConflict}
multiline
onCommit={(internalConflict) => patch({ internalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="External conflict"
value={character.externalConflict}
multiline
onCommit={(externalConflict) => patch({ externalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="Arc"
value={character.arcSummary}
multiline
onCommit={(arcSummary) => patch({ arcSummary })}
readOnly={!canWrite} readOnly={!canWrite}
/> />
<AutoField <AutoField
@@ -304,6 +280,15 @@ function CharacterSheet({
canDelete={canDelete} canDelete={canDelete}
/> />
<section id="character-tags" className="card mt-6 p-5">
<TagEditor
label="Tags"
tags={character.tags}
suggestions={allTags?.map((t) => t.name) ?? []}
onChange={(tags) => canWrite && patch({ tags })}
/>
</section>
{confirmingDelete && ( {confirmingDelete && (
<ConfirmModal <ConfirmModal
title="Delete character" title="Delete character"
@@ -48,23 +48,23 @@ public class CharacterServiceTests : ServiceTestFixture
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{ {
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest( var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest(
"Ines", Want: "To find her sister.", Need: "To let go of the guilt.")); "Ines", Motivation: "To find her sister.", Conflict: "Torn between vengeance and letting go."));
var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!; var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(renamed.Name, Is.EqualTo("Ines Vell")); Assert.That(renamed.Name, Is.EqualTo("Ines Vell"));
Assert.That(renamed.Want, Is.EqualTo("To find her sister.")); Assert.That(renamed.Motivation, Is.EqualTo("To find her sister."));
Assert.That(renamed.Need, Is.EqualTo("To let go of the guilt.")); Assert.That(renamed.Conflict, Is.EqualTo("Torn between vengeance and letting go."));
}); });
var cleared = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Need: "")))!; var cleared = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Conflict: "")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(cleared.Need, Is.Null); Assert.That(cleared.Conflict, Is.Null);
Assert.That(cleared.Want, Is.EqualTo("To find her sister.")); Assert.That(cleared.Motivation, Is.EqualTo("To find her sister."));
}); });
} }