< 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
0%
Covered lines: 0
Uncovered lines: 104
Coverable lines: 104
Total lines: 366
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 47
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%210%
OnInitializedAsync()100%210%
OnAfterRenderAsync()0%620%
OnCtrlM()100%210%
OnCtrlQ()100%210%
OnCtrlS()100%210%
OnCtrlN()0%756270%
OnAuthenticationStateChanged()100%210%
ToggleDrawer()100%210%
OnTreeSearchChanged()0%620%
ClearSearch()100%210%
OnGlobalSearchKeyDown(...)0%620%
ExecuteGlobalSearch()0%620%
BeginSignOut()100%210%
DisposeAsync()0%620%

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 Chronicis.Client.Models
 3@using Chronicis.Shared.DTOs
 4@using Chronicis.Client.Components.Quests
 5@inherits LayoutComponentBase
 6@inject NavigationManager Navigation
 7@inject ITreeStateService TreeState
 8@inject IAuthService AuthService
 9@inject IArticleApiService ArticleApi
 10@inject ISnackbar Snackbar
 11@inject IJSRuntime JSRuntime
 12@inject AuthenticationStateProvider AuthenticationStateProvider
 13@inject MudTheme ChronicisTheme
 14@inject IMetadataDrawerService MetadataDrawerService
 15@inject IQuestDrawerService QuestDrawerService
 16@inject IKeyboardShortcutService KeyboardShortcutService
 17@inject IAdminAuthService AdminAuthService
 18@inject ILogger<AuthenticatedLayout> Logger
 19@implements IAsyncDisposable
 20
 21<MudThemeProvider Theme="ChronicisTheme" />
 22<MudPopoverProvider />
 23<MudDialogProvider />
 24<MudSnackbarProvider />
 25
 26<MudLayout>
 27    <MudAppBar Elevation="2">
 28        <MudIconButton Icon="@Icons.Material.Filled.Menu"
 29                       Color="Color.Inherit"
 30                       Edge="Edge.Start"
 31                       OnClick="@ToggleDrawer" />
 32
 33        <a href="/dashboard" class="d-flex align-center chronicis-home-link">
 34            <img src="/images/logo.png" alt="Chronicis" class="chronicis-logo" />
 35            <MudText Typo="Typo.h6" Class="chronicis-title">Chronicis</MudText>
 36        </a>
 37
 38        <MudSpacer />
 39
 40        <MudTextField @bind-Value="_globalSearchQuery"
 41                      Placeholder="Search content..."
 42                      Adornment="Adornment.Start"
 43                      AdornmentIcon="@Icons.Material.Filled.Search"
 44                      Variant="Variant.Outlined"
 45                      Margin="Margin.Dense"
 46                      Style="max-width: 400px; background-color: rgba(255,255,255,0.1);"
 47                      Class="global-search-input"
 48                      Immediate="true"
 49                      @onkeydown="OnGlobalSearchKeyDown" />
 50
 51        <MudTooltip Text="Quick Start Guide">
 52            <MudIconButton Icon="@Icons.Material.Filled.Help"
 53                           Color="Color.Inherit"
 054                           OnClick="@(() => Navigation.NavigateTo("/getting-started"))" />
 55        </MudTooltip>
 56
 057        @if (_isSysAdmin)
 58        {
 59            <MudTooltip Text="Admin Utilities">
 60                <MudIconButton Icon="@Icons.Material.Filled.AdminPanelSettings"
 61                               Color="Color.Inherit"
 062                               OnClick="@(() => Navigation.NavigateTo("/admin/utilities"))" />
 63            </MudTooltip>
 64        }
 65
 66        <MudMenu Icon="@Icons.Material.Filled.AccountCircle"
 67                 Color="Color.Inherit"
 68                 AnchorOrigin="Origin.BottomRight"
 69                 TransformOrigin="Origin.TopRight">
 70            <ChildContent>
 71                <div class="user-info" style="padding: 12px 16px; min-width: 200px;">
 072                    @if (!string.IsNullOrEmpty(_currentUser?.AvatarUrl))
 73                    {
 74                        <MudImage Src="@_currentUser.AvatarUrl" Style="margin-bottom: 8px;" />
 75                    }
 76                    <div style="margin-top: 8px;">
 077                        <div style="font-weight: 600;">@_currentUser?.DisplayName</div>
 078                        <div style="font-size: 0.85rem; opacity: 0.8;">@_currentUser?.Email</div>
 79                    </div>
 80                </div>
 81                <MudDivider />
 82                <MudMenuItem Icon="@Icons.Material.Filled.Settings"
 83                    IconColor="Color.Default"
 84                    Href="/settings">
 85                    Settings
 86                </MudMenuItem>
 87                <MudMenuItem Icon="@Icons.Material.Filled.Logout"
 88                    IconColor="Color.Primary"
 89                    OnClick="BeginSignOut">
 90                    Logout
 91                </MudMenuItem>
 92            </ChildContent>
 93        </MudMenu>
 94    </MudAppBar>
 95
 96    <MudDrawer @bind-Open="_drawerOpen"
 97               Elevation="2"
 98               ClipMode="DrawerClipMode.Always"
 99               Width="320px">
 100
 101        <div class="chronicis-search-box">
 102            <MudTextField @bind-Value="_treeSearch"
 103                          @bind-Value:after="OnTreeSearchChanged"
 104                          Placeholder="Filter articles..."
 105                          Adornment="Adornment.End"
 106                          AdornmentIcon="@(_treeSearch?.Length > 0 ? Icons.Material.Filled.Clear : Icons.Material.Filled
 107                          AdornmentColor="Color.Primary"
 108                          OnAdornmentClick="ClearSearch"
 109                          Variant="Variant.Outlined"
 110                          Margin="Margin.Dense"
 111                          Immediate="true"
 112                          DebounceInterval="200"
 113                          Class="mb-2" />
 114        </div>
 115
 116        <QuickAddSession />
 117
 118        <ArticleTreeView />
 119    </MudDrawer>
 120
 121    <MudMainContent Class="mud-main-content">
 122        <div class="authenticated-content-wrapper">
 123            <MudContainer MaxWidth="MaxWidth.Large" Class="py-4">
 124                <ErrorBoundary>
 125                    <ChildContent>
 0126                        @Body
 127                    </ChildContent>
 128                    <ErrorContent Context="exception">
 129                        <div class="chronicis-error-state">
 130                            <MudPaper Elevation="3" Class="pa-4">
 131                                <div class="chronicis-empty-state">
 132                                    <div class="chronicis-empty-state-icon">⚠️</div>
 133                                    <MudText Typo="Typo.h5" Class="chronicis-empty-state-title mb-3">
 134                                        Something went wrong
 135                                    </MudText>
 136                                    <MudText Typo="Typo.body1" Class="chronicis-empty-state-message mb-4">
 137                                        An unexpected error occurred. Please try refreshing the page.
 138                                    </MudText>
 139                                    <MudButton Variant="Variant.Filled"
 140                                               Color="Color.Primary"
 0141                                               OnClick="@(() => Navigation.NavigateTo(Navigation.Uri, forceLoad: true))"
 142                                        Refresh Page
 143                                    </MudButton>
 144                                </div>
 145                            </MudPaper>
 146                        </div>
 147                    </ErrorContent>
 148                </ErrorBoundary>
 149            </MudContainer>
 150
 151            <PublicFooter />
 152        </div>
 153    </MudMainContent>
 154
 155    <!-- Quest Drawer (right side, Session/SessionNote pages only) -->
 156    <QuestDrawer />
 157</MudLayout>
 158
 159@code {
 0160    private bool _drawerOpen = true;
 161    private string? _treeSearch;
 0162    private string _globalSearchQuery = string.Empty;
 163    private UserInfo? _currentUser;
 164    private bool _isSysAdmin;
 165    private DotNetObjectReference<AuthenticatedLayout>? _dotNetRef;
 166
 167    protected override async Task OnInitializedAsync()
 168    {
 0169        _currentUser = await AuthService.GetCurrentUserAsync();
 0170        _isSysAdmin = await AdminAuthService.IsSysAdminAsync();
 0171        AuthenticationStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
 0172    }
 173
 174    protected override async Task OnAfterRenderAsync(bool firstRender)
 175    {
 0176        if (firstRender)
 177        {
 0178            _dotNetRef = DotNetObjectReference.Create(this);
 0179            await JSRuntime.InvokeVoidAsync("chronicisKeyboardShortcuts.initialize", _dotNetRef);
 180        }
 0181    }
 182
 183    [JSInvokable]
 184    public void OnCtrlM()
 185    {
 0186        MetadataDrawerService.Toggle();
 0187    }
 188
 189    [JSInvokable]
 190    public void OnCtrlQ()
 191    {
 0192        QuestDrawerService.Toggle();
 0193    }
 194
 195    [JSInvokable]
 196    public void OnCtrlS()
 197    {
 0198        KeyboardShortcutService.RequestSave();
 0199    }
 200
 201    [JSInvokable]
 202    public async Task OnCtrlN()
 203    {
 204        // Create a sibling article to the currently selected article
 0205        var selectedId = TreeState.SelectedNodeId;
 206
 0207        if (!selectedId.HasValue)
 208        {
 0209            Snackbar.Add("Select an article first, then press Ctrl+N to create a sibling", Severity.Info);
 0210            return;
 211        }
 212
 213        // Check if the selected node is an article
 0214        if (!TreeState.TryGetNode(selectedId.Value, out var selectedNode) || selectedNode == null)
 215        {
 0216            Snackbar.Add("Select an article first, then press Ctrl+N to create a sibling", Severity.Info);
 0217            return;
 218        }
 219
 220        // Only allow Ctrl+N for Article nodes
 0221        if (selectedNode.NodeType != TreeNodeType.Article)
 222        {
 0223            var nodeTypeName = selectedNode.NodeType switch
 0224            {
 0225                TreeNodeType.World => "World",
 0226                TreeNodeType.Campaign => "Campaign",
 0227                TreeNodeType.Arc => "Arc",
 0228                TreeNodeType.VirtualGroup => "folder",
 0229                TreeNodeType.ExternalLink => "link",
 0230                _ => "item"
 0231            };
 0232            Snackbar.Add($"Ctrl+N creates sibling articles. Select an article instead of a {nodeTypeName}.", Severity.In
 0233            return;
 234        }
 235
 236        // Get the current article to find its parent
 0237        var currentArticle = await ArticleApi.GetArticleDetailAsync(selectedId.Value);
 0238        if (currentArticle == null)
 239        {
 0240            Snackbar.Add("Could not load the selected article", Severity.Warning);
 0241            return;
 242        }
 243
 0244        Guid? parentId = currentArticle.ParentId;
 245
 246        // Create sibling (same parent, same world/campaign context)
 247        Guid? newArticleId;
 0248        if (parentId.HasValue)
 249        {
 0250            newArticleId = await TreeState.CreateChildArticleAsync(parentId.Value);
 251        }
 252        else
 253        {
 254            // Root-level article - create sibling with same context
 0255            var createDto = new ArticleCreateDto
 0256            {
 0257                Title = string.Empty,
 0258                Body = string.Empty,
 0259                ParentId = null,
 0260                WorldId = currentArticle.WorldId,
 0261                CampaignId = currentArticle.CampaignId,
 0262                ArcId = currentArticle.ArcId,
 0263                Type = currentArticle.Type, // Use same type as current article (e.g., SessionNote)
 0264                EffectiveDate = DateTime.Now
 0265            };
 266
 0267            var created = await ArticleApi.CreateArticleAsync(createDto);
 0268            if (created != null)
 269            {
 0270                await TreeState.RefreshAsync();
 0271                TreeState.SelectNode(created.Id);
 0272                newArticleId = created.Id;
 273            }
 274            else
 275            {
 0276                newArticleId = null;
 277            }
 0278        }
 279
 0280        if (newArticleId.HasValue)
 281        {
 0282            Snackbar.Add("Article created (Ctrl+N)", Severity.Success);
 283
 284            // Navigate to the new article
 0285            var newArticle = await ArticleApi.GetArticleDetailAsync(newArticleId.Value);
 0286            if (newArticle != null && newArticle.Breadcrumbs.Any())
 287            {
 0288                var path = string.Join("/", newArticle.Breadcrumbs.Select(b => b.Slug));
 0289                Navigation.NavigateTo($"/article/{path}");
 290            }
 291        }
 292        else
 293        {
 0294            Snackbar.Add("Failed to create article", Severity.Error);
 295        }
 0296    }
 297
 298    private async void OnAuthenticationStateChanged(Task<AuthenticationState> task)
 299    {
 0300        _currentUser = await AuthService.GetCurrentUserAsync();
 0301        await InvokeAsync(StateHasChanged);
 0302    }
 303
 304    private void ToggleDrawer()
 305    {
 0306        _drawerOpen = !_drawerOpen;
 0307    }
 308
 309    private void OnTreeSearchChanged()
 310    {
 311        // Real-time filtering as user types (debounced by MudTextField)
 0312        if (string.IsNullOrWhiteSpace(_treeSearch))
 313        {
 0314            TreeState.ClearSearch();
 315        }
 316        else
 317        {
 0318            TreeState.SetSearchQuery(_treeSearch);
 319        }
 0320    }
 321
 322    private void ClearSearch()
 323    {
 0324        _treeSearch = null;
 0325        TreeState.ClearSearch();
 0326    }
 327
 328    private void OnGlobalSearchKeyDown(KeyboardEventArgs e)
 329    {
 0330        if (e.Key == "Enter")
 331        {
 0332            ExecuteGlobalSearch();
 333        }
 0334    }
 335
 336    private void ExecuteGlobalSearch()
 337    {
 0338        if (!string.IsNullOrWhiteSpace(_globalSearchQuery))
 339        {
 0340            Navigation.NavigateTo($"/search?q={Uri.EscapeDataString(_globalSearchQuery)}");
 341        }
 0342    }
 343
 344    private void BeginSignOut()
 345    {
 0346        Navigation.NavigateToLogout("authentication/logout");
 0347    }
 348
 349    public async ValueTask DisposeAsync()
 350    {
 0351        AuthenticationStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
 352
 0353        if (_dotNetRef != null)
 354        {
 355            try
 356            {
 0357                await JSRuntime.InvokeVoidAsync("chronicisKeyboardShortcuts.dispose");
 0358            }
 0359            catch
 360            {
 361                // Ignore errors during disposal (e.g., if JS context is gone)
 0362            }
 0363            _dotNetRef.Dispose();
 364        }
 0365    }
 366}