< Summary

Information
Class: Chronicis.Client.Components.Articles.ArticleDetail
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Articles/ArticleDetail.razor
Line coverage
0%
Covered lines: 0
Uncovered lines: 619
Coverable lines: 619
Total lines: 1298
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 302
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
BuildRenderTree(...)0%7280%
.ctor()100%210%
OnInitializedAsync()100%210%
OnInitialized()100%210%
OnAfterRenderAsync()0%2040%
Dispose()0%2040%
HandleTitleEdited()100%210%
HandleIconChanged()0%156120%
HandleFocusTitleChanged(...)100%210%
ToggleMetadata()100%210%
OnTreeStateChanged()0%620%
<OnTreeStateChanged()0%506220%
LoadArticleAsync()0%210140%
LoadBreadcrumbs()0%4260%
UpdatePageTitle()0%2040%
SaveLastArticlePath()0%4260%
InitializeEditor()0%156120%
OnEditorUpdate(...)0%620%
AutoSave()100%210%
<AutoSave()0%2040%
SaveArticle()0%702260%
DeleteArticle()0%156120%
AutoLinkArticle()0%110100%
CreateRootArticle()0%4260%
CreateSiblingArticle()0%7280%
CreateChildArticle()0%110100%
OnMetadataDrawerToggle()100%210%
HandleSaveShortcut()100%210%
HandleCtrlN()0%620%
HandleCtrlNInstance()100%210%
OnAutocompleteTriggered()0%342180%
OnAutocompleteHidden()100%210%
OnAutocompleteArrowDown()0%620%
OnAutocompleteArrowUp()0%620%
OnAutocompleteEnter()0%2040%
OnAutocompleteIndexChanged(...)100%210%
OnAutocompleteSelect()0%272160%
OnAutocompleteCreate()0%156120%
TryParseExternalAutocompleteQuery(...)0%2040%
OnWikiLinkClicked()0%2040%
GetArticlePath()0%620%
GetArticleSummaryPreview()0%4260%
OnExternalLinkClicked()0%156120%
CloseExternalPreview()100%210%
GetExternalCacheKey(...)100%210%
OnImageUploadRequested()0%110100%
OnImageUploadConfirmed()0%110100%
GetImageProxyUrl(...)100%210%
ResolveImageUrl()0%2040%
OnImageUploadStarted(...)100%210%
OnImageUploadError(...)100%210%
InsertImageFromToolbar()0%2040%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Articles/ArticleDetail.razor

