Skip to content

Commit f1c6b06

Browse files
Enhance Player Details Page and Clipboard Functionality
- Added a "Copy to Clipboard" feature using `data-copy-target` attribute for player ID and GUID. - Updated PlayersController to reuse intelligence data for related players, reducing API calls. - Enhanced the RelatedPlayerEnrichedViewModel to include additional fields: LastSeen, HasActiveBan, and AdminActionCount. - Improved the UI for displaying player details, including admin action counts and risk levels. - Refactored the IP Intelligence section to provide clearer information and visual feedback. - Updated the project to use a newer version of the XtremeIdiots.Portal.Repository.Api.Client.V1 package.
1 parent b8df992 commit f1c6b06

6 files changed

Lines changed: 432 additions & 232 deletions

File tree

docs/ui-standards-guide.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,25 @@ Examples:
267267

268268
---
269269

270+
## Copy to Clipboard
271+
272+
Use the `data-copy-target` attribute pattern for copy-to-clipboard actions. Do **not** use inline `onclick` handlers.
273+
274+
```html
275+
<span id="playerId" class="mx-1">some-value</span>
276+
<i class="fa-solid fa-fw fa-copy cursor-pointer" data-copy-target="playerId" title="Copy Player ID" aria-hidden="true"></i>
277+
```
278+
279+
The click handler uses event delegation on `[data-copy-target]`, reads the `textContent` of the target element by ID, and copies it to the clipboard. Visual feedback is provided by temporarily swapping the icon to a checkmark.
280+
281+
### Rules
282+
283+
- The `data-copy-target` value must match the `id` of the element whose text content should be copied.
284+
- Use `fa-copy` icon with `cursor-pointer` class.
285+
- Never use `onclick="copyToClipboard(...)"` — use `data-copy-target` instead.
286+
287+
---
288+
270289
## Badges & Status Indicators
271290

272291
- Use SCSS-defined `badge-*` classes or Bootstrap 5 `bg-*` classes.
@@ -301,3 +320,6 @@ These patterns are deprecated. The SCSS retains bridge definitions for backward
301320
| `fa-save` | `fa-floppy-disk` |
302321
| `fa-edit` | `fa-pen-to-square` |
303322
| `type="button"` on `<a>` | Remove — not valid HTML on anchors |
323+
| `onclick="copyToClipboard(...)"` | `data-copy-target` attribute |
324+
| `style="display:none"` on anti-forgery form | `af-hidden` CSS class |
325+
| Inline `style="height: ..."` on map divs | `player-location-map` CSS class |

