< Summary

Information
Class: Chronicis.Client.Components.Layout.AuthenticatedLayout
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Layout/AuthenticatedLayout.razor
Line coverage
100%
Covered lines: 51
Uncovered lines: 0
Coverable lines: 51
Total lines: 489
Line coverage: 100%
Branch coverage
100%
Covered branches: 8
Total branches: 8
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%11100%
OnCtrlM()100%11100%
OnCtrlT()100%11100%
OnCtrlShiftF()100%11100%
OnCtrlQ()100%11100%
OnCtrlS()100%11100%
ToggleDrawer()100%11100%
ToggleTutorialDrawer()100%11100%
OpenAdminUtilities()100%11100%
OpenFeaturesDrawer()100%11100%
get_ShouldRenderGlobalDrawerHost()100%11100%
IsArticleRoute()100%22100%
HandleLocationChanged(...)100%11100%
HandleAppContextChanged()100%11100%
IsEligibleForOnboardingTutorialAutoOpen()100%11100%
GetCurrentPathOnly()100%11100%
OnTreeSearchChanged()100%22100%
ClearSearch()100%11100%
OnGlobalSearchKeyDown(...)100%22100%
ExecuteGlobalSearch()100%22100%
BeginSignOut()100%11100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Components/Layout/AuthenticatedLayout.razor

