< Summary

Line coverage
0%
Covered lines: 0
Uncovered lines: 260
Coverable lines: 260
Total lines: 1007
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 136
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Quests/QuestDrawer.razor

#LineLine coverage
 1@* QuestDrawer.razor - Right-side quest drawer for Session/SessionNote pages *@
 2@* Provides quick reference and update capabilities for Arc quests *@
 3
 4@using Chronicis.Shared.Enums
 5@using Chronicis.Shared.DTOs
 6@using Chronicis.Shared.DTOs.Quests
 7@inject IQuestDrawerService QuestDrawerService
 8@inject IQuestApiService QuestApi
 9@inject ITreeStateService TreeState
 10@inject IWikiLinkAutocompleteService AutocompleteService
 11@inject IAppContextService AppContext
 12@inject ISnackbar Snackbar
 13@inject ILogger<QuestDrawer> Logger
 14@inject IJSRuntime JSRuntime
 15@implements IAsyncDisposable
 16
 17<style>
 18    .quest-drawer,
 19    .quest-drawer .mud-drawer-content {
 20        background-color: transparent !important;
 21        border-right-color: transparent;
 22    }
 23    .quest-drawer .mud-drawer-content {
 24        padding: 8px;
 25        background-color: var(--chronicis-soft-off-white) !important;
 26    }
 27    .quest-drawer.mud-drawer-persistent {
 28        height: auto !important;
 29    }
 30
 31    /* Scrollable quest drawer */
 32    .quest-drawer-scrollable {
 33        height: calc(100vh - 180px);
 34        overflow-y: auto;
 35        overflow-x: hidden;
 36        padding-bottom: 24px;
 37    }
 38
 39    /* Section styling */
 40    .quest-section {
 41        margin-bottom: 8px;
 42    }
 43
 44    .quest-section-header {
 45        display: flex;
 46        align-items: center;
 47        gap: 8px;
 48        padding: 12px 16px 8px 16px;
 49    }
 50
 51    .quest-section-icon {
 52        color: var(--chronicis-beige-gold);
 53    }
 54
 55    .quest-section-title {
 56        color: var(--mud-palette-text-secondary);
 57        text-transform: uppercase;
 58        letter-spacing: 0.75px;
 59        font-size: 0.75rem;
 60        font-weight: 600;
 61    }
 62
 63    /* Quest item styling */
 64    .quest-item {
 65        padding: 12px 16px;
 66        cursor: pointer;
 67        transition: background-color 0.2s;
 68        border-left: 3px solid transparent;
 69    }
 70
 71    .quest-item:hover {
 72        background-color: rgba(196, 175, 142, 0.1);
 73    }
 74
 75    .quest-item-selected {
 76        background-color: rgba(196, 175, 142, 0.15);
 77        border-left-color: var(--chronicis-beige-gold);
 78    }
 79
 80    .quest-item-header {
 81        display: flex;
 82        align-items: center;
 83        justify-content: space-between;
 84        gap: 8px;
 85    }
 86
 87    .quest-item-title {
 88        font-size: 0.875rem;
 89        font-weight: 600;
 90        color: var(--mud-palette-text-primary);
 91        flex: 1;
 92        min-width: 0;
 93    }
 94
 95    .quest-item-description {
 96        font-size: 0.813rem;
 97        color: var(--mud-palette-text-secondary);
 98        line-height: 1.4;
 99    }
 100
 101    /* Empty/Loading states */
 102    .quest-drawer-empty-state,
 103    .quest-drawer-loading {
 104        display: flex;
 105        flex-direction: column;
 106        align-items: center;
 107        justify-content: center;
 108        padding: 48px 24px;
 109        text-align: center;
 110    }
 111
 112    /* Quest update editor */
 113    .quest-update-section {
 114        padding: 0 0 16px 0;
 115    }
 116
 117    .quest-editor-container {
 118        margin-bottom: 12px;
 119    }
 120
 121    .quest-tiptap-editor {
 122        min-height: 100px;
 123        max-height: 200px;
 124        overflow-y: auto;
 125        border: 1px solid rgba(0, 0, 0, 0.23);
 126        border-radius: 4px;
 127        padding: 12px;
 128        background-color: white;
 129    }
 130
 131    .quest-tiptap-editor:focus-within {
 132        border-color: var(--chronicis-beige-gold);
 133        outline: none;
 134    }
 135
 136    /* Override TipTap content styling for compact quest updates */
 137    .quest-tiptap-editor .chronicis-editor-content {
 138        padding: 0;
 139        min-height: unset;
 140        font-size: 0.875rem;
 141        line-height: 1.5;
 142    }
 143
 144    .quest-tiptap-editor .chronicis-editor-content h1 {
 145        font-size: 1.25rem;
 146        margin-top: 8px;
 147        margin-bottom: 4px;
 148        border-bottom: none;
 149    }
 150
 151    .quest-tiptap-editor .chronicis-editor-content h2 {
 152        font-size: 1.1rem;
 153        margin-top: 8px;
 154        margin-bottom: 4px;
 155        border-bottom: none;
 156    }
 157
 158    .quest-tiptap-editor .chronicis-editor-content h3 {
 159        font-size: 1rem;
 160        margin-top: 6px;
 161        margin-bottom: 4px;
 162    }
 163
 164    .quest-tiptap-editor .chronicis-editor-content p {
 165        margin-bottom: 8px;
 166        color: var(--mud-palette-text-primary);
 167    }
 168
 169    .quest-tiptap-editor .chronicis-editor-content p:last-child {
 170        margin-bottom: 0;
 171    }
 172
 173    .quest-tiptap-editor:focus-within {
 174        border-color: var(--chronicis-beige-gold);
 175        outline: 1px solid var(--chronicis-beige-gold);
 176    }
 177
 178    /* Session association checkbox - ensure label is visible */
 179    .quest-update-section .mud-checkbox .mud-typography,
 180    .quest-update-section .mud-checkbox-label,
 181    .quest-update-section .mud-input-label {
 182        color: var(--mud-palette-text-primary) !important;
 183        font-size: 0.875rem !important;
 184    }
 185
 186    .quest-update-section .mud-checkbox {
 187        color: var(--mud-palette-text-primary) !important;
 188    }
 189
 190    /* Quest drawer header - larger, centered */
 191    .quest-drawer-header {
 192        display: flex;
 193        align-items: center;
 194        padding: 16px;
 195    }
 196
 197    .quest-drawer-header .mud-typography {
 198        display: flex;
 199        align-items: center;
 200        gap: 12px;
 201        font-size: 1.25rem !important;
 202        font-weight: 600;
 203    }
 204
 205    .quest-drawer-header .mud-icon-root {
 206        font-size: 1.5rem !important;
 207    }
 208        background-color: white;
 209    }
 210
 211    .quest-tiptap-editor:focus-within {
 212        border-color: var(--chronicis-beige-gold);
 213        outline: 1px solid var(--chronicis-beige-gold);
 214    }
 215
 216    /* Quest updates list */
 217    .quest-updates-section {
 218        padding: 0 16px 16px 16px;
 219    }
 220
 221    .quest-updates-list {
 222        display: flex;
 223        flex-direction: column;
 224        gap: 12px;
 225    }
 226
 227    .quest-update-entry {
 228        padding: 12px;
 229        background-color: rgba(255, 255, 255, 0.6);
 230        border-radius: 4px;
 231        border-left: 3px solid rgba(196, 175, 142, 0.3);
 232    }
 233
 234    .quest-update-meta {
 235        display: flex;
 236        justify-content: space-between;
 237        align-items: center;
 238        gap: 8px;
 239        margin-bottom: 8px;
 240        font-size: 0.75rem;
 241    }
 242
 243    .quest-update-body {
 244        font-size: 0.813rem;
 245        color: var(--mud-palette-text-primary);
 246        line-height: 1.4;
 247    }
 248</style>
 249
 250<MudDrawer @bind-Open="IsOpen"
 251           Fixed="true"
 252           Anchor="Anchor.End"
 253           Elevation="9999"
 254           Variant="@DrawerVariant.Persistent"
 255           ClipMode="@DrawerClipMode.Always"
 256           Class="quest-drawer">
 257    <MudDrawerHeader Class="quest-drawer-header">
 258        <MudText Typo="Typo.h5">
 259            <MudIcon Icon="@Icons.Material.Filled.Assignment" />
 260            Quests
 261        </MudText>
 262    </MudDrawerHeader>
 263    <MudDrawerContainer Class="quest-drawer-scrollable chronicis-scrollbar-light">
 264
 0265        @if (_emptyStateMessage != null)
 266        {
 267            <!-- Empty State -->
 268            <div class="quest-drawer-empty-state">
 269                <MudIcon Icon="@Icons.Material.Filled.Assignment"
 270                         Size="Size.Large"
 271                         Style="font-size: 4rem; opacity: 0.3; color: var(--chronicis-beige-gold);" />
 272                <MudText Typo="Typo.body1" Align="Align.Center" Class="mt-3">
 0273                    @_emptyStateMessage
 274                </MudText>
 275            </div>
 276        }
 0277        else if (_loadingError != null)
 278        {
 279            <!-- Error State -->
 280            <div class="quest-drawer-empty-state">
 281                <MudIcon Icon="@Icons.Material.Filled.Error"
 282                         Size="Size.Large"
 283                         Color="Color.Error"
 284                         Style="font-size: 4rem; opacity: 0.5;" />
 285                <MudText Typo="Typo.body1" Align="Align.Center" Class="mt-3" Color="Color.Error">
 286                    Failed to load quests
 287                </MudText>
 288                <MudText Typo="Typo.body2" Align="Align.Center" Class="mt-2" Style="opacity: 0.7;">
 0289                    @_loadingError
 290                </MudText>
 291                <MudButton Variant="Variant.Outlined"
 292                           Color="Color.Primary"
 293                           Class="mt-3"
 294                           OnClick="LoadQuestsAsync">
 295                    Retry
 296                </MudButton>
 297            </div>
 298        }
 0299        else if (_isLoading)
 300        {
 301            <!-- Loading State -->
 302            <div class="quest-drawer-loading">
 303                <MudProgressCircular Color="Color.Primary" Indeterminate="true" />
 304                <MudText Typo="Typo.body2" Class="mt-3" Style="opacity: 0.7;">
 305                    Loading quests...
 306                </MudText>
 307            </div>
 308        }
 0309        else if (_quests != null && _quests.Any())
 310        {
 311            <!-- ========================================= -->
 312            <!-- SECTION 1: ACTIVE QUESTS                 -->
 313            <!-- ========================================= -->
 314            <div class="quest-section">
 315                <div class="quest-section-header">
 316                    <MudIcon Icon="@Icons.Material.Filled.Assignment" Size="Size.Small" Class="quest-section-icon" />
 317                    <MudText Typo="Typo.subtitle2" Class="quest-section-title">Active Quests</MudText>
 318                    <MudText Typo="Typo.caption" Style="color: var(--mud-palette-text-secondary); font-size: 0.7rem;">
 0319                        @_quests.Count
 320                    </MudText>
 321                </div>
 322            </div>
 323
 324            <!-- Quest List -->
 0325            @foreach (var quest in _quests)
 326            {
 327                <div class="quest-item @(_selectedQuestId == quest.Id ? "quest-item-selected" : "")"
 0328                     @onclick="@(() => SelectQuest(quest.Id))"
 329                     role="button"
 330                     tabindex="0"
 0331                     @onkeydown="@(e => HandleQuestItemKeyDown(e, quest.Id))">
 332                    <div class="quest-item-header">
 333                        <MudText Typo="Typo.subtitle2" Class="quest-item-title">
 0334                            @quest.Title
 335                        </MudText>
 336                        <QuestStatusChip Status="@quest.Status" />
 337                    </div>
 0338                    @if (_selectedQuestId == quest.Id && !string.IsNullOrEmpty(quest.Description))
 339                    {
 340                        <div class="quest-item-description mt-2">
 0341                            @((MarkupString)quest.Description)
 342                        </div>
 343                    }
 344                </div>
 345            }
 346
 0347            @if (_selectedQuest != null)
 348            {
 349                <MudDivider Class="my-4" Style="opacity: 0.3;" />
 350
 351                <!-- ========================================= -->
 352                <!-- SECTION 2: ADD UPDATE                    -->
 353                <!-- ========================================= -->
 354                <div class="quest-section">
 355                    <div class="quest-section-header">
 356                        <MudIcon Icon="@Icons.Material.Filled.Add" Size="Size.Small" Class="quest-section-icon" />
 357                        <MudText Typo="Typo.subtitle2" Class="quest-section-title">Add Update</MudText>
 358                    </div>
 359                </div>
 360
 361                <div class="quest-update-section">
 362                    <!-- TipTap Editor Container -->
 363                    <div class="quest-editor-container px-3">
 364                        <div id="quest-update-editor" class="quest-tiptap-editor"></div>
 365                    </div>
 366
 0367                    @if (_validationError != null)
 368                    {
 369                        <MudText Typo="Typo.caption" Color="Color.Error" Class="px-3 mt-1">
 0370                            @_validationError
 371                        </MudText>
 372                    }
 373
 374                    <!-- Session Association Checkbox -->
 0375                    @if (_canAssociateSession)
 376                    {
 377                        <div class="px-3 mt-2">
 378                            <MudCheckBox T="bool" @bind-Value="_associateWithSession"
 379                                         Label="Associate with this session"
 380                                         Dense="true"
 381                                         Color="Color.Primary" />
 382                        </div>
 383                    }
 384
 385                    <!-- Submit Button -->
 386                    <div class="px-3 mt-3">
 387                        <MudButton Variant="Variant.Filled"
 388                                   Color="Color.Primary"
 389                                   FullWidth="true"
 390                                   Disabled="@_isSubmitting"
 391                                   OnClick="SubmitUpdate">
 0392                            @if (_isSubmitting)
 393                            {
 394                                <MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
 395                                <span>Submitting...</span>
 396                            }
 397                            else
 398                            {
 399                                <span>Add Update</span>
 400                            }
 401                        </MudButton>
 402                    </div>
 403                </div>
 404
 405                <MudDivider Class="my-4" Style="opacity: 0.3;" />
 406
 407                <!-- ========================================= -->
 408                <!-- SECTION 3: RECENT UPDATES                -->
 409                <!-- ========================================= -->
 410                <div class="quest-section">
 411                    <div class="quest-section-header">
 412                        <MudIcon Icon="@Icons.Material.Filled.History" Size="Size.Small" Class="quest-section-icon" />
 413                        <MudText Typo="Typo.subtitle2" Class="quest-section-title">Recent Updates</MudText>
 414                    </div>
 415                </div>
 416
 417                <div class="quest-updates-section">
 0418                    @if (_loadingUpdates)
 419                    {
 420                        <div class="d-flex align-center gap-2">
 421                            <MudProgressCircular Size="Size.Small" Indeterminate="true" />
 422                            <MudText Typo="Typo.body2" Style="opacity: 0.6;">Loading updates...</MudText>
 423                        </div>
 424                    }
 0425                    else if (_recentUpdates == null || !_recentUpdates.Any())
 426                    {
 427                        <MudText Typo="Typo.body2" Style="opacity: 0.6;">
 428                            No updates yet. Be the first to add one!
 429                        </MudText>
 430                    }
 431                    else
 432                    {
 433                        <div class="quest-updates-list">
 0434                            @foreach (var update in _recentUpdates)
 435                            {
 436                                <div class="quest-update-entry">
 437                                    <div class="quest-update-meta">
 438                                        <MudText Typo="Typo.caption">
 0439                                            <strong>@update.CreatedByName</strong>
 0440                                            @if (update.SessionId.HasValue && !string.IsNullOrEmpty(update.SessionTitle)
 441                                            {
 0442                                                <span> in @update.SessionTitle</span>
 443                                            }
 444                                        </MudText>
 445                                        <MudText Typo="Typo.caption" Style="opacity: 0.7;">
 0446                                            @update.CreatedAt.ToString("MMM d 'at' h:mm tt")
 447                                        </MudText>
 448                                    </div>
 449                                    <div class="quest-update-body">
 0450                                        @((MarkupString)update.Body)
 451                                    </div>
 452                                </div>
 453                            }
 454                        </div>
 455                    }
 456                </div>
 457            }
 458        }
 459        else
 460        {
 461            <!-- No Quests State -->
 462            <div class="quest-drawer-empty-state">
 463                <MudIcon Icon="@Icons.Material.Filled.CheckCircle"
 464                         Size="Size.Large"
 465                         Style="font-size: 4rem; opacity: 0.3; color: var(--chronicis-beige-gold);" />
 466                <MudText Typo="Typo.body1" Align="Align.Center" Class="mt-3">
 467                    No quests in this arc yet
 468                </MudText>
 469                <MudText Typo="Typo.body2" Align="Align.Center" Class="mt-2" Style="opacity: 0.7;">
 470                    The GM can create quests from the Arc detail page
 471                </MudText>
 472            </div>
 473        }
 474
 475    </MudDrawerContainer>
 476</MudDrawer>
 477
 478@* Wiki Link Autocomplete Component *@
 479<WikiLinkAutocomplete EditorId="quest-update-editor"
 480                      WorldId="@_currentWorldId"
 481                      OnSuggestionSelected="HandleAutocompleteSuggestionSelected" />

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Quests/QuestDrawer.razor.cs

#LineLine coverage
 1using Chronicis.Client.Services;
 2using Chronicis.Shared.DTOs;
 3using Chronicis.Shared.DTOs.Quests;
 4using Chronicis.Shared.Enums;
 5using Microsoft.AspNetCore.Components;
 6using Microsoft.AspNetCore.Components.Web;
 7using Microsoft.JSInterop;
 8using MudBlazor;
 9
 10namespace Chronicis.Client.Components.Quests;
 11
 12public partial class QuestDrawer : IAsyncDisposable
 13{
 014    [Inject] private IArticleApiService ArticleApi { get; set; } = null!;
 15
 016    private bool IsOpen { get; set; }
 17    private bool _isLoading;
 18    private bool _isSubmitting;
 19    private bool _loadingUpdates;
 20    private string? _emptyStateMessage;
 21    private string? _loadingError;
 22    private string? _validationError;
 23
 24    private List<QuestDto>? _quests;
 25    private Guid? _selectedQuestId;
 26    private QuestDto? _selectedQuest;
 27    private List<QuestUpdateEntryDto>? _recentUpdates;
 28
 29    private Guid? _currentArcId;
 30    private Guid? _currentSessionId;
 31    private Guid? _currentWorldId;
 32    private bool _canAssociateSession;
 033    private bool _associateWithSession = true;
 34
 35    private IJSObjectReference? _editorModule;
 36    private bool _editorInitialized;
 37    private DotNetObjectReference<QuestDrawer>? _dotNetRef;
 38    private bool _disposed;
 39    private bool _questsLoadedForArc;
 40    private bool _needsEditorInit; // Flag to trigger editor init after render
 41
 42    protected override void OnInitialized()
 43    {
 044        QuestDrawerService.OnOpen += HandleOpen;
 045        QuestDrawerService.OnClose += HandleClose;
 046    }
 47
 48    protected override async Task OnAfterRenderAsync(bool firstRender)
 49    {
 50        // Initialize editor after DOM has been updated
 051        if (_needsEditorInit && !_editorInitialized)
 52        {
 053            _needsEditorInit = false;
 054            await InitializeEditorAsync();
 55        }
 056    }
 57
 58    private async void HandleOpen()
 59    {
 060        IsOpen = true;
 061        await InvokeAsync(async () =>
 062        {
 063            await LoadQuestsAsync();
 064            StateHasChanged();
 065
 066            // Focus the first quest selector after render
 067            if (_quests?.Any() == true)
 068            {
 069                await Task.Delay(100);
 070                await FocusFirstQuestAsync();
 071            }
 072        });
 073    }
 74
 75    private async void HandleClose()
 76    {
 077        IsOpen = false;
 078        await InvokeAsync(async () =>
 079        {
 080            await DisposeEditorAsync();
 081            _selectedQuestId = null;
 082            _selectedQuest = null;
 083            _recentUpdates = null;
 084            _validationError = null;
 085            StateHasChanged();
 086        });
 087    }
 88
 89    private async Task CloseDrawer()
 90    {
 091        QuestDrawerService.Close();
 92
 93        // Give drawer time to close, then return focus to main content
 094        await Task.Delay(200);
 095        await RestoreFocusAsync();
 096    }
 97
 98    private async Task LoadQuestsAsync()
 99    {
 0100        _isLoading = true;
 0101        _emptyStateMessage = null;
 0102        _loadingError = null;
 103
 104        try
 105        {
 106            // Resolve Arc and Session from current article
 0107            var selectedArticle = await GetCurrentArticleAsync();
 108
 0109            if (selectedArticle == null)
 110            {
 0111                _emptyStateMessage = "No article selected. Navigate to a session to use quest tracking.";
 0112                return;
 113            }
 114
 115            // Store the world ID for autocomplete
 0116            _currentWorldId = selectedArticle.WorldId;
 117
 118            // Check if we're on a Session or SessionNote page
 0119            if (selectedArticle.Type != ArticleType.Session && selectedArticle.Type != ArticleType.SessionNote)
 120            {
 0121                _emptyStateMessage = "Navigate to a session or session note to use quest tracking.";
 0122                return;
 123            }
 124
 0125            _currentArcId = selectedArticle.ArcId;
 126
 0127            if (!_currentArcId.HasValue)
 128            {
 0129                _emptyStateMessage = "This session is not associated with an arc.";
 0130                return;
 131            }
 132
 133            // Resolve SessionId
 0134            if (selectedArticle.Type == ArticleType.Session)
 135            {
 0136                _currentSessionId = selectedArticle.Id;
 0137                _canAssociateSession = true;
 138            }
 0139            else if (selectedArticle.Type == ArticleType.SessionNote)
 140            {
 0141                _currentSessionId = await ResolveSessionIdFromParentAsync(selectedArticle.Id);
 0142                _canAssociateSession = _currentSessionId.HasValue;
 143            }
 144
 145            // Only load quests if we haven't already loaded for this arc (prevent duplicate fetches)
 0146            if (!_questsLoadedForArc)
 147            {
 0148                _quests = await QuestApi.GetArcQuestsAsync(_currentArcId.Value);
 0149                _questsLoadedForArc = true;
 150            }
 151
 152            // Auto-select first quest if available and none selected
 0153            if (_quests?.Any() == true && !_selectedQuestId.HasValue)
 154            {
 0155                await SelectQuest(_quests.First().Id);
 156            }
 0157        }
 0158        catch (Exception ex)
 159        {
 0160            Logger.LogError(ex, "Failed to load quests");
 0161            _loadingError = ex.Message;
 0162            _quests = null;
 0163        }
 164        finally
 165        {
 0166            _isLoading = false;
 167        }
 0168    }
 169
 170    private async Task<ArticleDto?> GetCurrentArticleAsync()
 171    {
 0172        var selectedNodeId = TreeState.SelectedNodeId;
 173
 0174        if (!selectedNodeId.HasValue)
 0175            return null;
 176
 177        try
 178        {
 0179            return await ArticleApi.GetArticleDetailAsync(selectedNodeId.Value);
 180        }
 0181        catch
 182        {
 0183            return null;
 184        }
 0185    }
 186
 187    private async Task<Guid?> ResolveSessionIdFromParentAsync(Guid articleId)
 188    {
 189        try
 190        {
 0191            var article = await ArticleApi.GetArticleDetailAsync(articleId);
 192
 0193            while (article != null)
 194            {
 0195                if (article.Type == ArticleType.Session)
 0196                    return article.Id;
 197
 0198                if (!article.ParentId.HasValue)
 199                    break;
 200
 0201                article = await ArticleApi.GetArticleDetailAsync(article.ParentId.Value);
 202            }
 0203        }
 0204        catch (Exception ex)
 205        {
 0206            Logger.LogError(ex, "Failed to resolve session ID");
 0207        }
 208
 0209        return null;
 0210    }
 211
 212    private async Task SelectQuest(Guid questId)
 213    {
 0214        if (_selectedQuestId == questId)
 0215            return;
 216
 217        // Dispose old editor if switching quests
 0218        if (_editorInitialized)
 219        {
 0220            await DisposeEditorAsync();
 221        }
 222
 0223        _selectedQuestId = questId;
 0224        _selectedQuest = _quests?.FirstOrDefault(q => q.Id == questId);
 0225        _validationError = null;
 226
 0227        if (_selectedQuest != null)
 228        {
 229            // Load recent updates
 0230            await LoadRecentUpdatesAsync(questId);
 231
 232            // Set flag to initialize editor after next render
 0233            _needsEditorInit = true;
 234        }
 235
 236        // Trigger render - OnAfterRenderAsync will handle editor init
 0237        StateHasChanged();
 0238    }
 239
 240    private async Task HandleQuestItemKeyDown(KeyboardEventArgs e, Guid questId)
 241    {
 0242        if (e.Key == "Enter" || e.Key == " ")
 243        {
 0244            await SelectQuest(questId);
 245        }
 0246    }
 247
 248    private async Task LoadRecentUpdatesAsync(Guid questId)
 249    {
 0250        _loadingUpdates = true;
 0251        StateHasChanged();
 252
 253        try
 254        {
 0255            var result = await QuestApi.GetQuestUpdatesAsync(questId, skip: 0, take: 5);
 0256            _recentUpdates = result.Items;
 0257        }
 0258        catch (Exception ex)
 259        {
 0260            Logger.LogError(ex, "Failed to load quest updates");
 0261            _recentUpdates = new List<QuestUpdateEntryDto>();
 0262        }
 263        finally
 264        {
 0265            _loadingUpdates = false;
 0266            StateHasChanged();
 267        }
 0268    }
 269
 270    private async Task InitializeEditorAsync()
 271    {
 0272        if (_editorInitialized || _disposed)
 0273            return;
 274
 275        try
 276        {
 0277            _dotNetRef = DotNetObjectReference.Create(this);
 0278            _editorModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
 0279                "import", "./js/questEditor.js");
 280
 0281            await _editorModule.InvokeVoidAsync("initializeEditor", "quest-update-editor", _dotNetRef);
 0282            _editorInitialized = true;
 283
 284            // Focus the editor after initialization
 0285            await Task.Delay(50);
 0286            await FocusEditorAsync();
 0287        }
 0288        catch (Exception ex)
 289        {
 0290            Logger.LogError(ex, "Failed to initialize quest editor");
 0291            Snackbar.Add("Failed to initialize editor", Severity.Warning);
 0292        }
 0293    }
 294
 295    private async Task DisposeEditorAsync()
 296    {
 0297        if (!_editorInitialized || _disposed)
 0298            return;
 299
 300        try
 301        {
 0302            if (_editorModule != null)
 303            {
 0304                await _editorModule.InvokeVoidAsync("destroyEditor");
 305            }
 0306        }
 0307        catch (Exception ex)
 308        {
 0309            Logger.LogError(ex, "Error disposing quest editor");
 0310        }
 311        finally
 312        {
 0313            _editorInitialized = false;
 314        }
 0315    }
 316
 317    private async Task SubmitUpdate()
 318    {
 0319        if (_selectedQuest == null || _isSubmitting)
 0320            return;
 321
 0322        _isSubmitting = true;
 0323        _validationError = null;
 324
 325        try
 326        {
 327            // Get editor content
 0328            if (_editorModule == null)
 329            {
 0330                _validationError = "Editor not initialized";
 0331                return;
 332            }
 333
 0334            var content = await _editorModule.InvokeAsync<string>("getEditorContent");
 335
 336            // Validate content is not empty or whitespace
 0337            if (string.IsNullOrWhiteSpace(content) || content == "<p></p>" || content == "<p><br></p>")
 338            {
 0339                _validationError = "Update content cannot be empty";
 0340                return;
 341            }
 342
 343            // Create update DTO
 0344            var createDto = new QuestUpdateCreateDto
 0345            {
 0346                Body = content,
 0347                SessionId = _associateWithSession && _canAssociateSession ? _currentSessionId : null
 0348            };
 349
 350            // Submit via API
 0351            var result = await QuestApi.AddQuestUpdateAsync(_selectedQuest.Id, createDto);
 352
 0353            if (result != null)
 354            {
 355                // Clear editor
 0356                await _editorModule.InvokeVoidAsync("clearEditor");
 357
 358                // Refresh updates list
 0359                await LoadRecentUpdatesAsync(_selectedQuest.Id);
 360
 361                // Update the quest's UpdatedAt (refresh from server if needed)
 0362                var refreshedQuest = await QuestApi.GetQuestAsync(_selectedQuest.Id);
 0363                if (refreshedQuest != null)
 364                {
 0365                    var questInList = _quests?.FirstOrDefault(q => q.Id == _selectedQuest.Id);
 0366                    if (questInList != null)
 367                    {
 0368                        questInList.UpdatedAt = refreshedQuest.UpdatedAt;
 0369                        questInList.UpdateCount = refreshedQuest.UpdateCount;
 370                    }
 371
 0372                    _selectedQuest = refreshedQuest;
 373                }
 374
 0375                Snackbar.Add("Quest update added", Severity.Success);
 376            }
 377            else
 378            {
 0379                _validationError = "Failed to add quest update";
 380            }
 0381        }
 0382        catch (Exception ex)
 383        {
 0384            Logger.LogError(ex, "Failed to submit quest update");
 0385            _validationError = $"Error: {ex.Message}";
 0386        }
 387        finally
 388        {
 0389            _isSubmitting = false;
 0390            StateHasChanged();
 391        }
 0392    }
 393
 394    private async Task FocusFirstQuestAsync()
 395    {
 396        try
 397        {
 0398            await JSRuntime.InvokeVoidAsync("eval",
 0399                "document.querySelector('.quest-item')?.focus()");
 0400        }
 0401        catch
 402        {
 403            // Ignore focus errors
 0404        }
 0405    }
 406
 407    private async Task FocusEditorAsync()
 408    {
 409        try
 410        {
 0411            if (_editorModule != null)
 412            {
 0413                await _editorModule.InvokeVoidAsync("focusEditor");
 414            }
 0415        }
 0416        catch
 417        {
 418            // Ignore focus errors
 0419        }
 0420    }
 421
 422    private async Task RestoreFocusAsync()
 423    {
 424        try
 425        {
 0426            await JSRuntime.InvokeVoidAsync("eval",
 0427                "document.querySelector('.chronicis-article-card')?.focus() || document.querySelector('main')?.focus()")
 0428        }
 0429        catch
 430        {
 431            // Ignore focus errors
 0432        }
 0433    }
 434
 435    #region Wiki Link Autocomplete
 436
 437    [JSInvokable]
 438    public async Task OnAutocompleteTriggered(string query, double x, double y)
 439    {
 0440        await AutocompleteService.ShowAsync(query, x, y, _currentWorldId);
 0441    }
 442
 443    [JSInvokable]
 444    public Task OnAutocompleteHidden()
 445    {
 0446        AutocompleteService.Hide();
 0447        return Task.CompletedTask;
 448    }
 449
 450    [JSInvokable]
 451    public Task OnAutocompleteArrowDown()
 452    {
 0453        AutocompleteService.SelectNext();
 0454        return Task.CompletedTask;
 455    }
 456
 457    [JSInvokable]
 458    public Task OnAutocompleteArrowUp()
 459    {
 0460        AutocompleteService.SelectPrevious();
 0461        return Task.CompletedTask;
 462    }
 463
 464    [JSInvokable]
 465    public async Task OnAutocompleteEnter()
 466    {
 0467        var selected = AutocompleteService.GetSelectedSuggestion();
 0468        if (selected != null)
 469        {
 0470            await HandleAutocompleteSuggestionSelected(selected);
 471        }
 0472    }
 473
 474    private async Task HandleAutocompleteSuggestionSelected(WikiLinkAutocompleteItem suggestion)
 475    {
 0476        if (_editorModule == null)
 0477            return;
 478
 479        try
 480        {
 0481            if (suggestion.IsExternal)
 482            {
 483                // Insert external link: [[srd/spell-name]]
 0484                var linkText = $"{AutocompleteService.ExternalSourceKey}/{suggestion.ExternalKey}";
 0485                await _editorModule.InvokeVoidAsync("insertWikiLink", linkText, suggestion.DisplayText);
 486            }
 487            else
 488            {
 489                // Insert internal article link: [[article-title]]
 0490                await _editorModule.InvokeVoidAsync("insertWikiLink", suggestion.DisplayText, null);
 491            }
 492
 493            // Hide autocomplete
 0494            AutocompleteService.Hide();
 0495        }
 0496        catch (Exception ex)
 497        {
 0498            Logger.LogError(ex, "Failed to insert wiki link suggestion");
 0499        }
 0500    }
 501
 502    #endregion
 503
 504    public async ValueTask DisposeAsync()
 505    {
 0506        if (_disposed)
 0507            return;
 508
 0509        _disposed = true;
 510
 0511        QuestDrawerService.OnOpen -= HandleOpen;
 0512        QuestDrawerService.OnClose -= HandleClose;
 513
 514        // Properly dispose TipTap editor
 0515        await DisposeEditorAsync();
 516
 0517        _dotNetRef?.Dispose();
 518
 0519        if (_editorModule != null)
 520        {
 0521            await _editorModule.DisposeAsync();
 522        }
 523
 0524        GC.SuppressFinalize(this);
 0525    }
 526}