Add admin endpoints for editing and removing punishments and implement frontend dialog for punishment management
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
||||
<h2 mat-dialog-title>Edit {{ data.punishment.type }} #{{ data.punishment.id }}</h2>
|
||||
<div mat-dialog-content>
|
||||
<div class="dialog-content">
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Reason</mat-label>
|
||||
<input matInput type="text" [(ngModel)]="reason" placeholder="Enter reason"/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox [(ngModel)]="isPermanent">Permanent</mat-checkbox>
|
||||
|
||||
@if (!isPermanent) {
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Expires</mat-label>
|
||||
<input matInput type="datetime-local" [(ngModel)]="expiresAtLocal" [disabled]="isPermanent"/>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (errorMessage()) {
|
||||
<div class="error">{{ errorMessage() }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div mat-dialog-actions align="end">
|
||||
<button mat-button color="warn" (click)="onRemove()" [disabled]="isBusy()">Remove</button>
|
||||
<button mat-raised-button color="primary" (click)="onUpdate()" [disabled]="isBusy()">Update</button>
|
||||
<button mat-button (click)="onCancel()" [disabled]="isBusy()">Cancel</button>
|
||||
</div>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
.dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f; /* Material error color */
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import {Component, Inject, inject, signal} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatInput, MatLabel} from '@angular/material/input';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle
|
||||
} from '@angular/material/dialog';
|
||||
import {HistoryService, PunishmentHistory} from '@api';
|
||||
import {firstValueFrom} from 'rxjs';
|
||||
|
||||
interface EditPunishmentData {
|
||||
punishment: PunishmentHistory;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-punishment-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
MatCheckboxModule,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
],
|
||||
templateUrl: './edit-punishment-dialog.component.html',
|
||||
styleUrl: './edit-punishment-dialog.component.scss'
|
||||
})
|
||||
export class EditPunishmentDialogComponent {
|
||||
// Form model
|
||||
reason: string = '';
|
||||
isPermanent: boolean = false;
|
||||
expiresAtLocal: string = '';
|
||||
|
||||
// UI state
|
||||
isBusy = signal<boolean>(false);
|
||||
errorMessage = signal<string | null>(null);
|
||||
|
||||
private historyApi = inject(HistoryService);
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<EditPunishmentDialogComponent, PunishmentHistory | { removed: true } | null>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: EditPunishmentData
|
||||
) {
|
||||
const punishment = data.punishment;
|
||||
this.reason = punishment.reason ?? '';
|
||||
const permanent = punishment.expiryTime <= 0;
|
||||
this.isPermanent = permanent;
|
||||
if (!permanent) {
|
||||
const date = new Date(punishment.expiryTime);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hours = pad(date.getHours());
|
||||
const minutes = pad(date.getMinutes());
|
||||
this.expiresAtLocal = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
if (this.isBusy()) {
|
||||
return;
|
||||
}
|
||||
this.dialogRef.close(null);
|
||||
}
|
||||
|
||||
private computeUntilMs(): number {
|
||||
if (this.isPermanent) {
|
||||
return -1;
|
||||
}
|
||||
if (!this.expiresAtLocal) {
|
||||
return -1;
|
||||
}
|
||||
const ms = new Date(this.expiresAtLocal).getTime();
|
||||
return isNaN(ms) ? -1 : ms;
|
||||
}
|
||||
|
||||
onUpdate(): void {
|
||||
const punishment = this.data.punishment;
|
||||
if (!window.confirm('Are you sure you want to update this punishment?')) {
|
||||
return;
|
||||
}
|
||||
this.isBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
const updates: Array<Promise<PunishmentHistory>> = [] as any;
|
||||
|
||||
// Update reason if changed
|
||||
if ((this.reason ?? '') !== (punishment.reason ?? '')) {
|
||||
updates.push(firstValueFrom(this.historyApi.updatePunishmentReason(punishment.type, punishment.id, this.reason)));
|
||||
}
|
||||
|
||||
// Update expiry for ban/mute only
|
||||
if (punishment.type === 'ban' || punishment.type === 'mute') {
|
||||
const newUntil = this.computeUntilMs();
|
||||
if (newUntil !== punishment.expiryTime) {
|
||||
updates.push(firstValueFrom(this.historyApi.updatePunishmentUntil(punishment.type, punishment.id, newUntil)));
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
this.isBusy.set(false);
|
||||
this.dialogRef.close(null);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all(updates)
|
||||
.then(results => {
|
||||
const updated = results[results.length - 1];
|
||||
this.isBusy.set(false);
|
||||
this.dialogRef.close(updated);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
this.errorMessage.set('Failed to update punishment');
|
||||
this.isBusy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
const punishment = this.data.punishment;
|
||||
if (!window.confirm('Are you sure you want to remove this punishment?')) {
|
||||
return;
|
||||
}
|
||||
this.isBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
this.historyApi.removePunishment(punishment.type as any, punishment.id).subscribe({
|
||||
next: () => {
|
||||
this.isBusy.set(false);
|
||||
this.dialogRef.close({removed: true});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
this.errorMessage.set('Failed to remove punishment');
|
||||
this.isBusy.set(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,17 @@
|
||||
<table [cellSpacing]="0">
|
||||
<div class="historyTableHead">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="historyType">Type</th>
|
||||
<th class="historyPlayer">Player</th>
|
||||
<th class="historyPlayer">Banned By</th>
|
||||
<th class="historyReason">Reason</th>
|
||||
<th class="historyDate">Date</th>
|
||||
<th class="historyDate">Expires</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="historyType">Type</th>
|
||||
<th class="historyPlayer">Player</th>
|
||||
<th class="historyPlayer">Banned By</th>
|
||||
<th class="historyReason">Reason</th>
|
||||
<th class="historyDate">Date</th>
|
||||
<th class="historyDate">Expires</th>
|
||||
@if (canEdit()) {
|
||||
<th class="historyActions">Actions</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
</div>
|
||||
<div>
|
||||
@@ -26,30 +29,36 @@
|
||||
<td class="historyPlayer" (click)="setSearch(entry.username, 'player')">
|
||||
<div class="playerContainer">
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.uuid)" width="25" height="25"
|
||||
alt="{{entry.username}}'s Minecraft skin">
|
||||
<span class="username">{{ entry.username }}</span>
|
||||
</div>
|
||||
alt="{{entry.username}}'s Minecraft skin">
|
||||
<span class="username">{{ entry.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
|
||||
<div class="playerContainer">
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25"
|
||||
height="25"
|
||||
alt="{{entry.punishedBy}}'s Minecraft skin">
|
||||
<span>{{ entry.punishedBy }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="historyReason" (click)="showDetailedPunishment(entry)">
|
||||
{{ entry.reason | removeTrailingPeriod }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getPunishmentTime(entry) }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getExpiredTime(entry) }}
|
||||
</td>
|
||||
@if (canEdit()) {
|
||||
<td class="historyActions">
|
||||
<button (click)="$event.stopPropagation(); openEdit(entry)">Edit</button>
|
||||
</td>
|
||||
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
|
||||
<div class="playerContainer">
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
|
||||
alt="{{entry.punishedBy}}'s Minecraft skin">
|
||||
<span>{{ entry.punishedBy }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="historyReason" (click)="showDetailedPunishment(entry)">
|
||||
{{ entry.reason | removeTrailingPeriod }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getPunishmentTime(entry) }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getExpiredTime(entry) }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</div>
|
||||
</table>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</div>
|
||||
</table>
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import {HttpErrorResponse} from '@angular/common/http';
|
||||
import {HistoryFormatService} from '../history-format.service';
|
||||
import {SearchParams} from '../search-terms';
|
||||
import {Router} from '@angular/router';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {EditPunishmentDialogComponent} from '../edit-punishment-dialog/edit-punishment-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-history',
|
||||
@@ -35,7 +38,11 @@ export class HistoryComponent implements OnInit, OnChanges {
|
||||
|
||||
public history: PunishmentHistory[] = []
|
||||
|
||||
constructor(private historyApi: HistoryService, public historyFormat: HistoryFormatService, private router: Router) {
|
||||
constructor(private historyApi: HistoryService,
|
||||
public historyFormat: HistoryFormatService,
|
||||
private router: Router,
|
||||
private authService: AuthService,
|
||||
private dialog: MatDialog) {
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
@@ -101,4 +108,20 @@ export class HistoryComponent implements OnInit, OnChanges {
|
||||
public showDetailedPunishment(entry: PunishmentHistory) {
|
||||
this.router.navigate([`bans/${entry.type}/${entry.id}`]).then();
|
||||
}
|
||||
|
||||
public canEdit(): boolean {
|
||||
return this.authService.hasAccess(['SCOPE_head_mod']);
|
||||
}
|
||||
|
||||
public openEdit(punishment: PunishmentHistory) {
|
||||
if (!this.canEdit()) return;
|
||||
const ref = this.dialog.open(EditPunishmentDialogComponent, {
|
||||
data: {punishment}
|
||||
});
|
||||
ref.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.reloadHistory();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user