src/XtremeIdiots.Portal.Web/Controllers/PlayersController.cs

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -82,43 +82,29 @@ public async Task<IActionResult> Details(Guid id, CancellationToken cancellation
8282

8383
if (playerData.RelatedPlayers is not null && playerData.RelatedPlayers.Count != 0)
8484
{
85+
// All related players share the same IP (the current player's IP) — reuse existing intelligence
86+
var sharedIntelligence = playerDetailsViewModel.Intelligence;
87+
8588
foreach (var rp in playerData.RelatedPlayers)
8689
{
87-
if (rp is null)
88-
{
89-
continue;
90-
}
90+
if (rp is null) continue;
9191

9292
var vm = RelatedPlayerEnrichedViewModel.FromRelatedPlayerDto(rp);
93-
try
93+
94+
if (sharedIntelligence is not null)
9495
{
95-
if (!string.IsNullOrWhiteSpace(vm.IpAddress))
96+
if (!string.IsNullOrWhiteSpace(sharedIntelligence.CountryCode))
97+
vm.CountryCode = sharedIntelligence.CountryCode;
98+
99+
if (sharedIntelligence.ProxyCheck is not null)
96100
{
97-
var intelligenceResult = await geoLocationClient.GeoLookup.V1_1.GetIpIntelligence(vm.IpAddress, cancellationToken).ConfigureAwait(false);
98-
if (intelligenceResult.IsSuccess && intelligenceResult.Result?.Data is not null)
99-
{
100-
var intelligence = intelligenceResult.Result.Data;
101-
102-
if (!string.IsNullOrWhiteSpace(intelligence.CountryCode))
103-
{
104-
vm.CountryCode = intelligence.CountryCode;
105-
}
106-
107-
if (intelligence.ProxyCheck is not null)
108-
{
109-
vm.RiskScore = intelligence.ProxyCheck.RiskScore;
110-
vm.IsProxy = intelligence.ProxyCheck.IsProxy;
111-
vm.IsVpn = intelligence.ProxyCheck.IsVpn;
112-
vm.ProxyType = intelligence.ProxyCheck.ProxyType;
113-
}
114-
}
101+
vm.RiskScore = sharedIntelligence.ProxyCheck.RiskScore;
102+
vm.IsProxy = sharedIntelligence.ProxyCheck.IsProxy;
103+
vm.IsVpn = sharedIntelligence.ProxyCheck.IsVpn;
104+
vm.ProxyType = sharedIntelligence.ProxyCheck.ProxyType;
115105
}
116-
117-
}
118-
catch (Exception ex)
119-
{
120-
Logger.LogWarning(ex, "Failed to enrich related player {RelatedPlayerId} for {PlayerId}", vm.PlayerId, id);
121106
}
107+
122108
playerDetailsViewModel.EnrichedRelatedPlayers.Add(vm);
123109
}
124110
}
@@ -141,7 +127,7 @@ public async Task<IActionResult> Details(Guid id, CancellationToken cancellation
141127
{
142128
var playerApiResponse = await repositoryApiClient.Players.V1.GetPlayer(id,
143129
PlayerEntityOptions.Aliases | PlayerEntityOptions.IpAddresses | PlayerEntityOptions.AdminActions |
144-
PlayerEntityOptions.RelatedPlayers | PlayerEntityOptions.ProtectedNames | PlayerEntityOptions.Tags).ConfigureAwait(false);
130+
PlayerEntityOptions.RelatedPlayers | PlayerEntityOptions.ProtectedNames | PlayerEntityOptions.Tags | PlayerEntityOptions.Counts).ConfigureAwait(false);
145131

146132
if (playerApiResponse.IsNotFound)
147133
{
@@ -190,7 +176,8 @@ private async Task EnrichCurrentPlayerIntelligenceAsync(PlayerDetailsViewModel v
190176

191177
private async Task EnrichPlayerIpAddressesAsync(PlayerDetailsViewModel viewModel, PlayerDto playerData, Guid playerId, CancellationToken cancellationToken = default)
192178
{
193-
foreach (var ipAddress in playerData.PlayerIpAddresses.OrderByDescending(x => x.LastUsed).Take(10))
179+
var ipAddresses = playerData.PlayerIpAddresses.OrderByDescending(x => x.LastUsed).Take(10).ToList();
180+
var tasks = ipAddresses.Select(async ipAddress =>
194181
{
195182
var enrichedIp = new PlayerIpAddressViewModel
196183
{
@@ -212,7 +199,10 @@ private async Task EnrichPlayerIpAddressesAsync(PlayerDetailsViewModel viewModel
212199
ipAddress.Address, playerId);
213200
}
214201

215-
viewModel.EnrichedIpAddresses.Add(enrichedIp);
216-
}
202+
return enrichedIp;
203+
}).ToList();
204+
205+
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
206+
viewModel.EnrichedIpAddresses.AddRange(results);
217207
}
218208
}

src/XtremeIdiots.Portal.Web/Styles/features/_players.scss

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,23 @@
8888
margin-bottom: 0.5rem;
8989
}
9090
}
91+
92+
// Player details page
93+
.player-location-map {
94+
height: 200px;
95+
width: 100%;
96+
}
97+
98+
.player-admin-summary {
99+
dt {
100+
font-weight: $font-weight-semibold;
101+
color: $xi-text-muted;
102+
font-size: $font-size-sm;
103+
text-transform: uppercase;
104+
}
105+
}
106+
107+
// Hidden anti-forgery form
108+
.af-hidden {
109+
display: none;
110+
}

src/XtremeIdiots.Portal.Web/ViewModels/RelatedPlayerEnrichedViewModel.cs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,29 @@ public class RelatedPlayerEnrichedViewModel
1212
public string? IpAddress { get; set; }
1313
public int GameType { get; set; }
1414

15-
// Enrichment
15+
// Fields from enriched RelatedPlayerDto
16+
public DateTime LastSeen { get; set; }
17+
public bool HasActiveBan { get; set; }
18+
public int AdminActionCount { get; set; }
19+
20+
// Geo enrichment
1621
public int? RiskScore { get; set; }
1722
public bool? IsProxy { get; set; }
1823
public bool? IsVpn { get; set; }
1924
public string? ProxyType { get; set; }
2025
public string? CountryCode { get; set; }
2126

22-
public static RelatedPlayerEnrichedViewModel FromRelatedPlayerDto(object relatedPlayerDto)
27+
public static RelatedPlayerEnrichedViewModel FromRelatedPlayerDto(RelatedPlayerDto dto)
2328
{
24-
// We don't have the concrete type source here; map via dynamic to keep decoupled.
25-
dynamic d = relatedPlayerDto;
2629
return new RelatedPlayerEnrichedViewModel
2730
{
28-
PlayerId = d.PlayerId,
29-
Username = d.Username,
30-
IpAddress = d.IpAddress,
31-
GameType = (int)d.GameType
31+
PlayerId = dto.PlayerId,
32+
Username = dto.Username,
33+
IpAddress = dto.IpAddress,
34+
GameType = (int)dto.GameType,
35+
LastSeen = dto.LastSeen,
36+
HasActiveBan = dto.HasActiveBan,
37+
AdminActionCount = dto.AdminActionCount
3238
};
3339
}
3440
}

0 commit comments

Comments
 (0)