Implemented the Phase 14A follow-up fixes.

This commit is contained in:
Nick Beckley 2026-06-23 19:47:30 +01:00
parent b460b38583
commit d255407193
8 changed files with 156 additions and 58 deletions

View File

@ -42,6 +42,7 @@ public sealed class CharactersController(ICharacterService characters) : Control
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(CharacterEditViewModel model)
{
ValidateSexWorkflow(model);
if (!ModelState.IsValid)
{
var editModel = model.CharacterID == 0
@ -56,10 +57,10 @@ public sealed class CharactersController(ICharacterService characters) : Control
return View("Edit", editModel);
}
int characterId;
CharacterSaveResult saveResult;
try
{
characterId = await characters.SaveCharacterAsync(model);
saveResult = await characters.SaveCharacterAsync(model);
}
catch (SubscriptionLimitException ex) when (model.CharacterID == 0)
{
@ -70,18 +71,7 @@ public sealed class CharactersController(ICharacterService characters) : Control
return NotFound();
}
createModel.CharacterName = model.CharacterName;
createModel.ShortName = model.ShortName;
createModel.SexValueID = model.SexValueID;
createModel.Sex = model.Sex;
createModel.CustomSex = model.CustomSex;
createModel.BirthDate = model.BirthDate;
createModel.AgeAtSeriesStart = model.AgeAtSeriesStart;
createModel.Height = model.Height;
createModel.EyeColour = model.EyeColour;
createModel.CharacterImportance = model.CharacterImportance;
createModel.ShowInQuickAddBar = model.ShowInQuickAddBar;
createModel.DefaultDescription = model.DefaultDescription;
CopyCharacterFormValues(model, createModel);
return View("Edit", createModel);
}
catch (EntityImageUploadException ex)
@ -100,22 +90,29 @@ public sealed class CharactersController(ICharacterService characters) : Control
return NotFound();
}
editModel.CharacterName = model.CharacterName;
editModel.ShortName = model.ShortName;
editModel.SexValueID = model.SexValueID;
editModel.Sex = model.Sex;
editModel.CustomSex = model.CustomSex;
editModel.BirthDate = model.BirthDate;
editModel.AgeAtSeriesStart = model.AgeAtSeriesStart;
editModel.Height = model.Height;
editModel.EyeColour = model.EyeColour;
editModel.CharacterImportance = model.CharacterImportance;
editModel.ShowInQuickAddBar = model.ShowInQuickAddBar;
editModel.DefaultDescription = model.DefaultDescription;
CopyCharacterFormValues(model, editModel);
return View("Edit", editModel);
}
return RedirectToAction(nameof(Details), new { id = characterId });
return saveResult.UploadedCharacterImageID.HasValue
? RedirectToAction(nameof(CropAvatar), new { imageId = saveResult.UploadedCharacterImageID.Value })
: RedirectToAction(nameof(Details), new { id = saveResult.CharacterID });
}
private void ValidateSexWorkflow(CharacterEditViewModel model)
{
if (model.SexValueID == CharacterEditViewModel.CustomSexValue)
{
if (string.IsNullOrWhiteSpace(model.CustomSex))
{
ModelState.AddModelError(nameof(model.CustomSex), "Enter a custom sex value.");
}
}
else
{
ModelState.Remove(nameof(model.CustomSex));
model.CustomSex = null;
}
}
private static void CopyCharacterFormValues(CharacterEditViewModel source, CharacterEditViewModel target)
@ -132,6 +129,10 @@ public sealed class CharactersController(ICharacterService characters) : Control
target.CharacterImportance = source.CharacterImportance;
target.ShowInQuickAddBar = source.ShowInQuickAddBar;
target.DefaultDescription = source.DefaultDescription;
foreach (var option in target.SexOptions)
{
option.Selected = option.Value == source.SexValueID?.ToString();
}
}
[HttpPost]

View File

