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] [ValidateAntiForgeryToken]
public async Task<IActionResult> Save(CharacterEditViewModel model) public async Task<IActionResult> Save(CharacterEditViewModel model)
{ {
ValidateSexWorkflow(model);
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
var editModel = model.CharacterID == 0 var editModel = model.CharacterID == 0
@ -56,10 +57,10 @@ public sealed class CharactersController(ICharacterService characters) : Control
return View("Edit", editModel); return View("Edit", editModel);
} }
int characterId; CharacterSaveResult saveResult;
try try
{ {
characterId = await characters.SaveCharacterAsync(model); saveResult = await characters.SaveCharacterAsync(model);
} }
catch (SubscriptionLimitException ex) when (model.CharacterID == 0) catch (SubscriptionLimitException ex) when (model.CharacterID == 0)
{ {
@ -70,18 +71,7 @@ public sealed class CharactersController(ICharacterService characters) : Control
return NotFound(); return NotFound();
} }
createModel.CharacterName = model.CharacterName; CopyCharacterFormValues(model, createModel);
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;
return View("Edit", createModel); return View("Edit", createModel);
} }
catch (EntityImageUploadException ex) catch (EntityImageUploadException ex)
@ -100,22 +90,29 @@ public sealed class CharactersController(ICharacterService characters) : Control
return NotFound(); return NotFound();
} }
editModel.CharacterName = model.CharacterName; CopyCharacterFormValues(model, editModel);
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;
return View("Edit", 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) private static void CopyCharacterFormValues(CharacterEditViewModel source, CharacterEditViewModel target)
@ -132,6 +129,10 @@ public sealed class CharactersController(ICharacterService characters) : Control
target.CharacterImportance = source.CharacterImportance; target.CharacterImportance = source.CharacterImportance;
target.ShowInQuickAddBar = source.ShowInQuickAddBar; target.ShowInQuickAddBar = source.ShowInQuickAddBar;
target.DefaultDescription = source.DefaultDescription; target.DefaultDescription = source.DefaultDescription;
foreach (var option in target.SexOptions)
{
option.Selected = option.Value == source.SexValueID?.ToString();
}
} }
[HttpPost] [HttpPost]

View File

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

View File

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

View File

