| | | 1 | | @using Chronicis.Shared.DTOs |
| | | 2 | | @using Chronicis.Shared.Enums |
| | | 3 | | @inject IWorldApiService WorldApi |
| | | 4 | | @inject ISnackbar Snackbar |
| | | 5 | | @inject IDialogService DialogService |
| | | 6 | | @inject IJSRuntime JSRuntime |
| | | 7 | | |
| | | 8 | | <MudText Typo="Typo.h6" Class="mb-3" Style="color: var(--chronicis-beige-gold);"> |
| | | 9 | | Members & Invitations |
| | | 10 | | </MudText> |
| | | 11 | | |
| | | 12 | | <div class="mb-4 pa-3 rounded" style="background: var(--mud-palette-background-grey);"> |
| | | 13 | | <!-- Members List --> |
| | | 14 | | <div class="d-flex align-center justify-space-between mb-2"> |
| | 0 | 15 | | <MudText Typo="Typo.subtitle1">Members (@_members.Count)</MudText> |
| | | 16 | | </div> |
| | | 17 | | |
| | 0 | 18 | | @if (_members.Count == 0) |
| | | 19 | | { |
| | | 20 | | <MudText Typo="Typo.body2" Class="mud-text-secondary mb-3"> |
| | | 21 | | No members yet. Create an invitation to add players. |
| | | 22 | | </MudText> |
| | | 23 | | } |
| | | 24 | | else |
| | | 25 | | { |
| | | 26 | | <MudSimpleTable Dense="true" Hover="true" Class="mb-3"> |
| | | 27 | | <thead> |
| | | 28 | | <tr> |
| | | 29 | | <th>Member</th> |
| | | 30 | | <th>Role</th> |
| | | 31 | | <th>Joined</th> |
| | | 32 | | <th style="width: 100px;">Actions</th> |
| | | 33 | | </tr> |
| | | 34 | | </thead> |
| | | 35 | | <tbody> |
| | 0 | 36 | | @foreach (var member in _members) |
| | | 37 | | { |
| | | 38 | | <tr> |
| | | 39 | | <td> |
| | | 40 | | <div class="d-flex align-center gap-2"> |
| | 0 | 41 | | @if (!string.IsNullOrEmpty(member.AvatarUrl)) |
| | | 42 | | { |
| | | 43 | | <MudAvatar Size="Size.Small"> |
| | | 44 | | <MudImage Src="@member.AvatarUrl" /> |
| | | 45 | | </MudAvatar> |
| | | 46 | | } |
| | | 47 | | else |
| | | 48 | | { |
| | | 49 | | <MudAvatar Size="Size.Small" Color="Color.Primary"> |
| | 0 | 50 | | @member.DisplayName[0] |
| | | 51 | | </MudAvatar> |
| | | 52 | | } |
| | | 53 | | <div> |
| | 0 | 54 | | <MudText Typo="Typo.body2">@member.DisplayName</MudText> |
| | 0 | 55 | | <MudText Typo="Typo.caption" Class="mud-text-secondary">@member.Email</MudText> |
| | | 56 | | </div> |
| | | 57 | | </div> |
| | | 58 | | </td> |
| | | 59 | | <td> |
| | 0 | 60 | | @if (IsCurrentUserGM && member.UserId != CurrentUserId) |
| | | 61 | | { |
| | | 62 | | <MudSelect T="WorldRole" |
| | | 63 | | Value="member.Role" |
| | 0 | 64 | | ValueChanged="(role) => UpdateMemberRole(member, role)" |
| | | 65 | | Variant="Variant.Outlined" |
| | | 66 | | Margin="Margin.Dense" |
| | | 67 | | Dense="true" |
| | | 68 | | Style="min-width: 120px;"> |
| | | 69 | | <MudSelectItem Value="WorldRole.GM">GM</MudSelectItem> |
| | | 70 | | <MudSelectItem Value="WorldRole.Player">Player</MudSelectItem> |
| | | 71 | | <MudSelectItem Value="WorldRole.Observer">Observer</MudSelectItem> |
| | | 72 | | </MudSelect> |
| | | 73 | | } |
| | | 74 | | else |
| | | 75 | | { |
| | | 76 | | <MudChip T="string" Size="Size.Small" Color="@GetRoleColor(member.Role)"> |
| | 0 | 77 | | @member.Role |
| | | 78 | | </MudChip> |
| | | 79 | | } |
| | | 80 | | </td> |
| | | 81 | | <td> |
| | 0 | 82 | | <MudText Typo="Typo.body2">@member.JoinedAt.ToString("MMM d, yyyy")</MudText> |
| | | 83 | | </td> |
| | | 84 | | <td> |
| | 0 | 85 | | @if (IsCurrentUserGM && member.UserId != CurrentUserId) |
| | | 86 | | { |
| | | 87 | | <MudIconButton Icon="@Icons.Material.Filled.PersonRemove" |
| | | 88 | | Color="Color.Error" |
| | | 89 | | Size="Size.Small" |
| | 0 | 90 | | OnClick="() => RemoveMember(member)" |
| | | 91 | | title="Remove member" /> |
| | | 92 | | } |
| | | 93 | | </td> |
| | | 94 | | </tr> |
| | | 95 | | } |
| | | 96 | | </tbody> |
| | | 97 | | </MudSimpleTable> |
| | | 98 | | } |
| | | 99 | | |
| | | 100 | | <!-- Invitations Section (GM only) --> |
| | 0 | 101 | | @if (IsCurrentUserGM) |
| | | 102 | | { |
| | | 103 | | <MudDivider Class="my-3" /> |
| | | 104 | | |
| | | 105 | | <div class="d-flex align-center justify-space-between mb-2"> |
| | | 106 | | <MudText Typo="Typo.subtitle1">Invitations</MudText> |
| | | 107 | | <MudButton Variant="Variant.Filled" |
| | | 108 | | Color="Color.Primary" |
| | | 109 | | Size="Size.Small" |
| | | 110 | | StartIcon="@Icons.Material.Filled.PersonAdd" |
| | | 111 | | OnClick="CreateInvitation" |
| | | 112 | | Disabled="_isCreatingInvitation"> |
| | 0 | 113 | | @if (_isCreatingInvitation) |
| | | 114 | | { |
| | | 115 | | <MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" /> |
| | | 116 | | } |
| | | 117 | | Create Invitation |
| | | 118 | | </MudButton> |
| | | 119 | | </div> |
| | | 120 | | |
| | 0 | 121 | | @if (_invitations.Count == 0) |
| | | 122 | | { |
| | | 123 | | <MudText Typo="Typo.body2" Class="mud-text-secondary"> |
| | | 124 | | No active invitations. Create one to invite players to your world. |
| | | 125 | | </MudText> |
| | | 126 | | } |
| | | 127 | | else |
| | | 128 | | { |
| | | 129 | | <MudSimpleTable Dense="true" Hover="true"> |
| | | 130 | | <thead> |
| | | 131 | | <tr> |
| | | 132 | | <th>Code</th> |
| | | 133 | | <th>Role</th> |
| | | 134 | | <th>Uses</th> |
| | | 135 | | <th>Expires</th> |
| | | 136 | | <th style="width: 100px;">Actions</th> |
| | | 137 | | </tr> |
| | | 138 | | </thead> |
| | | 139 | | <tbody> |
| | 0 | 140 | | @foreach (var inv in _invitations.Where(i => i.IsActive)) |
| | | 141 | | { |
| | | 142 | | <tr> |
| | | 143 | | <td> |
| | | 144 | | <div class="d-flex align-center gap-1"> |
| | 0 | 145 | | <code style="font-size: 1.1em; letter-spacing: 1px;">@inv.Code</code> |
| | | 146 | | <MudIconButton Icon="@Icons.Material.Filled.ContentCopy" |
| | | 147 | | Size="Size.Small" |
| | 0 | 148 | | OnClick="() => CopyInvitationCode(inv.Code)" |
| | | 149 | | title="Copy code" /> |
| | | 150 | | </div> |
| | | 151 | | </td> |
| | | 152 | | <td> |
| | | 153 | | <MudChip T="string" Size="Size.Small" Color="@GetRoleColor(inv.Role)"> |
| | 0 | 154 | | @inv.Role |
| | | 155 | | </MudChip> |
| | | 156 | | </td> |
| | | 157 | | <td> |
| | 0 | 158 | | @if (inv.MaxUses.HasValue) |
| | | 159 | | { |
| | 0 | 160 | | <span>@inv.UsedCount / @inv.MaxUses</span> |
| | | 161 | | } |
| | | 162 | | else |
| | | 163 | | { |
| | 0 | 164 | | <span>@inv.UsedCount / ∞</span> |
| | | 165 | | } |
| | | 166 | | </td> |
| | | 167 | | <td> |
| | 0 | 168 | | @if (inv.ExpiresAt.HasValue) |
| | | 169 | | { |
| | 0 | 170 | | <span>@inv.ExpiresAt.Value.ToString("MMM d, yyyy")</span> |
| | | 171 | | } |
| | | 172 | | else |
| | | 173 | | { |
| | | 174 | | <span class="mud-text-secondary">Never</span> |
| | | 175 | | } |
| | | 176 | | </td> |
| | | 177 | | <td> |
| | | 178 | | <MudIconButton Icon="@Icons.Material.Filled.Delete" |
| | | 179 | | Color="Color.Error" |
| | | 180 | | Size="Size.Small" |
| | 0 | 181 | | OnClick="() => RevokeInvitation(inv)" |
| | | 182 | | title="Revoke invitation" /> |
| | | 183 | | </td> |
| | | 184 | | </tr> |
| | | 185 | | } |
| | | 186 | | </tbody> |
| | | 187 | | </MudSimpleTable> |
| | | 188 | | } |
| | | 189 | | } |
| | | 190 | | </div> |
| | | 191 | | |
| | | 192 | | @code { |
| | | 193 | | [Parameter] |
| | 0 | 194 | | public Guid WorldId { get; set; } |
| | | 195 | | |
| | | 196 | | [Parameter] |
| | 0 | 197 | | public Guid CurrentUserId { get; set; } |
| | | 198 | | |
| | | 199 | | [Parameter] |
| | 0 | 200 | | public bool IsCurrentUserGM { get; set; } |
| | | 201 | | |
| | | 202 | | [Parameter] |
| | 0 | 203 | | public EventCallback OnMembersChanged { get; set; } |
| | | 204 | | |
| | 0 | 205 | | private List<WorldMemberDto> _members = new(); |
| | 0 | 206 | | private List<WorldInvitationDto> _invitations = new(); |
| | | 207 | | private bool _isCreatingInvitation = false; |
| | | 208 | | |
| | | 209 | | protected override async Task OnParametersSetAsync() |
| | | 210 | | { |
| | 0 | 211 | | await LoadDataAsync(); |
| | 0 | 212 | | } |
| | | 213 | | |
| | | 214 | | private async Task LoadDataAsync() |
| | | 215 | | { |
| | | 216 | | try |
| | | 217 | | { |
| | 0 | 218 | | _members = await WorldApi.GetMembersAsync(WorldId); |
| | | 219 | | |
| | 0 | 220 | | if (IsCurrentUserGM) |
| | | 221 | | { |
| | 0 | 222 | | _invitations = await WorldApi.GetInvitationsAsync(WorldId); |
| | | 223 | | } |
| | 0 | 224 | | } |
| | 0 | 225 | | catch (Exception ex) |
| | | 226 | | { |
| | 0 | 227 | | Snackbar.Add($"Failed to load members: {ex.Message}", Severity.Error); |
| | 0 | 228 | | } |
| | 0 | 229 | | } |
| | | 230 | | |
| | | 231 | | private async Task UpdateMemberRole(WorldMemberDto member, WorldRole newRole) |
| | | 232 | | { |
| | | 233 | | try |
| | | 234 | | { |
| | 0 | 235 | | var dto = new WorldMemberUpdateDto { Role = newRole }; |
| | 0 | 236 | | var updated = await WorldApi.UpdateMemberRoleAsync(WorldId, member.Id, dto); |
| | | 237 | | |
| | 0 | 238 | | if (updated != null) |
| | | 239 | | { |
| | 0 | 240 | | member.Role = updated.Role; |
| | 0 | 241 | | Snackbar.Add($"Updated {member.DisplayName}'s role to {newRole}", Severity.Success); |
| | 0 | 242 | | await OnMembersChanged.InvokeAsync(); |
| | | 243 | | } |
| | | 244 | | else |
| | | 245 | | { |
| | 0 | 246 | | Snackbar.Add("Failed to update role. Cannot demote the last GM.", Severity.Warning); |
| | 0 | 247 | | await LoadDataAsync(); // Refresh to get correct state |
| | | 248 | | } |
| | 0 | 249 | | } |
| | 0 | 250 | | catch (Exception ex) |
| | | 251 | | { |
| | 0 | 252 | | Snackbar.Add($"Failed to update role: {ex.Message}", Severity.Error); |
| | 0 | 253 | | } |
| | | 254 | | |
| | 0 | 255 | | StateHasChanged(); |
| | 0 | 256 | | } |
| | | 257 | | |
| | | 258 | | private async Task RemoveMember(WorldMemberDto member) |
| | | 259 | | { |
| | 0 | 260 | | var confirmed = await DialogService.ShowMessageBox( |
| | 0 | 261 | | "Remove Member", |
| | 0 | 262 | | $"Are you sure you want to remove {member.DisplayName} from this world?", |
| | 0 | 263 | | yesText: "Remove", |
| | 0 | 264 | | cancelText: "Cancel"); |
| | | 265 | | |
| | 0 | 266 | | if (confirmed != true) return; |
| | | 267 | | |
| | | 268 | | try |
| | | 269 | | { |
| | 0 | 270 | | var success = await WorldApi.RemoveMemberAsync(WorldId, member.Id); |
| | | 271 | | |
| | 0 | 272 | | if (success) |
| | | 273 | | { |
| | 0 | 274 | | _members.Remove(member); |
| | 0 | 275 | | Snackbar.Add($"Removed {member.DisplayName} from the world", Severity.Success); |
| | 0 | 276 | | await OnMembersChanged.InvokeAsync(); |
| | 0 | 277 | | StateHasChanged(); |
| | | 278 | | } |
| | | 279 | | else |
| | | 280 | | { |
| | 0 | 281 | | Snackbar.Add("Failed to remove member. Cannot remove the last GM.", Severity.Warning); |
| | | 282 | | } |
| | 0 | 283 | | } |
| | 0 | 284 | | catch (Exception ex) |
| | | 285 | | { |
| | 0 | 286 | | Snackbar.Add($"Failed to remove member: {ex.Message}", Severity.Error); |
| | 0 | 287 | | } |
| | 0 | 288 | | } |
| | | 289 | | |
| | | 290 | | private async Task CreateInvitation() |
| | | 291 | | { |
| | 0 | 292 | | _isCreatingInvitation = true; |
| | 0 | 293 | | StateHasChanged(); |
| | | 294 | | |
| | | 295 | | try |
| | | 296 | | { |
| | 0 | 297 | | var dto = new WorldInvitationCreateDto |
| | 0 | 298 | | { |
| | 0 | 299 | | Role = WorldRole.Player // Default to Player |
| | 0 | 300 | | }; |
| | | 301 | | |
| | 0 | 302 | | var invitation = await WorldApi.CreateInvitationAsync(WorldId, dto); |
| | | 303 | | |
| | 0 | 304 | | if (invitation != null) |
| | | 305 | | { |
| | 0 | 306 | | _invitations.Insert(0, invitation); |
| | 0 | 307 | | Snackbar.Add($"Invitation created: {invitation.Code}", Severity.Success); |
| | 0 | 308 | | await CopyInvitationCode(invitation.Code); |
| | | 309 | | } |
| | | 310 | | else |
| | | 311 | | { |
| | 0 | 312 | | Snackbar.Add("Failed to create invitation", Severity.Error); |
| | | 313 | | } |
| | 0 | 314 | | } |
| | 0 | 315 | | catch (Exception ex) |
| | | 316 | | { |
| | 0 | 317 | | Snackbar.Add($"Failed to create invitation: {ex.Message}", Severity.Error); |
| | 0 | 318 | | } |
| | | 319 | | finally |
| | | 320 | | { |
| | 0 | 321 | | _isCreatingInvitation = false; |
| | 0 | 322 | | StateHasChanged(); |
| | | 323 | | } |
| | 0 | 324 | | } |
| | | 325 | | |
| | | 326 | | private async Task RevokeInvitation(WorldInvitationDto invitation) |
| | | 327 | | { |
| | 0 | 328 | | var confirmed = await DialogService.ShowMessageBox( |
| | 0 | 329 | | "Revoke Invitation", |
| | 0 | 330 | | $"Are you sure you want to revoke invitation {invitation.Code}?", |
| | 0 | 331 | | yesText: "Revoke", |
| | 0 | 332 | | cancelText: "Cancel"); |
| | | 333 | | |
| | 0 | 334 | | if (confirmed != true) return; |
| | | 335 | | |
| | | 336 | | try |
| | | 337 | | { |
| | 0 | 338 | | var success = await WorldApi.RevokeInvitationAsync(WorldId, invitation.Id); |
| | | 339 | | |
| | 0 | 340 | | if (success) |
| | | 341 | | { |
| | 0 | 342 | | invitation.IsActive = false; |
| | 0 | 343 | | Snackbar.Add("Invitation revoked", Severity.Success); |
| | 0 | 344 | | StateHasChanged(); |
| | | 345 | | } |
| | | 346 | | else |
| | | 347 | | { |
| | 0 | 348 | | Snackbar.Add("Failed to revoke invitation", Severity.Error); |
| | | 349 | | } |
| | 0 | 350 | | } |
| | 0 | 351 | | catch (Exception ex) |
| | | 352 | | { |
| | 0 | 353 | | Snackbar.Add($"Failed to revoke invitation: {ex.Message}", Severity.Error); |
| | 0 | 354 | | } |
| | 0 | 355 | | } |
| | | 356 | | |
| | | 357 | | private async Task CopyInvitationCode(string code) |
| | | 358 | | { |
| | | 359 | | try |
| | | 360 | | { |
| | 0 | 361 | | await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", code); |
| | 0 | 362 | | Snackbar.Add($"Copied {code} to clipboard", Severity.Success); |
| | 0 | 363 | | } |
| | 0 | 364 | | catch |
| | | 365 | | { |
| | 0 | 366 | | Snackbar.Add("Failed to copy code", Severity.Error); |
| | 0 | 367 | | } |
| | 0 | 368 | | } |
| | | 369 | | |
| | 0 | 370 | | private static Color GetRoleColor(WorldRole role) => role switch |
| | 0 | 371 | | { |
| | 0 | 372 | | WorldRole.GM => Color.Warning, |
| | 0 | 373 | | WorldRole.Player => Color.Primary, |
| | 0 | 374 | | WorldRole.Observer => Color.Default, |
| | 0 | 375 | | _ => Color.Default |
| | 0 | 376 | | }; |
| | | 377 | | } |