< Summary

Information
Class: Chronicis.Client.Pages.Maps.MapListing
Assembly: Chronicis.Client
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Client/Pages/Maps/MapListing.razor
Line coverage
100%
Covered lines: 159
Uncovered lines: 0
Coverable lines: 159
Total lines: 837
Line coverage: 100%
Branch coverage
100%
Covered branches: 42
Total branches: 42
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
BuildRenderTree(...)100%44100%
.cctor()100%11100%
.ctor()100%11100%
get__mapsTitle()100%11100%
OnBasemapDragEnter(...)100%66100%
OnBasemapDragOver(...)100%66100%
OnBasemapDragLeave(...)100%11100%
OnBasemapDrop(...)100%11100%
GetBasemapDropZoneClass()100%44100%
GetBasemapInputStyle()100%22100%
IsFileDragEvent(...)100%1212100%
IsDeleteInProgress(...)100%11100%
BuildGroupings(...)100%11100%
ResolveScope(...)100%22100%
DeriveScope(...)100%44100%
GetMapRoute(...)100%11100%
GetDisplayName(...)100%22100%

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Client/Pages/Maps/MapListing.razor

#LineLine coverage
 1@attribute [Authorize]
 2@implements IAsyncDisposable
 3@using Chronicis.Client.Components.Shared
 4@using Chronicis.Client.Services.Routing
 5@using Chronicis.Shared.DTOs
 6@using Chronicis.Shared.DTOs.Maps
 7@using Chronicis.Shared.Enums
 8@using Microsoft.AspNetCore.Components.Forms
 9@using Microsoft.AspNetCore.Components.Web
 10@using Microsoft.JSInterop
 11@inject IMapApiService MapApi
 12@inject IWorldApiService WorldApi
 13@inject IArcApiService ArcApi
 14@inject IAppUrlBuilder UrlBuilder
 15@inject IDialogService DialogService
 16@inject IJSRuntime JSRuntime
 17@inject ITreeStateService TreeState
 18@inject NavigationManager Navigation
 19
 2120@if (_isLoading)
 21{
 22    <LoadingSkeleton />
 23}
 2024else if (_hasLoadFailure)
 25{
 26    <MudAlert Severity="Severity.Error">
 27        World not found or access denied.
 28    </MudAlert>
 29}
 30else
 31{
 32    <MudPaper Elevation="2" Class="chronicis-article-card chronicis-fade-in">
 33        <DetailPageHeader Breadcrumbs="_breadcrumbs"
 34                          Icon="@Icons.Material.Filled.Map"
 35                          Title="@_mapsTitle"
 36                          Placeholder="Maps"
 37                          ReadOnly="true" />
 38
 39        <MudText Typo="Typo.body2" Class="mud-text-secondary mb-4">
 40            Maps grouped by world, campaign, and arc scope.
 41        </MudText>
 42
 43        <MudPaper Outlined="true" Class="pa-4 mb-4 maps-create-card">
 44            <MudText Typo="Typo.h6" Class="mb-2">
 45                <MudIcon Icon="@Icons.Material.Filled.AddPhotoAlternate" Size="Size.Small" Class="mr-2" />
 46                Create map
 47            </MudText>
 48            <MudStack Spacing="2">
 49                <MudTextField @bind-Value="_newMapName"
 50                              Label="Map name"
 51                              Variant="Variant.Outlined"
 52                              Required="true"
 53                              Disabled="_isCreatingMap" />
 54
 55                <div class="@GetBasemapDropZoneClass()"
 56                     style="position: relative;"
 57                     @ondragenter="OnBasemapDragEnter"
 58                     @ondragenter:preventDefault="true"
 59                     @ondragleave="OnBasemapDragLeave"
 60                     @ondragover="OnBasemapDragOver"
 61                     @ondragover:preventDefault="true"
 62                     @ondragover:stopPropagation="true"
 63                     @ondrop="OnBasemapDrop"
 64                     @ondrop:stopPropagation="true">
 65                    <InputFile id="mapBasemapFileInput"
 66                               OnChange="OnBasemapFileSelected"
 67                               accept="image/png,image/jpeg,image/webp"
 68                               disabled="@_isCreatingMap"
 69                               style="@GetBasemapInputStyle()"
 70                               class="maps-basemap-dropzone__input" />
 71                    <label for="mapBasemapFileInput" class="maps-basemap-dropzone__label">
 72                        <MudIcon Icon="@Icons.Material.Filled.UploadFile" Size="Size.Large" Class="mb-2" />
 73                        <MudText Typo="Typo.body1" Class="maps-basemap-dropzone__title">
 74                            @(_selectedBasemapFile == null ? "Choose basemap image" : _selectedBasemapFile.Name)
 75                        </MudText>
 76                        <MudText Typo="Typo.caption" Class="mud-text-secondary">
 77                            Click to browse or drag an image file here
 78                        </MudText>
 79                    </label>
 80                </div>
 81
 82                <MudText Typo="Typo.caption" Class="mud-text-secondary">
 83                    Supported types: image/png, image/jpeg, image/webp
 84                </MudText>
 85
 86                @if (!string.IsNullOrWhiteSpace(_createError))
 87                {
 88                    <MudAlert Severity="Severity.Error">@_createError</MudAlert>
 89                }
 90
 91                @if (!string.IsNullOrWhiteSpace(_createSuccess))
 92                {
 93                    <MudAlert Severity="Severity.Success">@_createSuccess</MudAlert>
 94                }
 95
 96                <MudButton Variant="Variant.Filled"
 97                           Color="Color.Primary"
 98                           OnClick="CreateMapWithBasemapAsync"
 99                           Disabled="_isCreatingMap">
 100                    @(_isCreatingMap ? "Uploading..." : "Create map")
 101                </MudButton>
 102            </MudStack>
 103        </MudPaper>
 104
 105        @if (!_maps.Any())
 106        {
 107            <MudAlert Severity="Severity.Info">
 108                No maps found for this world.
 109            </MudAlert>
 110        }
 111        else
 112        {
 113            @if (_worldScopedMaps.Any())
 114            {
 115                <MudPaper Outlined="true" Class="pa-4 mb-4 maps-scope-card">
 116                    <MudText Typo="Typo.h6" Class="mb-2 maps-scope-title">
 117                        <MudIcon Icon="@Icons.Material.Filled.Public" Size="Size.Small" Class="mr-2" />
 118                        World-scoped
 119                    </MudText>
 120                    <MudList T="MapSummaryDto" Dense="true">
 121                        @foreach (var map in _worldScopedMaps)
 122                        {
 123                            <EntityListItem Icon="@Icons.Material.Filled.Map"
 124                                            Title="@GetDisplayName(map.Name, "Untitled Map")"
 125                                            Href="@GetMapRoute(map.Slug)">
 126                                <span @onclick:stopPropagation="true">
 127                                    <MudIconButton Icon="@Icons.Material.Filled.Delete"
 128                                                   Color="Color.Error"
 129                                                   Size="Size.Small"
 130                                                   OnClick="@(() => OpenDeleteDialogAsync(map))"
 131                                                   Disabled="@IsDeleteInProgress(map.WorldMapId)" />
 132                                </span>
 133                            </EntityListItem>
 134                        }
 135                    </MudList>
 136                </MudPaper>
 137            }
 138
 139            @if (_campaignScopedGroups.Any())
 140            {
 141                <MudPaper Outlined="true" Class="pa-4 mb-4 maps-scope-card">
 142                    <MudText Typo="Typo.h6" Class="mb-2 maps-scope-title">
 143                        <MudIcon Icon="@Icons.Material.Filled.AutoStories" Size="Size.Small" Class="mr-2" />
 144                        Campaign-scoped
 145                    </MudText>
 146                    @foreach (var campaignGroup in _campaignScopedGroups)
 147                    {
 148                        <div class="maps-group-block">
 149                            <MudText Typo="Typo.subtitle1" Class="mb-1 maps-group-heading">
 150                                @campaignGroup.CampaignName
 151                            </MudText>
 152                            <MudList T="MapSummaryDto" Dense="true">
 153                                @foreach (var map in campaignGroup.Maps)
 154                                {
 155                                    <EntityListItem Icon="@Icons.Material.Filled.Map"
 156                                                    Title="@GetDisplayName(map.Name, "Untitled Map")"
 157                                                    Href="@GetMapRoute(map.Slug)">
 158                                        <span @onclick:stopPropagation="true">
 159                                            <MudIconButton Icon="@Icons.Material.Filled.Delete"
 160                                                           Color="Color.Error"
 161                                                           Size="Size.Small"
 162                                                           OnClick="@(() => OpenDeleteDialogAsync(map))"
 163                                                           Disabled="@IsDeleteInProgress(map.WorldMapId)" />
 164                                        </span>
 165                                    </EntityListItem>
 166                                }
 167                            </MudList>
 168                        </div>
 169                    }
 170                </MudPaper>
 171            }
 172
 173            @if (_arcScopedCampaignGroups.Any())
 174            {
 175                <MudPaper Outlined="true" Class="pa-4 maps-scope-card">
 176                    <MudText Typo="Typo.h6" Class="mb-2 maps-scope-title">
 177                        <MudIcon Icon="@Icons.Material.Filled.Bookmark" Size="Size.Small" Class="mr-2" />
 178                        Arc-scoped
 179                    </MudText>
 180                    @foreach (var campaignGroup in _arcScopedCampaignGroups)
 181                    {
 182                        <div class="maps-group-block">
 183                            <MudText Typo="Typo.subtitle1" Class="mb-1 maps-group-heading">
 184                                @campaignGroup.CampaignName
 185                            </MudText>
 186                            @foreach (var arcGroup in campaignGroup.ArcGroups)
 187                            {
 188                                <MudText Typo="Typo.subtitle2" Class="mb-1 maps-arc-heading">
 189                                    @arcGroup.ArcName
 190                                </MudText>
 191                                <MudList T="MapSummaryDto" Dense="true">
 192                                    @foreach (var map in arcGroup.Maps)
 193                                    {
 194                                        <EntityListItem Icon="@Icons.Material.Filled.Map"
 195                                                        Title="@GetDisplayName(map.Name, "Untitled Map")"
 196                                                        Href="@GetMapRoute(map.Slug)">
 197                                            <span @onclick:stopPropagation="true">
 198                                                <MudIconButton Icon="@Icons.Material.Filled.Delete"
 199                                                               Color="Color.Error"
 200                                                               Size="Size.Small"
 201                                                               OnClick="@(() => OpenDeleteDialogAsync(map))"
 202                                                               Disabled="@IsDeleteInProgress(map.WorldMapId)" />
 203                                            </span>
 204                                        </EntityListItem>
 205                                    }
 206                                </MudList>
 207                            }
 208                        </div>
 209                    }
 210                </MudPaper>
 211            }
 212        }
 213    </MudPaper>
 214}
 215
 216@code {
 1217    private static readonly DialogOptions _deleteDialogOptions = new()
 1218    {
 1219        CloseOnEscapeKey = true,
 1220        MaxWidth = MaxWidth.Small,
 1221        FullWidth = true,
 1222    };
 223
 224    [Parameter]
 225    public Guid WorldId { get; set; }
 226
 21227    private bool _isLoading = true;
 228    private bool _hasLoadFailure;
 21229    private string _worldSlug = string.Empty;
 21230    private string _worldName = "World";
 21231    private List<BreadcrumbItem> _breadcrumbs =
 21232    [
 21233        new("Dashboard", href: "/dashboard"),
 21234        new("World", href: null, disabled: true),
 21235        new("Maps", href: null, disabled: true)
 21236    ];
 21237    private List<MapSummaryDto> _maps = [];
 21238    private List<MapSummaryDto> _worldScopedMaps = [];
 21239    private List<CampaignMapGroup> _campaignScopedGroups = [];
 21240    private List<ArcCampaignGroup> _arcScopedCampaignGroups = [];
 21241    private string _newMapName = string.Empty;
 242    private IBrowserFile? _selectedBasemapFile;
 243    private byte[]? _selectedBasemapBytes;
 244    private bool _isBasemapDragOver;
 245    private bool _isCreatingMap;
 246    private bool _isDropGuardEnabled;
 21247    private readonly HashSet<Guid> _mapsBeingDeleted = [];
 21248    private string _createError = string.Empty;
 21249    private string _createSuccess = string.Empty;
 18250    private string _mapsTitle => $"{_worldName} Maps";
 251
 252    private const long MaxBasemapSizeBytes = 50 * 1024 * 1024;
 1253    private static readonly HashSet<string> AllowedBasemapContentTypes = new(StringComparer.OrdinalIgnoreCase)
 1254    {
 1255        "image/png",
 1256        "image/jpeg",
 1257        "image/webp"
 1258    };
 259
 260    private sealed class CampaignMapGroup
 261    {
 262        public Guid CampaignId { get; init; }
 263        public string CampaignName { get; init; } = string.Empty;
 264        public List<MapSummaryDto> Maps { get; init; } = [];
 265    }
 266
 267    private sealed class ArcMapGroup
 268    {
 269        public Guid ArcId { get; init; }
 270        public string ArcName { get; init; } = string.Empty;
 271        public List<MapSummaryDto> Maps { get; init; } = [];
 272    }
 273
 274    private sealed class ArcCampaignGroup
 275    {
 276        public Guid CampaignId { get; init; }
 277        public string CampaignName { get; init; } = string.Empty;
 278        public List<ArcMapGroup> ArcGroups { get; init; } = [];
 279    }
 280
 281    private sealed class ArcLookupItem
 282    {
 283        public Guid CampaignId { get; init; }
 284        public string ArcName { get; init; } = string.Empty;
 285    }
 286
 287    protected override async Task OnParametersSetAsync()
 288    {
 289        await LoadAsync();
 290    }
 291
 292    protected override async Task OnAfterRenderAsync(bool firstRender)
 293    {
 294        if (!firstRender)
 295        {
 296            return;
 297        }
 298
 299        try
 300        {
 301            await JSRuntime.InvokeVoidAsync("chronicisMapsDropGuard.enable", ".maps-basemap-dropzone");
 302            _isDropGuardEnabled = true;
 303        }
 304        catch (JSDisconnectedException)
 305        {
 306            // Navigation/disposal race; ignore.
 307        }
 308    }
 309
 310    private async Task LoadAsync()
 311    {
 312        _isLoading = true;
 313        _hasLoadFailure = false;
 314        _maps = [];
 315        _worldScopedMaps = [];
 316        _campaignScopedGroups = [];
 317        _arcScopedCampaignGroups = [];
 318        _breadcrumbs =
 319        [
 320            new("Dashboard", href: "/dashboard"),
 321            new("World", href: null, disabled: true),
 322            new("Maps", href: null, disabled: true)
 323        ];
 324
 325        try
 326        {
 327            var world = await WorldApi.GetWorldAsync(WorldId);
 328            if (world == null)
 329            {
 330                _hasLoadFailure = true;
 331                return;
 332            }
 333
 334            _worldSlug = world.Slug;
 335            _worldName = GetDisplayName(world.Name, "World");
 336            _breadcrumbs =
 337            [
 338                new("Dashboard", href: "/dashboard"),
 339                new(_worldName, href: UrlBuilder.ForWorld(_worldSlug)),
 340                new("Maps", href: null, disabled: true)
 341            ];
 342
 343            await SyncTreeSelectionAsync();
 344
 345            _maps = await MapApi.ListMapsForWorldAsync(WorldId);
 346
 347            var campaignLookup = world.Campaigns
 348                .GroupBy(c => c.Id)
 349                .ToDictionary(
 350                    g => g.Key,
 351                    g => GetDisplayName(g.First().Name, "Untitled Campaign"));
 352
 353            var arcLookup = await BuildArcLookupAsync(campaignLookup.Keys.ToList());
 354
 355            BuildGroupings(campaignLookup, arcLookup);
 356        }
 357        catch
 358        {
 359            _hasLoadFailure = true;
 360        }
 361        finally
 362        {
 363            _isLoading = false;
 364        }
 365    }
 366
 367    private async Task OnBasemapFileSelected(InputFileChangeEventArgs e)
 368    {
 369        _isBasemapDragOver = false;
 370        _createError = string.Empty;
 371        _createSuccess = string.Empty;
 372        _selectedBasemapFile = e.File;
 373        _selectedBasemapBytes = null;
 374
 375        if (_selectedBasemapFile == null)
 376        {
 377            return;
 378        }
 379
 380        if (!AllowedBasemapContentTypes.Contains(_selectedBasemapFile.ContentType))
 381        {
 382            _createError = "Unsupported file type. Use image/png, image/jpeg, or image/webp.";
 383            _selectedBasemapFile = null;
 384            return;
 385        }
 386
 387        try
 388        {
 389            using var stream = _selectedBasemapFile.OpenReadStream(MaxBasemapSizeBytes);
 390            using var memoryStream = new MemoryStream();
 391            await stream.CopyToAsync(memoryStream);
 392            _selectedBasemapBytes = memoryStream.ToArray();
 393        }
 394        catch (Exception ex)
 395        {
 396            _createError = $"Failed to read selected file: {ex.Message}";
 397            _selectedBasemapFile = null;
 398            _selectedBasemapBytes = null;
 399        }
 400    }
 401
 402    private void OnBasemapDragEnter(DragEventArgs e)
 403    {
 5404        if (_isCreatingMap)
 405        {
 1406            return;
 407        }
 408
 4409        _isBasemapDragOver = IsFileDragEvent(e);
 4410        if (_isBasemapDragOver && e.DataTransfer != null)
 411        {
 2412            e.DataTransfer.DropEffect = "copy";
 2413            e.DataTransfer.EffectAllowed = "copy";
 414        }
 4415    }
 416
 417    private void OnBasemapDragOver(DragEventArgs e)
 418    {
 3419        if (_isCreatingMap)
 420        {
 1421            return;
 422        }
 423
 2424        _isBasemapDragOver = IsFileDragEvent(e);
 2425        if (_isBasemapDragOver && e.DataTransfer != null)
 426        {
 1427            e.DataTransfer.DropEffect = "copy";
 1428            e.DataTransfer.EffectAllowed = "copy";
 429        }
 2430    }
 431
 432    private void OnBasemapDragLeave(DragEventArgs _)
 433    {
 1434        _isBasemapDragOver = false;
 1435    }
 436
 437    private void OnBasemapDrop(DragEventArgs _)
 438    {
 1439        _isBasemapDragOver = false;
 1440    }
 441
 442    private string GetBasemapDropZoneClass()
 443    {
 21444        var classes = new List<string> { "maps-basemap-dropzone" };
 21445        if (_isBasemapDragOver)
 446        {
 1447            classes.Add("maps-basemap-dropzone--dragover");
 1448            classes.Add("maps-basemap-dropzone--copy");
 449        }
 450
 21451        if (_isCreatingMap)
 452        {
 1453            classes.Add("maps-basemap-dropzone--disabled");
 454        }
 455
 21456        return string.Join(" ", classes);
 457    }
 458
 459    private string GetBasemapInputStyle()
 20460        => $"position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:{(_isBasemapDragOver ? "copy" : "pointer"
 461
 462    private static bool IsFileDragEvent(DragEventArgs e)
 463    {
 12464        var dataTransfer = e.DataTransfer;
 12465        if (dataTransfer == null)
 466        {
 2467            return false;
 468        }
 469
 10470        if (dataTransfer.Files != null && dataTransfer.Files.Length > 0)
 471        {
 3472            return true;
 473        }
 474
 7475        if (dataTransfer.Types == null)
 476        {
 2477            return false;
 478        }
 479
 16480        foreach (var dataType in dataTransfer.Types)
 481        {
 4482            if (string.Equals(dataType, "Files", StringComparison.OrdinalIgnoreCase))
 483            {
 2484                return true;
 485            }
 486        }
 487
 3488        return false;
 489    }
 490
 491    public async ValueTask DisposeAsync()
 492    {
 493        if (!_isDropGuardEnabled)
 494        {
 495            return;
 496        }
 497
 498        try
 499        {
 500            await JSRuntime.InvokeVoidAsync("chronicisMapsDropGuard.disable");
 501        }
 502        catch (JSDisconnectedException)
 503        {
 504            // Browser disconnected; nothing to clean up.
 505        }
 506    }
 507
 508    private async Task OpenDeleteDialogAsync(MapSummaryDto map)
 509    {
 510        var mapName = GetDisplayName(map.Name, "Untitled Map");
 511
 512        if (IsDeleteInProgress(map.WorldMapId))
 513        {
 514            return;
 515        }
 516
 517        var parameters = new DialogParameters<DeleteMapDialog>
 518        {
 519            { x => x.MapName, mapName }
 520        };
 521
 522        var dialog = await DialogService.ShowAsync<DeleteMapDialog>(
 523            $"Delete \"{mapName}\"",
 524            parameters,
 525            _deleteDialogOptions);
 526
 527        var result = await dialog.Result;
 528        if (result is { Canceled: false })
 529        {
 530            await DeleteMapAsync(map.WorldMapId, mapName);
 531        }
 532    }
 533
 534    private async Task DeleteMapAsync(Guid mapId, string mapName)
 535    {
 536        _createError = string.Empty;
 537        _createSuccess = string.Empty;
 538        _mapsBeingDeleted.Add(mapId);
 539
 540        try
 541        {
 542            var deleted = await MapApi.DeleteMapAsync(WorldId, mapId);
 543            if (!deleted)
 544            {
 545                _createError = $"Failed to delete map '{mapName}'.";
 546                return;
 547            }
 548
 549            _createSuccess = $"Map '{mapName}' deleted permanently.";
 550            await LoadAsync();
 551        }
 552        catch (Exception ex)
 553        {
 554            _createError = $"Failed to delete map '{mapName}': {ex.Message}";
 555        }
 556        finally
 557        {
 558            _mapsBeingDeleted.Remove(mapId);
 559        }
 560    }
 561
 8562    private bool IsDeleteInProgress(Guid mapId) => _mapsBeingDeleted.Contains(mapId);
 563
 564    private async Task CreateMapWithBasemapAsync()
 565    {
 566        _createError = string.Empty;
 567        _createSuccess = string.Empty;
 568
 569        if (string.IsNullOrWhiteSpace(_newMapName))
 570        {
 571            _createError = "Map name is required.";
 572            return;
 573        }
 574
 575        if (_selectedBasemapFile == null || _selectedBasemapBytes == null)
 576        {
 577            _createError = "Basemap file is required.";
 578            return;
 579        }
 580
 581        if (!AllowedBasemapContentTypes.Contains(_selectedBasemapFile.ContentType))
 582        {
 583            _createError = "Unsupported file type. Use image/png, image/jpeg, or image/webp.";
 584            return;
 585        }
 586
 587        _isCreatingMap = true;
 588
 589        try
 590        {
 591            var map = await MapApi.CreateMapAsync(
 592                WorldId,
 593                new MapCreateDto { Name = _newMapName.Trim() });
 594
 595            if (map == null)
 596            {
 597                _createError = "Failed to create map record.";
 598                return;
 599            }
 600
 601            var uploadRequest = new RequestBasemapUploadDto
 602            {
 603                FileName = _selectedBasemapFile.Name,
 604                ContentType = _selectedBasemapFile.ContentType
 605            };
 606
 607            var uploadResponse = await MapApi.RequestBasemapUploadAsync(WorldId, map.WorldMapId, uploadRequest);
 608            if (uploadResponse == null || string.IsNullOrWhiteSpace(uploadResponse.UploadUrl))
 609            {
 610                _createError = "Failed to request basemap upload URL.";
 611                return;
 612            }
 613
 614            using (var uploadContent = new ByteArrayContent(_selectedBasemapBytes))
 615            {
 616                uploadContent.Headers.ContentType =
 617                    new System.Net.Http.Headers.MediaTypeHeaderValue(_selectedBasemapFile.ContentType);
 618                uploadContent.Headers.Add("x-ms-blob-type", "BlockBlob");
 619
 620                using var uploadHttpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
 621                var uploadResult = await uploadHttpClient.PutAsync(uploadResponse.UploadUrl, uploadContent);
 622                if (!uploadResult.IsSuccessStatusCode)
 623                {
 624                    var errorBody = await uploadResult.Content.ReadAsStringAsync();
 625                    _createError = $"Basemap upload failed ({uploadResult.StatusCode}): {errorBody}";
 626                    return;
 627                }
 628            }
 629
 630            var confirmResult = await MapApi.ConfirmBasemapUploadAsync(
 631                WorldId,
 632                map.WorldMapId,
 633                $"maps/{map.WorldMapId}/basemap/{_selectedBasemapFile.Name}",
 634                _selectedBasemapFile.ContentType,
 635                _selectedBasemapFile.Name);
 636
 637            if (confirmResult == null)
 638            {
 639                _createError = "Failed to confirm basemap upload.";
 640                return;
 641            }
 642
 643            _createSuccess = $"Map '{confirmResult.Name}' created.";
 644            _newMapName = string.Empty;
 645            _selectedBasemapFile = null;
 646            _selectedBasemapBytes = null;
 647            await LoadAsync();
 648        }
 649        catch (Exception ex)
 650        {
 651            _createError = $"Failed to create map: {ex.Message}";
 652        }
 653        finally
 654        {
 655            _isCreatingMap = false;
 656        }
 657    }
 658
 659    private async Task<Dictionary<Guid, ArcLookupItem>> BuildArcLookupAsync(List<Guid> campaignIds)
 660    {
 661        var lookup = new Dictionary<Guid, ArcLookupItem>();
 662        var distinctCampaignIds = campaignIds.Distinct().ToList();
 663
 664        if (distinctCampaignIds.Count == 0)
 665        {
 666            return lookup;
 667        }
 668
 669        var arcTasks = distinctCampaignIds
 670            .Select(campaignId => ArcApi.GetArcsByCampaignAsync(campaignId))
 671            .ToList();
 672
 673        await Task.WhenAll(arcTasks);
 674
 675        for (int i = 0; i < distinctCampaignIds.Count; i++)
 676        {
 677            var campaignId = distinctCampaignIds[i];
 678            var arcs = arcTasks[i].Result;
 679
 680            foreach (var arc in arcs)
 681            {
 682                if (!lookup.ContainsKey(arc.Id))
 683                {
 684                    lookup[arc.Id] = new ArcLookupItem
 685                    {
 686                        CampaignId = campaignId,
 687                        ArcName = GetDisplayName(arc.Name, "Untitled Arc")
 688                    };
 689                }
 690            }
 691        }
 692
 693        return lookup;
 694    }
 695
 696    private void BuildGroupings(
 697        Dictionary<Guid, string> campaignLookup,
 698        Dictionary<Guid, ArcLookupItem> arcLookup)
 699    {
 20700        _worldScopedMaps = _maps
 20701            .Where(map => ResolveScope(map) == MapScope.WorldScoped)
 20702            .OrderBy(map => map.Name, StringComparer.OrdinalIgnoreCase)
 20703            .ThenBy(map => map.WorldMapId)
 20704            .ToList();
 705
 20706        _campaignScopedGroups = _maps
 20707            .Where(map => ResolveScope(map) == MapScope.CampaignScoped)
 20708            .SelectMany(map => map.CampaignIds.Distinct().Select(campaignId => new { campaignId, map }))
 20709            .GroupBy(x => x.campaignId)
 20710            .Select(group => new CampaignMapGroup
 20711            {
 20712                CampaignId = group.Key,
 20713                CampaignName = campaignLookup.TryGetValue(group.Key, out var campaignName)
 20714                    ? campaignName
 20715                    : $"Unknown Campaign ({group.Key})",
 20716                Maps = group
 20717                    .Select(x => x.map)
 20718                    .DistinctBy(x => x.WorldMapId)
 20719                    .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 20720                    .ThenBy(x => x.WorldMapId)
 20721                    .ToList()
 20722            })
 20723            .OrderBy(group => group.CampaignName, StringComparer.OrdinalIgnoreCase)
 20724            .ThenBy(group => group.CampaignId)
 20725            .ToList();
 726
 20727        _arcScopedCampaignGroups = _maps
 20728            .Where(map => ResolveScope(map) == MapScope.ArcScoped)
 20729            .SelectMany(map => map.ArcIds.Distinct().Select(arcId =>
 20730            {
 20731                if (arcLookup.TryGetValue(arcId, out var arc))
 20732                {
 20733                    var campaignName = campaignLookup.TryGetValue(arc.CampaignId, out var name)
 20734                        ? name
 20735                        : $"Unknown Campaign ({arc.CampaignId})";
 20736
 20737                    return new
 20738                    {
 20739                        CampaignId = arc.CampaignId,
 20740                        CampaignName = campaignName,
 20741                        ArcId = arcId,
 20742                        ArcName = arc.ArcName,
 20743                        Map = map
 20744                    };
 20745                }
 20746
 20747                return new
 20748                {
 20749                    CampaignId = Guid.Empty,
 20750                    CampaignName = "Unknown Campaign",
 20751                    ArcId = arcId,
 20752                    ArcName = $"Unknown Arc ({arcId})",
 20753                    Map = map
 20754                };
 20755            }))
 20756            .GroupBy(x => new { x.CampaignId, x.CampaignName })
 20757            .Select(campaignGroup => new ArcCampaignGroup
 20758            {
 20759                CampaignId = campaignGroup.Key.CampaignId,
 20760                CampaignName = campaignGroup.Key.CampaignName,
 20761                ArcGroups = campaignGroup
 20762                    .GroupBy(x => new { x.ArcId, x.ArcName })
 20763                    .Select(arcGroup => new ArcMapGroup
 20764                    {
 20765                        ArcId = arcGroup.Key.ArcId,
 20766                        ArcName = arcGroup.Key.ArcName,
 20767                        Maps = arcGroup
 20768                            .Select(x => x.Map)
 20769                            .DistinctBy(x => x.WorldMapId)
 20770                            .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 20771                            .ThenBy(x => x.WorldMapId)
 20772                            .ToList()
 20773                    })
 20774                    .OrderBy(group => group.ArcName, StringComparer.OrdinalIgnoreCase)
 20775                    .ThenBy(group => group.ArcId)
 20776                    .ToList()
 20777            })
 20778            .OrderBy(group => group.CampaignName, StringComparer.OrdinalIgnoreCase)
 20779            .ThenBy(group => group.CampaignId)
 20780            .ToList();
 20781    }
 782
 783    private static MapScope ResolveScope(MapSummaryDto map)
 784    {
 29785        var derivedScope = DeriveScope(map);
 29786        return map.Scope == derivedScope ? map.Scope : derivedScope;
 787    }
 788
 789    private static MapScope DeriveScope(MapSummaryDto map)
 790    {
 32791        if (map.ArcIds.Count > 0)
 792        {
 7793            return MapScope.ArcScoped;
 794        }
 795
 25796        if (map.CampaignIds.Count > 0)
 797        {
 11798            return MapScope.CampaignScoped;
 799        }
 800
 14801        return MapScope.WorldScoped;
 802    }
 803
 9804    private string GetMapRoute(string mapSlug) => UrlBuilder.ForMap(_worldSlug, mapSlug);
 805
 806    private static string GetDisplayName(string? value, string fallback)
 35807        => string.IsNullOrWhiteSpace(value) ? fallback : value;
 808
 809    private async Task SyncTreeSelectionAsync()
 810    {
 811        try
 812        {
 813            if (!TreeState.RootNodes.Any() && !TreeState.IsLoading)
 814            {
 815                await TreeState.InitializeAsync();
 816            }
 817
 818            var worldNode = TreeState.RootNodes.FirstOrDefault(n =>
 819                n.NodeType == TreeNodeType.World &&
 820                n.Id == WorldId);
 821
 822            var mapsGroupNode = worldNode?.Children.FirstOrDefault(n =>
 823                n.NodeType == TreeNodeType.VirtualGroup &&
 824                n.VirtualGroupType == VirtualGroupType.Maps);
 825
 826            if (mapsGroupNode != null)
 827            {
 828                TreeState.ExpandPathToAndSelect(mapsGroupNode.Id);
 829            }
 830        }
 831        catch
 832        {
 833            // Tree sync failures should not block page rendering.
 834        }
 835    }
 836}
 837