< Summary

Information
Class: Chronicis.Client.ViewModels.PublicWorldPageViewModel
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/ViewModels/PublicWorldPageViewModel.cs
Line coverage
100%
Covered lines: 84
Uncovered lines: 0
Coverable lines: 84
Total lines: 366
Line coverage: 100%
Branch coverage
100%
Covered branches: 44
Total branches: 44
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/ViewModels/PublicWorldPageViewModel.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2using Chronicis.Client.Abstractions;
 3using Chronicis.Client.Services;
 4using Chronicis.Shared.DTOs;
 5using Chronicis.Shared.Enums;
 6using Microsoft.JSInterop;
 7using MudBlazor;
 8
 9namespace Chronicis.Client.ViewModels;
 10
 11/// <summary>
 12/// ViewModel for the public (unauthenticated) world viewer page.
 13/// Owns world/article loading, breadcrumb building, page-title computation,
 14/// and the JS interop lifecycle for wiki-link click handling.
 15/// Implements <see cref="IAsyncDisposable"/> to dispose the
 16/// <see cref="DotNetObjectReference{T}"/> created for JS interop.
 17/// </summary>
 18public sealed class PublicWorldPageViewModel : ViewModelBase, IAsyncDisposable
 19{
 120    private static readonly Regex InlineImageReferenceRegex = new(
 121        @"chronicis-image:([0-9a-fA-F-]{36})",
 122        RegexOptions.Compiled | RegexOptions.CultureInvariant);
 23
 24    private const string PublicInlineImagePathPrefix = "/api/public/documents/";
 25
 26    private readonly IPublicApiService _publicApi;
 27    private readonly IAppNavigator _navigator;
 28    private readonly ILogger<PublicWorldPageViewModel> _logger;
 29
 30    private WorldDetailDto? _world;
 4131    private List<ArticleTreeDto> _articleTree = new();
 32    private ArticleDto? _currentArticle;
 4133    private bool _isLoading = true;
 34    private bool _isLoadingArticle = false;
 35    private bool _wikiLinksInitialized = false;
 36    private bool _isMapModalOpen;
 37    private Guid _selectedMapId;
 38    private Guid? _selectedMapFeatureId;
 39    private string? _selectedMapName;
 40    private DotNetObjectReference<PublicWorldPageViewModel>? _dotNetHelper;
 41
 42    // -------------------------------------------------------------------------
 43    // Observable properties
 44    // -------------------------------------------------------------------------
 45
 46    /// <summary>The loaded world, or <c>null</c> while loading or if not found.</summary>
 47    public WorldDetailDto? World
 48    {
 16649        get => _world;
 3950        private set => SetField(ref _world, value);
 51    }
 52
 53    /// <summary>The public article tree for the current world.</summary>
 54    public List<ArticleTreeDto> ArticleTree
 55    {
 4256        get => _articleTree;
 3457        private set => SetField(ref _articleTree, value);
 58    }
 59
 60    /// <summary>The currently displayed article, or <c>null</c> on the world landing view.</summary>
 61    public ArticleDto? CurrentArticle
 62    {
 15463        get => _currentArticle;
 3464        private set => SetField(ref _currentArticle, value);
 65    }
 66
 67    /// <summary>Whether the initial world load is in progress.</summary>
 68    public bool IsLoading
 69    {
 4070        get => _isLoading;
 9171        private set => SetField(ref _isLoading, value);
 72    }
 73
 74    /// <summary>Whether an individual article is currently loading.</summary>
 75    public bool IsLoadingArticle
 76    {
 1977        get => _isLoadingArticle;
 2678        private set => SetField(ref _isLoadingArticle, value);
 79    }
 80
 81    /// <summary>
 82    /// Whether wiki-link JS handlers have been initialised for the current article render.
 83    /// Reset to <c>false</c> on every navigation so <c>OnAfterRenderAsync</c> re-initialises them.
 84    /// </summary>
 85    public bool WikiLinksInitialized
 86    {
 2387        get => _wikiLinksInitialized;
 4688        private set => SetField(ref _wikiLinksInitialized, value);
 89    }
 90
 91    /// <summary>Whether the public map viewer modal is currently open.</summary>
 92    public bool IsMapModalOpen
 93    {
 4094        get => _isMapModalOpen;
 4695        private set => SetField(ref _isMapModalOpen, value);
 96    }
 97
 98    /// <summary>The selected map id for the public map viewer modal.</summary>
 99    public Guid SelectedMapId
 100    {
 5101        get => _selectedMapId;
 46102        private set => SetField(ref _selectedMapId, value);
 103    }
 104
 105    /// <summary>The selected target feature id for the public map viewer modal.</summary>
 106    public Guid? SelectedMapFeatureId
 107    {
 5108        get => _selectedMapFeatureId;
 46109        private set => SetField(ref _selectedMapFeatureId, value);
 110    }
 111
 112    /// <summary>The selected display name for the public map viewer modal.</summary>
 113    public string? SelectedMapName
 114    {
 6115        get => _selectedMapName;
 46116        private set => SetField(ref _selectedMapName, value);
 117    }
 118
 41119    public PublicWorldPageViewModel(
 41120        IPublicApiService publicApi,
 41121        IAppNavigator navigator,
 41122        ILogger<PublicWorldPageViewModel> logger)
 123    {
 41124        _publicApi = publicApi;
 41125        _navigator = navigator;
 41126        _logger = logger;
 41127    }
 128
 129    // -------------------------------------------------------------------------
 130    // Lifecycle
 131    // -------------------------------------------------------------------------
 132
 133    /// <summary>
 134    /// Loads world + tree and, if an article path is supplied, the article too.
 135    /// Resets wiki-link init flag so <c>OnAfterRenderAsync</c> re-binds on navigation.
 136    /// Call from <c>OnParametersSetAsync</c>.
 137    /// </summary>
 138    public async Task LoadWorldAsync(string publicSlug, string? articlePath)
 139    {
 140        WikiLinksInitialized = false;
 141        ResetMapModalState();
 142        IsLoading = true;
 143
 144        try
 145        {
 146            World = await _publicApi.GetPublicWorldAsync(publicSlug);
 147
 148            if (World != null)
 149            {
 150                ArticleTree = await _publicApi.GetPublicArticleTreeAsync(publicSlug);
 151
 152                if (!string.IsNullOrEmpty(articlePath))
 153                {
 154                    // Switch from page-level loading to article-level skeleton state.
 155                    IsLoading = false;
 156                    await LoadArticleAsync(publicSlug, articlePath);
 157                }
 158                else
 159                    CurrentArticle = null;
 160            }
 161        }
 162        finally
 163        {
 164            IsLoading = false;
 165        }
 166    }
 167
 168    /// <summary>
 169    /// Initialises the JS wiki-link click handlers if they have not yet been set up for the current render.
 170    /// Call from <c>OnAfterRenderAsync</c> when <see cref="CurrentArticle"/> has body content.
 171    /// </summary>
 172    public async Task InitializeWikiLinksAsync(IJSRuntime jsRuntime)
 173    {
 174        if (WikiLinksInitialized)
 175            return;
 176
 177        _dotNetHelper ??= DotNetObjectReference.Create(this);
 178        try
 179        {
 180            await jsRuntime.InvokeAsync<object>(
 181                "initializePublicWikiLinks", "public-article-body", _dotNetHelper);
 182            WikiLinksInitialized = true;
 183        }
 184        catch (Exception)
 185        {
 186            // Intentionally swallowed — JS interop may fail during pre-rendering
 187            // or if the component is disposed before the call completes.
 188        }
 189    }
 190
 191    /// <summary>
 192    /// Invoked from JavaScript when a wiki link in the public article body is clicked.
 193    /// Resolves the article path and navigates to it.
 194    /// </summary>
 195    [JSInvokable]
 196    public async Task OnPublicWikiLinkClicked(string targetArticleId)
 197    {
 198        if (!Guid.TryParse(targetArticleId, out var articleId))
 199            return;
 200
 201        // Prefer the true public slug used by anonymous routes.
 202        // Fall back to legacy world slug to preserve compatibility in older data/tests.
 203        var slug = !string.IsNullOrWhiteSpace(World?.PublicSlug)
 204            ? World!.PublicSlug!
 205            : World?.Slug ?? string.Empty;
 206        if (string.IsNullOrEmpty(slug))
 207            return;
 208
 209        try
 210        {
 211            var path = await _publicApi.ResolvePublicArticlePathAsync(slug, articleId);
 212            if (!string.IsNullOrEmpty(path))
 213                _navigator.NavigateTo($"/w/{slug}/{path}");
 214        }
 215        catch (Exception)
 216        {
 217            // Silently ignore — link may not be public or may not exist.
 218        }
 219    }
 220
 221    /// <summary>
 222    /// Invoked from JavaScript when a map chip in the public article body is clicked.
 223    /// Opens the public map viewer modal when world context is available.
 224    /// </summary>
 225    [JSInvokable]
 226    public Task OnPublicMapLinkClicked(string mapId, string? mapName)
 227    {
 5228        if (!Guid.TryParse(mapId, out var parsedMapId) || World == null || World.Id == Guid.Empty)
 229        {
 2230            return Task.CompletedTask;
 231        }
 232
 3233        SelectedMapId = parsedMapId;
 3234        SelectedMapFeatureId = null;
 3235        SelectedMapName = string.IsNullOrWhiteSpace(mapName) ? null : mapName.Trim();
 3236        IsMapModalOpen = true;
 3237        return Task.CompletedTask;
 238    }
 239
 240    /// <summary>
 241    /// Invoked from JavaScript when a map-feature chip in the public article body is clicked.
 242    /// Opens the public map viewer modal centered on the selected feature.
 243    /// </summary>
 244    [JSInvokable]
 245    public Task OnPublicMapFeatureChipClicked(string mapId, string featureId, string? mapName)
 246    {
 6247        if (!Guid.TryParse(mapId, out var parsedMapId)
 6248            || !Guid.TryParse(featureId, out var parsedFeatureId)
 6249            || World == null
 6250            || World.Id == Guid.Empty)
 251        {
 3252            return Task.CompletedTask;
 253        }
 254
 3255        SelectedMapId = parsedMapId;
 3256        SelectedMapFeatureId = parsedFeatureId;
 3257        SelectedMapName = string.IsNullOrWhiteSpace(mapName) ? null : mapName.Trim();
 3258        IsMapModalOpen = true;
 3259        return Task.CompletedTask;
 260    }
 261
 262    /// <summary>Closes the public map viewer modal and clears selection state.</summary>
 263    public Task CloseMapModalAsync()
 264    {
 1265        ResetMapModalState();
 1266        return Task.CompletedTask;
 267    }
 268
 269    // -------------------------------------------------------------------------
 270    // Navigation helpers (called by the component's event callbacks)
 271    // -------------------------------------------------------------------------
 272
 273    /// <summary>Navigates to <paramref name="articlePath"/> within the current world.</summary>
 274    public void NavigateToArticle(string publicSlug, string articlePath)
 275    {
 2276        if (string.IsNullOrEmpty(articlePath))
 1277            _navigator.NavigateTo($"/w/{publicSlug}");
 278        else
 1279            _navigator.NavigateTo($"/w/{publicSlug}/{articlePath}");
 1280    }
 281
 282    // -------------------------------------------------------------------------
 283    // Pure helpers
 284    // -------------------------------------------------------------------------
 285
 286    /// <summary>Builds breadcrumb items for the current article.</summary>
 287    public List<BreadcrumbItem> GetBreadcrumbItems(string publicSlug)
 288    {
 2289        return CurrentArticle == null
 2290            ? new List<BreadcrumbItem>()
 2291            : PublicBreadcrumbBuilder.Build(publicSlug, CurrentArticle);
 292    }
 293
 294    /// <summary>Returns the page title, incorporating the world name when loaded.</summary>
 295    public string GetPageTitle()
 296    {
 2297        var worldName = string.IsNullOrWhiteSpace(World?.Name) ? "World" : World.Name;
 2298        return $"{worldName} — Chronicis";
 299    }
 300
 301    /// <summary>
 302    /// Converts markdown/HTML to sanitized HTML and rewrites inline image references
 303    /// from chronicis-image:{guid} to the public image endpoint.
 304    /// </summary>
 305    public string GetRenderedArticleBodyHtml(IMarkdownService markdownService)
 306    {
 10307        if (CurrentArticle == null || string.IsNullOrWhiteSpace(CurrentArticle.Body))
 308        {
 1309            return string.Empty;
 310        }
 311
 9312        var rewrittenBody = InlineImageReferenceRegex.Replace(
 9313            CurrentArticle.Body,
 9314            $"{PublicInlineImagePathPrefix}$1");
 315
 9316        return markdownService.EnsureHtml(rewrittenBody);
 317    }
 318
 319    /// <summary>Maps an <see cref="ArticleType"/> to its display label.</summary>
 15320    public static string GetArticleTypeLabel(ArticleType type) => type switch
 15321    {
 1322        ArticleType.WikiArticle => "Wiki Article",
 1323        ArticleType.Character => "Character",
 1324        ArticleType.CharacterNote => "Character Note",
 1325        ArticleType.Session => "Session",
 1326        ArticleType.SessionNote => "Session Note",
 1327        ArticleType.Legacy => "Article",
 9328        _ => "Article",
 15329    };
 330
 331    // -------------------------------------------------------------------------
 332    // IAsyncDisposable
 333    // -------------------------------------------------------------------------
 334
 335    /// <inheritdoc />
 336    public ValueTask DisposeAsync()
 337    {
 11338        _dotNetHelper?.Dispose();
 11339        return ValueTask.CompletedTask;
 340    }
 341
 342    // -------------------------------------------------------------------------
 343    // Private helpers
 344    // -------------------------------------------------------------------------
 345
 346    private async Task LoadArticleAsync(string publicSlug, string articlePath)
 347    {
 348        IsLoadingArticle = true;
 349        try
 350        {
 351            CurrentArticle = await _publicApi.GetPublicArticleAsync(publicSlug, articlePath);
 352        }
 353        finally
 354        {
 355            IsLoadingArticle = false;
 356        }
 357    }
 358
 359    private void ResetMapModalState()
 360    {
 40361        IsMapModalOpen = false;
 40362        SelectedMapId = Guid.Empty;
 40363        SelectedMapFeatureId = null;
 40364        SelectedMapName = null;
 40365    }
 366}