#LineLine coverage
 1@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
 2@using Microsoft.AspNetCore.Components.Routing
 3@using Chronicis.Client.Abstractions
 4@using Chronicis.Client.Components.Drawers
 5@using Chronicis.Client.Models
 6@using Chronicis.Shared.DTOs
 7@inherits LayoutComponentBase
 8@inject NavigationManager Navigation
 9@inject IAppNavigator AppNavigator
 10@inject ITreeStateService TreeState
 11@inject IAuthService AuthService
 12@inject IArticleApiService ArticleApi
 13@inject ISnackbar Snackbar
 14@inject IJSRuntime JSRuntime
 15@inject AuthenticationStateProvider AuthenticationStateProvider
 16@inject MudTheme ChronicisTheme
 17@inject IMetadataDrawerService MetadataDrawerService
 18@inject IQuestDrawerService QuestDrawerService
 19@inject IKeyboardShortcutService KeyboardShortcutService
 20@inject IDrawerCoordinator DrawerCoordinator
 21@inject IAdminAuthService AdminAuthService
 22@inject IUserApiService UserApi
 23@inject IAppContextService AppContext
 24@inject ILogger<AuthenticatedLayout> Logger
 25@inject IVersionService VersionService
 26@implements IAsyncDisposable
 27
 28<MudThemeProvider Theme="ChronicisTheme" />
 29<MudPopoverProvider />
 30<MudDialogProvider />
 31<MudSnackbarProvider />
 32
 33<MudLayout>
 34    <MudAppBar Elevation="2">
 35        <MudIconButton Icon="@Icons.Material.Filled.Menu"
 36                       Color="Color.Inherit"
 37                       Edge="Edge.Start"
 38                       OnClick="@ToggleDrawer" />
 39
 40        <a href="/dashboard" class="d-flex align-center chronicis-home-link">
 41            <img src="/images/logo.png" alt="Chronicis" class="chronicis-logo" />
 42            <MudText Typo="Typo.h6" Class="chronicis-title">Chronicis</MudText>
 43        </a>
 44
 45        <MudSpacer />
 46
 47        <MudTextField @bind-Value="_globalSearchQuery"
 48                      Placeholder="Search content..."
 49                      Adornment="Adornment.Start"
 50                      AdornmentIcon="@Icons.Material.Filled.Search"
 51                      Variant="Variant.Outlined"
 52                      Margin="Margin.Dense"
 53                      Style="max-width: 400px; background-color: rgba(255,255,255,0.1);"
 54                      Class="global-search-input"
 55                      Immediate="true"
 56                      @onkeydown="OnGlobalSearchKeyDown" />
 57
 58        <MudTooltip Text="Tutorial (Ctrl+T)">
 59            <MudIconButton Icon="@Icons.Material.Filled.Help"
 60                           Color="Color.Inherit"
 61                           OnClick="ToggleTutorialDrawer" />
 62        </MudTooltip>
 63
 64        @if (_isSysAdmin)
 65        {
 66            <MudMenu Icon="@Icons.Material.Filled.AdminPanelSettings"
 67                     Color="Color.Inherit"
 68                     AnchorOrigin="Origin.BottomRight"
 69                     TransformOrigin="Origin.TopRight">
 70                <ChildContent>
 71                    <MudMenuItem Icon="@Icons.Material.Filled.AdminPanelSettings"
 72                                 IconColor="Color.Default"
 73                                 OnClick="OpenAdminUtilities">
 74                        Admin Utilities
 75                    </MudMenuItem>
 76                    <MudMenuItem Icon="@Icons.Material.Filled.AutoStories"
 77                                 IconColor="Color.Default"
 78                                 OnClick="OpenFeaturesDrawer">
 79                        Features
 80                    </MudMenuItem>
 81                </ChildContent>
 82            </MudMenu>
 83        }
 84
 85        <MudMenu Icon="@Icons.Material.Filled.AccountCircle"
 86                 Color="Color.Inherit"
 87                 AnchorOrigin="Origin.BottomRight"
 88                 TransformOrigin="Origin.TopRight">
 89            <ChildContent>
 90                <div class="user-info" style="padding: 12px 16px; min-width: 200px;">
 91                    @if (!string.IsNullOrEmpty(_currentUser?.AvatarUrl))
 92                    {
 93                        <MudImage Src="@_currentUser.AvatarUrl" Style="margin-bottom: 8px;" />
 94                    }
 95                    <div style="margin-top: 8px;">
 96                        <div style="font-weight: 600;">@_currentUser?.DisplayName</div>
 97                        <div style="font-size: 0.85rem; opacity: 0.8;">@_currentUser?.Email</div>
 98                    </div>
 99                </div>
 100                <MudDivider />
 101                <MudMenuItem Icon="@Icons.Material.Filled.Settings"
 102                    IconColor="Color.Default"
 103                    Href="/settings">
 104                    Settings
 105                </MudMenuItem>
 106                <MudMenuItem Icon="@Icons.Material.Filled.Logout"
 107                    IconColor="Color.Primary"
 108                    OnClick="BeginSignOut">
 109                    Logout
 110                </MudMenuItem>
 111            </ChildContent>
 112        </MudMenu>
 113    </MudAppBar>
 114
 115    <MudDrawer @bind-Open="_drawerOpen"
 116               Elevation="2"
 117               ClipMode="DrawerClipMode.Always"
 118               Width="320px">
 119
 120        <div class="chronicis-search-box">
 121            <MudTextField @bind-Value="_treeSearch"
 122                          @bind-Value:after="OnTreeSearchChanged"
 123                          Placeholder="Filter articles..."
 124                          Adornment="Adornment.End"
 125                          AdornmentIcon="@(_treeSearch?.Length > 0 ? Icons.Material.Filled.Clear : Icons.Material.Filled
 126                          AdornmentColor="Color.Primary"
 127                          OnAdornmentClick="ClearSearch"
 128                          Variant="Variant.Outlined"
 129                          Margin="Margin.Dense"
 130                          Immediate="true"
 131                          DebounceInterval="200"
 132                          Class="mb-2" />
 133        </div>
 134
 135        <QuickAddSession />
 136
 137        <ArticleTreeView />
 138    </MudDrawer>
 139
 140    <MudMainContent Class="mud-main-content">
 141        <div class="authenticated-content-wrapper">
 142            <MudContainer MaxWidth="MaxWidth.Large" Class="py-4">
 143                <ErrorBoundary>
 144                    <ChildContent>
 145                        @Body
 146                    </ChildContent>
 147                    <ErrorContent Context="exception">
 148                        <div class="chronicis-error-state">
 149                            <MudPaper Elevation="3" Class="pa-4">
 150                                <div class="chronicis-empty-state">
 151                                    <div class="chronicis-empty-state-icon">⚠️</div>
 152                                    <MudText Typo="Typo.h5" Class="chronicis-empty-state-title mb-3">
 153                                        Something went wrong
 154                                    </MudText>
 155                                    <MudText Typo="Typo.body1" Class="chronicis-empty-state-message mb-4">
 156                                        An unexpected error occurred. Please try refreshing the page.
 157                                    </MudText>
 158                                    <MudButton Variant="Variant.Filled"
 159                                               Color="Color.Primary"
 160                                               OnClick="@(() => Navigation.NavigateTo(Navigation.Uri, forceLoad: true))"
 161                                        Refresh Page
 162                                    </MudButton>
 163                                </div>
 164                            </MudPaper>
 165                        </div>
 166                    </ErrorContent>
 167                </ErrorBoundary>
 168            </MudContainer>
 169
 170            <PublicFooter />
 171        </div>
 172    </MudMainContent>
 173
 174    @if (ShouldRenderGlobalDrawerHost)
 175    {
 176        <DrawerHost />
 177    }
 178</MudLayout>
 179
 180@code {
 49181    private bool _drawerOpen = true;
 182    private string? _treeSearch;
 49183    private string _globalSearchQuery = string.Empty;
 184    private UserInfo? _currentUser;
 185    private UserProfileDto? _currentUserProfile;
 186    private bool _isSysAdmin;
 187    private bool _hasAutoOpenedTutorialForOnboarding;
 188    private DotNetObjectReference<AuthenticatedLayout>? _dotNetRef;
 189
 190    protected override async Task OnInitializedAsync()
 191    {
 192        _currentUser = await AuthService.GetCurrentUserAsync();
 193        _currentUserProfile = await UserApi.GetUserProfileAsync();
 194        _isSysAdmin = await AdminAuthService.IsSysAdminAsync();
 195        AuthenticationStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
 196        Navigation.LocationChanged += HandleLocationChanged;
 197        AppContext.OnContextChanged += HandleAppContextChanged;
 198        await EvaluateTutorialDrawerStateAsync(allowOnboardingAutoOpen: true);
 199    }
 200
 201    protected override async Task OnAfterRenderAsync(bool firstRender)
 202    {
 203        if (firstRender)
 204        {
 205            _dotNetRef = DotNetObjectReference.Create(this);
 206            await JSRuntime.InvokeVoidAsync("chronicisKeyboardShortcuts.initialize", _dotNetRef);
 207
 208            var buildInfo = await VersionService.GetBuildInfoAsync();
 209            await JSRuntime.InvokeVoidAsync("chronicisRum.setVersion", buildInfo.Version, buildInfo.Sha);
 210        }
 211    }
 212
 213    [JSInvokable]
 214    public void OnCtrlM()
 215    {
 1216        MetadataDrawerService.Toggle();
 1217    }
 218
 219    [JSInvokable]
 220    public void OnCtrlT()
 221    {
 1222        DrawerCoordinator.Toggle(DrawerType.Tutorial);
 1223    }
 224
 225    [JSInvokable]
 226    public void OnCtrlShiftF()
 227    {
 1228        OpenFeaturesDrawer();
 1229    }
 230
 231    [JSInvokable]
 232    public void OnCtrlQ()
 233    {
 1234        QuestDrawerService.Toggle();
 1235    }
 236
 237    [JSInvokable]
 238    public void OnCtrlS()
 239    {
 1240        KeyboardShortcutService.RequestSave();
 1241    }
 242
 243    [JSInvokable]
 244    public async Task OnCtrlN()
 245    {
 246        // Create a sibling article to the currently selected article
 247        var selectedId = TreeState.SelectedNodeId;
 248
 249        if (!selectedId.HasValue)
 250        {
 251            Snackbar.Add("Select an article first, then press Ctrl+N to create a sibling", Severity.Info);
 252            return;
 253        }
 254
 255        // Check if the selected node is an article
 256        if (!TreeState.TryGetNode(selectedId.Value, out var selectedNode) || selectedNode == null)
 257        {
 258            Snackbar.Add("Select an article first, then press Ctrl+N to create a sibling", Severity.Info);
 259            return;
 260        }
 261
 262        // Only allow Ctrl+N for Article nodes
 263        if (selectedNode.NodeType != TreeNodeType.Article)
 264        {
 265            var nodeTypeName = selectedNode.NodeType switch
 266            {
 267                TreeNodeType.World => "World",
 268                TreeNodeType.Campaign => "Campaign",
 269                TreeNodeType.Arc => "Arc",
 270                TreeNodeType.VirtualGroup => "folder",
 271                TreeNodeType.ExternalLink => "link",
 272                _ => "item"
 273            };
 274            Snackbar.Add($"Ctrl+N creates sibling articles. Select an article instead of a {nodeTypeName}.", Severity.In
 275            return;
 276        }
 277
 278        // Get the current article to find its parent
 279        var currentArticle = await ArticleApi.GetArticleDetailAsync(selectedId.Value);
 280        if (currentArticle == null)
 281        {
 282            Snackbar.Add("Could not load the selected article", Severity.Warning);
 283            return;
 284        }
 285
 286        Guid? parentId = currentArticle.ParentId;
 287
 288        // Create sibling (same parent, same world/campaign context)
 289        Guid? newArticleId;
 290        if (parentId.HasValue)
 291        {
 292            newArticleId = await TreeState.CreateChildArticleAsync(parentId.Value);
 293        }
 294        else
 295        {
 296            // Root-level article - create sibling with same context
 297            var createDto = new ArticleCreateDto
 298            {
 299                Title = string.Empty,
 300                Body = string.Empty,
 301                ParentId = null,
 302                WorldId = currentArticle.WorldId,
 303                CampaignId = currentArticle.CampaignId,
 304                ArcId = currentArticle.ArcId,
 305                Type = currentArticle.Type, // Use same type as current article (e.g., SessionNote)
 306                EffectiveDate = DateTime.Now
 307            };
 308
 309            var created = await ArticleApi.CreateArticleAsync(createDto);
 310            if (created != null)
 311            {
 312                await TreeState.RefreshAsync();
 313                TreeState.SelectNode(created.Id);
 314                newArticleId = created.Id;
 315            }
 316            else
 317            {
 318                newArticleId = null;
 319            }
 320        }
 321
 322        if (newArticleId.HasValue)
 323        {
 324            Snackbar.Add("Article created (Ctrl+N)", Severity.Success);
 325
 326            // Navigate to the new article
 327            var newArticle = await ArticleApi.GetArticleDetailAsync(newArticleId.Value);
 328            if (newArticle != null)
 329            {
 330                await AppNavigator.GoToArticleAsync(newArticle);
 331            }
 332        }
 333        else
 334        {
 335            Snackbar.Add("Failed to create article", Severity.Error);
 336        }
 337    }
 338
 339    private async void OnAuthenticationStateChanged(Task<AuthenticationState> task)
 340    {
 341        _currentUser = await AuthService.GetCurrentUserAsync();
 342        _currentUserProfile = await UserApi.GetUserProfileAsync();
 343        _isSysAdmin = await AdminAuthService.IsSysAdminAsync();
 344        _hasAutoOpenedTutorialForOnboarding = false;
 345        await EvaluateTutorialDrawerStateAsync(allowOnboardingAutoOpen: true);
 346        await InvokeAsync(StateHasChanged);
 347    }
 348
 349    private void ToggleDrawer()
 350    {
 1351        _drawerOpen = !_drawerOpen;
 1352    }
 353
 354    private void ToggleTutorialDrawer()
 355    {
 1356        DrawerCoordinator.Toggle(DrawerType.Tutorial);
 1357    }
 358
 359    private void OpenAdminUtilities()
 360    {
 1361        Navigation.NavigateTo("/admin/utilities");
 1362    }
 363
 364    private void OpenFeaturesDrawer()
 365    {
 2366        DrawerCoordinator.Open(DrawerType.FeaturedArticle);
 2367    }
 368
 34369    private bool ShouldRenderGlobalDrawerHost => !IsArticleRoute();
 370
 371    private bool IsArticleRoute()
 372    {
 37373        var pathOnly = GetCurrentPathOnly();
 374
 37375        return pathOnly.Equals("article", StringComparison.OrdinalIgnoreCase)
 37376            || pathOnly.StartsWith("article/", StringComparison.OrdinalIgnoreCase);
 377    }
 378
 379    private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
 380    {
 3381        _ = InvokeAsync(async () =>
 3382        {
 3383            DrawerCoordinator.Close();
 3384            await EvaluateTutorialDrawerStateAsync(allowOnboardingAutoOpen: true);
 3385        });
 3386    }
 387
 388    private void HandleAppContextChanged()
 389    {
 1390        _ = InvokeAsync(() => EvaluateTutorialDrawerStateAsync(allowOnboardingAutoOpen: true));
 1391    }
 392
 393    private async Task EvaluateTutorialDrawerStateAsync(bool allowOnboardingAutoOpen)
 394    {
 395        var isTutorialWorld = AppContext.CurrentWorld?.IsTutorial == true;
 396        DrawerCoordinator.IsForcedOpen = false;
 397
 398        if (isTutorialWorld)
 399        {
 400            DrawerCoordinator.Open(DrawerType.Tutorial);
 401            return;
 402        }
 403
 404        if (!allowOnboardingAutoOpen
 405            || _hasAutoOpenedTutorialForOnboarding
 406            || _currentUserProfile?.HasCompletedOnboarding != false
 407            || !IsEligibleForOnboardingTutorialAutoOpen())
 408        {
 409            return;
 410        }
 411
 412        DrawerCoordinator.Open(DrawerType.Tutorial);
 413        _hasAutoOpenedTutorialForOnboarding = true;
 414        await InvokeAsync(StateHasChanged);
 415    }
 416
 417    private bool IsEligibleForOnboardingTutorialAutoOpen()
 418    {
 1419        var pathOnly = GetCurrentPathOnly();
 420
 1421        return !pathOnly.StartsWith("authentication/", StringComparison.OrdinalIgnoreCase);
 422    }
 423
 424    private string GetCurrentPathOnly()
 425    {
 38426        var relativePath = Navigation.ToBaseRelativePath(Navigation.Uri);
 38427        return relativePath.Split('?', '#')[0];
 428    }
 429
 430    private void OnTreeSearchChanged()
 431    {
 432        // Real-time filtering as user types (debounced by MudTextField)
 2433        if (string.IsNullOrWhiteSpace(_treeSearch))
 434        {
 1435            TreeState.ClearSearch();
 436        }
 437        else
 438        {
 1439            TreeState.SetSearchQuery(_treeSearch);
 440        }
 1441    }
 442
 443    private void ClearSearch()
 444    {
 1445        _treeSearch = null;
 1446        TreeState.ClearSearch();
 1447    }
 448
 449    private void OnGlobalSearchKeyDown(KeyboardEventArgs e)
 450    {
 2451        if (e.Key == "Enter")
 452        {
 1453            ExecuteGlobalSearch();
 454        }
 2455    }
 456
 457    private void ExecuteGlobalSearch()
 458    {
 2459        if (!string.IsNullOrWhiteSpace(_globalSearchQuery))
 460        {
 1461            Navigation.NavigateTo($"/search?q={Uri.EscapeDataString(_globalSearchQuery)}");
 462        }
 2463    }
 464
 465    private void BeginSignOut()
 466    {
 1467        Navigation.NavigateToLogout("authentication/logout");
 1468    }
 469
 470    public async ValueTask DisposeAsync()
 471    {
 472        AuthenticationStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
 473        Navigation.LocationChanged -= HandleLocationChanged;
 474        AppContext.OnContextChanged -= HandleAppContextChanged;
 475
 476        if (_dotNetRef != null)
 477        {
 478            try
 479            {
 480                await JSRuntime.InvokeVoidAsync("chronicisKeyboardShortcuts.dispose");
 481            }
 482            catch
 483            {
 484                // Ignore errors during disposal (e.g., if JS context is gone)
 485            }
 486            _dotNetRef.Dispose();
 487        }
 488    }
 489}