@ -36,13 +36,14 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="SexValueID" class="form-label"></label> <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" /> <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>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="CustomSex" class="form-label"></label> <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>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="BirthDate" class="form-label"></label> <label asp-for="BirthDate" class="form-label"></label>
@ -86,6 +87,10 @@
<label asp-for="ImageUpload" class="form-label"></label> <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" /> <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> <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)) @if (!string.IsNullOrWhiteSpace(Model.ImagePath) || !string.IsNullOrWhiteSpace(Model.ThumbnailPath))
{ {
<label class="form-check mt-2"> <label class="form-check mt-2">
@ -106,4 +111,24 @@
@section Scripts { @section Scripts {
<partial name="_ValidationScriptsPartial" /> <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> <p class="muted">Click a connecting line or choose a relationship below to inspect its current state and history.</p>
</div> </div>
<div id="relationshipMapDetail" class="d-none"> <div id="relationshipMapDetail" class="d-none">
<h2 data-map-detail-title></h2> <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"> <div class="relationship-map-detail-badges">
<span class="status-pill" data-map-detail-category></span> <span class="status-pill" data-map-detail-category></span>
<span class="status-pill" data-map-detail-reciprocal></span> <span class="status-pill" data-map-detail-reciprocal></span>
@ -147,6 +150,7 @@
const links = @Html.Raw(JsonSerializer.Serialize(Model.Links, jsonOptions)); const links = @Html.Raw(JsonSerializer.Serialize(Model.Links, jsonOptions));
const relationships = @Html.Raw(JsonSerializer.Serialize(Model.Relationships, jsonOptions)); const relationships = @Html.Raw(JsonSerializer.Serialize(Model.Relationships, jsonOptions));
const relationshipById = new Map(relationships.map(item => [item.characterRelationshipID, item])); 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 graph = document.getElementById("relationshipMapGraph");
const error = document.getElementById("relationshipMapError"); const error = document.getElementById("relationshipMapError");
if (!graph || !nodes.length) return; if (!graph || !nodes.length) return;
@ -238,9 +242,11 @@
cy.on("tap", "node", event => { cy.on("tap", "node", event => {
const characterId = event.target.data("characterID"); const characterId = event.target.data("characterID");
const characterName = event.target.data("label"); const characterName = event.target.data("label");
const character = nodeById.get(characterId);
document.getElementById("relationshipMapEmptyDetail")?.classList.add("d-none"); document.getElementById("relationshipMapEmptyDetail")?.classList.add("d-none");
document.getElementById("relationshipMapDetail")?.classList.remove("d-none"); document.getElementById("relationshipMapDetail")?.classList.remove("d-none");
document.querySelector("[data-map-detail-title]").textContent = characterName; document.querySelector("[data-map-detail-title]").textContent = characterName;
renderDetailAvatar(character);
document.querySelector("[data-map-detail-category]").textContent = "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-reciprocal]").textContent = characterId === focusCharacterId ? "Focused" : "Node";
document.querySelector("[data-map-detail-type]").textContent = "Character"; document.querySelector("[data-map-detail-type]").textContent = "Character";
@ -296,6 +302,7 @@
document.getElementById("relationshipMapEmptyDetail")?.classList.add("d-none"); document.getElementById("relationshipMapEmptyDetail")?.classList.add("d-none");
document.getElementById("relationshipMapDetail")?.classList.remove("d-none"); document.getElementById("relationshipMapDetail")?.classList.remove("d-none");
document.querySelector("[data-map-detail-title]").textContent = `${relationship.characterAName} - ${relationship.characterBName}`; 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-category]").textContent = relationship.relationshipCategoryName;
document.querySelector("[data-map-detail-reciprocal]").textContent = relationship.isReciprocal ? "Reciprocal" : "Directional"; document.querySelector("[data-map-detail-reciprocal]").textContent = relationship.isReciprocal ? "Reciprocal" : "Directional";
document.querySelector("[data-map-detail-type]").textContent = relationship.relationshipTypeName; document.querySelector("[data-map-detail-type]").textContent = relationship.relationshipTypeName;
@ -316,6 +323,43 @@
return intensity ? `${name} / Intensity ${intensity}` : name; 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) { function buildRelationshipTooltip(relationship) {
if (!relationship) return ""; if (!relationship) return "";
return `${relationship.characterAName} - ${relationship.characterBName}: ${relationship.relationshipTypeName}; current ${formatState(relationship.currentStateName, relationship.currentIntensity)}`; 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" asp-route-FocusID="@appearance.CharacterID"
title="@characterMarkerTooltip" title="@characterMarkerTooltip"
aria-label="@characterMarkerTooltip"> aria-label="@characterMarkerTooltip">
@if (!string.IsNullOrWhiteSpace(appearance.CharacterThumbnailPath) || !string.IsNullOrWhiteSpace(appearance.CharacterImagePath)) @CharacterMarkerLabel(appearance, firstAppearance, lastAppearance)
{
<img src="@(appearance.CharacterThumbnailPath ?? appearance.CharacterImagePath)" alt="" loading="lazy" />
}
else
{
@CharacterMarkerLabel(appearance, firstAppearance, lastAppearance)
}
</a> </a>
} }
</div> </div>

View File

@ -1236,14 +1236,6 @@ a:focus-visible {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #fff4d9; background: #fff4d9;
overflow: hidden;
}
.character-marker img {
width: 24px;
height: 24px;
object-fit: cover;
border-radius: 999px;
} }
.drag-chip, .drag-chip,
@ -2899,6 +2891,16 @@ a:focus-visible {
overflow: auto; 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 { .relationship-map-detail-badges {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

File diff suppressed because one or more lines are too long