@ -229,7 +229,7 @@ public interface ICharacterService
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
Task<int> SaveCharacterAsync(CharacterEditViewModel model);
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model);
Task RemoveCharacterAvatarAsync(int characterId);
@ -248,6 +248,8 @@ public interface ICharacterService
string DisplayAge(Character character, DateTime? sceneStartDateTime = null);
}
public sealed record CharacterSaveResult(int CharacterID, int? UploadedCharacterImageID = null);
public interface ITimelineService
{
Task<TimelineViewModel?> GetAsync(TimelineFilterViewModel filter);
@ -5455,6 +5457,10 @@ public sealed class RelationshipMapService(IProjectRepository projects, ICharact
CharacterID = x.CharacterID,
CharacterName = x.CharacterName,
CharacterImportance = x.CharacterImportance,
AvatarImagePath = x.AvatarImagePath,
AvatarThumbnailPath = x.AvatarThumbnailPath,
ImagePath = x.ImagePath,
ThumbnailPath = x.ThumbnailPath,
IsFocus = x.CharacterID == focusCharacterId
})
.ToList();
@ -7808,6 +7814,7 @@ public sealed class FloorPlanService(
ISceneFloorPlanOccupancyRepository sceneOccupancy,
IFloorPlanRepository floorPlans,
ILocationRepository locations,
ICharacterRepository characters,
IFloorPlanBackgroundImageService backgroundImages,
IProjectActivityService activity) : IFloorPlanService
{
@ -7839,6 +7846,7 @@ public sealed class FloorPlanService(
}
var projectLocations = await locations.ListByProjectAsync(project.ProjectID);
var projectCharacters = (await characters.ListCharactersAsync(project.ProjectID)).ToDictionary(x => x.CharacterID);
var orderedFloors = data.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).ToList();
var occupancyScenes = await scenes.ListByFloorPlanAsync(floorPlanId);
var occupancyRows = await sceneOccupancy.ListByFloorPlanAsync(floorPlanId);
@ -7924,6 +7932,14 @@ public sealed class FloorPlanService(
return null;
}
var characterImagePath = x.CharacterImagePath;
var characterThumbnailPath = x.CharacterThumbnailPath;
if (x.CharacterID.HasValue && projectCharacters.TryGetValue(x.CharacterID.Value, out var character))
{
characterImagePath = character.AvatarImagePath ?? character.ImagePath ?? characterImagePath;
characterThumbnailPath = character.AvatarThumbnailPath ?? character.ThumbnailPath ?? characterThumbnailPath;
}
return new FloorPlanOccupancyTokenViewModel
{
SceneID = x.SceneID,
@ -7933,8 +7949,8 @@ public sealed class FloorPlanService(
EntityType = x.CharacterID.HasValue ? "Character" : "Asset",
EntityID = x.CharacterID ?? x.StoryAssetID ?? 0,
DisplayName = x.CharacterName ?? x.AssetName ?? "Occupant",
ImagePath = x.CharacterImagePath ?? x.AssetImagePath,
ThumbnailPath = x.CharacterThumbnailPath ?? x.AssetThumbnailPath
ImagePath = characterImagePath ?? x.AssetImagePath,
ThumbnailPath = characterThumbnailPath ?? x.AssetThumbnailPath
};
})
.Where(x => x is not null)
@ -8854,6 +8870,7 @@ public sealed class CharacterService(
DefaultDescription = character.DefaultDescription,
ImagePath = character.ImagePath,
ThumbnailPath = character.ThumbnailPath,
AvatarSourceCharacterImageID = character.AvatarSourceCharacterImageID,
AvatarImagePath = character.AvatarImagePath,
AvatarThumbnailPath = character.AvatarThumbnailPath,
Project = await projects.GetAsync(character.ProjectID)
@ -8955,7 +8972,7 @@ public sealed class CharacterService(
};
}
public async Task<int> SaveCharacterAsync(CharacterEditViewModel model)
public async Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model)
{
if (model.CharacterID == 0)
{
@ -8986,6 +9003,7 @@ public sealed class CharacterService(
var imageHost = existing ?? new Character { CharacterID = characterId, ProjectID = model.ProjectID };
imageHost.CharacterID = characterId;
imageHost.ProjectID = model.ProjectID;
int? uploadedCharacterImageId = null;
if (model.ImageUpload is { Length: > 0 } upload)
{
var result = await visualIdentityImages.UploadCharacterImageAsync(imageHost, upload);
@ -8993,13 +9011,15 @@ public sealed class CharacterService(
{
throw new EntityImageUploadException(result.ErrorMessage ?? "Character image could not be uploaded.", characterId);
}
uploadedCharacterImageId = result.CharacterImageID;
}
else if (model.RemoveImage && existing is not null)
{
await visualIdentityImages.RemoveCharacterImageAsync(existing);
}
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Character", characterId, model.CharacterName);
return characterId;
return new CharacterSaveResult(characterId, uploadedCharacterImageId);
}
private async Task<CharacterEditViewModel> PopulateCharacterEditOptionsAsync(CharacterEditViewModel model)
@ -9009,10 +9029,6 @@ public sealed class CharacterService(
? await characters.ListSexValuesAsync(ownerUserId.Value)
: [];
model.SexOptions = new[] { new SelectListItem("Not set", string.Empty) }
.Concat(sexValues.Select(value => new SelectListItem(value.SexName, value.CharacterSexValueID.ToString(), value.CharacterSexValueID == model.SexValueID)))
.ToList();
if (model.SexValueID is null && !string.IsNullOrWhiteSpace(model.Sex))
{
var matching = sexValues.FirstOrDefault(value => string.Equals(value.SexName, model.Sex, StringComparison.OrdinalIgnoreCase));
@ -9022,6 +9038,11 @@ public sealed class CharacterService(
}
}
model.SexOptions = new[] { new SelectListItem("Not set", string.Empty) }
.Concat(sexValues.Select(value => new SelectListItem(value.SexName, value.CharacterSexValueID.ToString(), value.CharacterSexValueID == model.SexValueID)))
.Concat(new[] { new SelectListItem("Add new...", CharacterEditViewModel.CustomSexValue.ToString(), model.SexValueID == CharacterEditViewModel.CustomSexValue) })
.ToList();
return model;
}
@ -9033,8 +9054,13 @@ public sealed class CharacterService(
return (sexValueId, fallbackSex);
}
if (!string.IsNullOrWhiteSpace(customSex))
if (sexValueId == CharacterEditViewModel.CustomSexValue)
{
if (string.IsNullOrWhiteSpace(customSex))
{
throw new InvalidOperationException("Enter a custom sex value.");
}
var trimmed = customSex.Trim();
var customId = await characters.GetOrCreateSexValueAsync(ownerUserId.Value, trimmed);
return (customId, trimmed);

View File

@ -1596,6 +1596,8 @@ public sealed class CharacterListViewModel
public sealed class CharacterEditViewModel
{
public const int CustomSexValue = -1;
public int CharacterID { get; set; }
public int ProjectID { get; set; }
@ -1637,6 +1639,7 @@ public sealed class CharacterEditViewModel
public string? ImagePath { get; set; }
public string? ThumbnailPath { get; set; }
public int? AvatarSourceCharacterImageID { get; set; }
public string? AvatarImagePath { get; set; }
public string? AvatarThumbnailPath { get; set; }
@ -1745,6 +1748,10 @@ public sealed class RelationshipMapNodeViewModel
public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int? CharacterImportance { get; set; }
public string? AvatarImagePath { get; set; }
public string? AvatarThumbnailPath { get; set; }
public string? ImagePath { get; set; }
public string? ThumbnailPath { get; set; }
public bool IsFocus { get; set; }
}

View File

@ -36,13 +36,14 @@
</div>
<div class="col-md-4">
<label asp-for="SexValueID" class="form-label"></label>
<select asp-for="SexValueID" asp-items="Model.SexOptions" class="form-select"></select>
<select asp-for="SexValueID" asp-items="Model.SexOptions" class="form-select" data-sex-select data-custom-sex-value="@CharacterEditViewModel.CustomSexValue"></select>
<input asp-for="Sex" type="hidden" />
<div class="form-text">Choose a saved value, or add a custom value below.</div>
<div class="form-text">Choose a saved value, or select Add new... to create one.</div>
</div>
<div class="col-md-4">
<label asp-for="CustomSex" class="form-label"></label>
<input asp-for="CustomSex" class="form-control" placeholder="Optional custom value" />
<input asp-for="CustomSex" class="form-control" placeholder="Custom value" data-custom-sex-input />
<span asp-validation-for="CustomSex" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="BirthDate" class="form-label"></label>
@ -86,6 +87,10 @@
<label asp-for="ImageUpload" class="form-label"></label>
<input asp-for="ImageUpload" class="form-control" type="file" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" />
<div class="form-text">JPG, PNG or WebP. The image is added to the character gallery and a thumbnail is generated automatically.</div>
@if (Model.AvatarSourceCharacterImageID.HasValue)
{
<a class="btn btn-outline-primary btn-sm mt-2" asp-action="CropAvatar" asp-route-imageId="@Model.AvatarSourceCharacterImageID.Value">Re-crop Avatar</a>
}
@if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath))
{
<label class="form-check mt-2">
@ -106,4 +111,24 @@
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
(() => {
const select = document.querySelector("[data-sex-select]");
const custom = document.querySelector("[data-custom-sex-input]");
if (!select || !custom) return;
const customValue = select.dataset.customSexValue;
const syncCustomSex = () => {
const isCustom = select.value === customValue;
custom.disabled = !isCustom;
custom.required = isCustom;
if (!isCustom) {
custom.value = "";
}
};
select.addEventListener("change", syncCustomSex);
syncCustomSex();
})();
</script>
}

View File

@ -99,7 +99,10 @@
<p class="muted">Click a connecting line or choose a relationship below to inspect its current state and history.</p>
</div>
<div id="relationshipMapDetail" class="d-none">
<div class="relationship-map-detail-heading">
<span class="visual-avatar visual-avatar--lg d-none" data-map-detail-avatar aria-hidden="true"></span>
<h2 data-map-detail-title></h2>
</div>
<div class="relationship-map-detail-badges">
<span class="status-pill" data-map-detail-category></span>
<span class="status-pill" data-map-detail-reciprocal></span>
@ -147,6 +150,7 @@
const links = @Html.Raw(JsonSerializer.Serialize(Model.Links, jsonOptions));
const relationships = @Html.Raw(JsonSerializer.Serialize(Model.Relationships, jsonOptions));
const relationshipById = new Map(relationships.map(item => [item.characterRelationshipID, item]));
const nodeById = new Map(nodes.map(item => [item.characterID, item]));
const graph = document.getElementById("relationshipMapGraph");
const error = document.getElementById("relationshipMapError");
if (!graph || !nodes.length) return;
@ -238,9 +242,11 @@
cy.on("tap", "node", event => {
const characterId = event.target.data("characterID");
const characterName = event.target.data("label");
const character = nodeById.get(characterId);
document.getElementById("relationshipMapEmptyDetail")?.classList.add("d-none");
document.getElementById("relationshipMapDetail")?.classList.remove("d-none");
document.querySelector("[data-map-detail-title]").textContent = characterName;
renderDetailAvatar(character);
document.querySelector("[data-map-detail-category]").textContent = "Character";
document.querySelector("[data-map-detail-reciprocal]").textContent = characterId === focusCharacterId ? "Focused" : "Node";
document.querySelector("[data-map-detail-type]").textContent = "Character";
@ -296,6 +302,7 @@
document.getElementById("relationshipMapEmptyDetail")?.classList.add("d-none");
document.getElementById("relationshipMapDetail")?.classList.remove("d-none");
document.querySelector("[data-map-detail-title]").textContent = `${relationship.characterAName} - ${relationship.characterBName}`;
renderDetailAvatar(null);
document.querySelector("[data-map-detail-category]").textContent = relationship.relationshipCategoryName;
document.querySelector("[data-map-detail-reciprocal]").textContent = relationship.isReciprocal ? "Reciprocal" : "Directional";
document.querySelector("[data-map-detail-type]").textContent = relationship.relationshipTypeName;
@ -316,6 +323,43 @@
return intensity ? `${name} / Intensity ${intensity}` : name;
}
function renderDetailAvatar(character) {
const avatar = document.querySelector("[data-map-detail-avatar]");
if (!avatar) return;
avatar.innerHTML = "";
if (!character) {
avatar.classList.add("d-none");
return;
}
const imagePath = character.avatarThumbnailPath || character.avatarImagePath || character.thumbnailPath || character.imagePath;
if (imagePath) {
const image = document.createElement("img");
image.src = imagePath;
image.alt = "";
image.loading = "lazy";
avatar.append(image);
} else {
const fallback = document.createElement("span");
fallback.textContent = initials(character.characterName);
avatar.append(fallback);
}
avatar.title = character.characterName;
avatar.classList.remove("d-none");
}
function initials(value) {
const parts = (value || "")
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
const text = parts.map(part => part[0]).join("").toUpperCase();
return text || "?";
}
function buildRelationshipTooltip(relationship) {
if (!relationship) return "";
return `${relationship.characterAName} - ${relationship.characterBName}: ${relationship.relationshipTypeName}; current ${formatState(relationship.currentStateName, relationship.currentIntensity)}`;

View File

@ -1516,14 +1516,7 @@ else
asp-route-FocusID="@appearance.CharacterID"
title="@characterMarkerTooltip"
aria-label="@characterMarkerTooltip">
@if (!string.IsNullOrWhiteSpace(appearance.CharacterThumbnailPath) || !string.IsNullOrWhiteSpace(appearance.CharacterImagePath))
{
<img src="@(appearance.CharacterThumbnailPath ?? appearance.CharacterImagePath)" alt="" loading="lazy" />
}
else
{
@CharacterMarkerLabel(appearance, firstAppearance, lastAppearance)
}
</a>
}
</div>

View File

@ -1236,14 +1236,6 @@ a:focus-visible {
align-items: center;
justify-content: center;
background: #fff4d9;
overflow: hidden;
}
.character-marker img {
width: 24px;
height: 24px;
object-fit: cover;
border-radius: 999px;
}
.drag-chip,
@ -2899,6 +2891,16 @@ a:focus-visible {
overflow: auto;
}
.relationship-map-detail-heading {
display: flex;
align-items: center;
gap: 0.75rem;
}
.relationship-map-detail-heading h2 {
margin-bottom: 0;
}
.relationship-map-detail-badges {
display: flex;
flex-wrap: wrap;

File diff suppressed because one or more lines are too long