< Summary

Information
Class: Chronicis.Client.Pages.WorldDetail
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Pages/WorldDetail.razor
Line coverage
0%
Covered lines: 0
Uncovered lines: 428
Coverable lines: 428
Total lines: 1202
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 220
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/Pages/WorldDetail.razor

#LineLine coverage
 1@page "/world/{WorldId:guid}"
 2@attribute [Authorize]
 3@using Chronicis.Shared.DTOs
 4@using Chronicis.Shared.Enums
 5@using Chronicis.Client.Components.Dialogs
 6@using Chronicis.Client.Components.Shared
 7@using Chronicis.Client.Components.World
 8@using Chronicis.Client.Components.Settings
 9@using Chronicis.Client.Utilities
 10@inject IWorldApiService WorldApi
 11@inject ICampaignApiService CampaignApi
 12@inject ITreeStateService TreeState
 13@inject IBreadcrumbService BreadcrumbService
 14@inject ISnackbar Snackbar
 15@inject IDialogService DialogService
 16@inject NavigationManager Navigation
 17@inject IJSRuntime JSRuntime
 18@inject AuthenticationStateProvider AuthStateProvider
 19
 020@if (_isLoading)
 21{
 22    <LoadingSkeleton />
 23}
 024else if (_world != null)
 25{
 26    <MudPaper Elevation="2" Class="chronicis-article-card chronicis-fade-in">
 27        <DetailPageHeader Breadcrumbs="_breadcrumbs"
 28                          Icon="@Icons.Material.Filled.Public"
 29                          @bind-Title="_editName"
 30                          Placeholder="World Name"
 31                          OnTitleEdited="OnNameChanged"
 32                          OnEnterPressed="SaveWorld" />
 33
 34        <!-- Description -->
 35        <MudTextField @bind-Value="_editDescription"
 36                      Label="Description"
 37                      Variant="Variant.Outlined"
 38                      Lines="5"
 39                      AutoGrow="true"
 40                      MaxLines="20"
 41                      Placeholder="Describe your world..."
 42                      Class="mb-4"
 43                      Immediate="true"
 44                      @onkeyup="OnDescriptionChanged" />
 45
 46        <!-- Overview Section: Stats + Quick Actions -->
 47        <div class="world-overview mb-4">
 48            <div class="world-stats-grid">
 49                <div class="stat-card">
 50                    <MudIcon Icon="@Icons.Material.Filled.AutoStories" Class="stat-icon" />
 51                    <div class="stat-content">
 052                        <div class="stat-value">@_world.CampaignCount</div>
 53                        <div class="stat-label">Campaigns</div>
 54                    </div>
 55                </div>
 56                <div class="stat-card">
 57                    <MudIcon Icon="@Icons.Material.Filled.Group" Class="stat-icon" />
 58                    <div class="stat-content">
 059                        <div class="stat-value">@_world.MemberCount</div>
 60                        <div class="stat-label">Members</div>
 61                    </div>
 62                </div>
 63                <div class="stat-card">
 64                    <MudIcon Icon="@Icons.Material.Filled.CalendarToday" Class="stat-icon" />
 65                    <div class="stat-content">
 066                        <div class="stat-value">@_world.CreatedAt.ToString("MMM yyyy")</div>
 67                        <div class="stat-label">Created</div>
 68                    </div>
 69                </div>
 70            </div>
 71
 72            <div class="quick-actions-compact">
 73                <MudButton Variant="Variant.Filled"
 74                           Color="Color.Primary"
 75                           StartIcon="@Icons.Material.Filled.AutoStories"
 76                           OnClick="CreateCampaign"
 77                           Size="Size.Small">
 78                    New Campaign
 79                </MudButton>
 80                <MudButton Variant="Variant.Filled"
 81                           Color="Color.Primary"
 82                           StartIcon="@Icons.Material.Filled.Person"
 83                           OnClick="CreateCharacter"
 84                           Size="Size.Small">
 85                    New Character
 86                </MudButton>
 87                <MudButton Variant="Variant.Filled"
 88                           Color="Color.Primary"
 89                           StartIcon="@Icons.Material.Filled.MenuBook"
 90                           OnClick="CreateWikiArticle"
 91                           Size="Size.Small">
 92                    New Article
 93                </MudButton>
 94            </div>
 95        </div>
 96
 97        <!-- Tabbed Content -->
 98        <MudTabs Elevation="0"
 99                 Rounded="true"
 100                 ApplyEffectsToContainer="true"
 101                 PanelClass="pa-4"
 102                 @bind-ActivePanelIndex="_activeTabIndex">
 103
 104            <!-- Resources Tab -->
 105            <MudTabPanel Text="Resources" Icon="@Icons.Material.Filled.Folder">
 106                <div class="resources-section">
 107                    <!-- Links Subsection -->
 108                    <div class="resource-subsection mb-4">
 109                        <div class="d-flex align-center justify-space-between mb-2">
 110                            <MudText Typo="Typo.subtitle1" Style="font-weight: 600;">External Links</MudText>
 111                            <MudButton Variant="Variant.Text"
 112                                       Color="Color.Primary"
 113                                       StartIcon="@Icons.Material.Filled.Add"
 114                                       OnClick="StartAddLink"
 115                                       Size="Size.Small">
 116                                Add Link
 117                            </MudButton>
 118                        </div>
 119
 0120                        @if (_links.Count == 0 && !_isAddingLink)
 121                        {
 122                            <MudText Typo="Typo.body2" Class="mud-text-secondary">
 123                                No external links yet. Add links to Roll20, D&D Beyond, or other resources.
 124                            </MudText>
 125                        }
 126
 0127                        @foreach (var link in _links)
 128                        {
 129                            <div class="resource-item">
 0130                                @if (_editingLinkId == link.Id)
 131                                {
 132                                    <!-- Edit Mode -->
 133                                    <MudIcon Icon="@Icons.Material.Filled.Link"
 134                                             Size="Size.Small"
 135                                             Style="color: var(--chronicis-beige-gold); flex-shrink: 0;" />
 136                                    <MudTextField @bind-Value="_editLinkTitle"
 137                                                  Placeholder="Title"
 138                                                  Variant="Variant.Outlined"
 139                                                  Margin="Margin.Dense"
 140                                                  Style="min-width: 150px;" />
 141                                    <MudTextField @bind-Value="_editLinkUrl"
 142                                                  Placeholder="URL"
 143                                                  Variant="Variant.Outlined"
 144                                                  Margin="Margin.Dense"
 145                                                  Class="flex-grow-1" />
 146                                    <MudTextField @bind-Value="_editLinkDescription"
 147                                                  Placeholder="Description (optional)"
 148                                                  Variant="Variant.Outlined"
 149                                                  Margin="Margin.Dense"
 150                                                  Style="min-width: 150px;" />
 151                                    <MudIconButton Icon="@Icons.Material.Filled.Check"
 152                                                   Color="Color.Success"
 153                                                   Size="Size.Small"
 154                                                   OnClick="SaveEditLink"
 155                                                   Disabled="_isSavingLink" />
 156                                    <MudIconButton Icon="@Icons.Material.Filled.Close"
 157                                                   Color="Color.Default"
 158                                                   Size="Size.Small"
 159                                                   OnClick="CancelEditLink" />
 160                                }
 161                                else
 162                                {
 163                                    <!-- View Mode -->
 164                                    <img src="@GetFaviconUrl(link.Url)"
 165                                         alt=""
 166                                         style="width: 20px; height: 20px; flex-shrink: 0;"
 167                                         @onerror="HandleFaviconError" />
 168                                    <div class="flex-grow-1">
 169                                        <MudLink Href="@link.Url"
 170                                                 Target="_blank"
 171                                                 Typo="Typo.body1"
 172                                                 Style="color: var(--chronicis-beige-gold);">
 0173                                            @link.Title
 174                                            <MudIcon Icon="@Icons.Material.Filled.OpenInNew"
 175                                                     Size="Size.Small"
 176                                                     Style="font-size: 14px; vertical-align: middle; margin-left: 4px;" 
 177                                        </MudLink>
 0178                                        @if (!string.IsNullOrWhiteSpace(link.Description))
 179                                        {
 180                                            <MudText Typo="Typo.caption" Class="mud-text-secondary">
 0181                                                @link.Description
 182                                            </MudText>
 183                                        }
 184                                    </div>
 185                                    <MudIconButton Icon="@Icons.Material.Filled.Edit"
 186                                                   Color="Color.Default"
 187                                                   Size="Size.Small"
 0188                                                   OnClick="() => StartEditLink(link)" />
 189                                    <MudIconButton Icon="@Icons.Material.Filled.Delete"
 190                                                   Color="Color.Error"
 191                                                   Size="Size.Small"
 0192                                                   OnClick="() => DeleteLink(link)" />
 193                                }
 194                            </div>
 195                        }
 196
 0197                        @if (_isAddingLink)
 198                        {
 199                            <div class="resource-item">
 200                                <MudIcon Icon="@Icons.Material.Filled.AddLink"
 201                                         Size="Size.Small"
 202                                         Style="color: var(--chronicis-beige-gold); flex-shrink: 0;" />
 203                                <MudTextField @bind-Value="_newLinkTitle"
 204                                              Placeholder="Title (e.g., Roll20 Campaign)"
 205                                              Variant="Variant.Outlined"
 206                                              Margin="Margin.Dense"
 207                                              Style="min-width: 150px;" />
 208                                <MudTextField @bind-Value="_newLinkUrl"
 209                                              Placeholder="URL (e.g., https://roll20.net/...)"
 210                                              Variant="Variant.Outlined"
 211                                              Margin="Margin.Dense"
 212                                              Class="flex-grow-1" />
 213                                <MudTextField @bind-Value="_newLinkDescription"
 214                                              Placeholder="Description (optional)"
 215                                              Variant="Variant.Outlined"
 216                                              Margin="Margin.Dense"
 217                                              Style="min-width: 150px;" />
 218                                <MudIconButton Icon="@Icons.Material.Filled.Check"
 219                                               Color="Color.Success"
 220                                               Size="Size.Small"
 221                                               OnClick="SaveNewLink"
 222                                               Disabled="_isSavingLink" />
 223                                <MudIconButton Icon="@Icons.Material.Filled.Close"
 224                                               Color="Color.Default"
 225                                               Size="Size.Small"
 226                                               OnClick="CancelAddLink" />
 227                            </div>
 228                        }
 229                    </div>
 230
 231                    <MudDivider Class="my-4" />
 232
 233                    <!-- Documents Subsection -->
 234                    <div class="resource-subsection">
 235                        <div class="d-flex align-center justify-space-between mb-2">
 236                            <MudText Typo="Typo.subtitle1" Style="font-weight: 600;">Documents</MudText>
 0237                            @if (_isCurrentUserGM)
 238                            {
 239                                <MudButton Variant="Variant.Text"
 240                                           Color="Color.Primary"
 241                                           StartIcon="@Icons.Material.Filled.Upload"
 242                                           OnClick="OpenUploadDialog"
 243                                           Size="Size.Small">
 244                                    Upload
 245                                </MudButton>
 246                            }
 247                        </div>
 248
 0249                        @if (_documents.Count == 0)
 250                        {
 251                            <MudText Typo="Typo.body2" Class="mud-text-secondary">
 252                                No documents uploaded yet. Upload PDFs, Office files, images, or other documents.
 253                            </MudText>
 254                        }
 255                        else
 256                        {
 0257                            @foreach (var doc in _documents.OrderBy(d => d.Title))
 258                            {
 259                                <div class="resource-item">
 0260                                    @if (_editingDocumentId == doc.Id)
 261                                    {
 262                                        <!-- Edit Mode -->
 263                                        <MudIcon Icon="@GetDocumentIcon(doc.ContentType)"
 264                                                 Size="Size.Small"
 265                                                 Style="color: var(--chronicis-beige-gold); flex-shrink: 0;" />
 266                                        <MudTextField @bind-Value="_editDocumentTitle"
 267                                                      Placeholder="Title"
 268                                                      Variant="Variant.Outlined"
 269                                                      Margin="Margin.Dense"
 270                                                      Style="min-width: 200px;" />
 271                                        <MudTextField @bind-Value="_editDocumentDescription"
 272                                                      Placeholder="Description (optional)"
 273                                                      Variant="Variant.Outlined"
 274                                                      Margin="Margin.Dense"
 275                                                      Style="flex: 1;" />
 276                                        <MudIconButton Icon="@Icons.Material.Filled.Check"
 277                                                       Color="Color.Success"
 278                                                       Size="Size.Small"
 279                                                       OnClick="SaveDocumentEdit"
 280                                                       Disabled="_isSavingDocument" />
 281                                        <MudIconButton Icon="@Icons.Material.Filled.Close"
 282                                                       Color="Color.Default"
 283                                                       Size="Size.Small"
 284                                                       OnClick="CancelDocumentEdit" />
 285                                    }
 286                                    else
 287                                    {
 288                                        <!-- View Mode -->
 289                                        <MudIcon Icon="@GetDocumentIcon(doc.ContentType)"
 290                                                 Size="Size.Small"
 291                                                 Style="color: var(--chronicis-beige-gold); flex-shrink: 0;" />
 292                                        <div style="flex: 1; min-width: 0;">
 293                                            <MudText Typo="Typo.body2" Style="font-weight: 500;">
 0294                                                @doc.Title
 295                                            </MudText>
 0296                                            @if (!string.IsNullOrEmpty(doc.Description))
 297                                            {
 298                                                <MudText Typo="Typo.caption" Class="mud-text-secondary">
 0299                                                    @doc.Description
 300                                                </MudText>
 301                                            }
 302                                            <MudText Typo="Typo.caption" Class="mud-text-secondary">
 0303                                                @FormatFileSize(doc.FileSizeBytes) â€¢ Uploaded @doc.UploadedAt.ToString("
 304                                            </MudText>
 305                                        </div>
 306                                        <MudIconButton Icon="@Icons.Material.Filled.Download"
 307                                                       Color="Color.Primary"
 308                                                       Size="Size.Small"
 0309                                                       OnClick="@(() => DownloadDocument(doc.Id))"
 310                                                       title="Download" />
 0311                                        @if (_isCurrentUserGM)
 312                                        {
 313                                            <MudIconButton Icon="@Icons.Material.Filled.Edit"
 314                                                           Color="Color.Default"
 315                                                           Size="Size.Small"
 0316                                                           OnClick="@(() => StartEditDocument(doc))"
 317                                                           title="Edit" />
 318                                            <MudIconButton Icon="@Icons.Material.Filled.Delete"
 319                                                           Color="Color.Error"
 320                                                           Size="Size.Small"
 0321                                                           OnClick="@(() => DeleteDocument(doc.Id))"
 322                                                           title="Delete" />
 323                                        }
 324                                    }
 325                                </div>
 326                            }
 327                        }
 328                    </div>
 329                </div>
 330            </MudTabPanel>
 331
 332            <!-- Members & Sharing Tab -->
 333            <MudTabPanel Text="Members & Sharing" Icon="@Icons.Material.Filled.People">
 334                <!-- Members Section -->
 335                <WorldMembersPanel WorldId="WorldId"
 336                                   CurrentUserId="_currentUserId"
 337                                   IsCurrentUserGM="_isCurrentUserGM"
 338                                   OnMembersChanged="OnMembersChanged" />
 339
 340                <MudDivider Class="my-4" />
 341
 342                <!-- Public Sharing Section -->
 343                <div class="sharing-section">
 344                    <MudText Typo="Typo.subtitle1" Class="mb-3" Style="font-weight: 600;">
 345                        Public Sharing
 346                    </MudText>
 347
 348                    <div class="pa-3 rounded" style="background: var(--mud-palette-background-grey);">
 349                        <div class="d-flex align-center gap-3 mb-3">
 350                            <MudSwitch @bind-Value="_isPublic"
 351                                       Color="Color.Primary"
 352                                       Label="Make this world publicly accessible"
 353                                       T="bool"
 354                                       @bind-Value:after="OnPublicToggleChanged" />
 355                        </div>
 356
 0357                        @if (_isPublic)
 358                        {
 359                            <MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
 360                                Anyone with the link can view articles marked as "Public". Members-only and private arti
 361                            </MudText>
 362
 363                            <div class="d-flex align-center gap-2 mb-2">
 0364                                <MudText Typo="Typo.body2" Style="white-space: nowrap;">@GetPublicUrlBase()</MudText>
 365                                <MudTextField @bind-Value="_publicSlug"
 366                                              Placeholder="your-world-slug"
 367                                              Variant="Variant.Outlined"
 368                                              Margin="Margin.Dense"
 369                                              Immediate="true"
 370                                              DebounceInterval="500"
 371                                              @bind-Value:after="CheckSlugAvailability"
 372                                              Style="max-width: 300px;"
 373                                              Disabled="_isCheckingSlug"
 374                                              Error="@(!string.IsNullOrEmpty(_slugError))"
 375                                              ErrorText="@_slugError"
 376                                              HelperText="@_slugHelperText" />
 0377                                @if (_isCheckingSlug)
 378                                {
 379                                    <MudProgressCircular Size="Size.Small" Indeterminate="true" />
 380                                }
 0381                                else if (_slugIsAvailable && !string.IsNullOrEmpty(_publicSlug))
 382                                {
 383                                    <MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" />
 384                                }
 385                            </div>
 386
 0387                            @if (_world?.IsPublic == true && !string.IsNullOrEmpty(_world?.PublicSlug))
 388                            {
 389                                <div class="d-flex align-center gap-2 mt-3">
 390                                    <MudTextField Value="@GetFullPublicUrl()"
 391                                                  Label="Public URL"
 392                                                  Variant="Variant.Outlined"
 393                                                  Margin="Margin.Dense"
 394                                                  ReadOnly="true"
 395                                                  Adornment="Adornment.End"
 396                                                  AdornmentIcon="@Icons.Material.Filled.ContentCopy"
 397                                                  OnAdornmentClick="CopyPublicUrl"
 398                                                  Style="max-width: 500px;" />
 399                                    <MudButton Variant="Variant.Outlined"
 400                                               Color="Color.Primary"
 401                                               StartIcon="@Icons.Material.Filled.OpenInNew"
 402                                               Href="@GetFullPublicUrl()"
 403                                               Target="_blank">
 404                                        Preview
 405                                    </MudButton>
 406                                </div>
 407                            }
 408                        }
 409                        else
 410                        {
 411                            <MudText Typo="Typo.body2" Class="mud-text-secondary">
 412                                Your world is currently private. Only you and campaign members can access it.
 413                            </MudText>
 414                        }
 415                    </div>
 416                </div>
 417            </MudTabPanel>
 418
 419            <!-- Settings Tab -->
 420            <MudTabPanel Text="Settings" Icon="@Icons.Material.Filled.Settings">
 0421                @if (_isCurrentUserGM)
 422                {
 423                    <WorldResourceProviders WorldId="WorldId" />
 424                }
 425                else
 426                {
 427                    <MudAlert Severity="Severity.Info">
 428                        Only the world owner can manage settings.
 429                    </MudAlert>
 430                }
 431            </MudTabPanel>
 432        </MudTabs>
 433
 434        <!-- Save Status -->
 435        <div class="chronicis-flex-between mt-4">
 436            <SaveStatusIndicator IsSaving="_isSaving" HasUnsavedChanges="_hasUnsavedChanges" />
 437
 438            <MudButton Variant="Variant.Filled"
 439                       Color="Color.Primary"
 440                       OnClick="SaveWorld"
 441                       Disabled="_isSaving"
 442                       StartIcon="@Icons.Material.Filled.Save">
 443                Save
 444            </MudButton>
 445        </div>
 446    </MudPaper>
 447}
 448
 449@code {
 450    [Parameter]
 0451    public Guid WorldId { get; set; }
 452
 453    private WorldDetailDto? _world;
 0454    private string _editName = string.Empty;
 0455    private string _editDescription = string.Empty;
 0456    private bool _isLoading = true;
 457    private bool _isSaving = false;
 458    private bool _hasUnsavedChanges = false;
 0459    private List<BreadcrumbItem> _breadcrumbs = new();
 460
 461    // Current user info
 462    private Guid _currentUserId;
 463    private bool _isCurrentUserGM = false;
 464
 465    // Links state
 0466    private List<WorldLinkDto> _links = new();
 467    private bool _isAddingLink = false;
 468    private bool _isSavingLink = false;
 0469    private string _newLinkTitle = string.Empty;
 0470    private string _newLinkUrl = string.Empty;
 0471    private string _newLinkDescription = string.Empty;
 472
 473    // Edit link state
 474    private Guid? _editingLinkId = null;
 0475    private string _editLinkTitle = string.Empty;
 0476    private string _editLinkUrl = string.Empty;
 0477    private string _editLinkDescription = string.Empty;
 478
 479    // Documents state
 0480    private List<WorldDocumentDto> _documents = new();
 481    private Guid? _editingDocumentId = null;
 0482    private string _editDocumentTitle = string.Empty;
 0483    private string _editDocumentDescription = string.Empty;
 484    private bool _isSavingDocument = false;
 485
 486    // Public sharing state
 487    private bool _isPublic = false;
 0488    private string _publicSlug = string.Empty;
 489    private bool _isCheckingSlug = false;
 490    private bool _slugIsAvailable = false;
 491    private string? _slugError = null;
 492    private string? _slugHelperText = null;
 493
 494    // Tab state
 495    private int _activeTabIndex = 0;
 496
 497    protected override async Task OnParametersSetAsync()
 498    {
 0499        await LoadWorldAsync();
 0500    }
 501
 502    private async Task LoadWorldAsync()
 503    {
 0504        _isLoading = true;
 0505        StateHasChanged();
 506
 507        try
 508        {
 509            // Get auth state for current user matching
 0510            var authState = await AuthStateProvider.GetAuthenticationStateAsync();
 511
 0512            _world = await WorldApi.GetWorldAsync(WorldId);
 0513            if (_world == null)
 514            {
 0515                Navigation.NavigateTo("/dashboard", replace: true);
 0516                return;
 517            }
 518            else
 519            {
 0520                _editName = _world.Name;
 0521                _editDescription = _world.Description ?? string.Empty;
 0522                _hasUnsavedChanges = false;
 523
 524                // Initialize public sharing state
 0525                _isPublic = _world.IsPublic;
 0526                _publicSlug = _world.PublicSlug ?? string.Empty;
 0527                _slugIsAvailable = _world.IsPublic; // If already public, slug is valid
 0528                _slugError = null;
 0529                _slugHelperText = null;
 530
 531                // Check current user's role
 0532                if (_world.Members != null)
 533                {
 534                    // Get current user's email from auth claims
 0535                    var userEmail = authState.User.FindFirst("https://chronicis.app/email")?.Value
 0536                                 ?? authState.User.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value
 0537                                 ?? authState.User.FindFirst("email")?.Value;
 538
 0539                    if (!string.IsNullOrEmpty(userEmail))
 540                    {
 0541                        var currentMember = _world.Members.FirstOrDefault(m =>
 0542                            m.Email.Equals(userEmail, StringComparison.OrdinalIgnoreCase));
 543
 0544                        if (currentMember != null)
 545                        {
 0546                            _currentUserId = currentMember.UserId;
 0547                            _isCurrentUserGM = currentMember.Role == WorldRole.GM;
 548                        }
 549                    }
 550                }
 551
 0552                _breadcrumbs = BreadcrumbService.ForWorld(_world);
 553
 0554                await JSRuntime.InvokeVoidAsync("eval", $"document.title = '{JsUtilities.EscapeForJs(_world.Name)} - Chr
 555
 556                // Load links
 0557                _links = await WorldApi.GetWorldLinksAsync(WorldId);
 558
 559                // Load documents
 0560                _documents = await WorldApi.GetWorldDocumentsAsync(WorldId);
 561
 562                // Highlight in tree
 0563                TreeState.ExpandPathToAndSelect(WorldId);
 564            }
 0565        }
 0566        catch (Exception ex)
 567        {
 0568            Snackbar.Add($"Failed to load world: {ex.Message}", Severity.Error);
 0569        }
 570        finally
 571        {
 0572            _isLoading = false;
 573        }
 0574    }
 575
 0576    private void OnNameChanged() => _hasUnsavedChanges = true;
 0577    private void OnDescriptionChanged() => _hasUnsavedChanges = true;
 578
 579    private async Task OnMembersChanged()
 580    {
 581        // Reload world to get updated member count
 0582        if (_world != null)
 583        {
 0584            var updated = await WorldApi.GetWorldAsync(WorldId);
 0585            if (updated != null)
 586            {
 0587                _world.MemberCount = updated.MemberCount;
 0588                _world.Members = updated.Members;
 0589                StateHasChanged();
 590            }
 591        }
 0592    }
 593
 594    private async Task SaveWorld()
 595    {
 0596        if (_world == null || _isSaving) return;
 597
 598        // Validate public slug if making public
 0599        if (_isPublic && !_slugIsAvailable)
 600        {
 0601            Snackbar.Add("Please enter a valid, available public slug before saving", Severity.Warning);
 0602            return;
 603        }
 604
 0605        _isSaving = true;
 0606        StateHasChanged();
 607
 608        try
 609        {
 0610            var updateDto = new WorldUpdateDto
 0611            {
 0612                Name = _editName.Trim(),
 0613                Description = string.IsNullOrWhiteSpace(_editDescription) ? null : _editDescription.Trim(),
 0614                IsPublic = _isPublic,
 0615                PublicSlug = _isPublic ? _publicSlug.Trim().ToLowerInvariant() : null
 0616            };
 617
 0618            var updated = await WorldApi.UpdateWorldAsync(WorldId, updateDto);
 0619            if (updated != null)
 620            {
 0621                _world.Name = updated.Name;
 0622                _world.Description = updated.Description;
 0623                _world.IsPublic = updated.IsPublic;
 0624                _world.PublicSlug = updated.PublicSlug;
 0625                _hasUnsavedChanges = false;
 626
 0627                await TreeState.RefreshAsync();
 0628                await JSRuntime.InvokeVoidAsync("eval", $"document.title = '{JsUtilities.EscapeForJs(_editName)} - Chron
 629
 0630                Snackbar.Add("World saved", Severity.Success);
 631            }
 0632        }
 0633        catch (Exception ex)
 634        {
 0635            Snackbar.Add($"Failed to save: {ex.Message}", Severity.Error);
 0636        }
 637        finally
 638        {
 0639            _isSaving = false;
 0640            StateHasChanged();
 641        }
 0642    }
 643
 644    // ===== Link Management =====
 645
 646    private void StartAddLink()
 647    {
 0648        _isAddingLink = true;
 0649        _newLinkTitle = string.Empty;
 0650        _newLinkUrl = string.Empty;
 0651        _newLinkDescription = string.Empty;
 0652    }
 653
 654    private void CancelAddLink()
 655    {
 0656        _isAddingLink = false;
 0657        _newLinkTitle = string.Empty;
 0658        _newLinkUrl = string.Empty;
 0659        _newLinkDescription = string.Empty;
 0660    }
 661
 662    private async Task SaveNewLink()
 663    {
 0664        if (string.IsNullOrWhiteSpace(_newLinkTitle) || string.IsNullOrWhiteSpace(_newLinkUrl))
 665        {
 0666            Snackbar.Add("Title and URL are required", Severity.Warning);
 0667            return;
 668        }
 669
 670        // Basic URL validation
 0671        if (!Uri.TryCreate(_newLinkUrl, UriKind.Absolute, out var uri) ||
 0672            (uri.Scheme != "http" && uri.Scheme != "https"))
 673        {
 0674            Snackbar.Add("Please enter a valid URL (starting with http:// or https://)", Severity.Warning);
 0675            return;
 676        }
 677
 0678        _isSavingLink = true;
 0679        StateHasChanged();
 680
 681        try
 682        {
 0683            var dto = new WorldLinkCreateDto
 0684            {
 0685                Title = _newLinkTitle.Trim(),
 0686                Url = _newLinkUrl.Trim(),
 0687                Description = string.IsNullOrWhiteSpace(_newLinkDescription) ? null : _newLinkDescription.Trim()
 0688            };
 689
 0690            var created = await WorldApi.CreateWorldLinkAsync(WorldId, dto);
 0691            if (created != null)
 692            {
 693                // Reload links to get proper alphabetical order
 0694                _links = await WorldApi.GetWorldLinksAsync(WorldId);
 0695                await TreeState.RefreshAsync();
 0696                _isAddingLink = false;
 0697                _newLinkTitle = string.Empty;
 0698                _newLinkUrl = string.Empty;
 0699                _newLinkDescription = string.Empty;
 0700                Snackbar.Add("Link added", Severity.Success);
 701            }
 702            else
 703            {
 0704                Snackbar.Add("Failed to add link", Severity.Error);
 705            }
 0706        }
 0707        catch (Exception ex)
 708        {
 0709            Snackbar.Add($"Failed to add link: {ex.Message}", Severity.Error);
 0710        }
 711        finally
 712        {
 0713            _isSavingLink = false;
 0714            StateHasChanged();
 715        }
 0716    }
 717
 718    private void StartEditLink(WorldLinkDto link)
 719    {
 0720        _editingLinkId = link.Id;
 0721        _editLinkTitle = link.Title;
 0722        _editLinkUrl = link.Url;
 0723        _editLinkDescription = link.Description ?? string.Empty;
 0724    }
 725
 726    private void CancelEditLink()
 727    {
 0728        _editingLinkId = null;
 0729        _editLinkTitle = string.Empty;
 0730        _editLinkUrl = string.Empty;
 0731        _editLinkDescription = string.Empty;
 0732    }
 733
 734    private async Task SaveEditLink()
 735    {
 0736        if (_editingLinkId == null) return;
 737
 0738        if (string.IsNullOrWhiteSpace(_editLinkTitle) || string.IsNullOrWhiteSpace(_editLinkUrl))
 739        {
 0740            Snackbar.Add("Title and URL are required", Severity.Warning);
 0741            return;
 742        }
 743
 744        // Basic URL validation
 0745        if (!Uri.TryCreate(_editLinkUrl, UriKind.Absolute, out var uri) ||
 0746            (uri.Scheme != "http" && uri.Scheme != "https"))
 747        {
 0748            Snackbar.Add("Please enter a valid URL (starting with http:// or https://)", Severity.Warning);
 0749            return;
 750        }
 751
 0752        _isSavingLink = true;
 0753        StateHasChanged();
 754
 755        try
 756        {
 0757            var dto = new WorldLinkUpdateDto
 0758            {
 0759                Title = _editLinkTitle.Trim(),
 0760                Url = _editLinkUrl.Trim(),
 0761                Description = string.IsNullOrWhiteSpace(_editLinkDescription) ? null : _editLinkDescription.Trim()
 0762            };
 763
 0764            var updated = await WorldApi.UpdateWorldLinkAsync(WorldId, _editingLinkId.Value, dto);
 0765            if (updated != null)
 766            {
 767                // Reload links to get proper alphabetical order
 0768                _links = await WorldApi.GetWorldLinksAsync(WorldId);
 0769                await TreeState.RefreshAsync();
 0770                _editingLinkId = null;
 0771                _editLinkTitle = string.Empty;
 0772                _editLinkUrl = string.Empty;
 0773                _editLinkDescription = string.Empty;
 0774                Snackbar.Add("Link updated", Severity.Success);
 775            }
 776            else
 777            {
 0778                Snackbar.Add("Failed to update link", Severity.Error);
 779            }
 0780        }
 0781        catch (Exception ex)
 782        {
 0783            Snackbar.Add($"Failed to update link: {ex.Message}", Severity.Error);
 0784        }
 785        finally
 786        {
 0787            _isSavingLink = false;
 0788            StateHasChanged();
 789        }
 0790    }
 791
 792    private async Task DeleteLink(WorldLinkDto link)
 793    {
 0794        var confirmed = await DialogService.ShowMessageBox(
 0795            "Delete Link",
 0796            $"Are you sure you want to delete \"{link.Title}\"?",
 0797            yesText: "Delete",
 0798            cancelText: "Cancel");
 799
 0800        if (confirmed != true) return;
 801
 802        try
 803        {
 0804            var deleted = await WorldApi.DeleteWorldLinkAsync(WorldId, link.Id);
 0805            if (deleted)
 806            {
 0807                _links.Remove(link);
 0808                await TreeState.RefreshAsync();
 0809                Snackbar.Add("Link deleted", Severity.Success);
 0810                StateHasChanged();
 811            }
 812            else
 813            {
 0814                Snackbar.Add("Failed to delete link", Severity.Error);
 815            }
 0816        }
 0817        catch (Exception ex)
 818        {
 0819            Snackbar.Add($"Failed to delete link: {ex.Message}", Severity.Error);
 0820        }
 0821    }
 822
 823    private static string GetFaviconUrl(string url)
 824    {
 825        try
 826        {
 0827            var uri = new Uri(url);
 0828            return $"https://www.google.com/s2/favicons?domain={uri.Host}&sz=32";
 829        }
 0830        catch
 831        {
 0832            return string.Empty;
 833        }
 0834    }
 835
 836    private void HandleFaviconError()
 837    {
 838        // Favicon errors are handled via CSS fallback
 0839    }
 840
 841    // ===== Document Upload =====
 842
 843    private async Task OpenUploadDialog()
 844    {
 0845        var parameters = new DialogParameters
 0846        {
 0847            { "WorldId", WorldId }
 0848        };
 849
 0850        var options = new DialogOptions
 0851        {
 0852            MaxWidth = MaxWidth.Small,
 0853            FullWidth = true
 0854        };
 855
 0856        var dialog = await DialogService.ShowAsync<WorldDocumentUploadDialog>("Upload Document", parameters, options);
 0857        var result = await dialog.Result;
 858
 0859        if (result != null && !result.Canceled)
 860        {
 861            // Reload documents
 0862            _documents = await WorldApi.GetWorldDocumentsAsync(WorldId);
 863
 864            // Refresh tree to show new document
 0865            await TreeState.RefreshAsync();
 866
 0867            StateHasChanged();
 868        }
 0869    }
 870
 871    private void StartEditDocument(WorldDocumentDto document)
 872    {
 0873        _editingDocumentId = document.Id;
 0874        _editDocumentTitle = document.Title;
 0875        _editDocumentDescription = document.Description ?? string.Empty;
 0876    }
 877
 878    private void CancelDocumentEdit()
 879    {
 0880        _editingDocumentId = null;
 0881        _editDocumentTitle = string.Empty;
 0882        _editDocumentDescription = string.Empty;
 0883    }
 884
 885    private async Task SaveDocumentEdit()
 886    {
 0887        if (_editingDocumentId == null) return;
 888
 0889        _isSavingDocument = true;
 0890        StateHasChanged();
 891
 892        try
 893        {
 0894            var updateDto = new WorldDocumentUpdateDto
 0895            {
 0896                Title = _editDocumentTitle,
 0897                Description = _editDocumentDescription
 0898            };
 899
 0900            var updated = await WorldApi.UpdateDocumentAsync(WorldId, _editingDocumentId.Value, updateDto);
 901
 0902            if (updated != null)
 903            {
 904                // Update local list
 0905                var doc = _documents.FirstOrDefault(d => d.Id == _editingDocumentId.Value);
 0906                if (doc != null)
 907                {
 0908                    doc.Title = updated.Title;
 0909                    doc.Description = updated.Description;
 910                }
 911
 0912                _editingDocumentId = null;
 0913                _editDocumentTitle = string.Empty;
 0914                _editDocumentDescription = string.Empty;
 915
 916                // Refresh tree to show updated name
 0917                await TreeState.RefreshAsync();
 918
 0919                Snackbar.Add("Document updated", Severity.Success);
 920            }
 0921        }
 0922        catch (Exception ex)
 923        {
 0924            Snackbar.Add($"Failed to update document: {ex.Message}", Severity.Error);
 0925        }
 926        finally
 927        {
 0928            _isSavingDocument = false;
 0929            StateHasChanged();
 930        }
 0931    }
 932
 933    private async Task DeleteDocument(Guid documentId)
 934    {
 0935        var doc = _documents.FirstOrDefault(d => d.Id == documentId);
 0936        if (doc == null) return;
 937
 0938        var confirmed = await DialogService.ShowMessageBox(
 0939            "Delete Document",
 0940            $"Are you sure you want to delete \"{doc.Title}\"? This action cannot be undone.",
 0941            yesText: "Delete",
 0942            cancelText: "Cancel");
 943
 0944        if (confirmed == true)
 945        {
 0946            var success = await WorldApi.DeleteDocumentAsync(WorldId, documentId);
 947
 0948            if (success)
 949            {
 0950                _documents.Remove(doc);
 0951                await TreeState.RefreshAsync();
 0952                Snackbar.Add("Document deleted", Severity.Success);
 953            }
 954            else
 955            {
 0956                Snackbar.Add("Failed to delete document", Severity.Error);
 957            }
 958        }
 0959    }
 960
 961    private async Task DownloadDocument(Guid documentId)
 962    {
 963        try
 964        {
 0965            var download = await WorldApi.DownloadDocumentAsync(documentId);
 966
 0967            if (download == null)
 968            {
 0969                Snackbar.Add("Failed to get download URL", Severity.Error);
 0970                return;
 971            }
 972
 973            // Open the SAS URL in a new tab for download
 0974            await JSRuntime.InvokeVoidAsync("open", download.DownloadUrl, "_blank");
 975
 0976            Snackbar.Add($"Opening {download.FileName}", Severity.Success);
 0977        }
 0978        catch (Exception ex)
 979        {
 0980            Snackbar.Add($"Error downloading document: {ex.Message}", Severity.Error);
 0981        }
 0982    }
 983
 984    private string GetDocumentIcon(string contentType)
 985    {
 0986        return contentType.ToLowerInvariant() switch
 0987        {
 0988            "application/pdf" => Icons.Material.Filled.PictureAsPdf,
 0989            "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => Icons.Material.Filled.Descripti
 0990            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => Icons.Material.Filled.TableChart,
 0991            "application/vnd.openxmlformats-officedocument.presentationml.presentation" => Icons.Material.Filled.Slidesh
 0992            "text/plain" => Icons.Material.Filled.TextSnippet,
 0993            "text/markdown" => Icons.Material.Filled.Article,
 0994            string ct when ct.StartsWith("image/") => Icons.Material.Filled.Image,
 0995            _ => Icons.Material.Filled.InsertDriveFile
 0996        };
 997    }
 998
 999    private string FormatFileSize(long bytes)
 1000    {
 01001        string[] sizes = { "B", "KB", "MB", "GB" };
 01002        double len = bytes;
 01003        int order = 0;
 01004        while (len >= 1024 && order < sizes.Length - 1)
 1005        {
 01006            order++;
 01007            len = len / 1024;
 1008        }
 01009        return $"{len:0.##} {sizes[order]}";
 1010    }
 1011
 1012    // ===== Article Creation =====
 1013
 1014    private async Task CreateCampaign()
 1015    {
 01016        var parameters = new DialogParameters
 01017        {
 01018            { "WorldId", WorldId }
 01019        };
 1020
 01021        var dialog = await DialogService.ShowAsync<CreateCampaignDialog>("New Campaign", parameters);
 01022        var result = await dialog.Result;
 1023
 01024        if (result != null && !result.Canceled && result.Data is CampaignDto campaign)
 1025        {
 01026            await TreeState.RefreshAsync();
 01027            await LoadWorldAsync();
 01028            Navigation.NavigateTo($"/campaign/{campaign.Id}");
 01029            Snackbar.Add("Campaign created", Severity.Success);
 1030        }
 01031    }
 1032
 1033    private async Task CreateCharacter()
 1034    {
 01035        var parameters = new DialogParameters
 01036        {
 01037            { "WorldId", WorldId },
 01038            { "ArticleType", Chronicis.Shared.Enums.ArticleType.Character }
 01039        };
 1040
 01041        var dialog = await DialogService.ShowAsync<CreateArticleDialog>("New Player Character", parameters);
 01042        var result = await dialog.Result;
 1043
 01044        if (result != null && !result.Canceled && result.Data is ArticleDto article)
 1045        {
 01046            await TreeState.RefreshAsync();
 01047            NavigateToArticle(article);
 01048            Snackbar.Add("Character created", Severity.Success);
 1049        }
 01050    }
 1051
 1052    private async Task CreateWikiArticle()
 1053    {
 01054        var parameters = new DialogParameters
 01055        {
 01056            { "WorldId", WorldId },
 01057            { "ArticleType", Chronicis.Shared.Enums.ArticleType.WikiArticle }
 01058        };
 1059
 01060        var dialog = await DialogService.ShowAsync<CreateArticleDialog>("New Wiki Article", parameters);
 01061        var result = await dialog.Result;
 1062
 01063        if (result != null && !result.Canceled && result.Data is ArticleDto article)
 1064        {
 01065            await TreeState.RefreshAsync();
 01066            NavigateToArticle(article);
 01067            Snackbar.Add("Article created", Severity.Success);
 1068        }
 01069    }
 1070
 1071    private void NavigateToArticle(ArticleDto article)
 1072    {
 1073        // Build full path from breadcrumbs to ensure correct world context
 01074        if (article.Breadcrumbs != null && article.Breadcrumbs.Any())
 1075        {
 01076            var path = BreadcrumbService.BuildArticleUrl(article.Breadcrumbs);
 01077            Navigation.NavigateTo(path);
 1078        }
 1079        else
 1080        {
 1081            // Fallback to slug only (shouldn't happen for properly created articles)
 01082            Navigation.NavigateTo($"/article/{article.Slug}");
 1083        }
 01084    }
 1085
 1086    // ===== Public Sharing =====
 1087
 1088    private void OnPublicToggleChanged()
 1089    {
 01090        _hasUnsavedChanges = true;
 1091
 01092        if (_isPublic && string.IsNullOrEmpty(_publicSlug))
 1093        {
 1094            // Suggest a slug based on world name
 01095            _publicSlug = GenerateSlugFromName(_world?.Name ?? "");
 01096            _ = CheckSlugAvailability();
 1097        }
 01098    }
 1099
 1100    private static string GenerateSlugFromName(string name)
 1101    {
 01102        if (string.IsNullOrWhiteSpace(name)) return string.Empty;
 1103
 01104        var slug = name.Trim().ToLowerInvariant();
 01105        slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[\s_]+", "-");
 01106        slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[^a-z0-9-]", "");
 01107        slug = System.Text.RegularExpressions.Regex.Replace(slug, @"-+", "-");
 01108        slug = slug.Trim('-');
 1109
 01110        return slug.Length >= 3 ? slug : slug.PadRight(3, '0');
 1111    }
 1112
 1113    private async Task CheckSlugAvailability()
 1114    {
 01115        if (string.IsNullOrWhiteSpace(_publicSlug))
 1116        {
 01117            _slugIsAvailable = false;
 01118            _slugError = null;
 01119            _slugHelperText = null;
 01120            return;
 1121        }
 1122
 01123        _isCheckingSlug = true;
 01124        _slugError = null;
 01125        _slugHelperText = null;
 01126        StateHasChanged();
 1127
 1128        try
 1129        {
 01130            var result = await WorldApi.CheckPublicSlugAsync(WorldId, _publicSlug);
 1131
 01132            if (result == null)
 1133            {
 01134                _slugError = "Failed to check availability";
 01135                _slugIsAvailable = false;
 1136            }
 01137            else if (!string.IsNullOrEmpty(result.ValidationError))
 1138            {
 01139                _slugError = result.ValidationError;
 01140                _slugIsAvailable = false;
 01141                if (!string.IsNullOrEmpty(result.SuggestedSlug))
 1142                {
 01143                    _slugHelperText = $"Try: {result.SuggestedSlug}";
 1144                }
 1145            }
 01146            else if (!result.IsAvailable)
 1147            {
 01148                _slugError = "This slug is already taken";
 01149                _slugIsAvailable = false;
 01150                if (!string.IsNullOrEmpty(result.SuggestedSlug))
 1151                {
 01152                    _slugHelperText = $"Try: {result.SuggestedSlug}";
 1153                }
 1154            }
 1155            else
 1156            {
 01157                _slugIsAvailable = true;
 01158                _slugHelperText = "Available!";
 1159            }
 1160
 01161            _hasUnsavedChanges = true;
 01162        }
 01163        catch (Exception ex)
 1164        {
 01165            _slugError = $"Error: {ex.Message}";
 01166            _slugIsAvailable = false;
 01167        }
 1168        finally
 1169        {
 01170            _isCheckingSlug = false;
 01171            StateHasChanged();
 1172        }
 01173    }
 1174
 1175    private string GetPublicUrlBase()
 1176    {
 01177        var baseUri = Navigation.BaseUri.TrimEnd('/');
 01178        return $"{baseUri}/w/";
 1179    }
 1180
 1181    private string GetFullPublicUrl()
 1182    {
 01183        if (_world == null || string.IsNullOrEmpty(_world.PublicSlug)) return string.Empty;
 01184        return $"{GetPublicUrlBase()}{_world.PublicSlug}";
 1185    }
 1186
 1187    private async Task CopyPublicUrl()
 1188    {
 01189        var url = GetFullPublicUrl();
 01190        if (string.IsNullOrEmpty(url)) return;
 1191
 1192        try
 1193        {
 01194            await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", url);
 01195            Snackbar.Add("Public URL copied to clipboard", Severity.Success);
 01196        }
 01197        catch
 1198        {
 01199            Snackbar.Add("Failed to copy URL", Severity.Error);
 01200        }
 01201    }
 1202}