< 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: 158
Uncovered lines: 0
Coverable lines: 158
Total lines: 834
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@page "/world/{WorldId:guid}/maps"
 2@attribute [Authorize]
 3@implements IAsyncDisposable
 4@using Chronicis.Client.Components.Shared
 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 IDialogService DialogService
 15@inject IJSRuntime JSRuntime
 16@inject ITreeStateService TreeState
 17@inject NavigationManager Navigation
 18
 2119@if (_isLoading)
 20{
 21    <LoadingSkeleton />
 22}
 2023else if (_hasLoadFailure)
 24{
 25    <MudAlert Severity="Severity.Error">
 26        World not found or access denied.
 27    </MudAlert>
 28}
 29else
 30{
 31    <MudPaper Elevation="2" Class="chronicis-article-card chronicis-fade-in">
 32        <DetailPageHeader Breadcrumbs="_breadcrumbs"
 33                          Icon="@Icons.Material.Filled.Map"
 34                          Title="@_mapsTitle"
 35                          Placeholder="Maps"
 36                          ReadOnly="true" />
 37
 38        <MudText Typo="Typo.body2" Class="mud-text-secondary mb-4">
 39            Maps grouped by world, campaign, and arc scope.
 40        </MudText>
 41
 42        <MudPaper Outlined="true" Class="pa-4 mb-4 maps-create-card">
 43            <MudText Typo="Typo.h6" Class="mb-2">
 44                <MudIcon Icon="@Icons.Material.Filled.AddPhotoAlternate" Size="Size.Small" Class="mr-2" />
 45                Create map
 46            </MudText>
 47            <MudStack Spacing="2">
 48                <MudTextField @bind-Value="_newMapName"
 49                              Label="Map name"
 50                              Variant="Variant.Outlined"
 51                              Required="true"
 52                              Disabled="_isCreatingMap" />
 53
 54                <div class="@GetBasemapDropZoneClass()"
 55                     style="position: relative;"
 56                     @ondragenter="OnBasemapDragEnter"
 57                     @ondragenter:preventDefault="true"
 58                     @ondragleave="OnBasemapDragLeave"
 59                     @ondragover="OnBasemapDragOver"
 60                     @ondragover:preventDefault="true"
 61                     @ondragover:stopPropagation="true"
 62                     @ondrop="OnBasemapDrop"
 63                     @ondrop:stopPropagation="true">
 64                    <InputFile id="mapBasemapFileInput"
 65                               OnChange="OnBasemapFileSelected"
 66                               accept="image/png,image/jpeg,image/webp"
 67                               disabled="@_isCreatingMap"
 68                               style="@GetBasemapInputStyle()"
 69                               class="maps-basemap-dropzone__input" />
 70                    <label for="mapBasemapFileInput" class="maps-basemap-dropzone__label">
 71                        <MudIcon Icon="@Icons.Material.Filled.UploadFile" Size="Size.Large" Class="mb-2" />
 72                        <MudText Typo="Typo.body1" Class="maps-basemap-dropzone__title">
 73                            @(_selectedBasemapFile == null ? "Choose basemap image" : _selectedBasemapFile.Name)
 74                        </MudText>
 75                        <MudText Typo="Typo.caption" Class="mud-text-secondary">
 76                            Click to browse or drag an image file here
 77                        </MudText>
 78                    </label>
 79                </div>
 80
 81                <MudText Typo="Typo.caption" Class="mud-text-secondary">
 82                    Supported types: image/png, image/jpeg, image/webp
 83                </MudText>
 84
 85                @if (!string.IsNullOrWhiteSpace(_createError))
 86                {
 87                    <MudAlert Severity="Severity.Error">@_createError</MudAlert>
 88                }
 89
 90                @if (!string.IsNullOrWhiteSpace(_createSuccess))
 91                {
 92                    <MudAlert Severity="Severity.Success">@_createSuccess</MudAlert>
 93                }
 94
 95                <MudButton Variant="Variant.Filled"
 96                           Color="Color.Primary"
 97                           OnClick="CreateMapWithBasemapAsync"
 98                           Disabled="_isCreatingMap">
 99                    @(_isCreatingMap ? "Uploading..." : "Create map")
 100                </MudButton>
 101            </MudStack>
 102        </MudPaper>
 103
 104        @if (!_maps.Any())
 105        {
 106            <MudAlert Severity="Severity.Info">
 107                No maps found for this world.
 108            </MudAlert>
 109        }
 110        else
 111        {
 112            @if (_worldScopedMaps.Any())
 113            {
 114                <MudPaper Outlined="true" Class="pa-4 mb-4 maps-scope-card">
 115                    <MudText Typo="Typo.h6" Class="mb-2 maps-scope-title">
 116                        <MudIcon Icon="@Icons.Material.Filled.Public" Size="Size.Small" Class="mr-2" />
 117                        World-scoped
 118                    </MudText>
 119                    <MudList T="MapSummaryDto" Dense="true">
 120                        @foreach (var map in _worldScopedMaps)
 121                        {
 122                            <EntityListItem Icon="@Icons.Material.Filled.Map"
 123                                            Title="@GetDisplayName(map.Name, "Untitled Map")"
 124                                            Href="@GetMapRoute(map.WorldMapId)">
 125                                <span @onclick:stopPropagation="true">
 126                                    <MudIconButton Icon="@Icons.Material.Filled.Delete"
 127                                                   Color="Color.Error"
 128                                                   Size="Size.Small"
 129                                                   OnClick="@(() => OpenDeleteDialogAsync(map))"
 130                                                   Disabled="@IsDeleteInProgress(map.WorldMapId)" />
 131                                </span>
 132                            </EntityListItem>
 133                        }
 134                    </MudList>
 135                </MudPaper>
 136            }
 137
 138            @if (_campaignScopedGroups.Any())
 139            {
 140                <MudPaper Outlined="true" Class="pa-4 mb-4 maps-scope-card">
 141                    <MudText Typo="Typo.h6" Class="mb-2 maps-scope-title">
 142                        <MudIcon Icon="@Icons.Material.Filled.AutoStories" Size="Size.Small" Class="mr-2" />
 143                        Campaign-scoped
 144                    </MudText>
 145                    @foreach (var campaignGroup in _campaignScopedGroups)
 146                    {
 147                        <div class="maps-group-block">
 148                            <MudText Typo="Typo.subtitle1" Class="mb-1 maps-group-heading">
 149                                @campaignGroup.CampaignName
 150                            </MudText>
 151                            <MudList T="MapSummaryDto" Dense="true">
 152                                @foreach (var map in campaignGroup.Maps)
 153                                {
 154                                    <EntityListItem Icon="@Icons.Material.Filled.Map"
 155                                                    Title="@GetDisplayName(map.Name, "Untitled Map")"
 156                                                    Href="@GetMapRoute(map.WorldMapId)">
 157                                        <span @onclick:stopPropagation="true">
 158                                            <MudIconButton Icon="@Icons.Material.Filled.Delete"
 159                                                           Color="Color.Error"
 160                                                           Size="Size.Small"
 161                                                           OnClick="@(() => OpenDeleteDialogAsync(map))"
 162                                                           Disabled="@IsDeleteInProgress(map.WorldMapId)" />
 163                                        </span>
 164                                    </EntityListItem>
 165                                }
 166                            </MudList>
 167                        </div>
 168                    }
 169                </MudPaper>
 170            }
 171
 172            @if (_arcScopedCampaignGroups.Any())
 173            {
 174                <MudPaper Outlined="true" Class="pa-4 maps-scope-card">
 175                    <MudText Typo="Typo.h6" Class="mb-2 maps-scope-title">
 176                        <MudIcon Icon="@Icons.Material.Filled.Bookmark" Size="Size.Small" Class="mr-2" />
 177                        Arc-scoped
 178                    </MudText>
 179                    @foreach (var campaignGroup in _arcScopedCampaignGroups)
 180                    {
 181                        <div class="maps-group-block">
 182                            <MudText Typo="Typo.subtitle1" Class="mb-1 maps-group-heading">
 183                                @campaignGroup.CampaignName
 184                            </MudText>
 185                            @foreach (var arcGroup in campaignGroup.ArcGroups)
 186                            {
 187                                <MudText Typo="Typo.subtitle2" Class="mb-1 maps-arc-heading">
 188                                    @arcGroup.ArcName
 189                                </MudText>
 190                                <MudList T="MapSummaryDto" Dense="true">
 191                                    @foreach (var map in arcGroup.Maps)
 192                                    {
 193                                        <EntityListItem Icon="@Icons.Material.Filled.Map"
 194                                                        Title="@GetDisplayName(map.Name, "Untitled Map")"
 195                                                        Href="@GetMapRoute(map.WorldMapId)">
 196                                            <span @onclick:stopPropagation="true">
 197                                                <MudIconButton Icon="@Icons.Material.Filled.Delete"
 198                                                               Color="Color.Error"
 199                                                               Size="Size.Small"
 200                                                               OnClick="@(() => OpenDeleteDialogAsync(map))"
 201                                                               Disabled="@IsDeleteInProgress(map.WorldMapId)" />
 202                                            </span>
 203                                        </EntityListItem>
 204                                    }
 205                                </MudList>
 206                            }
 207                        </div>
 208                    }
 209                </MudPaper>
 210            }
 211        }
 212    </MudPaper>
 213}
 214
 215@code {
 1216    private static readonly DialogOptions _deleteDialogOptions = new()
 1217    {
 1218        CloseOnEscapeKey = true,
 1219        MaxWidth = MaxWidth.Small,
 1220        FullWidth = true,
 1221    };
 222
 223    [Parameter]
 224    public Guid WorldId { get; set; }
 225
 21226    private bool _isLoading = true;
 227    private bool _hasLoadFailure;
 21228    private string _worldName = "World";
 21229    private List<BreadcrumbItem> _breadcrumbs =
 21230    [
 21231        new("Dashboard", href: "/dashboard"),
 21232        new("World", href: null, disabled: true),
 21233        new("Maps", href: null, disabled: true)
 21234    ];
 21235    private List<MapSummaryDto> _maps = [];
 21236    private List<MapSummaryDto> _worldScopedMaps = [];
 21237    private List<CampaignMapGroup> _campaignScopedGroups = [];
 21238    private List<ArcCampaignGroup> _arcScopedCampaignGroups = [];
 21239    private string _newMapName = string.Empty;
 240    private IBrowserFile? _selectedBasemapFile;
 241    private byte[]? _selectedBasemapBytes;
 242    private bool _isBasemapDragOver;
 243    private bool _isCreatingMap;
 244    private bool _isDropGuardEnabled;
 21245    private readonly HashSet<Guid> _mapsBeingDeleted = [];
 21246    private string _createError = string.Empty;
 21247    private string _createSuccess = string.Empty;
 18248    private string _mapsTitle => $"{_worldName} Maps";
 249
 250    private const long MaxBasemapSizeBytes = 50 * 1024 * 1024;
 1251    private static readonly HashSet<string> AllowedBasemapContentTypes = new(StringComparer.OrdinalIgnoreCase)
 1252    {
 1253        "image/png",
 1254        "image/jpeg",
 1255        "image/webp"
 1256    };
 257
 258    private sealed class CampaignMapGroup
 259    {
 260        public Guid CampaignId { get; init; }
 261        public string CampaignName { get; init; } = string.Empty;
 262        public List<MapSummaryDto> Maps { get; init; } = [];
 263    }
 264
 265    private sealed class ArcMapGroup
 266    {
 267        public Guid ArcId { get; init; }
 268        public string ArcName { get; init; } = string.Empty;
 269        public List<MapSummaryDto> Maps { get; init; } = [];
 270    }
 271
 272    private sealed class ArcCampaignGroup
 273    {
 274        public Guid CampaignId { get; init; }
 275        public string CampaignName { get; init; } = string.Empty;
 276        public List<ArcMapGroup> ArcGroups { get; init; } = [];
 277    }
 278
 279    private sealed class ArcLookupItem
 280    {
 281        public Guid CampaignId { get; init; }
 282        public string ArcName { get; init; } = string.Empty;
 283    }
 284
 285    protected override async Task OnParametersSetAsync()
 286    {
 287        await LoadAsync();
 288    }
 289
 290    protected override async Task OnAfterRenderAsync(bool firstRender)
 291    {
 292        if (!firstRender)
 293        {
 294            return;
 295        }
 296
 297        try
 298        {
 299            await JSRuntime.InvokeVoidAsync("chronicisMapsDropGuard.enable", ".maps-basemap-dropzone");
 300            _isDropGuardEnabled = true;
 301        }
 302        catch (JSDisconnectedException)
 303        {
 304            // Navigation/disposal race; ignore.
 305        }
 306    }
 307
 308    private async Task LoadAsync()
 309    {
 310        _isLoading = true;
 311        _hasLoadFailure = false;
 312        _maps = [];
 313        _worldScopedMaps = [];
 314        _campaignScopedGroups = [];
 315        _arcScopedCampaignGroups = [];
 316        _breadcrumbs =
 317        [
 318            new("Dashboard", href: "/dashboard"),
 319            new("World", href: null, disabled: true),
 320            new("Maps", href: null, disabled: true)
 321        ];
 322
 323        try
 324        {
 325            var world = await WorldApi.GetWorldAsync(WorldId);
 326            if (world == null)
 327            {
 328                _hasLoadFailure = true;
 329                return;
 330            }
 331
 332            _worldName = GetDisplayName(world.Name, "World");
 333            _breadcrumbs =
 334            [
 335                new("Dashboard", href: "/dashboard"),
 336                new(_worldName, href: $"/world/{WorldId}"),
 337                new("Maps", href: null, disabled: true)
 338            ];
 339
 340            await SyncTreeSelectionAsync();
 341
 342            _maps = await MapApi.ListMapsForWorldAsync(WorldId);
 343
 344            var campaignLookup = world.Campaigns
 345                .GroupBy(c => c.Id)
 346                .ToDictionary(
 347                    g => g.Key,
 348                    g => GetDisplayName(g.First().Name, "Untitled Campaign"));
 349
 350            var arcLookup = await BuildArcLookupAsync(campaignLookup.Keys.ToList());
 351
 352            BuildGroupings(campaignLookup, arcLookup);
 353        }
 354        catch
 355        {
 356            _hasLoadFailure = true;
 357        }
 358        finally
 359        {
 360            _isLoading = false;
 361        }
 362    }
 363
 364    private async Task OnBasemapFileSelected(InputFileChangeEventArgs e)
 365    {
 366        _isBasemapDragOver = false;
 367        _createError = string.Empty;
 368        _createSuccess = string.Empty;
 369        _selectedBasemapFile = e.File;
 370        _selectedBasemapBytes = null;
 371
 372        if (_selectedBasemapFile == null)
 373        {
 374            return;
 375        }
 376
 377        if (!AllowedBasemapContentTypes.Contains(_selectedBasemapFile.ContentType))
 378        {
 379            _createError = "Unsupported file type. Use image/png, image/jpeg, or image/webp.";
 380            _selectedBasemapFile = null;
 381            return;
 382        }
 383
 384        try
 385        {
 386            using var stream = _selectedBasemapFile.OpenReadStream(MaxBasemapSizeBytes);
 387            using var memoryStream = new MemoryStream();
 388            await stream.CopyToAsync(memoryStream);
 389            _selectedBasemapBytes = memoryStream.ToArray();
 390        }
 391        catch (Exception ex)
 392        {
 393            _createError = $"Failed to read selected file: {ex.Message}";
 394            _selectedBasemapFile = null;
 395            _selectedBasemapBytes = null;
 396        }
 397    }
 398
 399    private void OnBasemapDragEnter(DragEventArgs e)
 400    {
 5401        if (_isCreatingMap)
 402        {
 1403            return;
 404        }
 405
 4406        _isBasemapDragOver = IsFileDragEvent(e);
 4407        if (_isBasemapDragOver && e.DataTransfer != null)
 408        {
 2409            e.DataTransfer.DropEffect = "copy";
 2410            e.DataTransfer.EffectAllowed = "copy";
 411        }
 4412    }
 413
 414    private void OnBasemapDragOver(DragEventArgs e)
 415    {
 3416        if (_isCreatingMap)
 417        {
 1418            return;
 419        }
 420
 2421        _isBasemapDragOver = IsFileDragEvent(e);
 2422        if (_isBasemapDragOver && e.DataTransfer != null)
 423        {
 1424            e.DataTransfer.DropEffect = "copy";
 1425            e.DataTransfer.EffectAllowed = "copy";
 426        }
 2427    }
 428
 429    private void OnBasemapDragLeave(DragEventArgs _)
 430    {
 1431        _isBasemapDragOver = false;
 1432    }
 433
 434    private void OnBasemapDrop(DragEventArgs _)
 435    {
 1436        _isBasemapDragOver = false;
 1437    }
 438
 439    private string GetBasemapDropZoneClass()
 440    {
 21441        var classes = new List<string> { "maps-basemap-dropzone" };
 21442        if (_isBasemapDragOver)
 443        {
 1444            classes.Add("maps-basemap-dropzone--dragover");
 1445            classes.Add("maps-basemap-dropzone--copy");
 446        }
 447
 21448        if (_isCreatingMap)
 449        {
 1450            classes.Add("maps-basemap-dropzone--disabled");
 451        }
 452
 21453        return string.Join(" ", classes);
 454    }
 455
 456    private string GetBasemapInputStyle()
 20457        => $"position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:{(_isBasemapDragOver ? "copy" : "pointer"
 458
 459    private static bool IsFileDragEvent(DragEventArgs e)
 460    {
 12461        var dataTransfer = e.DataTransfer;
 12462        if (dataTransfer == null)
 463        {
 2464            return false;
 465        }
 466
 10467        if (dataTransfer.Files != null && dataTransfer.Files.Length > 0)
 468        {
 3469            return true;
 470        }
 471
 7472        if (dataTransfer.Types == null)
 473        {
 2474            return false;
 475        }
 476
 16477        foreach (var dataType in dataTransfer.Types)
 478        {
 4479            if (string.Equals(dataType, "Files", StringComparison.OrdinalIgnoreCase))
 480            {
 2481                return true;
 482            }
 483        }
 484
 3485        return false;
 486    }
 487
 488    public async ValueTask DisposeAsync()
 489    {
 490        if (!_isDropGuardEnabled)
 491        {
 492            return;
 493        }
 494
 495        try
 496        {
 497            await JSRuntime.InvokeVoidAsync("chronicisMapsDropGuard.disable");
 498        }
 499        catch (JSDisconnectedException)
 500        {
 501            // Browser disconnected; nothing to clean up.
 502        }
 503    }
 504
 505    private async Task OpenDeleteDialogAsync(MapSummaryDto map)
 506    {
 507        var mapName = GetDisplayName(map.Name, "Untitled Map");
 508
 509        if (IsDeleteInProgress(map.WorldMapId))
 510        {
 511            return;
 512        }
 513
 514        var parameters = new DialogParameters<DeleteMapDialog>
 515        {
 516            { x => x.MapName, mapName }
 517        };
 518
 519        var dialog = await DialogService.ShowAsync<DeleteMapDialog>(
 520            $"Delete \"{mapName}\"",
 521            parameters,
 522            _deleteDialogOptions);
 523
 524        var result = await dialog.Result;
 525        if (result is { Canceled: false })
 526        {
 527            await DeleteMapAsync(map.WorldMapId, mapName);
 528        }
 529    }
 530
 531    private async Task DeleteMapAsync(Guid mapId, string mapName)
 532    {
 533        _createError = string.Empty;
 534        _createSuccess = string.Empty;
 535        _mapsBeingDeleted.Add(mapId);
 536
 537        try
 538        {
 539            var deleted = await MapApi.DeleteMapAsync(WorldId, mapId);
 540            if (!deleted)
 541            {
 542                _createError = $"Failed to delete map '{mapName}'.";
 543                return;
 544            }
 545
 546            _createSuccess = $"Map '{mapName}' deleted permanently.";
 547            await LoadAsync();
 548        }
 549        catch (Exception ex)
 550        {
 551            _createError = $"Failed to delete map '{mapName}': {ex.Message}";
 552        }
 553        finally
 554        {
 555            _mapsBeingDeleted.Remove(mapId);
 556        }
 557    }
 558
 8559    private bool IsDeleteInProgress(Guid mapId) => _mapsBeingDeleted.Contains(mapId);
 560
 561    private async Task CreateMapWithBasemapAsync()
 562    {
 563        _createError = string.Empty;
 564        _createSuccess = string.Empty;
 565
 566        if (string.IsNullOrWhiteSpace(_newMapName))
 567        {
 568            _createError = "Map name is required.";
 569            return;
 570        }
 571
 572        if (_selectedBasemapFile == null || _selectedBasemapBytes == null)
 573        {
 574            _createError = "Basemap file is required.";
 575            return;
 576        }
 577
 578        if (!AllowedBasemapContentTypes.Contains(_selectedBasemapFile.ContentType))
 579        {
 580            _createError = "Unsupported file type. Use image/png, image/jpeg, or image/webp.";
 581            return;
 582        }
 583
 584        _isCreatingMap = true;
 585
 586        try
 587        {
 588            var map = await MapApi.CreateMapAsync(
 589                WorldId,
 590                new MapCreateDto { Name = _newMapName.Trim() });
 591
 592            if (map == null)
 593            {
 594                _createError = "Failed to create map record.";
 595                return;
 596            }
 597
 598            var uploadRequest = new RequestBasemapUploadDto
 599            {
 600                FileName = _selectedBasemapFile.Name,
 601                ContentType = _selectedBasemapFile.ContentType
 602            };
 603
 604            var uploadResponse = await MapApi.RequestBasemapUploadAsync(WorldId, map.WorldMapId, uploadRequest);
 605            if (uploadResponse == null || string.IsNullOrWhiteSpace(uploadResponse.UploadUrl))
 606            {
 607                _createError = "Failed to request basemap upload URL.";
 608                return;
 609            }
 610
 611            using (var uploadContent = new ByteArrayContent(_selectedBasemapBytes))
 612            {
 613                uploadContent.Headers.ContentType =
 614                    new System.Net.Http.Headers.MediaTypeHeaderValue(_selectedBasemapFile.ContentType);
 615                uploadContent.Headers.Add("x-ms-blob-type", "BlockBlob");
 616
 617                using var uploadHttpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
 618                var uploadResult = await uploadHttpClient.PutAsync(uploadResponse.UploadUrl, uploadContent);
 619                if (!uploadResult.IsSuccessStatusCode)
 620                {
 621                    var errorBody = await uploadResult.Content.ReadAsStringAsync();
 622                    _createError = $"Basemap upload failed ({uploadResult.StatusCode}): {errorBody}";
 623                    return;
 624                }
 625            }
 626
 627            var confirmResult = await MapApi.ConfirmBasemapUploadAsync(
 628                WorldId,
 629                map.WorldMapId,
 630                $"maps/{map.WorldMapId}/basemap/{_selectedBasemapFile.Name}",
 631                _selectedBasemapFile.ContentType,
 632                _selectedBasemapFile.Name);
 633
 634            if (confirmResult == null)
 635            {
 636                _createError = "Failed to confirm basemap upload.";
 637                return;
 638            }
 639
 640            _createSuccess = $"Map '{confirmResult.Name}' created.";
 641            _newMapName = string.Empty;
 642            _selectedBasemapFile = null;
 643            _selectedBasemapBytes = null;
 644            await LoadAsync();
 645        }
 646        catch (Exception ex)
 647        {
 648            _createError = $"Failed to create map: {ex.Message}";
 649        }
 650        finally
 651        {
 652            _isCreatingMap = false;
 653        }
 654    }
 655
 656    private async Task<Dictionary<Guid, ArcLookupItem>> BuildArcLookupAsync(List<Guid> campaignIds)
 657    {
 658        var lookup = new Dictionary<Guid, ArcLookupItem>();
 659        var distinctCampaignIds = campaignIds.Distinct().ToList();
 660
 661        if (distinctCampaignIds.Count == 0)
 662        {
 663            return lookup;
 664        }
 665
 666        var arcTasks = distinctCampaignIds
 667            .Select(campaignId => ArcApi.GetArcsByCampaignAsync(campaignId))
 668            .ToList();
 669
 670        await Task.WhenAll(arcTasks);
 671
 672        for (int i = 0; i < distinctCampaignIds.Count; i++)
 673        {
 674            var campaignId = distinctCampaignIds[i];
 675            var arcs = arcTasks[i].Result;
 676
 677            foreach (var arc in arcs)
 678            {
 679                if (!lookup.ContainsKey(arc.Id))
 680                {
 681                    lookup[arc.Id] = new ArcLookupItem
 682                    {
 683                        CampaignId = campaignId,
 684                        ArcName = GetDisplayName(arc.Name, "Untitled Arc")
 685                    };
 686                }
 687            }
 688        }
 689
 690        return lookup;
 691    }
 692
 693    private void BuildGroupings(
 694        Dictionary<Guid, string> campaignLookup,
 695        Dictionary<Guid, ArcLookupItem> arcLookup)
 696    {
 21697        _worldScopedMaps = _maps
 21698            .Where(map => ResolveScope(map) == MapScope.WorldScoped)
 21699            .OrderBy(map => map.Name, StringComparer.OrdinalIgnoreCase)
 21700            .ThenBy(map => map.WorldMapId)
 21701            .ToList();
 702
 21703        _campaignScopedGroups = _maps
 21704            .Where(map => ResolveScope(map) == MapScope.CampaignScoped)
 21705            .SelectMany(map => map.CampaignIds.Distinct().Select(campaignId => new { campaignId, map }))
 21706            .GroupBy(x => x.campaignId)
 21707            .Select(group => new CampaignMapGroup
 21708            {
 21709                CampaignId = group.Key,
 21710                CampaignName = campaignLookup.TryGetValue(group.Key, out var campaignName)
 21711                    ? campaignName
 21712                    : $"Unknown Campaign ({group.Key})",
 21713                Maps = group
 21714                    .Select(x => x.map)
 21715                    .DistinctBy(x => x.WorldMapId)
 21716                    .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 21717                    .ThenBy(x => x.WorldMapId)
 21718                    .ToList()
 21719            })
 21720            .OrderBy(group => group.CampaignName, StringComparer.OrdinalIgnoreCase)
 21721            .ThenBy(group => group.CampaignId)
 21722            .ToList();
 723
 21724        _arcScopedCampaignGroups = _maps
 21725            .Where(map => ResolveScope(map) == MapScope.ArcScoped)
 21726            .SelectMany(map => map.ArcIds.Distinct().Select(arcId =>
 21727            {
 21728                if (arcLookup.TryGetValue(arcId, out var arc))
 21729                {
 21730                    var campaignName = campaignLookup.TryGetValue(arc.CampaignId, out var name)
 21731                        ? name
 21732                        : $"Unknown Campaign ({arc.CampaignId})";
 21733
 21734                    return new
 21735                    {
 21736                        CampaignId = arc.CampaignId,
 21737                        CampaignName = campaignName,
 21738                        ArcId = arcId,
 21739                        ArcName = arc.ArcName,
 21740                        Map = map
 21741                    };
 21742                }
 21743
 21744                return new
 21745                {
 21746                    CampaignId = Guid.Empty,
 21747                    CampaignName = "Unknown Campaign",
 21748                    ArcId = arcId,
 21749                    ArcName = $"Unknown Arc ({arcId})",
 21750                    Map = map
 21751                };
 21752            }))
 21753            .GroupBy(x => new { x.CampaignId, x.CampaignName })
 21754            .Select(campaignGroup => new ArcCampaignGroup
 21755            {
 21756                CampaignId = campaignGroup.Key.CampaignId,
 21757                CampaignName = campaignGroup.Key.CampaignName,
 21758                ArcGroups = campaignGroup
 21759                    .GroupBy(x => new { x.ArcId, x.ArcName })
 21760                    .Select(arcGroup => new ArcMapGroup
 21761                    {
 21762                        ArcId = arcGroup.Key.ArcId,
 21763                        ArcName = arcGroup.Key.ArcName,
 21764                        Maps = arcGroup
 21765                            .Select(x => x.Map)
 21766                            .DistinctBy(x => x.WorldMapId)
 21767                            .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
 21768                            .ThenBy(x => x.WorldMapId)
 21769                            .ToList()
 21770                    })
 21771                    .OrderBy(group => group.ArcName, StringComparer.OrdinalIgnoreCase)
 21772                    .ThenBy(group => group.ArcId)
 21773                    .ToList()
 21774            })
 21775            .OrderBy(group => group.CampaignName, StringComparer.OrdinalIgnoreCase)
 21776            .ThenBy(group => group.CampaignId)
 21777            .ToList();
 21778    }
 779
 780    private static MapScope ResolveScope(MapSummaryDto map)
 781    {
 29782        var derivedScope = DeriveScope(map);
 29783        return map.Scope == derivedScope ? map.Scope : derivedScope;
 784    }
 785
 786    private static MapScope DeriveScope(MapSummaryDto map)
 787    {
 32788        if (map.ArcIds.Count > 0)
 789        {
 7790            return MapScope.ArcScoped;
 791        }
 792
 25793        if (map.CampaignIds.Count > 0)
 794        {
 11795            return MapScope.CampaignScoped;
 796        }
 797
 14798        return MapScope.WorldScoped;
 799    }
 800
 9801    private string GetMapRoute(Guid mapId) => $"/world/{WorldId}/maps/{mapId}";
 802
 803    private static string GetDisplayName(string? value, string fallback)
 35804        => string.IsNullOrWhiteSpace(value) ? fallback : value;
 805
 806    private async Task SyncTreeSelectionAsync()
 807    {
 808        try
 809        {
 810            if (!TreeState.RootNodes.Any() && !TreeState.IsLoading)
 811            {
 812                await TreeState.InitializeAsync();
 813            }
 814
 815            var worldNode = TreeState.RootNodes.FirstOrDefault(n =>
 816                n.NodeType == TreeNodeType.World &&
 817                n.Id == WorldId);
 818
 819            var mapsGroupNode = worldNode?.Children.FirstOrDefault(n =>
 820                n.NodeType == TreeNodeType.VirtualGroup &&
 821                n.VirtualGroupType == VirtualGroupType.Maps);
 822
 823            if (mapsGroupNode != null)
 824            {
 825                TreeState.ExpandPathToAndSelect(mapsGroupNode.Id);
 826            }
 827        }
 828        catch
 829        {
 830            // Tree sync failures should not block page rendering.
 831        }
 832    }
 833}
 834