| | | 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 | | |
| | 21 | 19 | | @if (_isLoading) |
| | | 20 | | { |
| | | 21 | | <LoadingSkeleton /> |
| | | 22 | | } |
| | 20 | 23 | | else if (_hasLoadFailure) |
| | | 24 | | { |
| | | 25 | | <MudAlert Severity="Severity.Error"> |
| | | 26 | | World not found or access denied. |
| | | 27 | | </MudAlert> |
| | | 28 | | } |
| | | 29 | | else |
| | | 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 { |
| | 1 | 216 | | private static readonly DialogOptions _deleteDialogOptions = new() |
| | 1 | 217 | | { |
| | 1 | 218 | | CloseOnEscapeKey = true, |
| | 1 | 219 | | MaxWidth = MaxWidth.Small, |
| | 1 | 220 | | FullWidth = true, |
| | 1 | 221 | | }; |
| | | 222 | | |
| | | 223 | | [Parameter] |
| | | 224 | | public Guid WorldId { get; set; } |
| | | 225 | | |
| | 21 | 226 | | private bool _isLoading = true; |
| | | 227 | | private bool _hasLoadFailure; |
| | 21 | 228 | | private string _worldName = "World"; |
| | 21 | 229 | | private List<BreadcrumbItem> _breadcrumbs = |
| | 21 | 230 | | [ |
| | 21 | 231 | | new("Dashboard", href: "/dashboard"), |
| | 21 | 232 | | new("World", href: null, disabled: true), |
| | 21 | 233 | | new("Maps", href: null, disabled: true) |
| | 21 | 234 | | ]; |
| | 21 | 235 | | private List<MapSummaryDto> _maps = []; |
| | 21 | 236 | | private List<MapSummaryDto> _worldScopedMaps = []; |
| | 21 | 237 | | private List<CampaignMapGroup> _campaignScopedGroups = []; |
| | 21 | 238 | | private List<ArcCampaignGroup> _arcScopedCampaignGroups = []; |
| | 21 | 239 | | 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; |
| | 21 | 245 | | private readonly HashSet<Guid> _mapsBeingDeleted = []; |
| | 21 | 246 | | private string _createError = string.Empty; |
| | 21 | 247 | | private string _createSuccess = string.Empty; |
| | 18 | 248 | | private string _mapsTitle => $"{_worldName} Maps"; |
| | | 249 | | |
| | | 250 | | private const long MaxBasemapSizeBytes = 50 * 1024 * 1024; |
| | 1 | 251 | | private static readonly HashSet<string> AllowedBasemapContentTypes = new(StringComparer.OrdinalIgnoreCase) |
| | 1 | 252 | | { |
| | 1 | 253 | | "image/png", |
| | 1 | 254 | | "image/jpeg", |
| | 1 | 255 | | "image/webp" |
| | 1 | 256 | | }; |
| | | 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 | | { |
| | 5 | 401 | | if (_isCreatingMap) |
| | | 402 | | { |
| | 1 | 403 | | return; |
| | | 404 | | } |
| | | 405 | | |
| | 4 | 406 | | _isBasemapDragOver = IsFileDragEvent(e); |
| | 4 | 407 | | if (_isBasemapDragOver && e.DataTransfer != null) |
| | | 408 | | { |
| | 2 | 409 | | e.DataTransfer.DropEffect = "copy"; |
| | 2 | 410 | | e.DataTransfer.EffectAllowed = "copy"; |
| | | 411 | | } |
| | 4 | 412 | | } |
| | | 413 | | |
| | | 414 | | private void OnBasemapDragOver(DragEventArgs e) |
| | | 415 | | { |
| | 3 | 416 | | if (_isCreatingMap) |
| | | 417 | | { |
| | 1 | 418 | | return; |
| | | 419 | | } |
| | | 420 | | |
| | 2 | 421 | | _isBasemapDragOver = IsFileDragEvent(e); |
| | 2 | 422 | | if (_isBasemapDragOver && e.DataTransfer != null) |
| | | 423 | | { |
| | 1 | 424 | | e.DataTransfer.DropEffect = "copy"; |
| | 1 | 425 | | e.DataTransfer.EffectAllowed = "copy"; |
| | | 426 | | } |
| | 2 | 427 | | } |
| | | 428 | | |
| | | 429 | | private void OnBasemapDragLeave(DragEventArgs _) |
| | | 430 | | { |
| | 1 | 431 | | _isBasemapDragOver = false; |
| | 1 | 432 | | } |
| | | 433 | | |
| | | 434 | | private void OnBasemapDrop(DragEventArgs _) |
| | | 435 | | { |
| | 1 | 436 | | _isBasemapDragOver = false; |
| | 1 | 437 | | } |
| | | 438 | | |
| | | 439 | | private string GetBasemapDropZoneClass() |
| | | 440 | | { |
| | 21 | 441 | | var classes = new List<string> { "maps-basemap-dropzone" }; |
| | 21 | 442 | | if (_isBasemapDragOver) |
| | | 443 | | { |
| | 1 | 444 | | classes.Add("maps-basemap-dropzone--dragover"); |
| | 1 | 445 | | classes.Add("maps-basemap-dropzone--copy"); |
| | | 446 | | } |
| | | 447 | | |
| | 21 | 448 | | if (_isCreatingMap) |
| | | 449 | | { |
| | 1 | 450 | | classes.Add("maps-basemap-dropzone--disabled"); |
| | | 451 | | } |
| | | 452 | | |
| | 21 | 453 | | return string.Join(" ", classes); |
| | | 454 | | } |
| | | 455 | | |
| | | 456 | | private string GetBasemapInputStyle() |
| | 20 | 457 | | => $"position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:{(_isBasemapDragOver ? "copy" : "pointer" |
| | | 458 | | |
| | | 459 | | private static bool IsFileDragEvent(DragEventArgs e) |
| | | 460 | | { |
| | 12 | 461 | | var dataTransfer = e.DataTransfer; |
| | 12 | 462 | | if (dataTransfer == null) |
| | | 463 | | { |
| | 2 | 464 | | return false; |
| | | 465 | | } |
| | | 466 | | |
| | 10 | 467 | | if (dataTransfer.Files != null && dataTransfer.Files.Length > 0) |
| | | 468 | | { |
| | 3 | 469 | | return true; |
| | | 470 | | } |
| | | 471 | | |
| | 7 | 472 | | if (dataTransfer.Types == null) |
| | | 473 | | { |
| | 2 | 474 | | return false; |
| | | 475 | | } |
| | | 476 | | |
| | 16 | 477 | | foreach (var dataType in dataTransfer.Types) |
| | | 478 | | { |
| | 4 | 479 | | if (string.Equals(dataType, "Files", StringComparison.OrdinalIgnoreCase)) |
| | | 480 | | { |
| | 2 | 481 | | return true; |
| | | 482 | | } |
| | | 483 | | } |
| | | 484 | | |
| | 3 | 485 | | 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 | | |
| | 8 | 559 | | 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 | | { |
| | 21 | 697 | | _worldScopedMaps = _maps |
| | 21 | 698 | | .Where(map => ResolveScope(map) == MapScope.WorldScoped) |
| | 21 | 699 | | .OrderBy(map => map.Name, StringComparer.OrdinalIgnoreCase) |
| | 21 | 700 | | .ThenBy(map => map.WorldMapId) |
| | 21 | 701 | | .ToList(); |
| | | 702 | | |
| | 21 | 703 | | _campaignScopedGroups = _maps |
| | 21 | 704 | | .Where(map => ResolveScope(map) == MapScope.CampaignScoped) |
| | 21 | 705 | | .SelectMany(map => map.CampaignIds.Distinct().Select(campaignId => new { campaignId, map })) |
| | 21 | 706 | | .GroupBy(x => x.campaignId) |
| | 21 | 707 | | .Select(group => new CampaignMapGroup |
| | 21 | 708 | | { |
| | 21 | 709 | | CampaignId = group.Key, |
| | 21 | 710 | | CampaignName = campaignLookup.TryGetValue(group.Key, out var campaignName) |
| | 21 | 711 | | ? campaignName |
| | 21 | 712 | | : $"Unknown Campaign ({group.Key})", |
| | 21 | 713 | | Maps = group |
| | 21 | 714 | | .Select(x => x.map) |
| | 21 | 715 | | .DistinctBy(x => x.WorldMapId) |
| | 21 | 716 | | .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) |
| | 21 | 717 | | .ThenBy(x => x.WorldMapId) |
| | 21 | 718 | | .ToList() |
| | 21 | 719 | | }) |
| | 21 | 720 | | .OrderBy(group => group.CampaignName, StringComparer.OrdinalIgnoreCase) |
| | 21 | 721 | | .ThenBy(group => group.CampaignId) |
| | 21 | 722 | | .ToList(); |
| | | 723 | | |
| | 21 | 724 | | _arcScopedCampaignGroups = _maps |
| | 21 | 725 | | .Where(map => ResolveScope(map) == MapScope.ArcScoped) |
| | 21 | 726 | | .SelectMany(map => map.ArcIds.Distinct().Select(arcId => |
| | 21 | 727 | | { |
| | 21 | 728 | | if (arcLookup.TryGetValue(arcId, out var arc)) |
| | 21 | 729 | | { |
| | 21 | 730 | | var campaignName = campaignLookup.TryGetValue(arc.CampaignId, out var name) |
| | 21 | 731 | | ? name |
| | 21 | 732 | | : $"Unknown Campaign ({arc.CampaignId})"; |
| | 21 | 733 | | |
| | 21 | 734 | | return new |
| | 21 | 735 | | { |
| | 21 | 736 | | CampaignId = arc.CampaignId, |
| | 21 | 737 | | CampaignName = campaignName, |
| | 21 | 738 | | ArcId = arcId, |
| | 21 | 739 | | ArcName = arc.ArcName, |
| | 21 | 740 | | Map = map |
| | 21 | 741 | | }; |
| | 21 | 742 | | } |
| | 21 | 743 | | |
| | 21 | 744 | | return new |
| | 21 | 745 | | { |
| | 21 | 746 | | CampaignId = Guid.Empty, |
| | 21 | 747 | | CampaignName = "Unknown Campaign", |
| | 21 | 748 | | ArcId = arcId, |
| | 21 | 749 | | ArcName = $"Unknown Arc ({arcId})", |
| | 21 | 750 | | Map = map |
| | 21 | 751 | | }; |
| | 21 | 752 | | })) |
| | 21 | 753 | | .GroupBy(x => new { x.CampaignId, x.CampaignName }) |
| | 21 | 754 | | .Select(campaignGroup => new ArcCampaignGroup |
| | 21 | 755 | | { |
| | 21 | 756 | | CampaignId = campaignGroup.Key.CampaignId, |
| | 21 | 757 | | CampaignName = campaignGroup.Key.CampaignName, |
| | 21 | 758 | | ArcGroups = campaignGroup |
| | 21 | 759 | | .GroupBy(x => new { x.ArcId, x.ArcName }) |
| | 21 | 760 | | .Select(arcGroup => new ArcMapGroup |
| | 21 | 761 | | { |
| | 21 | 762 | | ArcId = arcGroup.Key.ArcId, |
| | 21 | 763 | | ArcName = arcGroup.Key.ArcName, |
| | 21 | 764 | | Maps = arcGroup |
| | 21 | 765 | | .Select(x => x.Map) |
| | 21 | 766 | | .DistinctBy(x => x.WorldMapId) |
| | 21 | 767 | | .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) |
| | 21 | 768 | | .ThenBy(x => x.WorldMapId) |
| | 21 | 769 | | .ToList() |
| | 21 | 770 | | }) |
| | 21 | 771 | | .OrderBy(group => group.ArcName, StringComparer.OrdinalIgnoreCase) |
| | 21 | 772 | | .ThenBy(group => group.ArcId) |
| | 21 | 773 | | .ToList() |
| | 21 | 774 | | }) |
| | 21 | 775 | | .OrderBy(group => group.CampaignName, StringComparer.OrdinalIgnoreCase) |
| | 21 | 776 | | .ThenBy(group => group.CampaignId) |
| | 21 | 777 | | .ToList(); |
| | 21 | 778 | | } |
| | | 779 | | |
| | | 780 | | private static MapScope ResolveScope(MapSummaryDto map) |
| | | 781 | | { |
| | 29 | 782 | | var derivedScope = DeriveScope(map); |
| | 29 | 783 | | return map.Scope == derivedScope ? map.Scope : derivedScope; |
| | | 784 | | } |
| | | 785 | | |
| | | 786 | | private static MapScope DeriveScope(MapSummaryDto map) |
| | | 787 | | { |
| | 32 | 788 | | if (map.ArcIds.Count > 0) |
| | | 789 | | { |
| | 7 | 790 | | return MapScope.ArcScoped; |
| | | 791 | | } |
| | | 792 | | |
| | 25 | 793 | | if (map.CampaignIds.Count > 0) |
| | | 794 | | { |
| | 11 | 795 | | return MapScope.CampaignScoped; |
| | | 796 | | } |
| | | 797 | | |
| | 14 | 798 | | return MapScope.WorldScoped; |
| | | 799 | | } |
| | | 800 | | |
| | 9 | 801 | | private string GetMapRoute(Guid mapId) => $"/world/{WorldId}/maps/{mapId}"; |
| | | 802 | | |
| | | 803 | | private static string GetDisplayName(string? value, string fallback) |
| | 35 | 804 | | => 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 | | |