< Summary

Information
Class: Chronicis.Client.Pages.SessionDetail
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Pages/SessionDetail.razor
Line coverage
100%
Covered lines: 117
Uncovered lines: 0
Coverable lines: 117
Total lines: 1294
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/Pages/SessionDetail.razor

#LineLine coverage
 1@attribute [Authorize]
 2@implements IDisposable
 3@using System.ComponentModel
 4@using Chronicis.Client.Components.Articles
 5@using Chronicis.Client.Components.Maps
 6@using Chronicis.Client.Components.Shared
 7@using Chronicis.Client.Services
 8@using Chronicis.Client.Abstractions
 9@using ArticleWikiLinkAutocompleteItem = Chronicis.Client.Components.Articles.WikiLinkAutocompleteItem
 10@using Chronicis.Shared.DTOs
 11@using Chronicis.Shared.Enums
 12@using Microsoft.AspNetCore.Components.Web
 13@using Microsoft.AspNetCore.Authorization
 14@using Microsoft.JSInterop
 15@inject SessionDetailViewModel ViewModel
 16@inject IDialogService DialogService
 17@inject IKeyboardShortcutService KeyboardShortcutService
 18@inject IJSRuntime JSRuntime
 19@inject NavigationManager Navigation
 20@inject ISnackbar Snackbar
 21@inject ILogger<SessionDetail> Logger
 22@inject ILinkApiService LinkApiService
 23@inject IExternalLinkApiService ExternalLinkApiService
 24@inject IMapApiService MapApi
 25@inject IWikiLinkService WikiLinkService
 26@inject IWikiLinkCommitService WikiLinkCommitService
 27@inject IAppContextService AppContext
 28@inject IAppNavigator AppNavigator
 29@inject IArticleCacheService ArticleCache
 30@inject IAISummaryApiService SummaryApi
 31@inject IWorldApiService WorldApi
 32@inject IDrawerCoordinator DrawerCoordinator
 33
 5434@if (ViewModel.IsLoading)
 35{
 36    <LoadingSkeleton />
 37}
 5338else if (ViewModel.Session == null)
 39{
 40    <NotFoundAlert EntityType="Session" />
 41}
 42else
 43{
 44    <MudPaper Elevation="2" Class="chronicis-article-card chronicis-fade-in">
 45        <ChroniclsBreadcrumbs Items="ViewModel.Breadcrumbs" Class="mb-2" />
 46
 47        <div class="d-flex align-center mb-3">
 48            <MudIcon Icon="@Icons.Material.Filled.EventNote"
 49                     Size="Size.Large"
 50                     Class="mr-3 generic-container-icon"
 51                     Style="color: var(--chronicis-beige-gold); font-size: 2.5rem;" />
 52            <div class="flex-grow-1">
 53                @if (ViewModel.CanManageSessionDetails)
 54                {
 55                    <MudTextField @bind-Value="ViewModel.EditName"
 56                                  Variant="Variant.Text"
 57                                  Placeholder="Untitled Session"
 58                                  Immediate="true"
 59                                  Underline="false"
 60                                  Class="flex-grow-1"
 61                                  Style="font-size: 2rem; font-family: var(--chronicis-font-heading);"
 62                                  @onkeydown="OnSessionTitleKeyDown" />
 63                    <div class="d-flex align-center flex-wrap">
 64                        <MudText Typo="Typo.body2" Color="Color.Secondary" Class="mr-2">
 65                            Session Date:
 66                        </MudText>
 67                        <MudDatePicker Date="@ViewModel.EditSessionDate"
 68                                       DateChanged="OnSessionDateChanged"
 69                                       DateFormat="MMMM d, yyyy"
 70                                       Variant="Variant.Text"
 71                                       Margin="Margin.Dense"
 72                                       Underline="false"
 73                                       Clearable="true"
 74                                       Editable="true"
 75                                       Class="mt-0" />
 76                    </div>
 77                }
 78                else
 79                {
 80                    <MudText Typo="Typo.h4" Style="font-family: var(--chronicis-font-heading);">
 81                        @ViewModel.Session.Name
 82                    </MudText>
 83                    <MudText Typo="Typo.body2" Color="Color.Secondary">
 84                        Session Date: @FormatSessionDate(ViewModel.Session.SessionDate)
 85                    </MudText>
 86                }
 87            </div>
 88            @if (ViewModel.CanManageSessionDetails)
 89            {
 90                <MudChip T="string" Color="Color.Warning" Variant="Variant.Outlined" Size="Size.Small">
 91                    GM
 92                </MudChip>
 93            }
 94        </div>
 95
 96        <div class="chronicis-rune-divider mb-4"></div>
 97
 98        <MudTabs Elevation="0"
 99                 Rounded="true"
 100                 ApplyEffectsToContainer="true"
 101                 KeepPanelsAlive="true"
 102                 PanelClass="pa-4 mb-2">
 103            <MudTabPanel Text="Notes" Icon="@Icons.Material.Filled.Public">
 104                <MudText Typo="Typo.h6" Class="mb-2" Style="color: var(--chronicis-beige-gold);">
 105                    <MudIcon Icon="@Icons.Material.Filled.Public" Size="Size.Small" Class="mr-2" />
 106                    Public Notes
 107                </MudText>
 108
 109                @if (ViewModel.CanManageSessionDetails)
 110                {
 111                    <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-1">
 112                        Public Notes
 113                    </MudText>
 114                    <div id="@PublicEditorElementId" class="chronicis-editor-container mb-4"></div>
 115                }
 116                else if (!string.IsNullOrWhiteSpace(ViewModel.Session.PublicNotes))
 117                {
 118                    <MudPaper Outlined="true" Class="pa-4 mb-4">
 119                        @((MarkupString)ViewModel.Session.PublicNotes)
 120                    </MudPaper>
 121                }
 122                else
 123                {
 124                    <MudAlert Severity="Severity.Info" Class="mb-4">
 125                        No public notes yet.
 126                    </MudAlert>
 127                }
 128            </MudTabPanel>
 129
 130            @if (ViewModel.CanViewPrivateNotes)
 131            {
 132                <MudTabPanel Text="Private Notes" Icon="@Icons.Material.Filled.Lock">
 133                    <MudText Typo="Typo.h6" Class="mb-2" Style="color: var(--chronicis-beige-gold);">
 134                        <MudIcon Icon="@Icons.Material.Filled.Lock" Size="Size.Small" Class="mr-2" />
 135                        Private Notes
 136                    </MudText>
 137
 138                    @if (ViewModel.CanManageSessionDetails)
 139                    {
 140                        <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-1">
 141                            Private Notes (World owner / GM only)
 142                        </MudText>
 143                        <div id="@PrivateEditorElementId" class="chronicis-editor-container mb-4"></div>
 144                    }
 145                    else
 146                    {
 147                        <MudAlert Severity="Severity.Info" Class="mb-4">
 148                            Private notes are visible to the world owner and GMs only.
 149                        </MudAlert>
 150                    }
 151                </MudTabPanel>
 152            }
 153        </MudTabs>
 154
 155        @if (ViewModel.CanManageSessionDetails)
 156        {
 157            <div class="chronicis-flex-between mb-4">
 158                <SaveStatusIndicator IsSaving="ViewModel.IsSavingNotes" HasUnsavedChanges="ViewModel.HasUnsavedChanges" 
 159
 160                <div class="d-flex align-center">
 161                    <MudButton Variant="Variant.Filled"
 162                               Color="Color.Primary"
 163                               OnClick="SaveSessionNotesAsync"
 164                               Disabled="@(ViewModel.IsSavingNotes || ViewModel.IsDeleting || !ViewModel.HasUnsavedChang
 165                               StartIcon="@Icons.Material.Filled.Save">
 166                        Save
 167                    </MudButton>
 168
 169                    <MudButton Variant="Variant.Outlined"
 170                               Color="Color.Error"
 171                               OnClick="OpenDeleteDialogAsync"
 172                               Disabled="@ViewModel.IsDeleting"
 173                               StartIcon="@Icons.Material.Filled.Delete"
 174                               Class="ml-2">
 175                        @(ViewModel.IsDeleting ? "Deleting..." : "Delete")
 176                    </MudButton>
 177                </div>
 178            </div>
 179        }
 180
 181        <MudDivider Class="my-4" />
 182
 183        <div class="d-flex align-center justify-space-between mb-2">
 184            <MudText Typo="Typo.h6" Style="color: var(--chronicis-beige-gold);">
 185                <MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Size="Size.Small" Class="mr-2" />
 186                AI Summary
 187            </MudText>
 188            <div class="d-flex align-center">
 189                @if (!string.IsNullOrWhiteSpace(ViewModel.Session.AiSummary))
 190                {
 191                    <MudTooltip Text="Refresh summary">
 192                        <MudIconButton Icon="@Icons.Material.Filled.Refresh"
 193                                       Color="Color.Secondary"
 194                                       Disabled="@(ViewModel.IsGeneratingSummary || ViewModel.IsDeletingSummary || ViewM
 195                                       OnClick="@ViewModel.GenerateAiSummaryAsync" />
 196                    </MudTooltip>
 197                    <MudTooltip Text="Delete summary">
 198                        <MudIconButton Icon="@Icons.Material.Filled.Delete"
 199                                       Color="Color.Error"
 200                                       Disabled="@(ViewModel.IsGeneratingSummary || ViewModel.IsDeletingSummary || ViewM
 201                                       OnClick="@ViewModel.ClearAiSummaryAsync" />
 202                    </MudTooltip>
 203                }
 204                else
 205                {
 206                    <MudButton Variant="Variant.Filled"
 207                               Color="Color.Secondary"
 208                               OnClick="@ViewModel.GenerateAiSummaryAsync"
 209                               Disabled="@(ViewModel.IsGeneratingSummary || ViewModel.IsDeleting)"
 210                               StartIcon="@Icons.Material.Filled.AutoAwesome">
 211                        @(ViewModel.IsGeneratingSummary ? "Generating..." : "Generate")
 212                    </MudButton>
 213                }
 214            </div>
 215        </div>
 216
 217        @if (ViewModel.IsGeneratingSummary || ViewModel.IsDeletingSummary)
 218        {
 219            <MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-3" />
 220        }
 221
 222        @if (!string.IsNullOrWhiteSpace(ViewModel.Session.AiSummary))
 223        {
 224            <MudPaper Outlined="true" Class="pa-4 mb-2">
 225                <MudText Typo="Typo.body1" Style="white-space: pre-wrap;">@ViewModel.Session.AiSummary</MudText>
 226            </MudPaper>
 227            @if (ViewModel.Session.AiSummaryGeneratedAt.HasValue)
 228            {
 229                <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-4">
 230                    Generated @ViewModel.Session.AiSummaryGeneratedAt.Value.ToLocalTime().ToString("g")
 231                </MudText>
 232            }
 233        }
 234        else
 235        {
 236            <MudAlert Severity="Severity.Info" Class="mb-4">
 237                No AI summary yet.
 238            </MudAlert>
 239        }
 240
 241        <MudDivider Class="my-4" />
 242
 243        <div class="d-flex align-center justify-space-between flex-wrap mb-2">
 244            <MudText Typo="Typo.h6" Style="color: var(--chronicis-beige-gold);">
 245                <MudIcon Icon="@Icons.Material.Filled.Description" Size="Size.Small" Class="mr-2" />
 246                Session Notes
 247            </MudText>
 248
 249            @if (ViewModel.CanCreateSessionNote)
 250            {
 251                <MudButton Variant="Variant.Outlined"
 252                           Color="Color.Primary"
 253                           Size="Size.Small"
 254                           OnClick="@ViewModel.CreateSessionNoteAsync"
 255                           Disabled="@(ViewModel.IsCreatingSessionNote || ViewModel.IsDeleting)"
 256                           StartIcon="@Icons.Material.Filled.NoteAdd">
 257                    @(ViewModel.IsCreatingSessionNote ? "Adding..." : "Add Session Note")
 258                </MudButton>
 259                <MudButton Variant="Variant.Outlined"
 260                           Color="Color.Primary"
 261                           Size="Size.Small"
 262                           OnClick="@CreateHandwrittenSessionNoteAsync"
 263                           Disabled="@(ViewModel.IsCreatingSessionNote || ViewModel.IsDeleting || _isCreatingHandwritten
 264                           StartIcon="@Icons.Material.Filled.Draw">
 265                    @(_isCreatingHandwrittenNote ? "Adding..." : "Add a handwritten note")
 266                </MudButton>
 267            }
 268        </div>
 269
 270        @if (ViewModel.SessionNotes.Any())
 271        {
 272            <MudList T="ArticleTreeDto" Dense="true">
 273                @foreach (var note in ViewModel.SessionNotes)
 274                {
 275                    <EntityListItem Icon="@Icons.Material.Filled.Description"
 276                                    Title="@(string.IsNullOrWhiteSpace(note.Title) ? "Untitled Session Note" : note.Titl
 277                                    OnClick="@(() => ViewModel.OpenSessionNoteAsync(note))">
 278                        <MudChip T="string"
 279                                 Size="Size.Small"
 280                                 Variant="Variant.Outlined"
 281                                 Color="@GetVisibilityColor(note.Visibility)">
 282                            @note.Visibility
 283                        </MudChip>
 284                    </EntityListItem>
 285                }
 286            </MudList>
 287        }
 288        else
 289        {
 290            <MudAlert Severity="Severity.Info">
 291                No attached SessionNote articles found.
 292            </MudAlert>
 293        }
 294
 295        @if (ViewModel.CanManageSessionDetails)
 296        {
 297            <MudDrawer @bind-Open="_externalPreviewOpen"
 298                       Anchor="Anchor.End"
 299                       Variant="@DrawerVariant.Temporary"
 300                       Elevation="9999"
 301                       Class="external-link-preview-drawer">
 302                <MudDrawerHeader>
 303                    <MudSpacer />
 304                    <MudIconButton Icon="@Icons.Material.Filled.Close" OnClick="CloseExternalPreview" />
 305                </MudDrawerHeader>
 306                <MudDrawerContainer>
 307                    <div class="external-link-preview-header">
 308                        @if (!string.IsNullOrWhiteSpace(_externalPreviewSource))
 309                        {
 310                            <span class="external-link-preview-source">@_externalPreviewSource.ToUpperInvariant()</span>
 311                        }
 312                        <MudText Typo="Typo.h6">@(_externalPreviewTitle ?? "External Link")</MudText>
 313                    </div>
 314                    @if (!string.IsNullOrWhiteSpace(_externalPreviewContent?.Kind))
 315                    {
 316                        <MudText Typo="Typo.caption" Style="color: var(--mud-palette-text-secondary);">
 317                            @_externalPreviewContent.Kind
 318                        </MudText>
 319                    }
 320                    @if (_externalPreviewLoading)
 321                    {
 322                        <div class="d-flex justify-center align-center" style="padding: 24px;">
 323                            <MudProgressCircular Indeterminate="true" />
 324                        </div>
 325                    }
 326                    else if (!string.IsNullOrWhiteSpace(_externalPreviewError))
 327                    {
 328                        <MudAlert Severity="Severity.Error">@_externalPreviewError</MudAlert>
 329                    }
 330                    else if (_externalPreviewContent != null)
 331                    {
 332                        <ExternalLinkDetailPanel Content="_externalPreviewContent" />
 333                    }
 334                </MudDrawerContainer>
 335            </MudDrawer>
 336
 337            @if (_isMapModalOpen)
 338            {
 339                <SessionMapViewerModal IsOpen="_isMapModalOpen"
 340                                       WorldId="CurrentWorldId"
 341                                       MapId="_selectedMapId"
 342                                       MapName="@_selectedMapName"
 343                                       OnClose="CloseMapModalAsync" />
 344            }
 345
 346            @if (_showAutocomplete)
 347            {
 348                <ArticleDetailWikiLinkAutocomplete Suggestions="@_autocompleteSuggestions"
 349                                                   Loading="@_autocompleteLoading"
 350                                                   SelectedIndex="@_autocompleteSelectedIndex"
 351                                                   SelectedIndexChanged="OnAutocompleteIndexChanged"
 352                                                   OnSelect="OnAutocompleteSelect"
 353                                                   OnCreate="OnAutocompleteCreate"
 354                                                   Position="@_autocompletePosition"
 355                                                   Query="@_autocompleteQuery"
 356                                                   IsExternalQuery="@_autocompleteIsExternalQuery" />
 357            }
 358        }
 359    </MudPaper>
 360}
 361
 362@code {
 1363    private static readonly DialogOptions _deleteDialogOptions = new()
 1364    {
 1365        CloseOnEscapeKey = true,
 1366        MaxWidth = MaxWidth.Small,
 1367        FullWidth = true,
 1368    };
 369
 370    private bool _isCreatingHandwrittenNote;
 371
 372    [Parameter]
 373    public Guid SessionId { get; set; }
 374
 375    private enum SessionEditorKind
 376    {
 377        Public,
 378        Private
 379    }
 380
 381    private sealed class SessionEditorInteropBridge
 382    {
 383        private readonly SessionDetail _parent;
 384        private readonly SessionEditorKind _editorKind;
 385
 386        public SessionEditorInteropBridge(SessionDetail parent, SessionEditorKind editorKind)
 387        {
 32388            _parent = parent;
 32389            _editorKind = editorKind;
 32390        }
 391
 392        [JSInvokable]
 1393        public Task OnEditorUpdate(string html) => _parent.HandleEditorUpdateAsync(_editorKind, html);
 394
 395        [JSInvokable]
 396        public Task OnAutocompleteTriggered(string query, double x, double y)
 1397            => _parent.HandleAutocompleteTriggeredAsync(_editorKind, query, x, y);
 398
 399        [JSInvokable]
 1400        public Task OnAutocompleteHidden() => _parent.HandleAutocompleteHiddenAsync(_editorKind);
 401
 402        [JSInvokable]
 1403        public Task OnAutocompleteArrowDown() => _parent.HandleAutocompleteArrowDownAsync(_editorKind);
 404
 405        [JSInvokable]
 1406        public Task OnAutocompleteArrowUp() => _parent.HandleAutocompleteArrowUpAsync(_editorKind);
 407
 408        [JSInvokable]
 1409        public Task OnAutocompleteEnter() => _parent.HandleAutocompleteEnterAsync(_editorKind);
 410
 411        [JSInvokable]
 2412        public Task OnWikiLinkClicked(string targetArticleId) => _parent.OnWikiLinkClicked(targetArticleId);
 413
 414        [JSInvokable]
 1415        public Task OnBrokenLinkClicked(string targetArticleId) => _parent.OnBrokenLinkClicked(targetArticleId);
 416
 417        [JSInvokable]
 418        public Task OnExternalLinkClicked(string source, string id, string title)
 1419            => _parent.OnExternalLinkClicked(source, id, title);
 420
 421        [JSInvokable]
 422        public Task OnMapLinkClicked(string mapId, string? mapName)
 4423            => _parent.HandleMapLinkClickedAsync(mapId, mapName);
 424
 425        [JSInvokable]
 1426        public Task<string?> GetArticlePath(string targetArticleId) => _parent.GetArticlePath(targetArticleId);
 427
 428        [JSInvokable]
 429        public Task<object?> GetArticleSummaryPreview(string targetArticleId)
 1430            => _parent.GetArticleSummaryPreview(targetArticleId);
 431    }
 432
 433    private SessionEditorInteropBridge? _publicEditorBridge;
 434    private SessionEditorInteropBridge? _privateEditorBridge;
 435    private DotNetObjectReference<SessionEditorInteropBridge>? _publicEditorBridgeRef;
 436    private DotNetObjectReference<SessionEditorInteropBridge>? _privateEditorBridgeRef;
 437    private bool _publicEditorInitialized;
 438    private bool _privateEditorInitialized;
 439    private bool _editorsInitializing;
 440    private SessionEditorKind _activeEditorKind = SessionEditorKind.Public;
 441
 442    private bool _showAutocomplete;
 443    private bool _autocompleteLoading;
 16444    private List<ArticleWikiLinkAutocompleteItem> _autocompleteSuggestions = new();
 445    private int _autocompleteSelectedIndex;
 16446    private (double X, double Y) _autocompletePosition = (0, 0);
 16447    private string _autocompleteQuery = string.Empty;
 448    private bool _autocompleteIsExternalQuery;
 449    private string? _autocompleteExternalSourceKey;
 450    private bool _autocompleteIsMapQuery;
 451
 452    private bool _externalPreviewOpen;
 453    private bool _externalPreviewLoading;
 454    private string? _externalPreviewError;
 455    private ExternalLinkContentDto? _externalPreviewContent;
 456    private string? _externalPreviewSource;
 457    private string? _externalPreviewTitle;
 16458    private readonly Dictionary<string, ExternalLinkContentDto> _externalLinkCache = new(StringComparer.OrdinalIgnoreCas
 459    private bool _isMapModalOpen;
 460    private Guid _selectedMapId;
 461    private string? _selectedMapName;
 462
 131463    private string PublicEditorElementId => $"session-public-editor-{SessionId}";
 130464    private string PrivateEditorElementId => $"session-private-editor-{SessionId}";
 6465    private Guid CurrentWorldId => ViewModel.World?.Id ?? AppContext.CurrentWorldId ?? Guid.Empty;
 466
 467    protected override void OnInitialized()
 468    {
 16469        KeyboardShortcutService.OnSaveRequested += HandleSaveShortcut;
 16470        DrawerCoordinator.OnChanged += HandleDrawerCoordinatorChanged;
 16471        _publicEditorBridge = new SessionEditorInteropBridge(this, SessionEditorKind.Public);
 16472        _privateEditorBridge = new SessionEditorInteropBridge(this, SessionEditorKind.Private);
 16473        _publicEditorBridgeRef = DotNetObjectReference.Create(_publicEditorBridge);
 16474        _privateEditorBridgeRef = DotNetObjectReference.Create(_privateEditorBridge);
 16475    }
 476
 477    protected override async Task OnParametersSetAsync()
 478    {
 479        await DestroyEditorsAsync();
 480        ViewModel.PropertyChanged -= OnViewModelChanged;
 481        ViewModel.PropertyChanged += OnViewModelChanged;
 482        await ViewModel.LoadAsync(SessionId);
 483
 484        if (ViewModel.World != null &&
 485            (AppContext.CurrentWorldId != ViewModel.World.Id || AppContext.CurrentCampaignId != ViewModel.Campaign?.Id))
 486        {
 487            await AppContext.SelectWorldAsync(ViewModel.World.Id, ViewModel.Campaign?.Id);
 488        }
 489    }
 490
 491    protected override async Task OnAfterRenderAsync(bool firstRender)
 492    {
 493        if (ViewModel.IsLoading || !ViewModel.CanManageSessionDetails || ViewModel.Session == null || ViewModel.Session.
 494        {
 495            return;
 496        }
 497
 498        if (_editorsInitializing || (_publicEditorInitialized && _privateEditorInitialized))
 499        {
 500            return;
 501        }
 502
 503        await InitializeEditorsAsync();
 504    }
 505
 506    private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
 180507        => InvokeAsync(StateHasChanged);
 508
 509    private async Task OnSessionTitleKeyDown(KeyboardEventArgs e)
 510    {
 511        if (e.Key == "Enter" && ViewModel.CanManageSessionDetails)
 512        {
 513            await SaveSessionNotesAsync();
 514        }
 515    }
 516
 517    private async void HandleSaveShortcut()
 518    {
 519        if (!ViewModel.CanManageSessionDetails || ViewModel.Session == null)
 520        {
 521            return;
 522        }
 523
 524        await InvokeAsync(SaveSessionNotesAsync);
 525    }
 526
 527    private void OnSessionDateChanged(DateTime? value)
 528    {
 3529        ViewModel.EditSessionDate = value?.Date;
 3530    }
 531
 532    private async Task OpenDeleteDialogAsync()
 533    {
 534        if (!ViewModel.IsCurrentUserGM || ViewModel.Session == null || ViewModel.IsDeleting)
 535        {
 536            return;
 537        }
 538
 539        var parameters = new DialogParameters<DeleteSessionDialog>
 540        {
 541            { x => x.SessionName, ViewModel.Session.Name }
 542        };
 543
 544        var dialog = await DialogService.ShowAsync<DeleteSessionDialog>(
 545            $"Delete \"{ViewModel.Session.Name}\"", parameters, _deleteDialogOptions);
 546
 547        var result = await dialog.Result;
 548        if (result is { Canceled: false })
 549        {
 550            await ViewModel.DeleteSessionAsync();
 551        }
 552    }
 553
 554    private static string FormatSessionDate(DateTime? sessionDate)
 4555        => sessionDate?.ToLocalTime().ToString("MMMM d, yyyy") ?? "Not set";
 556
 4557    private static Color GetVisibilityColor(ArticleVisibility visibility) => visibility switch
 4558    {
 1559        ArticleVisibility.Public => Color.Success,
 1560        ArticleVisibility.MembersOnly => Color.Warning,
 1561        ArticleVisibility.Private => Color.Error,
 1562        _ => Color.Default
 4563    };
 564
 565    public void Dispose()
 566    {
 16567        KeyboardShortcutService.OnSaveRequested -= HandleSaveShortcut;
 16568        DrawerCoordinator.OnChanged -= HandleDrawerCoordinatorChanged;
 16569        ViewModel.PropertyChanged -= OnViewModelChanged;
 16570        _publicEditorBridgeRef?.Dispose();
 16571        _privateEditorBridgeRef?.Dispose();
 15572    }
 573
 574    private void HandleDrawerCoordinatorChanged()
 575    {
 3576        if (!_externalPreviewOpen || DrawerCoordinator.Current == DrawerType.None)
 577        {
 2578            return;
 579        }
 580
 1581        _externalPreviewOpen = false;
 1582        _ = InvokeAsync(StateHasChanged);
 1583    }
 584
 585    private async Task InitializeEditorsAsync()
 586    {
 587        if (ViewModel.Session == null || !ViewModel.CanManageSessionDetails || _publicEditorBridgeRef == null || _privat
 588        {
 589            return;
 590        }
 591
 592        _editorsInitializing = true;
 593
 594        try
 595        {
 596            await JSRuntime.InvokeVoidAsync("initializeTipTapEditor", PublicEditorElementId, ViewModel.EditPublicNotes, 
 597            await JSRuntime.InvokeVoidAsync("initializeWikiLinkAutocomplete", PublicEditorElementId, _publicEditorBridge
 598            await JSRuntime.InvokeVoidAsync("initializeImageUpload", PublicEditorElementId, _publicEditorBridgeRef);
 599            await JSRuntime.InvokeVoidAsync("resolveEditorImages", PublicEditorElementId, _publicEditorBridgeRef);
 600            _publicEditorInitialized = true;
 601
 602            await JSRuntime.InvokeVoidAsync("initializeTipTapEditor", PrivateEditorElementId, ViewModel.EditPrivateNotes
 603            await JSRuntime.InvokeVoidAsync("initializeWikiLinkAutocomplete", PrivateEditorElementId, _privateEditorBrid
 604            await JSRuntime.InvokeVoidAsync("initializeImageUpload", PrivateEditorElementId, _privateEditorBridgeRef);
 605            await JSRuntime.InvokeVoidAsync("resolveEditorImages", PrivateEditorElementId, _privateEditorBridgeRef);
 606            _privateEditorInitialized = true;
 607        }
 608        catch (JSDisconnectedException)
 609        {
 610            // Navigation/disposal race.
 611        }
 612        catch (Exception ex)
 613        {
 614            Logger.LogError(ex, "Failed to initialize session note editors for {SessionId}", SessionId);
 615            Snackbar.Add("Failed to initialize session editors", Severity.Warning);
 616        }
 617        finally
 618        {
 619            _editorsInitializing = false;
 620        }
 621    }
 622
 623    private async Task DestroyEditorsAsync()
 624    {
 625        try
 626        {
 627            if (_publicEditorInitialized)
 628            {
 629                await JSRuntime.InvokeVoidAsync("destroyTipTapEditor", PublicEditorElementId);
 630            }
 631
 632            if (_privateEditorInitialized)
 633            {
 634                await JSRuntime.InvokeVoidAsync("destroyTipTapEditor", PrivateEditorElementId);
 635            }
 636        }
 637        catch (JSDisconnectedException)
 638        {
 639            // Ignore during navigation/disposal.
 640        }
 641        catch (Exception ex)
 642        {
 643            Logger.LogDebug(ex, "Failed to destroy session editors");
 644        }
 645        finally
 646        {
 647            _publicEditorInitialized = false;
 648            _privateEditorInitialized = false;
 649            _showAutocomplete = false;
 650            _autocompleteSuggestions = new();
 651        }
 652    }
 653
 654    private async Task SaveSessionNotesAsync()
 655    {
 656        await SyncEditorsToViewModelAsync();
 657        await ViewModel.SaveNotesAsync();
 658    }
 659
 660    private async Task SyncEditorsToViewModelAsync()
 661    {
 662        if (!ViewModel.CanManageSessionDetails)
 663        {
 664            return;
 665        }
 666
 667        try
 668        {
 669            if (_publicEditorInitialized)
 670            {
 671                ViewModel.EditPublicNotes = await JSRuntime.InvokeAsync<string>("getTipTapContent", PublicEditorElementI
 672            }
 673
 674            if (_privateEditorInitialized)
 675            {
 676                ViewModel.EditPrivateNotes = await JSRuntime.InvokeAsync<string>("getTipTapContent", PrivateEditorElemen
 677            }
 678        }
 679        catch (JSDisconnectedException)
 680        {
 681            // Ignore during navigation/disposal.
 682        }
 683    }
 684
 685    private Task HandleEditorUpdateAsync(SessionEditorKind editorKind, string html)
 686    {
 4687        if (!ViewModel.CanManageSessionDetails)
 688        {
 1689            return Task.CompletedTask;
 690        }
 691
 3692        if (editorKind == SessionEditorKind.Public)
 693        {
 2694            ViewModel.EditPublicNotes = html;
 695        }
 696        else
 697        {
 1698            ViewModel.EditPrivateNotes = html;
 699        }
 700
 3701        return Task.CompletedTask;
 702    }
 703
 704    private async Task HandleAutocompleteTriggeredAsync(SessionEditorKind editorKind, string query, double x, double y)
 705    {
 706        var normalizedQuery = query?.TrimStart() ?? string.Empty;
 707        _activeEditorKind = editorKind;
 708        _autocompletePosition = (x, y);
 709        _showAutocomplete = true;
 710        _autocompleteSelectedIndex = 0;
 711        var isMapAutocomplete = normalizedQuery.StartsWith("maps/", StringComparison.OrdinalIgnoreCase);
 712        string? mapSearch = null;
 713
 714        if (isMapAutocomplete)
 715        {
 716            var remainder = normalizedQuery.Substring("maps/".Length);
 717            mapSearch = string.IsNullOrWhiteSpace(remainder) ? null : remainder.Trim();
 718            _autocompleteIsExternalQuery = false;
 719            _autocompleteExternalSourceKey = null;
 720            _autocompleteQuery = mapSearch ?? string.Empty;
 721        }
 722        else
 723        {
 724            _autocompleteIsExternalQuery = TryParseExternalAutocompleteQuery(normalizedQuery, out var sourceKey, out var
 725            var mapsExternalSource = _autocompleteIsExternalQuery
 726                && string.Equals(sourceKey, "maps", StringComparison.OrdinalIgnoreCase);
 727
 728            if (mapsExternalSource)
 729            {
 730                isMapAutocomplete = true;
 731                mapSearch = string.IsNullOrWhiteSpace(remainder) ? null : remainder.Trim();
 732                _autocompleteIsExternalQuery = false;
 733                _autocompleteExternalSourceKey = null;
 734                _autocompleteQuery = mapSearch ?? string.Empty;
 735            }
 736            else
 737            {
 738                _autocompleteExternalSourceKey = _autocompleteIsExternalQuery ? sourceKey : null;
 739                _autocompleteQuery = _autocompleteIsExternalQuery ? remainder : normalizedQuery;
 740            }
 741
 742            var minLength = _autocompleteIsExternalQuery ? 0 : 3;
 743            if (!isMapAutocomplete && _autocompleteQuery.Length < minLength)
 744            {
 745                _autocompleteIsMapQuery = false;
 746                _autocompleteSuggestions = new();
 747                await InvokeAsync(StateHasChanged);
 748                return;
 749            }
 750        }
 751
 752        _autocompleteIsMapQuery = isMapAutocomplete;
 753        _autocompleteLoading = true;
 754        await InvokeAsync(StateHasChanged);
 755
 756        try
 757        {
 758            var worldId = ViewModel.World?.Id ?? AppContext.CurrentWorldId ?? Guid.Empty;
 759
 760            if (isMapAutocomplete)
 761            {
 762                var mapSuggestions = await MapApi.GetMapAutocompleteAsync(worldId, mapSearch);
 763                _autocompleteSuggestions = mapSuggestions
 764                    .Select(ArticleWikiLinkAutocompleteItem.FromMapAutocomplete)
 765                    .ToList();
 766            }
 767            else if (_autocompleteIsExternalQuery)
 768            {
 769                var externalSuggestions = await ExternalLinkApiService.GetSuggestionsAsync(
 770                    worldId,
 771                    _autocompleteExternalSourceKey ?? string.Empty,
 772                    _autocompleteQuery,
 773                    CancellationToken.None);
 774
 775                _autocompleteSuggestions = externalSuggestions.Select(ArticleWikiLinkAutocompleteItem.FromExternal).ToLi
 776            }
 777            else
 778            {
 779                var internalSuggestions = await LinkApiService.GetSuggestionsAsync(worldId, _autocompleteQuery);
 780                _autocompleteSuggestions = internalSuggestions.Select(ArticleWikiLinkAutocompleteItem.FromInternal).ToLi
 781            }
 782        }
 783        catch (Exception ex)
 784        {
 785            Logger.LogError(ex, "Error getting session editor autocomplete suggestions");
 786            _autocompleteSuggestions = new();
 787        }
 788        finally
 789        {
 790            _autocompleteLoading = false;
 791            await InvokeAsync(StateHasChanged);
 792        }
 793    }
 794
 795    private Task HandleAutocompleteHiddenAsync(SessionEditorKind editorKind)
 796    {
 3797        if (_activeEditorKind != editorKind)
 798        {
 1799            return Task.CompletedTask;
 800        }
 801
 2802        _showAutocomplete = false;
 2803        _autocompleteSuggestions = new();
 2804        _autocompleteIsExternalQuery = false;
 2805        _autocompleteExternalSourceKey = null;
 2806        return InvokeAsync(StateHasChanged);
 807    }
 808
 809    private Task HandleAutocompleteArrowDownAsync(SessionEditorKind editorKind)
 810    {
 3811        _activeEditorKind = editorKind;
 812
 3813        if (_autocompleteSuggestions.Any())
 814        {
 1815            _autocompleteSelectedIndex = (_autocompleteSelectedIndex + 1) % _autocompleteSuggestions.Count;
 1816            return InvokeAsync(StateHasChanged);
 817        }
 818
 2819        return Task.CompletedTask;
 820    }
 821
 822    private Task HandleAutocompleteArrowUpAsync(SessionEditorKind editorKind)
 823    {
 3824        _activeEditorKind = editorKind;
 825
 3826        if (_autocompleteSuggestions.Any())
 827        {
 1828            _autocompleteSelectedIndex = (_autocompleteSelectedIndex - 1 + _autocompleteSuggestions.Count) % _autocomple
 1829            return InvokeAsync(StateHasChanged);
 830        }
 831
 2832        return Task.CompletedTask;
 833    }
 834
 835    private async Task HandleAutocompleteEnterAsync(SessionEditorKind editorKind)
 836    {
 837        _activeEditorKind = editorKind;
 838
 839        var decision = WikiLinkCommitService.Decide(
 840            _autocompleteQuery,
 841            _autocompleteSuggestions.Count,
 842            _autocompleteSelectedIndex,
 843            _autocompleteIsExternalQuery,
 844            _autocompleteIsMapQuery);
 845
 846        switch (decision)
 847        {
 848            case AutocompleteCommitDecision.SelectExisting select:
 849                await OnAutocompleteSelect(_autocompleteSuggestions[select.Index]);
 850                break;
 851            case AutocompleteCommitDecision.CreateNew create:
 852                await OnAutocompleteCreate(create.Name);
 853                break;
 854        }
 855    }
 856
 857    private Task OnAutocompleteIndexChanged(int index)
 858    {
 1859        _autocompleteSelectedIndex = index;
 1860        StateHasChanged();
 1861        return Task.CompletedTask;
 862    }
 863
 864    private async Task CreateHandwrittenSessionNoteAsync()
 865    {
 866        if (_isCreatingHandwrittenNote || !ViewModel.CanCreateSessionNote)
 867            return;
 868
 869        _isCreatingHandwrittenNote = true;
 870        StateHasChanged();
 871
 872        try
 873        {
 874            // Capture the current URI before navigation
 875            var uriBefore = Navigation.Uri;
 876
 877            await ViewModel.CreateSessionNoteAsync();
 878
 879            // After CreateSessionNoteAsync, the ViewModel navigated to the new note's URL.
 880            // Append ?draw=true to trigger drawing mode on the ArticleDetail page.
 881            var uriAfter = Navigation.Uri;
 882            if (uriAfter != uriBefore)
 883            {
 884                var baseUri = new Uri(uriAfter).GetLeftPart(UriPartial.Path);
 885                Navigation.NavigateTo(baseUri + "?draw=true", forceLoad: false, replace: true);
 886            }
 887        }
 888        catch (Exception ex)
 889        {
 890            Snackbar.Add("Failed to create handwritten note", MudBlazor.Severity.Error);
 891            Logger.LogError(ex, "Failed to create handwritten session note");
 892        }
 893        finally
 894        {
 895            _isCreatingHandwrittenNote = false;
 896            StateHasChanged();
 897        }
 898    }
 899
 900    private async Task OnAutocompleteSelect(ArticleWikiLinkAutocompleteItem suggestion)
 901    {
 902        if (!ViewModel.CanManageSessionDetails)
 903        {
 904            return;
 905        }
 906
 907        var editorElementId = _activeEditorKind == SessionEditorKind.Public ? PublicEditorElementId : PrivateEditorEleme
 908
 909        try
 910        {
 911            if (suggestion.IsCategory && !string.IsNullOrEmpty(suggestion.CategoryKey))
 912            {
 913                await JSRuntime.InvokeVoidAsync("updateAutocompleteText", editorElementId, $"{suggestion.Source}/{sugges
 914                return;
 915            }
 916
 917            if (suggestion.MapId.HasValue)
 918            {
 919                await JSRuntime.InvokeVoidAsync(
 920                    "insertMapLinkToken",
 921                    editorElementId,
 922                    suggestion.MapId.Value.ToString(),
 923                    suggestion.Title);
 924            }
 925
 926            else if (suggestion.IsExternal)
 927            {
 928                if (string.IsNullOrWhiteSpace(suggestion.Source) || string.IsNullOrWhiteSpace(suggestion.ExternalId))
 929                {
 930                    Logger.LogWarning("External suggestion missing source or id");
 931                    return;
 932                }
 933
 934                await JSRuntime.InvokeVoidAsync(
 935                    "insertExternalLinkToken",
 936                    editorElementId,
 937                    suggestion.Source,
 938                    suggestion.ExternalId,
 939                    suggestion.Title);
 940            }
 941            else
 942            {
 943                if (!suggestion.ArticleId.HasValue)
 944                {
 945                    Logger.LogWarning("Internal suggestion missing article id");
 946                    return;
 947                }
 948
 949                var displayText = !string.IsNullOrWhiteSpace(suggestion.MatchedAlias)
 950                    ? $"{suggestion.MatchedAlias} â†’ {suggestion.Title}"
 951                    : suggestion.Title;
 952
 953                await JSRuntime.InvokeVoidAsync("insertWikiLink", editorElementId, suggestion.ArticleId.Value.ToString()
 954            }
 955
 956            _showAutocomplete = false;
 957            _autocompleteSuggestions = new();
 958            StateHasChanged();
 959        }
 960        catch (Exception ex)
 961        {
 962            Logger.LogError(ex, "Error inserting session editor link");
 963            Snackbar.Add("Failed to insert link", Severity.Error);
 964        }
 965    }
 966
 967    private async Task OnAutocompleteCreate(string articleName)
 968    {
 969        if (_autocompleteIsExternalQuery || string.IsNullOrWhiteSpace(articleName))
 970        {
 971            return;
 972        }
 973
 974        var worldId = AppContext.CurrentWorldId ?? ViewModel.World?.Id ?? Guid.Empty;
 975        if (worldId == Guid.Empty)
 976        {
 977            Snackbar.Add("Unable to determine world for new article", Severity.Warning);
 978            return;
 979        }
 980
 981        var editorElementId = _activeEditorKind == SessionEditorKind.Public ? PublicEditorElementId : PrivateEditorEleme
 982
 983        try
 984        {
 985            var created = await WikiLinkService.CreateArticleFromAutocompleteAsync(articleName, worldId);
 986            if (created == null)
 987            {
 988                Snackbar.Add("Failed to create article", Severity.Error);
 989                return;
 990            }
 991
 992            await JSRuntime.InvokeVoidAsync("insertWikiLink", editorElementId, created.Id.ToString(), created.Title);
 993            _showAutocomplete = false;
 994            _autocompleteSuggestions = new();
 995            StateHasChanged();
 996            Snackbar.Add($"Created and linked '{articleName}'", Severity.Success);
 997        }
 998        catch (Exception ex)
 999        {
 1000            Logger.LogError(ex, "Error creating article from session editor autocomplete");
 1001            Snackbar.Add($"Failed to create article: {ex.Message}", Severity.Error);
 1002        }
 1003    }
 1004
 1005    private static bool TryParseExternalAutocompleteQuery(string query, out string sourceKey, out string remainder)
 1006    {
 81007        sourceKey = string.Empty;
 81008        remainder = string.Empty;
 1009
 81010        if (string.IsNullOrWhiteSpace(query))
 1011        {
 11012            return false;
 1013        }
 1014
 71015        var slashIndex = query.IndexOf('/');
 71016        if (slashIndex <= 0)
 1017        {
 41018            return false;
 1019        }
 1020
 31021        sourceKey = query.Substring(0, slashIndex).Trim().ToLowerInvariant();
 31022        remainder = query.Substring(slashIndex + 1);
 31023        return !string.IsNullOrWhiteSpace(sourceKey);
 1024    }
 1025
 1026    private Task HandleMapLinkClickedAsync(string mapId, string? mapName)
 1027    {
 41028        if (!Guid.TryParse(mapId, out var parsedMapId))
 1029        {
 11030            return Task.CompletedTask;
 1031        }
 1032
 31033        _selectedMapId = parsedMapId;
 31034        _selectedMapName = string.IsNullOrWhiteSpace(mapName) ? null : mapName.Trim();
 31035        _isMapModalOpen = true;
 1036
 31037        return InvokeAsync(StateHasChanged);
 1038    }
 1039
 1040    private Task CloseMapModalAsync()
 1041    {
 11042        _isMapModalOpen = false;
 11043        _selectedMapId = Guid.Empty;
 11044        _selectedMapName = null;
 1045
 11046        return InvokeAsync(StateHasChanged);
 1047    }
 1048
 1049    private async Task OnWikiLinkClicked(string targetArticleId)
 1050    {
 1051        if (!Guid.TryParse(targetArticleId, out var articleId))
 1052        {
 1053            return;
 1054        }
 1055
 1056        try
 1057        {
 1058            var info = await ArticleCache.GetArticleInfoAsync(articleId);
 1059            if (info?.FullArticle != null)
 1060            {
 1061                await AppNavigator.GoToArticleAsync(info.FullArticle);
 1062            }
 1063            else
 1064            {
 1065                Snackbar.Add("Article not found", Severity.Warning);
 1066            }
 1067        }
 1068        catch (Exception ex)
 1069        {
 1070            Logger.LogError(ex, "Error navigating to wiki link {ArticleId}", targetArticleId);
 1071            Snackbar.Add("Failed to navigate to article", Severity.Error);
 1072        }
 1073    }
 1074
 1075    private Task OnBrokenLinkClicked(string targetArticleId)
 1076    {
 21077        Snackbar.Add("This link points to a missing article", Severity.Warning);
 21078        return Task.CompletedTask;
 1079    }
 1080
 1081    private async Task<string?> GetArticlePath(string targetArticleId)
 1082    {
 1083        if (!Guid.TryParse(targetArticleId, out var articleId))
 1084        {
 1085            return null;
 1086        }
 1087
 1088        try
 1089        {
 1090            return await ArticleCache.GetArticlePathAsync(articleId);
 1091        }
 1092        catch (Exception ex)
 1093        {
 1094            Logger.LogError(ex, "Error getting article path {ArticleId}", targetArticleId);
 1095            return null;
 1096        }
 1097    }
 1098
 1099    private async Task<object?> GetArticleSummaryPreview(string targetArticleId)
 1100    {
 1101        if (!Guid.TryParse(targetArticleId, out var articleId))
 1102        {
 1103            return null;
 1104        }
 1105
 1106        try
 1107        {
 1108            var preview = await SummaryApi.GetSummaryPreviewAsync(articleId);
 1109            if (preview == null || !preview.HasSummary)
 1110            {
 1111                return null;
 1112            }
 1113
 1114            return new
 1115            {
 1116                title = preview.Title,
 1117                summary = preview.Summary,
 1118                templateName = preview.TemplateName
 1119            };
 1120        }
 1121        catch (Exception ex)
 1122        {
 1123            Logger.LogError(ex, "Error getting article summary preview {ArticleId}", targetArticleId);
 1124            return null;
 1125        }
 1126    }
 1127
 1128    private async Task OnExternalLinkClicked(string source, string id, string title)
 1129    {
 1130        if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(id))
 1131        {
 1132            return;
 1133        }
 1134
 1135        DrawerCoordinator.Close();
 1136        if (DrawerCoordinator.Current != DrawerType.None)
 1137        {
 1138            return;
 1139        }
 1140
 1141        _externalPreviewOpen = true;
 1142        _externalPreviewLoading = true;
 1143        _externalPreviewError = null;
 1144        _externalPreviewSource = source;
 1145        _externalPreviewTitle = string.IsNullOrWhiteSpace(title) ? "External Link" : title;
 1146        _externalPreviewContent = null;
 1147        StateHasChanged();
 1148
 1149        var cacheKey = $"{source}:{id}".ToLowerInvariant();
 1150        if (_externalLinkCache.TryGetValue(cacheKey, out var cached))
 1151        {
 1152            _externalPreviewContent = cached;
 1153            _externalPreviewLoading = false;
 1154            StateHasChanged();
 1155            return;
 1156        }
 1157
 1158        try
 1159        {
 1160            var content = await ExternalLinkApiService.GetContentAsync(source, id, CancellationToken.None);
 1161
 1162            if (content == null || string.IsNullOrWhiteSpace(content.Markdown))
 1163            {
 1164                _externalPreviewError = "No content available.";
 1165            }
 1166            else
 1167            {
 1168                _externalPreviewContent = content;
 1169                _externalLinkCache[cacheKey] = content;
 1170            }
 1171        }
 1172        catch (Exception ex)
 1173        {
 1174            Logger.LogError(ex, "Error loading external link preview for {Source} {Id}", source, id);
 1175            _externalPreviewError = "Failed to load external content.";
 1176        }
 1177        finally
 1178        {
 1179            _externalPreviewLoading = false;
 1180            StateHasChanged();
 1181        }
 1182    }
 1183
 1184    private void CloseExternalPreview()
 1185    {
 11186        _externalPreviewOpen = false;
 11187        StateHasChanged();
 11188    }
 1189
 1190    private async Task InsertPrivateImageFromToolbarAsync()
 1191    {
 1192        if (!_privateEditorInitialized || _privateEditorBridgeRef == null)
 1193        {
 1194            return;
 1195        }
 1196
 1197        await JSRuntime.InvokeVoidAsync("triggerImageUpload", PrivateEditorElementId, _privateEditorBridgeRef);
 1198    }
 1199
 1200    [JSInvokable]
 1201    public async Task<object?> OnImageUploadRequested(string fileName, string contentType, long fileSize)
 1202    {
 1203        var worldId = ViewModel.World?.Id ?? AppContext.CurrentWorldId ?? Guid.Empty;
 1204        if (worldId == Guid.Empty)
 1205        {
 1206            return null;
 1207        }
 1208
 1209        try
 1210        {
 1211            var request = new WorldDocumentUploadRequestDto
 1212            {
 1213                FileName = fileName,
 1214                ContentType = contentType,
 1215                FileSizeBytes = fileSize,
 1216                ArticleId = null,
 1217                Description = $"Inline image for session private notes: {ViewModel.Session?.Name ?? "Session"}"
 1218            };
 1219
 1220            var response = await WorldApi.RequestDocumentUploadAsync(worldId, request);
 1221            if (response == null)
 1222            {
 1223                return null;
 1224            }
 1225
 1226            return new
 1227            {
 1228                uploadUrl = response.UploadUrl,
 1229                documentId = response.DocumentId.ToString()
 1230            };
 1231        }
 1232        catch (Exception ex)
 1233        {
 1234            Logger.LogError(ex, "Error requesting session private note image upload for world {WorldId}", worldId);
 1235            return null;
 1236        }
 1237    }
 1238
 1239    [JSInvokable]
 1240    public async Task OnImageUploadConfirmed(string documentIdStr)
 1241    {
 1242        var worldId = ViewModel.World?.Id ?? AppContext.CurrentWorldId ?? Guid.Empty;
 1243        if (worldId == Guid.Empty || !Guid.TryParse(documentIdStr, out var documentId))
 1244        {
 1245            return;
 1246        }
 1247
 1248        try
 1249        {
 1250            await WorldApi.ConfirmDocumentUploadAsync(worldId, documentId);
 1251        }
 1252        catch (Exception ex)
 1253        {
 1254            Logger.LogError(ex, "Error confirming session private note image upload {DocumentId}", documentId);
 1255        }
 1256    }
 1257
 1258    [JSInvokable]
 11259    public string GetImageProxyUrl(string documentIdStr) => $"chronicis-image:{documentIdStr}";
 1260
 1261    [JSInvokable]
 1262    public async Task<string?> ResolveImageUrl(string documentIdStr)
 1263    {
 1264        if (!Guid.TryParse(documentIdStr, out var documentId))
 1265        {
 1266            return null;
 1267        }
 1268
 1269        try
 1270        {
 1271            var result = await WorldApi.DownloadDocumentAsync(documentId);
 1272            return result?.DownloadUrl;
 1273        }
 1274        catch (Exception ex)
 1275        {
 1276            Logger.LogError(ex, "Error resolving session private note image {DocumentId}", documentId);
 1277            return null;
 1278        }
 1279    }
 1280
 1281    [JSInvokable]
 1282    public void OnImageUploadStarted(string fileName)
 1283    {
 11284        Snackbar.Add($"Uploading {fileName}...", Severity.Info);
 11285        StateHasChanged();
 11286    }
 1287
 1288    [JSInvokable]
 1289    public void OnImageUploadError(string message)
 1290    {
 11291        Snackbar.Add(message, Severity.Error);
 11292        StateHasChanged();
 11293    }
 1294}

Methods/Properties

BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder)
.cctor()
.ctor(Chronicis.Client.Pages.SessionDetail,Chronicis.Client.Pages.SessionDetail/SessionEditorKind)
OnEditorUpdate(System.String)
OnAutocompleteTriggered(System.String,System.Double,System.Double)
OnAutocompleteHidden()
OnAutocompleteArrowDown()
OnAutocompleteArrowUp()
OnAutocompleteEnter()
OnWikiLinkClicked(System.String)
OnBrokenLinkClicked(System.String)
OnExternalLinkClicked(System.String,System.String,System.String)
OnMapLinkClicked(System.String,System.String)
GetArticlePath(System.String)
GetArticleSummaryPreview(System.String)
.ctor()
get_PublicEditorElementId()
get_PrivateEditorElementId()
get_CurrentWorldId()
OnInitialized()
OnViewModelChanged(System.Object,System.ComponentModel.PropertyChangedEventArgs)
OnSessionDateChanged(System.Nullable`1<System.DateTime>)
FormatSessionDate(System.Nullable`1<System.DateTime>)
GetVisibilityColor(Chronicis.Shared.Enums.ArticleVisibility)
Dispose()
HandleDrawerCoordinatorChanged()
HandleEditorUpdateAsync(Chronicis.Client.Pages.SessionDetail/SessionEditorKind,System.String)
HandleAutocompleteHiddenAsync(Chronicis.Client.Pages.SessionDetail/SessionEditorKind)
HandleAutocompleteArrowDownAsync(Chronicis.Client.Pages.SessionDetail/SessionEditorKind)
HandleAutocompleteArrowUpAsync(Chronicis.Client.Pages.SessionDetail/SessionEditorKind)
OnAutocompleteIndexChanged(System.Int32)
TryParseExternalAutocompleteQuery(System.String,System.String&,System.String&)
HandleMapLinkClickedAsync(System.String,System.String)
CloseMapModalAsync()
OnBrokenLinkClicked(System.String)
CloseExternalPreview()
GetImageProxyUrl(System.String)
OnImageUploadStarted(System.String)
OnImageUploadError(System.String)