#LineLine coverage
 1@using Chronicis.Client.Components.Shared
 2@using Chronicis.Client.Components.Characters
 3@using Chronicis.Client.Utilities
 4@using Chronicis.Shared.DTOs
 5@using Chronicis.Shared.Enums
 6@using Chronicis.Shared.Utilities
 7@using Blazored.LocalStorage
 8@using Microsoft.JSInterop
 9@inject IArticleApiService ArticleApi
 10@inject ILinkApiService LinkApiService
 11@inject IExternalLinkApiService ExternalLinkApiService
 12@inject IWikiLinkService WikiLinkService
 13@inject IAISummaryApiService SummaryApi
 14@inject IMarkdownService MarkdownService
 15@inject ITreeStateService TreeState
 16@inject IAppContextService AppContext
 17@inject IBreadcrumbService BreadcrumbService
 18@inject ISnackbar Snackbar
 19@inject IJSRuntime JSRuntime
 20@inject NavigationManager Navigation
 21@inject ILocalStorageService LocalStorage
 22@inject ILogger<ArticleDetail> Logger
 23@inject IArticleCacheService ArticleCache
 24@inject IMetadataDrawerService MetadataDrawerService
 25@inject IKeyboardShortcutService KeyboardShortcutService
 26@inject IWorldApiService WorldApi
 27
 28@implements IDisposable
 29
 030@if (_isLoading)
 31{
 32    <LoadingSkeleton />
 33}
 034else if (_article == null)
 35{
 36    <EmptyState
 37        Icon="📝"
 38        Title="No Article Selected"
 39        Message="Select an article from the tree to view and edit its content."
 40        ActionText="Create New Article"
 41        OnActionClick="CreateRootArticle" />
 42}
 43else
 44{
 45    <style>
 46        .mud-input.mud-input-underline:before,
 47        .mud-input.mud-input-underline:after,
 48        .mud-input.mud-input-underline:hover:not(.mud-disabled):before,
 49        .mud-input.mud-input-underline:hover:not(.mud-disabled):after {
 50            border-bottom: none !important;
 51        }
 52
 53        .mud-input,
 54        .chronicis-editor-content {
 55            border: solid 1px #c7c7c4;
 56            border-radius: 4px;
 57        }
 58
 59        .chronicis-article-title .mud-input {
 60            padding-left:8px;
 61        }
 62    </style>
 63
 64    <MudPaper Elevation="2" Class="chronicis-article-card chronicis-fade-in">
 65
 66        <!-- Article Header (Breadcrumbs, Icon, Title, Divider) -->
 67        <ArticleHeader Breadcrumbs="_breadcrumbs"
 68                       @bind-Title="_editTitle"
 69                       IconEmoji="@_article.IconEmoji"
 70                       OnIconChanged="HandleIconChanged"
 71                       OnTitleEdited="HandleTitleEdited"
 72                       OnEnterPressed="SaveArticle"
 73                       OnMetadataToggle="ToggleMetadata"
 74                       ShouldFocusTitle="_shouldFocusTitle"
 75                       ShouldFocusTitleChanged="HandleFocusTitleChanged" />
 76
 77        @* Character Claim Button - only show for Character type articles *@
 078        @if (_article.Type == ArticleType.Character)
 79        {
 80            <div class="mb-3">
 81                <CharacterClaimButton CharacterId="@_article.Id" />
 82            </div>
 83        }
 84
 85        <!-- Editor Toolbar -->
 86        <div class="chronicis-editor-toolbar mb-2">
 87            <MudTooltip Text="Insert Image" Placement="Placement.Top">
 88                <MudIconButton Icon="@Icons.Material.Filled.Image"
 89                               Size="Size.Small"
 90                               Color="Color.Default"
 91                               OnClick="InsertImageFromToolbar"
 92                               Disabled="!_editorInitialized" />
 93            </MudTooltip>
 94        </div>
 95
 96        <!-- TipTap Editor Container -->
 97        <div id="tiptap-editor-@_article.Id" class="chronicis-editor-container mb-4"></div>
 98
 99        <!-- AI Summary Section -->
 100        <AISummarySection EntityId="@_article.Id"
 101                          EntityType="Article"
 102                          IsExpanded="@_isSummaryExpanded"
 0103                          IsExpandedChanged="@((expanded) => _isSummaryExpanded = expanded)" />
 104
 105        <!-- Action Bar (Save Status + Buttons) -->
 106        <ArticleActionBar IsSaving="_isSaving"
 107                          IsAutoLinking="_isAutoLinking"
 108                          IsCreatingChild="_isCreatingChild"
 109                          HasUnsavedChanges="_hasUnsavedChanges"
 110                          LastSaveTime="@_lastSaveTime"
 111                          OnSave="SaveArticle"
 112                          OnDelete="DeleteArticle"
 113                          OnAutoLink="AutoLinkArticle"
 114                          OnCreateChild="CreateChildArticle" />
 115    </MudPaper>
 116
 117    <!-- Metadata Drawer -->
 0118    <ArticleMetadataDrawer @ref="_metadataDrawer"
 0119                           Article="_article"
 0120                           @bind-IsOpen="_openMetadata" />
 0121
 0122    <!-- External Link Preview Drawer -->
 0123    <MudDrawer @bind-Open="_externalPreviewOpen"
 124               Anchor="Anchor.End"
 125               Variant="@DrawerVariant.Temporary"
 126               Elevation="9999"
 127               Class="external-link-preview-drawer">
 128        <MudDrawerHeader>
 129            <MudSpacer />
 130            <MudIconButton Icon="@Icons.Material.Filled.Close" OnClick="CloseExternalPreview" />
 131
 132        </MudDrawerHeader>
 133        <MudDrawerContainer>
 134            <div class="external-link-preview-header">
 0135                @if (!string.IsNullOrWhiteSpace(_externalPreviewSource))
 136                {
 0137                    <span class="external-link-preview-source">@_externalPreviewSource.ToUpperInvariant()</span>
 138                }
 0139                <MudText Typo="Typo.h6">@(_externalPreviewTitle ?? "External Link")</MudText>
 140            </div>
 0141            @if (!string.IsNullOrWhiteSpace(_externalPreviewContent?.Kind))
 142            {
 143                <MudText Typo="Typo.caption" Style="color: var(--mud-palette-text-secondary);">
 0144                    @_externalPreviewContent.Kind
 145                </MudText>
 146            }
 0147            @if (_externalPreviewLoading)
 148            {
 149                <div class="d-flex justify-center align-center" style="padding: 24px;">
 150                    <MudProgressCircular Indeterminate="true" />
 151                </div>
 152            }
 0153            else if (!string.IsNullOrWhiteSpace(_externalPreviewError))
 154            {
 0155                <MudAlert Severity="Severity.Error">@_externalPreviewError</MudAlert>
 156            }
 0157            else if (_externalPreviewContent != null)
 158            {
 159                <ExternalLinkDetailPanel Content="_externalPreviewContent" />
 160            }
 161        </MudDrawerContainer>
 162    </MudDrawer>
 163
 164    <!-- Wiki Link Autocomplete -->
 0165    @if (_showAutocomplete && _article != null)
 166    {
 167        <ArticleDetailWikiLinkAutocomplete Suggestions="@_autocompleteSuggestions"
 168                                           Loading="@_autocompleteLoading"
 169                                           SelectedIndex="@_autocompleteSelectedIndex"
 170                                           SelectedIndexChanged="OnAutocompleteIndexChanged"
 171                                           OnSelect="OnAutocompleteSelect"
 172                                           OnCreate="OnAutocompleteCreate"
 173                                           Position="@_autocompletePosition"
 174                                           Query="@_autocompleteQuery"
 175                                           IsExternalQuery="@_autocompleteIsExternalQuery" />
 176    }
 177}
 178
 179@code {
 180    // Article state
 181    private ArticleDto? _article;
 182    private List<BreadcrumbItem>? _breadcrumbs;
 0183    private string _editTitle = string.Empty;
 0184    private string _editBody = string.Empty;
 185
 186    // UI state
 187    private bool _isLoading = false;
 188    private bool _isSaving = false;
 189    private bool _isAutoLinking = false;
 190    private bool _isCreatingChild = false;
 191    private bool _hasUnsavedChanges = false;
 192    private bool _editorInitialized = false;
 0193    private string _lastSaveTime = "just now";
 194    private bool _openMetadata = false;
 195    private bool _isSummaryExpanded = false;
 196    private bool _shouldFocusTitle = false;
 197
 198    // Timer and references
 199    private Timer? _autoSaveTimer;
 200    private DotNetObjectReference<ArticleDetail>? _dotNetHelper;
 201    private ArticleMetadataDrawer? _metadataDrawer;
 202    private bool _disposed = false;
 203
 204    // Autocomplete state
 205    private bool _showAutocomplete = false;
 206    private bool _autocompleteLoading = false;
 0207    private List<WikiLinkAutocompleteItem> _autocompleteSuggestions = new();
 208    private int _autocompleteSelectedIndex = 0;
 0209    private (double X, double Y) _autocompletePosition = (0, 0);
 0210    private string _autocompleteQuery = string.Empty;
 211    private bool _autocompleteIsExternalQuery = false;
 212    private string? _autocompleteExternalSourceKey;
 213
 214    // External link preview
 215    private bool _externalPreviewOpen = false;
 216    private bool _externalPreviewLoading = false;
 217    private string? _externalPreviewError;
 218    private ExternalLinkContentDto? _externalPreviewContent;
 219    private string? _externalPreviewSource;
 220    private string? _externalPreviewId;
 221    private string? _externalPreviewTitle;
 0222    private readonly Dictionary<string, ExternalLinkContentDto> _externalLinkCache = new(StringComparer.OrdinalIgnoreCas
 223
 224    private const string LastArticlePathKey = "chronicis_last_article_path";
 225
 226    #region Lifecycle
 227
 228    protected override async Task OnInitializedAsync()
 229    {
 0230        TreeState.OnStateChanged += OnTreeStateChanged;
 0231        MetadataDrawerService.OnToggle += OnMetadataDrawerToggle;
 0232        KeyboardShortcutService.OnSaveRequested += HandleSaveShortcut;
 0233        _dotNetHelper = DotNetObjectReference.Create(this);
 0234        await Task.CompletedTask;
 0235    }
 236
 237    protected override void OnInitialized()
 238    {
 0239        OnCtrlNPressed += HandleCtrlNInstance;
 0240        base.OnInitialized();
 0241    }
 242
 243    protected override async Task OnAfterRenderAsync(bool firstRender)
 244    {
 0245        if (firstRender && TreeState.SelectedArticleId.HasValue)
 246        {
 0247            await LoadArticleAsync(TreeState.SelectedArticleId.Value);
 0248            StateHasChanged();
 0249            await Task.Delay(100);
 0250            await InitializeEditor();
 251        }
 0252    }
 253
 254    public void Dispose()
 255    {
 0256        _disposed = true;
 0257        TreeState.OnStateChanged -= OnTreeStateChanged;
 0258        MetadataDrawerService.OnToggle -= OnMetadataDrawerToggle;
 0259        KeyboardShortcutService.OnSaveRequested -= HandleSaveShortcut;
 0260        OnCtrlNPressed -= HandleCtrlNInstance;
 0261        _autoSaveTimer?.Dispose();
 262
 263        // Clear and dispose the DotNetObjectReference
 0264        var helper = _dotNetHelper;
 0265        _dotNetHelper = null;
 0266        helper?.Dispose();
 0267    }
 268
 269    #endregion
 270
 271    #region Header Event Handlers
 272
 0273    private void HandleTitleEdited() => _hasUnsavedChanges = true;
 274
 275    private async Task HandleIconChanged(string? newIcon)
 276    {
 0277        if (_article == null || _isSaving) return;
 278
 0279        _article.IconEmoji = newIcon;
 0280        _isSaving = true;
 0281        StateHasChanged();
 282
 283        try
 284        {
 0285            var updateDto = new ArticleUpdateDto
 0286            {
 0287                Title = _editTitle?.Trim() ?? string.Empty,
 0288                Body = _editBody,
 0289                EffectiveDate = _article.EffectiveDate,
 0290                IconEmoji = newIcon
 0291            };
 292
 0293            await ArticleApi.UpdateArticleAsync(_article.Id, updateDto);
 0294            _article.ModifiedAt = DateTime.Now;
 0295            _lastSaveTime = "just now";
 296
 0297            await TreeState.RefreshAsync();
 0298            TreeState.UpdateNodeDisplay(_article.Id, _editTitle ?? string.Empty, newIcon);
 299
 0300            Snackbar.Add($"Icon {(string.IsNullOrEmpty(newIcon) ? "removed" : "updated")}", Severity.Success);
 0301        }
 0302        catch (Exception ex)
 303        {
 0304            Snackbar.Add($"Failed to save icon: {ex.Message}", Severity.Error);
 0305        }
 306        finally
 307        {
 0308            _isSaving = false;
 0309            StateHasChanged();
 310        }
 0311    }
 312
 0313    private void HandleFocusTitleChanged(bool value) => _shouldFocusTitle = value;
 314
 0315    private void ToggleMetadata() => _openMetadata = !_openMetadata;
 316
 317    #endregion
 318
 319    #region Article Loading
 320
 321    private async void OnTreeStateChanged()
 322    {
 0323        if (_disposed) return;
 324
 0325        await InvokeAsync(async () =>
 0326        {
 0327            if (_disposed) return;
 0328
 0329            try
 0330            {
 0331                if (_editorInitialized && _article != null)
 0332                {
 0333                    await JSRuntime.InvokeVoidAsync("destroyTipTapEditor", $"tiptap-editor-{_article.Id}");
 0334                }
 0335
 0336                _editorInitialized = false;
 0337
 0338                if (_disposed) return;
 0339
 0340                if (TreeState.SelectedArticleId.HasValue && TreeState.SelectedArticleId.Value != Guid.Empty)
 0341                {
 0342                    await LoadArticleAsync(TreeState.SelectedArticleId.Value);
 0343                }
 0344                else
 0345                {
 0346                    _article = null;
 0347                    if (!_disposed)
 0348                    {
 0349                        await JSRuntime.InvokeVoidAsync("eval", "document.title = 'Chronicis'");
 0350                    }
 0351                }
 0352
 0353                if (_disposed) return;
 0354                StateHasChanged();
 0355
 0356                if (_article != null && !_disposed)
 0357                {
 0358                    await Task.Delay(100);
 0359                    if (!_disposed)
 0360                    {
 0361                        await InitializeEditor();
 0362                    }
 0363                }
 0364            }
 0365            catch (ObjectDisposedException)
 0366            {
 0367                // Component was disposed during async operation - this is expected during navigation
 0368            }
 0369            catch (JSDisconnectedException)
 0370            {
 0371                // JS runtime disconnected - this is expected during navigation
 0372            }
 0373        });
 0374    }
 375
 376    private async Task LoadArticleAsync(Guid articleId)
 377    {
 0378        _isLoading = true;
 0379        _externalPreviewOpen = false;
 0380        StateHasChanged();
 381
 382        try
 383        {
 0384            _article = await ArticleApi.GetArticleAsync(articleId);
 0385            _editTitle = _article?.Title ?? string.Empty;
 0386            _editBody = _article?.Body ?? string.Empty;
 0387            _hasUnsavedChanges = false;
 388
 0389            if (_article != null)
 390            {
 0391                ArticleCache.CacheArticle(_article);
 392            }
 393
 0394            LoadBreadcrumbs();
 0395            await UpdatePageTitle();
 0396            await SaveLastArticlePath();
 397
 0398            if (string.IsNullOrEmpty(_editTitle) || TreeState.ShouldFocusTitle)
 399            {
 0400                _shouldFocusTitle = true;
 0401                TreeState.ShouldFocusTitle = false;
 0402                StateHasChanged();
 403            }
 0404        }
 0405        catch (Exception ex)
 406        {
 0407            Snackbar.Add($"Failed to load article: {ex.Message}", Severity.Error);
 0408        }
 409        finally
 410        {
 0411            _isLoading = false;
 412        }
 0413    }
 414
 415    private void LoadBreadcrumbs()
 416    {
 0417        if (_article?.Breadcrumbs != null && _article.Breadcrumbs.Any())
 418        {
 0419            _breadcrumbs = BreadcrumbService.ForArticle(_article.Breadcrumbs);
 420        }
 421        else
 422        {
 0423            _breadcrumbs = new List<BreadcrumbItem> { new("Dashboard", href: "/dashboard") };
 424        }
 0425    }
 426
 427    private async Task UpdatePageTitle()
 428    {
 0429        var pageTitle = string.IsNullOrEmpty(_article?.Title) ? "Untitled - Chronicis" : $"{_article.Title} - Chronicis"
 0430        await JSRuntime.InvokeVoidAsync("eval", $"document.title = '{JsUtilities.EscapeForJs(pageTitle)}'");
 0431    }
 432
 433    private async Task SaveLastArticlePath()
 434    {
 0435        if (_article?.Breadcrumbs != null && _article.Breadcrumbs.Any())
 436        {
 0437            var path = BreadcrumbService.BuildArticleUrl(_article.Breadcrumbs);
 0438            await LocalStorage.SetItemAsStringAsync(LastArticlePathKey, path);
 439        }
 0440    }
 441
 442    #endregion
 443
 444    #region Editor
 445
 446    private async Task InitializeEditor()
 447    {
 0448        if (_article != null && !_editorInitialized && !_disposed && _dotNetHelper != null)
 449        {
 450            try
 451            {
 0452                await JSRuntime.InvokeVoidAsync("initializeTipTapEditor", $"tiptap-editor-{_article.Id}", _editBody, _do
 453
 0454                if (_disposed) return;
 455
 0456                await JSRuntime.InvokeVoidAsync("initializeWikiLinkAutocomplete", $"tiptap-editor-{_article.Id}", _dotNe
 457
 458                // Initialize image upload (drag-drop, paste)
 0459                await JSRuntime.InvokeVoidAsync("initializeImageUpload", $"tiptap-editor-{_article.Id}", _dotNetHelper);
 460
 461                // Resolve any chronicis-image: references to real SAS URLs
 0462                await JSRuntime.InvokeVoidAsync("resolveEditorImages", $"tiptap-editor-{_article.Id}", _dotNetHelper);
 463
 0464                _editorInitialized = true;
 0465            }
 0466            catch (ObjectDisposedException)
 467            {
 468                // Component was disposed during async operation - this is expected during navigation
 0469            }
 0470            catch (JSDisconnectedException)
 471            {
 472                // JS runtime disconnected - this is expected during navigation
 0473            }
 0474            catch (Exception ex)
 475            {
 0476                if (!_disposed)
 477                {
 0478                    Snackbar.Add($"Failed to initialize editor: {ex.Message}", Severity.Warning);
 479                }
 0480            }
 481        }
 0482    }
 483
 484    [JSInvokable]
 485    public void OnEditorUpdate(string markdown)
 486    {
 0487        _editBody = markdown;
 0488        _hasUnsavedChanges = true;
 0489        _autoSaveTimer?.Dispose();
 0490        _autoSaveTimer = new Timer(async _ => await AutoSave(), null, 500, Timeout.Infinite);
 0491    }
 492
 493    private async Task AutoSave()
 494    {
 0495        await InvokeAsync(async () =>
 0496        {
 0497            if (_hasUnsavedChanges && !_isSaving)
 0498                await SaveArticle();
 0499        });
 0500    }
 501
 502    #endregion
 503
 504    #region Save & Delete
 505
 506    private async Task SaveArticle()
 507    {
 0508        if (_article == null || _isSaving) return;
 509
 0510        _isSaving = true;
 0511        StateHasChanged();
 512
 513        try
 514        {
 0515            var originalTitle = _article.Title;
 0516            var newTitle = _editTitle?.Trim() ?? string.Empty;
 0517            var titleChanged = originalTitle != newTitle;
 0518            string? newSlug = null;
 519
 0520            if (titleChanged)
 521            {
 0522                var suggestedSlug = SlugGenerator.GenerateSlug(newTitle);
 0523                if (suggestedSlug != _article.Slug)
 0524                    newSlug = suggestedSlug;
 525            }
 526
 0527            var updateDto = new ArticleUpdateDto
 0528            {
 0529                Title = newTitle,
 0530                Slug = newSlug,
 0531                Body = _editBody,
 0532                EffectiveDate = _article.EffectiveDate,
 0533                IconEmoji = _article.IconEmoji
 0534            };
 535
 0536            await ArticleApi.UpdateArticleAsync(_article.Id, updateDto);
 0537            ArticleCache.InvalidateCache();
 538
 0539            _article.Title = newTitle;
 0540            _article.Body = _editBody;
 0541            _article.ModifiedAt = DateTime.Now;
 0542            _hasUnsavedChanges = false;
 0543            _lastSaveTime = "just now";
 544
 0545            if (newSlug != null || titleChanged)
 546            {
 0547                await UpdatePageTitle();
 0548                TreeState.UpdateNodeDisplay(_article.Id, newTitle, _article.IconEmoji);
 549            }
 550
 0551            if (newSlug != null)
 552            {
 0553                TreeState.ExpandPathToAndSelect(_article.Id);
 0554                _article = await ArticleApi.GetArticleAsync(_article.Id);
 555
 0556                if (_article?.Breadcrumbs != null && _article.Breadcrumbs.Any())
 557                {
 0558                    var path = BreadcrumbService.BuildArticleUrl(_article.Breadcrumbs);
 0559                    Navigation.NavigateTo(path, replace: true);
 560                }
 561            }
 0562        }
 0563        catch (Exception ex)
 564        {
 0565            Snackbar.Add($"Failed to save: {ex.Message}", Severity.Error);
 0566        }
 567        finally
 568        {
 0569            _isSaving = false;
 0570            StateHasChanged();
 571            // Only refresh panels if the metadata drawer is open
 0572            if (_metadataDrawer != null && _openMetadata)
 0573                await _metadataDrawer.RefreshPanelsAsync();
 574        }
 0575    }
 576
 577    private async Task DeleteArticle()
 578    {
 0579        if (_article == null) return;
 580
 0581        var message = $"Are you sure you want to delete '{_article.Title}'?";
 0582        if (_article.ChildCount > 0)
 583        {
 0584            var childText = _article.ChildCount == 1 ? "1 child article" : $"{_article.ChildCount} child articles";
 0585            message = $"Are you sure you want to delete '{_article.Title}'?\n\n⚠️ WARNING: This will also delete {childT
 586        }
 0587        message += "\n\nThis action cannot be undone.";
 588
 0589        if (!await JSRuntime.InvokeAsync<bool>("confirm", message)) return;
 590
 591        try
 592        {
 0593            var success = await ArticleApi.DeleteArticleAsync(_article.Id);
 0594            if (success)
 595            {
 0596                ArticleCache.InvalidateCache();
 0597                Snackbar.Add("Article deleted successfully", Severity.Success);
 0598                await TreeState.RefreshAsync();
 0599                _article = null;
 600            }
 601            else
 602            {
 0603                Snackbar.Add("Failed to delete article. You may not have permission.", Severity.Error);
 604            }
 0605        }
 0606        catch (Exception ex)
 607        {
 0608            Snackbar.Add($"Failed to delete: {ex.Message}", Severity.Error);
 0609        }
 0610    }
 611
 612    private async Task AutoLinkArticle()
 613    {
 0614        if (_article == null || _isAutoLinking) return;
 615
 0616        _isAutoLinking = true;
 0617        StateHasChanged();
 618
 619        try
 620        {
 0621            var result = await LinkApiService.AutoLinkAsync(_article.Id, _editBody);
 622
 0623            if (result == null)
 624            {
 0625                Snackbar.Add("Failed to scan for links", Severity.Error);
 0626                return;
 627            }
 628
 0629            if (result.LinksFound == 0)
 630            {
 0631                Snackbar.Add("No linkable content found", Severity.Info);
 0632                return;
 633            }
 634
 0635            var matchList = string.Join("\n", result.Matches.Select(m => $"• \"{m.MatchedText}\" → {m.ArticleTitle}"));
 0636            if (!await JSRuntime.InvokeAsync<bool>("confirm", $"Found {result.LinksFound} potential link(s):\n\n{matchLi
 0637                return;
 638
 639            // Convert matches to JS-friendly format and insert via TipTap
 640            // For alias matches, format as "Alias → Title", otherwise just use matched text
 0641            var jsMatches = result.Matches.Select(m => new
 0642            {
 0643                articleId = m.ArticleId.ToString(),
 0644                displayText = m.IsAliasMatch ? $"{m.MatchedText} → {m.ArticleTitle}" : m.MatchedText,
 0645                startIndex = m.StartIndex,
 0646                endIndex = m.EndIndex
 0647            }).ToArray();
 648
 0649            await JSRuntime.InvokeVoidAsync("insertWikiLinksAtPositions", $"tiptap-editor-{_article.Id}", jsMatches);
 650
 651            // Get the updated content from the editor
 0652            _editBody = await JSRuntime.InvokeAsync<string>("getTipTapContent", $"tiptap-editor-{_article.Id}");
 0653            _hasUnsavedChanges = true;
 0654            await SaveArticle();
 0655            Snackbar.Add($"Added {result.LinksFound} link(s)", Severity.Success);
 0656        }
 0657        catch (Exception ex)
 658        {
 0659            Logger.LogError(ex, "Error auto-linking article {ArticleId}", _article.Id);
 0660            Snackbar.Add($"Failed to auto-link: {ex.Message}", Severity.Error);
 0661        }
 662        finally
 663        {
 0664            _isAutoLinking = false;
 0665            StateHasChanged();
 666        }
 0667    }
 668
 669    #endregion
 670
 671    #region Article Creation
 672
 673    private async Task CreateRootArticle()
 674    {
 0675        var worldId = AppContext.CurrentWorldId;
 0676        if (!worldId.HasValue || worldId == Guid.Empty)
 677        {
 0678            Snackbar.Add("Please select a World first", Severity.Warning);
 0679            return;
 680        }
 681
 682        try
 683        {
 0684            var createDto = new ArticleCreateDto
 0685            {
 0686                Title = "Untitled",
 0687                Body = string.Empty,
 0688                ParentId = null,
 0689                EffectiveDate = DateTime.Now,
 0690                WorldId = worldId.Value
 0691            };
 692
 0693            await ArticleApi.CreateArticleAsync(createDto);
 0694            await TreeState.RefreshAsync();
 0695            Snackbar.Add("Article created", Severity.Success);
 0696        }
 0697        catch (Exception ex)
 698        {
 0699            Snackbar.Add($"Failed to create article: {ex.Message}", Severity.Error);
 0700        }
 0701    }
 702
 703    private async Task CreateSiblingArticle()
 704    {
 0705        if (_article == null) return;
 706
 707        try
 708        {
 0709            var createDto = new ArticleCreateDto
 0710            {
 0711                Title = string.Empty,
 0712                Body = string.Empty,
 0713                ParentId = _article.ParentId,
 0714                EffectiveDate = DateTime.Now,
 0715                WorldId = _article.WorldId,
 0716                CampaignId = _article.CampaignId
 0717            };
 718
 0719            var created = await ArticleApi.CreateArticleAsync(createDto);
 0720            if (created == null)
 721            {
 0722                Snackbar.Add("Failed to create article", Severity.Error);
 0723                return;
 724            }
 725
 0726            await TreeState.RefreshAsync();
 0727            TreeState.ExpandPathToAndSelect(created.Id);
 728
 729            // Fetch the full article to get breadcrumbs for navigation
 0730            var articleDetail = await ArticleApi.GetArticleDetailAsync(created.Id);
 0731            if (articleDetail != null && articleDetail.Breadcrumbs.Any())
 732            {
 0733                var path = BreadcrumbService.BuildArticleUrl(articleDetail.Breadcrumbs);
 0734                Navigation.NavigateTo(path);
 735            }
 736            else
 737            {
 738                // Fallback to slug-only path
 0739                Navigation.NavigateTo($"/article/{created.Slug}");
 740            }
 741
 0742            Snackbar.Add("New article created", Severity.Success);
 0743        }
 0744        catch (Exception ex)
 745        {
 0746            Snackbar.Add($"Failed to create article: {ex.Message}", Severity.Error);
 0747        }
 0748    }
 749
 750    private async Task CreateChildArticle()
 751    {
 0752        if (_article == null || _isCreatingChild) return;
 753
 0754        _isCreatingChild = true;
 0755        StateHasChanged();
 756
 757        try
 758        {
 0759            var createDto = new ArticleCreateDto
 0760            {
 0761                Title = string.Empty,
 0762                Body = string.Empty,
 0763                ParentId = _article.Id, // Current article becomes the parent
 0764                EffectiveDate = DateTime.Now,
 0765                WorldId = _article.WorldId,
 0766                CampaignId = _article.CampaignId
 0767            };
 768
 0769            var created = await ArticleApi.CreateArticleAsync(createDto);
 0770            if (created == null)
 771            {
 0772                Snackbar.Add("Failed to create child article", Severity.Error);
 0773                return;
 774            }
 775
 0776            await TreeState.RefreshAsync();
 0777            TreeState.ExpandPathToAndSelect(created.Id);
 0778            TreeState.ShouldFocusTitle = true;
 779
 780            // Fetch the full article to get breadcrumbs for navigation
 0781            var articleDetail = await ArticleApi.GetArticleDetailAsync(created.Id);
 0782            if (articleDetail != null && articleDetail.Breadcrumbs.Any())
 783            {
 0784                var path = BreadcrumbService.BuildArticleUrl(articleDetail.Breadcrumbs);
 0785                Navigation.NavigateTo(path);
 786            }
 787            else
 788            {
 789                // Fallback to slug-only path
 0790                Navigation.NavigateTo($"/article/{created.Slug}");
 791            }
 792
 0793            Snackbar.Add("Child article created", Severity.Success);
 0794        }
 0795        catch (Exception ex)
 796        {
 0797            Logger.LogError(ex, "Error creating child article under {ParentId}", _article.Id);
 0798            Snackbar.Add($"Failed to create child article: {ex.Message}", Severity.Error);
 0799        }
 800        finally
 801        {
 0802            _isCreatingChild = false;
 0803            StateHasChanged();
 804        }
 0805    }
 806
 807    #endregion
 808
 809    #region Keyboard Shortcuts
 810
 811    private void OnMetadataDrawerToggle()
 812    {
 0813        _openMetadata = !_openMetadata;
 0814        InvokeAsync(StateHasChanged);
 0815    }
 816
 817    private async void HandleSaveShortcut()
 818    {
 0819        await InvokeAsync(SaveArticle);
 0820    }
 821
 822    [JSInvokable("HandleCtrlN")]
 823    public static Task HandleCtrlN()
 824    {
 0825        OnCtrlNPressed?.Invoke();
 0826        return Task.CompletedTask;
 827    }
 828
 829    private static event Action? OnCtrlNPressed;
 830
 0831    private async void HandleCtrlNInstance() => await InvokeAsync(CreateSiblingArticle);
 832
 833    #endregion
 834
 835    #region Wiki Link Autocomplete
 836
 837    [JSInvokable]
 838    public async Task OnAutocompleteTriggered(string query, double x, double y)
 839    {
 0840        _autocompletePosition = (x, y);
 0841        _showAutocomplete = true;
 0842        _autocompleteSelectedIndex = 0;
 843
 0844        _autocompleteIsExternalQuery = TryParseExternalAutocompleteQuery(query, out var sourceKey, out var remainder);
 0845        _autocompleteExternalSourceKey = _autocompleteIsExternalQuery ? sourceKey : null;
 0846        _autocompleteQuery = _autocompleteIsExternalQuery ? remainder : query;
 847
 848        // For external queries (like srd/...), we have different length requirements:
 849        // - Empty or short remainder: show categories (handled by server)
 850        // - Any length after category selected: search immediately
 851        // For internal queries: require 3 characters minimum
 0852        var minLength = _autocompleteIsExternalQuery ? 0 : 3;
 853
 0854        if (_autocompleteQuery.Length < minLength)
 855        {
 0856            _autocompleteSuggestions = new();
 0857            StateHasChanged();
 0858            return;
 859        }
 860
 0861        _autocompleteLoading = true;
 0862        StateHasChanged();
 863
 864        try
 865        {
 0866            var worldId = AppContext.CurrentWorldId ?? _article?.WorldId ?? Guid.Empty;
 867
 0868            if (_autocompleteIsExternalQuery)
 869            {
 0870                var externalSuggestions = await ExternalLinkApiService.GetSuggestionsAsync(
 0871                    worldId,
 0872                    _autocompleteExternalSourceKey ?? string.Empty,
 0873                    _autocompleteQuery,
 0874                    CancellationToken.None);
 0875                _autocompleteSuggestions = externalSuggestions
 0876                    .Select(WikiLinkAutocompleteItem.FromExternal)
 0877                    .ToList();
 878            }
 879            else
 880            {
 0881                var internalSuggestions = await LinkApiService.GetSuggestionsAsync(worldId, _autocompleteQuery);
 0882                _autocompleteSuggestions = internalSuggestions
 0883                    .Select(WikiLinkAutocompleteItem.FromInternal)
 0884                    .ToList();
 885            }
 0886        }
 0887        catch (Exception ex)
 888        {
 0889            Logger.LogError(ex, "Error getting autocomplete suggestions");
 0890            _autocompleteSuggestions = new();
 0891        }
 892        finally
 893        {
 0894            _autocompleteLoading = false;
 0895            StateHasChanged();
 896        }
 0897    }
 898
 899    [JSInvokable]
 900    public Task OnAutocompleteHidden()
 901    {
 0902        _showAutocomplete = false;
 0903        _autocompleteSuggestions = new();
 0904        _autocompleteIsExternalQuery = false;
 0905        _autocompleteExternalSourceKey = null;
 0906        StateHasChanged();
 0907        return Task.CompletedTask;
 908    }
 909
 910    [JSInvokable]
 911    public Task OnAutocompleteArrowDown()
 912    {
 0913        if (_autocompleteSuggestions.Any())
 914        {
 0915            _autocompleteSelectedIndex = (_autocompleteSelectedIndex + 1) % _autocompleteSuggestions.Count;
 0916            StateHasChanged();
 917        }
 0918        return Task.CompletedTask;
 919    }
 920
 921    [JSInvokable]
 922    public Task OnAutocompleteArrowUp()
 923    {
 0924        if (_autocompleteSuggestions.Any())
 925        {
 0926            _autocompleteSelectedIndex = (_autocompleteSelectedIndex - 1 + _autocompleteSuggestions.Count) % _autocomple
 0927            StateHasChanged();
 928        }
 0929        return Task.CompletedTask;
 930    }
 931
 932    [JSInvokable]
 933    public async Task OnAutocompleteEnter()
 934    {
 0935        if (_autocompleteSuggestions.Any() && _autocompleteSelectedIndex < _autocompleteSuggestions.Count)
 0936            await OnAutocompleteSelect(_autocompleteSuggestions[_autocompleteSelectedIndex]);
 0937    }
 938
 939    private Task OnAutocompleteIndexChanged(int index)
 940    {
 0941        _autocompleteSelectedIndex = index;
 0942        StateHasChanged();
 0943        return Task.CompletedTask;
 944    }
 945
 946    private async Task OnAutocompleteSelect(WikiLinkAutocompleteItem suggestion)
 947    {
 0948        if (_article == null) return;
 949
 950        try
 951        {
 952            // Handle category selection - update the editor text instead of inserting a link
 0953            if (suggestion.IsCategory && !string.IsNullOrEmpty(suggestion.CategoryKey))
 954            {
 0955                await JSRuntime.InvokeVoidAsync(
 0956                    "updateAutocompleteText",
 0957                    $"tiptap-editor-{_article.Id}",
 0958                    $"{suggestion.Source}/{suggestion.CategoryKey}/");
 959
 960                // Trigger autocomplete again with the new text
 961                // The user can now type a search term
 0962                return;
 963            }
 964
 0965            if (suggestion.IsExternal)
 966            {
 0967                if (string.IsNullOrWhiteSpace(suggestion.Source) || string.IsNullOrWhiteSpace(suggestion.ExternalId))
 968                {
 0969                    Logger.LogWarning("External suggestion missing source or id");
 0970                    return;
 971                }
 972
 0973                await JSRuntime.InvokeVoidAsync(
 0974                    "insertExternalLinkToken",
 0975                    $"tiptap-editor-{_article.Id}",
 0976                    suggestion.Source,
 0977                    suggestion.ExternalId,
 0978                    suggestion.Title);
 979            }
 980            else
 981            {
 0982                if (!suggestion.ArticleId.HasValue)
 983                {
 0984                    Logger.LogWarning("Internal suggestion missing article id");
 0985                    return;
 986                }
 987
 988                // If matched via alias, display "Alias → Title", otherwise just title
 0989                var displayText = !string.IsNullOrWhiteSpace(suggestion.MatchedAlias)
 0990                    ? $"{suggestion.MatchedAlias} → {suggestion.Title}"
 0991                    : suggestion.Title;
 992
 0993                await JSRuntime.InvokeVoidAsync(
 0994                    "insertWikiLink",
 0995                    $"tiptap-editor-{_article.Id}",
 0996                    suggestion.ArticleId.Value.ToString(),
 0997                    displayText);
 998            }
 0999            _showAutocomplete = false;
 01000            _autocompleteSuggestions = new();
 01001            StateHasChanged();
 01002            await Task.Delay(50);
 01003            await SaveArticle();
 01004        }
 01005        catch (Exception ex)
 1006        {
 01007            Logger.LogError(ex, "Error inserting wiki link");
 01008            Snackbar.Add("Failed to insert link", Severity.Error);
 01009        }
 01010    }
 1011
 1012    private async Task OnAutocompleteCreate(string articleName)
 1013    {
 01014        if (_autocompleteIsExternalQuery)
 1015        {
 01016            return;
 1017        }
 1018
 01019        if (_article == null || string.IsNullOrWhiteSpace(articleName)) return;
 1020
 1021        try
 1022        {
 01023            var worldId = AppContext.CurrentWorldId ?? _article.WorldId ?? Guid.Empty;
 01024            var created = await WikiLinkService.CreateArticleFromAutocompleteAsync(articleName, worldId);
 1025
 01026            if (created == null)
 1027            {
 01028                Snackbar.Add("Failed to create article", Severity.Error);
 01029                return;
 1030            }
 1031
 01032            await JSRuntime.InvokeVoidAsync("insertWikiLink", $"tiptap-editor-{_article.Id}", created.Id.ToString(), cre
 01033            _showAutocomplete = false;
 01034            _autocompleteSuggestions = new();
 01035            StateHasChanged();
 1036
 01037            await Task.Delay(50);
 01038            await SaveArticle();
 01039            await TreeState.RefreshAsync();
 01040            Snackbar.Add($"Created and linked '{articleName}'", Severity.Success);
 01041        }
 01042        catch (Exception ex)
 1043        {
 01044            Logger.LogError(ex, "Error creating article from autocomplete");
 01045            Snackbar.Add($"Failed to create article: {ex.Message}", Severity.Error);
 01046        }
 01047    }
 1048
 1049    private static bool TryParseExternalAutocompleteQuery(string query, out string sourceKey, out string remainder)
 1050    {
 01051        sourceKey = string.Empty;
 01052        remainder = string.Empty;
 1053
 01054        if (string.IsNullOrWhiteSpace(query))
 1055        {
 01056            return false;
 1057        }
 1058
 01059        var slashIndex = query.IndexOf('/');
 01060        if (slashIndex <= 0)
 1061        {
 01062            return false;
 1063        }
 1064
 01065        sourceKey = query.Substring(0, slashIndex).Trim().ToLowerInvariant();
 01066        remainder = query.Substring(slashIndex + 1);
 1067
 01068        return !string.IsNullOrWhiteSpace(sourceKey);
 1069    }
 1070
 1071    [JSInvokable]
 1072    public async Task OnWikiLinkClicked(string targetArticleId)
 1073    {
 01074        if (!Guid.TryParse(targetArticleId, out var articleId)) return;
 1075
 1076        try
 1077        {
 01078            var path = await ArticleCache.GetNavigationPathAsync(articleId);
 01079            if (!string.IsNullOrEmpty(path))
 01080                Navigation.NavigateTo($"/article/{path}");
 1081            else
 01082                Snackbar.Add("Article not found", Severity.Warning);
 01083        }
 01084        catch (Exception ex)
 1085        {
 01086            Logger.LogError(ex, "Error navigating to wiki link: {ArticleId}", targetArticleId);
 01087            Snackbar.Add("Failed to navigate to article", Severity.Error);
 01088        }
 01089    }
 1090
 1091    [JSInvokable]
 1092    public async Task<string?> GetArticlePath(string targetArticleId)
 1093    {
 01094        if (!Guid.TryParse(targetArticleId, out var articleId)) return null;
 1095
 1096        try
 1097        {
 01098            return await ArticleCache.GetArticlePathAsync(articleId);
 1099        }
 01100        catch (Exception ex)
 1101        {
 01102            Logger.LogError(ex, "Error getting article path: {ArticleId}", targetArticleId);
 01103            return null;
 1104        }
 01105    }
 1106
 1107    [JSInvokable]
 1108    public async Task<object?> GetArticleSummaryPreview(string targetArticleId)
 1109    {
 01110        if (!Guid.TryParse(targetArticleId, out var articleId)) return null;
 1111
 1112        try
 1113        {
 01114            var preview = await SummaryApi.GetSummaryPreviewAsync(articleId);
 01115            if (preview == null || !preview.HasSummary) return null;
 1116
 01117            return new
 01118            {
 01119                title = preview.Title,
 01120                summary = preview.Summary,
 01121                templateName = preview.TemplateName
 01122            };
 1123        }
 01124        catch (Exception ex)
 1125        {
 01126            Logger.LogError(ex, "Error getting summary preview: {ArticleId}", targetArticleId);
 01127            return null;
 1128        }
 01129    }
 1130
 1131    [JSInvokable]
 1132    public async Task OnExternalLinkClicked(string source, string id, string title)
 1133    {
 01134        if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(id)) return;
 1135
 01136        _externalPreviewOpen = true;
 01137        _externalPreviewLoading = true;
 01138        _externalPreviewError = null;
 01139        _externalPreviewSource = source;
 01140        _externalPreviewId = id;
 01141        _externalPreviewTitle = string.IsNullOrWhiteSpace(title) ? "External Link" : title;
 01142        _externalPreviewContent = null;
 01143        StateHasChanged();
 1144
 01145        var cacheKey = GetExternalCacheKey(source, id);
 01146        if (_externalLinkCache.TryGetValue(cacheKey, out var cached))
 1147        {
 01148            _externalPreviewContent = cached;
 01149            _externalPreviewLoading = false;
 01150            StateHasChanged();
 01151            return;
 1152        }
 1153
 1154        try
 1155        {
 01156            var content = await ExternalLinkApiService.GetContentAsync(source, id, CancellationToken.None);
 1157
 01158            if (content == null || string.IsNullOrWhiteSpace(content.Markdown))
 1159            {
 01160                _externalPreviewError = "No content available.";
 01161                _externalPreviewContent = null;
 1162            }
 1163            else
 1164            {
 01165                _externalPreviewContent = content;
 01166                _externalLinkCache[cacheKey] = content;
 1167            }
 01168        }
 01169        catch (Exception ex)
 1170        {
 01171            Logger.LogError(ex, "Error loading external link preview for {Source} {Id}", source, id);
 01172            _externalPreviewError = "Failed to load external content.";
 01173            _externalPreviewContent = null;
 01174        }
 1175        finally
 1176        {
 01177            _externalPreviewLoading = false;
 01178            StateHasChanged();
 1179        }
 01180    }
 1181
 1182    private void CloseExternalPreview()
 1183    {
 01184        _externalPreviewOpen = false;
 01185        StateHasChanged();
 01186    }
 1187
 01188    private static string GetExternalCacheKey(string source, string id) => $"{source}:{id}".ToLowerInvariant();
 1189
 1190    #endregion
 1191
 1192    #region Image Upload
 1193
 1194    [JSInvokable]
 1195    public async Task<object?> OnImageUploadRequested(string fileName, string contentType, long fileSize)
 1196    {
 01197        if (_article == null) return null;
 1198
 01199        var worldId = AppContext.CurrentWorldId ?? _article.WorldId ?? Guid.Empty;
 01200        if (worldId == Guid.Empty) return null;
 1201
 1202        try
 1203        {
 01204            var request = new WorldDocumentUploadRequestDto
 01205            {
 01206                FileName = fileName,
 01207                ContentType = contentType,
 01208                FileSizeBytes = fileSize,
 01209                ArticleId = _article.Id,
 01210                Description = $"Inline image for article: {_article.Title}"
 01211            };
 1212
 01213            var response = await WorldApi.RequestDocumentUploadAsync(worldId, request);
 01214            if (response == null) return null;
 1215
 01216            return new
 01217            {
 01218                uploadUrl = response.UploadUrl,
 01219                documentId = response.DocumentId.ToString()
 01220            };
 1221        }
 01222        catch (Exception ex)
 1223        {
 01224            Logger.LogError(ex, "Error requesting image upload for article {ArticleId}", _article.Id);
 01225            return null;
 1226        }
 01227    }
 1228
 1229    [JSInvokable]
 1230    public async Task OnImageUploadConfirmed(string documentIdStr)
 1231    {
 01232        if (_article == null || !Guid.TryParse(documentIdStr, out var documentId)) return;
 1233
 01234        var worldId = AppContext.CurrentWorldId ?? _article.WorldId ?? Guid.Empty;
 01235        if (worldId == Guid.Empty) return;
 1236
 1237        try
 1238        {
 01239            await WorldApi.ConfirmDocumentUploadAsync(worldId, documentId);
 01240        }
 01241        catch (Exception ex)
 1242        {
 01243            Logger.LogError(ex, "Error confirming image upload {DocumentId}", documentId);
 01244        }
 01245    }
 1246
 1247    [JSInvokable]
 1248    public string GetImageProxyUrl(string documentIdStr)
 1249    {
 1250        // Return stable chronicis-image: reference (not a URL)
 01251        return $"chronicis-image:{documentIdStr}";
 1252    }
 1253
 1254    [JSInvokable]
 1255    public async Task<string?> ResolveImageUrl(string documentIdStr)
 1256    {
 01257        if (!Guid.TryParse(documentIdStr, out var documentId)) return null;
 1258
 1259        try
 1260        {
 01261            var result = await WorldApi.DownloadDocumentAsync(documentId);
 01262            return result?.DownloadUrl;
 1263        }
 01264        catch (Exception ex)
 1265        {
 01266            Logger.LogError(ex, "Error resolving image URL for document {DocumentId}", documentId);
 01267            return null;
 1268        }
 01269    }
 1270
 1271    [JSInvokable]
 1272    public void OnImageUploadStarted(string fileName)
 1273    {
 01274        InvokeAsync(() =>
 01275        {
 01276            Snackbar.Add($"Uploading {fileName}...", Severity.Info);
 01277            StateHasChanged();
 01278        });
 01279    }
 1280
 1281    [JSInvokable]
 1282    public void OnImageUploadError(string message)
 1283    {
 01284        InvokeAsync(() =>
 01285        {
 01286            Snackbar.Add(message, Severity.Error);
 01287            StateHasChanged();
 01288        });
 01289    }
 1290
 1291    private async Task InsertImageFromToolbar()
 1292    {
 01293        if (_article == null || !_editorInitialized) return;
 01294        await JSRuntime.InvokeVoidAsync("triggerImageUpload", $"tiptap-editor-{_article.Id}", _dotNetHelper);
 01295    }
 1296
 1297    #endregion
 1298}