Rework folder structure in frontend

Pages are now grouped per group they appear in on in the header (where possible)
Utilities used by multiple pages in the project are grouped in folders such as services/pipes/etc
This commit is contained in:
2025-07-04 19:50:21 +02:00
parent c42fc38b2c
commit ebe66c87c0
169 changed files with 123 additions and 113 deletions
@@ -0,0 +1,90 @@
<ng-container>
<app-header [current_page]="'bans'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">>
<div class="title" header-content>
<h1>Minecraft Punishments</h1>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<div class="container">
<div class="columnSection">
<div class="historyButtonContainer">
<div [id]="getCurrentButtonId('all')" class="button-outer" (click)="changeHistoryPunishment('all')">
<span class="button-inner"
[ngClass]="active">All</span>
</div>
<div [id]="getCurrentButtonId('ban')" class="button-outer" (click)="changeHistoryPunishment('ban')">
<span class="button-inner"
[ngClass]="active">Bans</span>
</div>
<div [id]="getCurrentButtonId('mute')" class="button-outer" (click)="changeHistoryPunishment('mute')">
<span class="button-inner"
[ngClass]="active">Mutes</span>
</div>
<div [id]="getCurrentButtonId('warn')" class="button-outer" (click)="changeHistoryPunishment('warn')">
<span class="button-inner"
[ngClass]="active">Warnings</span>
</div>
<div [id]="getCurrentUserTypeButtonId('player')" class="button-outer" (click)="changeUserType('player')"
style="margin-left: 120px;">
<span class="button-inner"
[ngClass]="active">Player</span>
</div>
<div [id]="getCurrentUserTypeButtonId('staff')" class="button-outer" (click)="changeUserType('staff')">
<span class="button-inner"
[ngClass]="active">Staff</span>
</div>
</div>
<div class="historySearchContainer">
<input class="historySearch"
type="search"
placeholder="Search.."
[(ngModel)]="searchTerm"
(input)="filterNames()"
(keyup.enter)="search()"
>
<div class="dropdown-results" *ngIf="filteredNames.length > 0 && searchTerm">
<div
class="dropdown-item"
*ngFor="let name of filteredNames"
(mousedown)="selectName(name)">
{{ name }}
</div>
</div>
</div>
</div>
<div class="historyTable">
<app-history [userType]="userType" [punishmentType]="punishmentType"
[page]="page" [searchTerm]="finalSearchTerm" (pageChange)="updatePageSize($event)"
(selectItem)="setSearch($event)">
</app-history>
</div>
<div class="changePageButtons">
<button [ngClass]="{'active': buttonActive(0), 'disabled': !buttonActive(0)}"
[disabled]="!buttonActive(0)"
(click)="setPage(0)" class="historyPageButton">
First page
</button>
<button [ngClass]="{'active': buttonActive(0), 'disabled': !buttonActive(0)}"
[disabled]="!buttonActive(0)"
(click)="previousPage()" class="historyPageButton">
Previous page
</button>
<span class="pageNumber">{{ this.page }} / {{ getMaxPage() }}</span>
<button [ngClass]="{'active': buttonActive(getMaxPage()), 'disabled': !buttonActive(getMaxPage())}"
[disabled]="!buttonActive(getMaxPage())"
(click)="nextPage()" class="historyPageButton">
Next page
</button>
<button [ngClass]="{'active': buttonActive(getMaxPage()), 'disabled': !buttonActive(getMaxPage())}"
[disabled]="!buttonActive(getMaxPage())"
(click)="setPage(getMaxPage())" class="historyPageButton">
Last page
</button>
</div>
</div>
</section>
</main>
</ng-container>
@@ -0,0 +1,171 @@
main .container {
padding: 0;
}
.container {
max-width: 1300px;
}
.columnSection {
padding: 20px 0 20px 0;
max-width: 1300px;
}
.historyButtonContainer {
width: 80%;
box-sizing: border-box;
}
.historySearchContainer {
width: 20%;
box-sizing: border-box;
text-align: right;
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
}
.button-outer {
margin-right: 15px;
background: var(--history-link-color);
}
.button-outer.active {
color: var(--navlink-color);
}
#currentButton {
text-decoration: none;
color: var(--white);
background: var(--link-color);
border-radius: 5px;
}
input.historySearch {
font-family: 'open-sans', sans-serif;
width: 260px;
border: 1px solid #DEDEDE;
padding: 9px 4px 9px 40px;
background: #DEDEDE url(/public/img/random/search.svg) no-repeat 10px;
background-size: 18px 18px;
border-radius: 5px;
}
.searchButton {
vertical-align: -15px;
}
.historyTable {
font-family: 'open-sans', sans-serif;
margin-bottom: 20px;
width: 100%;
text-align: center;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
border-radius: 5px;
z-index: 1;
}
.dropdown-results {
font-family: 'open-sans', sans-serif;
position: absolute;
top: 35px;
left: 0;
width: 100%;
max-height: 300px;
overflow-y: auto;
background-color: #DEDEDE;
border: 1px solid #ddd;
border-top: none;
z-index: 1050;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
}
.dropdown-item {
padding: 8px 12px;
cursor: pointer;
}
.dropdown-item:hover {
background-color: #f5f5f5;
}
.changePageButtons {
margin: 0 auto;
padding: 10px 0 30px 0;
}
.historyPageButton {
align-items: center;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: .25rem;
box-shadow: rgba(0, 0, 0, 0.02) 0 1px 3px 0;
box-sizing: border-box;
background-color: var(--history-page-button-background);
color: var(--font-color);
cursor: pointer;
display: inline-flex;
font-family: 'opensans', sans-serif;
font-size: 16px;
font-weight: 600;
justify-content: center;
line-height: 1.25;
margin: 0 5px 0 5px;
min-height: 3rem;
padding: calc(.875rem - 1px) calc(1.5rem - 1px);
position: relative;
text-decoration: none;
transition: all 250ms;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
vertical-align: baseline;
width: auto;
}
.historyPageButton.active:hover,
.historyPageButton.active:focus {
border-color: rgba(0, 0, 0, 0.15);
box-shadow: rgba(0, 0, 0, 0.1) 0 4px 12px;
color: var(--font-color-hover);
}
.historyPageButton.active:hover {
transform: translateY(-1px);
}
.historyPageButton.active:active {
border-color: rgba(0, 0, 0, 0.15);
box-shadow: rgba(0, 0, 0, 0.06) 0 2px 4px;
color: var(--font-color-hover);
transform: translateY(0);
}
.historyPageButton.disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--color-quaternary);
color: var(--font-color);
box-shadow: none;
pointer-events: none;
}
/* Prevent hover effects on disabled buttons */
.historyPageButton.disabled:hover,
.historyPageButton.disabled:focus {
border-color: rgba(0, 0, 0, 0.1);
box-shadow: none;
transform: none;
color: #999;
}
.pageNumber {
font-family: 'open-sans', sans-serif;
margin: 0 25px 0 25px;
color: var(--font-color);
transition: 0.5s ease;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BansComponent } from './bans.component';
describe('BansComponent', () => {
let component: BansComponent;
let fixture: ComponentFixture<BansComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BansComponent]
})
.compileComponents();
fixture = TestBed.createComponent(BansComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,221 @@
import {Component, OnInit} from '@angular/core';
import {HeaderComponent} from "@header/header.component";
import {HistoryComponent} from './history/history.component';
import {HistoryCount, HistoryService} from '@api';
import {NgClass, NgForOf, NgIf} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {catchError, map, Observable} from 'rxjs';
import {SearchParams} from './search-terms';
@Component({
selector: 'app-bans',
imports: [
HeaderComponent,
HistoryComponent,
NgIf,
FormsModule,
NgForOf,
NgClass
],
templateUrl: './bans.component.html',
styleUrl: './bans.component.scss'
})
export class BansComponent implements OnInit {
constructor(public historyApi: HistoryService) {
}
private PAGE_SIZE: number = 10;
private uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
private actualPage: number = 0;
private totalSearchResults: number = -1;
public active: string = '';
public userType: 'player' | 'staff' = "player";
public punishmentType: 'all' | 'ban' | 'mute' | 'kick' | 'warn' = "all";
public page: number = 0;
public names: string[] = [];
public finalSearchTerm: string = '';
public searchTerm: string = '';
public filteredNames: string[] = [];
public pageSize = 10;
public historyCount: HistoryCount = {
bans: 0,
mutes: 0,
kicks: 0,
warnings: 0
}
ngOnInit() {
this.historyApi.getUserNames(this.userType, this.punishmentType).subscribe(names => {
this.names = names;
});
this.historyApi.getTotalPunishments().subscribe(totalPunishments => {
this.historyCount = totalPunishments;
})
this.pushState();
window.addEventListener('popstate', (event) => {
if (event.state && event.state.searchTerm !== undefined) {
this.searchTerm = event.state.searchTerm;
this.finalSearchTerm = event.state.searchTerm;
this.page = event.state.page;
this.userType = event.state.userType;
this.punishmentType = event.state.punishmentType;
}
});
}
public getCurrentButtonId(options: 'all' | 'ban' | 'mute' | 'kick' | 'warn') {
if (options == this.punishmentType) {
return 'currentButton'
}
return null;
}
public getCurrentUserTypeButtonId(options: 'player' | 'staff') {
if (options == this.userType) {
return 'currentButton'
}
return null;
}
public filterNames() {
if (!this.searchTerm) {
this.filteredNames = [];
return;
}
this.filteredNames = this.names.filter(name =>
name.toLowerCase().startsWith(this.searchTerm.toLowerCase())
).slice(0, 10);
}
public selectName(name: string) {
this.searchTerm = name;
this.filteredNames = [];
this.search();
}
public search() {
this.pushState()
this.finalSearchTerm = this.searchTerm;
this.page = 0;
if (this.finalSearchTerm.length === 0) {
this.totalSearchResults = -1
return
}
let totalSearchResultsObservable: Observable<number>;
if (this.uuidRegex.test(this.finalSearchTerm)) {
totalSearchResultsObservable = this.historyApi.getTotalResultsForUuidSearch(this.userType, this.punishmentType, this.finalSearchTerm);
} else {
totalSearchResultsObservable = this.historyApi.getTotalResultsForUserSearch(this.userType, this.punishmentType, this.finalSearchTerm);
}
totalSearchResultsObservable.pipe(
map(totalSearchResults => {
this.totalSearchResults = totalSearchResults;
}),
catchError(err => {
console.error(err);
return [];
})
).subscribe();
}
public changeUserType(type: 'player' | 'staff') {
this.pushState();
this.userType = type;
this.page = 0;
this.finalSearchTerm = '';
this.searchTerm = '';
this.filteredNames = [];
}
public changeHistoryPunishment(type: 'all' | 'ban' | 'mute' | 'kick' | 'warn') {
this.pushState();
this.punishmentType = type;
this.page = 0;
}
public nextPage() {
if (this.page === this.getMaxPage()) {
return;
}
this.setPage(this.page + 1)
}
public previousPage() {
if (this.page === 0) {
return;
}
this.setPage(this.page - 1)
}
public setPage(page: number) {
if (this.actualPage !== this.page) {
console.warn('Changing page too quickly, the previous page change has not loaded yet');
return;
}
this.pushState();
this.page = page
}
private pushState() {
const state = {
searchTerm: this.searchTerm,
page: this.page,
userType: this.userType,
punishmentType: this.punishmentType
};
const title = 'Altitude Bans';
const url = window.location.href;
window.history.pushState(state, title, url);
}
public getMaxPage() {
const all = this.historyCount.bans + this.historyCount.mutes + this.historyCount.warnings;
if (this.finalSearchTerm.length !== 0) {
return Math.floor(this.totalSearchResults / this.PAGE_SIZE);
}
switch (this.punishmentType) {
case 'all':
return Math.floor(all / this.PAGE_SIZE);
case 'ban':
return Math.floor(this.historyCount.bans / this.PAGE_SIZE);
case 'mute':
return Math.floor(this.historyCount.mutes / this.PAGE_SIZE);
case 'kick':
return Math.floor(this.historyCount.kicks / this.PAGE_SIZE);
case 'warn':
return Math.floor(this.historyCount.warnings / this.PAGE_SIZE);
default:
return 0;
}
}
public buttonActive(compare: number): boolean {
if (compare !== 0 && this.pageSize < this.PAGE_SIZE) {
return false;
}
return this.page !== compare;
}
public updatePageSize(pageSize: number) {
if (pageSize < 0) {
return;
} else {
this.pageSize = pageSize;
this.actualPage = this.page;
}
}
public setSearch(searchParams: SearchParams) {
this.userType = searchParams.userType
this.searchTerm = searchParams.searchTerm
this.punishmentType = searchParams.punishmentType
this.search();
}
}
@@ -0,0 +1,109 @@
<ng-container>
<app-header [current_page]="'bans'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">>
<div class="title" header-content>
<h1>Minecraft Punishments</h1>
</div>
</app-header>
<main>
<section class="darkmodeSection">
<section class="columnSection">
<div class="detailsBackButton">
<ng-container *ngIf="punishment === undefined">
<p>Loading...</p>
</ng-container>
<a [routerLink]="['/bans']">< Back</a>
</div>
</section>
<section class="columnSection center">
<ng-container *ngIf="punishment">
<div>
<span class="tag tagInfo"
[ngClass]="{
'tagPermanent': this.historyFormat.isPermanent(punishment),
'tagExpired': !this.historyFormat.isPermanent(punishment)
}">
{{ this.historyFormat.getType(punishment) }}
</span>
</div>
<div>
<span
class="tag tagInfo"
[ngClass]="{
'tagActive': this.historyFormat.isActive(punishment),
'tagInactive': !this.historyFormat.isActive(punishment)
}">
{{ this.historyFormat.isActive(punishment) ? 'Active' : 'Inactive' }}
</span>
</div>
</ng-container>
</section>
<section class="columnSection">
<div class="columnContainer">
<div class="columnParagraph">
<ng-container *ngIf="punishment">
<div class="playerContainer">
<h2>Player</h2>
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(punishment.uuid, '150')"
width="150"
height="150"
alt="{{punishment.username}}'s Minecraft skin"
>
<h3 class="detailsUsername">{{ punishment.username }}</h3>
</div>
</ng-container>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<ng-container *ngIf="punishment">
<div class="playerContainer">
<h2>Moderator</h2>
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(punishment.punishedByUuid, '150')"
width="150"
height="150"
alt="{{punishment.punishedBy}}'s Minecraft skin"
>
<h3 class="detailsUsername">{{ punishment.punishedBy }}</h3>
</div>
</ng-container>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<ng-container *ngIf="punishment">
<div class="detailsInfo">
<h2>Reason</h2>
<p>{{ punishment.reason | removeTrailingPeriod }}</p>
</div>
</ng-container>
</div>
</div>
<div class="columnContainer">
<div class="columnParagraph">
<ng-container *ngIf="punishment">
<div class="detailsInfo">
<h2>Date</h2>
<p>{{ this.historyFormat.getPunishmentTime(punishment) }}</p>
</div>
</ng-container>
</div>
</div>
</section>
</section>
</main>
</ng-container>
<section class="columnSection">
<ng-container *ngIf="punishment">
<span>Expires</span>
<span>{{ this.historyFormat.getExpiredTime(punishment) }}</span>
<ng-container *ngIf="punishment.removedBy !== undefined && punishment.removedBy.length > 0">
<span>Un{{ this.historyFormat.getType(punishment).toLocaleLowerCase() }} reason</span>
<span>{{ punishment.removedReason == null ? 'No reason specified' : punishment.removedReason }}</span>
</ng-container>
</ng-container>
</section>
@@ -0,0 +1,68 @@
.detailsBackButton {
font-family: open-sans, sans-serif;
}
.columnSection {
padding-top: 30px;
}
h2 {
text-align: center;
}
h3 {
text-align: center;
}
.avatar {
padding: 10px 0;
}
.detailsUsername {
font-size: 1.2em;
font-family: 'opensans-bold', sans-serif;
}
.detailsInfo {
padding-top: 50px;
}
.detailsInfo p {
text-align: center;
}
.tag {
display: inline-block;
padding: 5px 10px;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.25rem;
font-family: 'opensans-bold', sans-serif;
}
.tagInfo {
margin: 0 20px;
font-size: 1.1em;
}
.tagActive {
color: #FFFFFF;
background-color: #EE5555;
}
.tagInactive {
color: #FFFFFF;
background-color: #F79720;
}
.tagPermanent {
color: #FFFFFF;
background-color: #EE5555;
}
.tagExpired {
color: #FFFFFF;
background-color: #777777
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DetailsComponent } from './details.component';
describe('DetailsComponent', () => {
let component: DetailsComponent;
let fixture: ComponentFixture<DetailsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DetailsComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,71 @@
import {Component, OnInit} from '@angular/core';
import {HistoryService, PunishmentHistory} from '@api';
import {NgClass, NgIf, NgOptimizedImage} from '@angular/common';
import {RemoveTrailingPeriodPipe} from '@pipes/RemoveTrailingPeriodPipe';
import {HistoryFormatService} from '../history-format.service';
import {ActivatedRoute, RouterLink} from '@angular/router';
import {catchError, map} from 'rxjs';
import {HeaderComponent} from '@header/header.component';
@Component({
selector: 'app-details',
imports: [
NgIf,
NgOptimizedImage,
RemoveTrailingPeriodPipe,
HeaderComponent,
RouterLink,
NgClass
],
templateUrl: './details.component.html',
styleUrl: './details.component.scss'
})
export class DetailsComponent implements OnInit {
type: 'all' | 'ban' | 'mute' | 'kick' | 'warn' = 'all';
id: number = -1;
punishment: PunishmentHistory | undefined;
constructor(private route: ActivatedRoute,
private historyApi: HistoryService,
public historyFormat: HistoryFormatService) {
}
ngOnInit(): void {
this.route.paramMap.subscribe(params => {
switch (params.get('type')) {
case 'ban':
this.type = 'ban';
break;
case 'mute':
this.type = 'mute';
break;
case 'kick':
this.type = 'kick';
break;
case 'warn':
this.type = 'warn';
break;
default:
throw new Error("Invalid type");
}
this.id = Number(params.get('id') || '-1');
this.loadPunishmentData();
});
}
private loadPunishmentData() {
if (!this.type || this.type === 'all' || this.id < 0 || isNaN(this.id)) {
console.error("Invalid type or id");
return;
}
this.historyApi.getHistoryById(this.type, this.id).pipe(
map(punishment => {
this.punishment = punishment;
}),
catchError(err => {
console.error(err);
return [];
})
).subscribe();
}
}
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { HistoryFormatService } from './history-format.service';
describe('HistoryFormatService', () => {
let service: HistoryFormatService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(HistoryFormatService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
@@ -0,0 +1,68 @@
import {Injectable} from '@angular/core';
import {PunishmentHistory} from '@api';
@Injectable({
providedIn: 'root'
})
export class HistoryFormatService {
constructor() {
}
public getPunishmentTime(entry: PunishmentHistory) {
const date = new Date(entry.punishmentTime);
return date.toLocaleString(navigator.language, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
public isActive(entry: PunishmentHistory): boolean {
if (entry.removedBy !== null) {
return false;
}
if (entry.expiryTime <= 0) {
return true;
}
return entry.expiryTime > Date.now();
}
public isPermanent(entry: PunishmentHistory): boolean {
return entry.expiryTime <= 0;
}
public getType(entry: PunishmentHistory): string {
return entry.type.charAt(0).toUpperCase() + entry.type.slice(1);
}
public getExpiredTime(entry: PunishmentHistory): string {
let suffix: string = '';
if (entry.removedBy !== null && entry.removedBy !== undefined) {
suffix = '(Unbanned by ' + entry.removedBy + ')';
}
if (entry.expiryTime <= 0) {
return "Permanent " + this.getType(entry) + " " + suffix;
}
const date = new Date(entry.expiryTime);
return date.toLocaleString(navigator.language, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}) + " " + suffix;
}
public getAvatarUrl(entry: string, size: string = '25'): string {
let uuid = entry.replace('-', '');
if (uuid === 'C') {
uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
}
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;
}
}
@@ -0,0 +1,53 @@
<ng-container *ngIf="history.length === 0">
<p>No history found</p>
</ng-container>
<ng-container *ngIf="history.length > 0">
<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>
</thead>
</div>
<div>
<tbody>
<tr class="historyPlayerRow" *ngFor="let entry of history">
<td class="historyType" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getType(entry) }}
</td>
<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>
</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>
</ng-container>
@@ -0,0 +1,113 @@
table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
border-radius: 5px;
}
thead {
height: 25px;
font-size: 1.1em;
}
tbody {
font-size: 0.9em;
}
td {
padding: 5px 0 5px 0;
height: 55px;
}
th {
padding: 5px 0 5px 0;
}
th, td {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding: 5px 0 8px 0;
box-sizing: border-box;
}
th.historyType, td.historyType {
width: 100px !important;
min-width: 100px;
max-width: 100px;
white-space: nowrap;
}
th.historyPlayer, td.historyPlayer {
width: 180px !important;
min-width: 180px;
max-width: 180px;
}
th.historyReason, td.historyReason {
width: 540px !important;
min-width: 540px;
max-width: 540px;
}
th.historyDate, td.historyDate {
width: 150px !important;
min-width: 150px;
max-width: 150px;
}
table tr td {
vertical-align: middle;
}
.historyPlayerRow:hover {
background-color: var(--history-table-row-color);
cursor: pointer;
}
.historyTableHead {
width: 1300px;
background-color: var(--history-table-head-color);
transition: all 100ms cubic-bezier(0.55, 0.085, 0.68, 0.53), 0.5s background ease;
border-radius: 5px;
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.2), 0 6px 20px -8px rgba(0, 0, 0, 0.19);
}
.historyType {
max-width: 100%;
width: 80px;
}
.historyPlayer {
max-width: 100%;
width: 200px;
text-overflow: clip;
}
.playerContainer {
display: inline-flex;
flex-direction: column;
align-items: center;
}
.username {
text-align: center;
}
img {
vertical-align: middle;
border-style: none;
}
.avatar {
margin-bottom: 5px;
border-radius: 2px;
}
.historyReason {
width: 650px;
}
.historyDate {
width: 170px;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HistoryComponent } from './history.component';
describe('HistoryComponent', () => {
let component: HistoryComponent;
let fixture: ComponentFixture<HistoryComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HistoryComponent]
})
.compileComponents();
fixture = TestBed.createComponent(HistoryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,108 @@
import {Component, EventEmitter, Input, OnChanges, OnInit, Output} from '@angular/core';
import {BASE_PATH, HistoryService, PunishmentHistory} from '@api';
import {catchError, map, Observable, shareReplay} from 'rxjs';
import {NgForOf, NgIf, NgOptimizedImage} from '@angular/common';
import {CookieService} from 'ngx-cookie-service';
import {RemoveTrailingPeriodPipe} from '@pipes/RemoveTrailingPeriodPipe';
import {HttpErrorResponse} from '@angular/common/http';
import {environment} from '@environment';
import {HistoryFormatService} from '../history-format.service';
import {SearchParams} from '../search-terms';
import {Router} from '@angular/router';
@Component({
selector: 'app-history',
imports: [
NgIf,
NgForOf,
NgOptimizedImage,
RemoveTrailingPeriodPipe
],
templateUrl: './history.component.html',
styleUrl: './history.component.scss',
providers: [
CookieService,
{provide: BASE_PATH, useValue: environment.apiUrl}
],
})
export class HistoryComponent implements OnInit, OnChanges {
@Input() userType: 'player' | 'staff' = "player";
@Input() punishmentType: 'all' | 'ban' | 'mute' | 'kick' | 'warn' = "all";
@Input() page: number = 0;
@Input() searchTerm: string = '';
@Output() pageChange = new EventEmitter<number>();
@Output() selectItem = new EventEmitter<SearchParams>();
private uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
public history: PunishmentHistory[] = []
constructor(private historyApi: HistoryService, public historyFormat: HistoryFormatService, private router: Router) {
}
ngOnChanges(): void {
this.reloadHistory();
}
ngOnInit(): void {
this.reloadHistory();
}
private reloadHistory(): void {
let historyObservable: Observable<PunishmentHistory[]>;
if (this.searchTerm.length === 0) {
historyObservable = this.historyApi.getHistoryForAll(this.userType, this.punishmentType, this.page);
} else {
if (this.uuidRegex.test(this.searchTerm)) {
historyObservable = this.historyApi.getHistoryForUuid(this.userType, this.punishmentType, this.searchTerm, this.page);
} else {
historyObservable = this.historyApi.getHistoryForUsers(this.userType, this.punishmentType, this.searchTerm, this.page);
}
}
historyObservable.pipe(
map(history => {
this.history = history;
this.pageChange.emit(this.history.length);
}),
catchError(err => {
this.pageChange.emit(-1);
let retrySeconds = 5;
if (err instanceof HttpErrorResponse) {
if (err.status !== 429) {
return this.history;
}
const headers = err.headers;
const retryAfterHeader = headers.get('Retry-After');
console.warn(err.error);
if (retryAfterHeader) {
const retryAfter = parseInt(retryAfterHeader || '0', 10);
if (!isNaN(retryAfter) && retryAfter > 0) {
retrySeconds = retryAfter + 1;
}
}
} else {
console.error(err);
}
setTimeout(() => this.reloadHistory(), retrySeconds * 1000);
return this.history;
}),
shareReplay(1)
).subscribe();
}
setSearch(name: string, userType: 'player' | 'staff') {
let searchParams: SearchParams = {
userType: userType,
punishmentType: 'all',
searchTerm: name
}
this.selectItem.emit(searchParams);
}
public showDetailedPunishment(entry: PunishmentHistory) {
this.router.navigate([`bans/${entry.type}/${entry.id}`]).then();
}
}
@@ -0,0 +1,5 @@
export interface SearchParams {
userType: 'player' | 'staff';
searchTerm: string;
punishmentType: 'all' | 'ban' | 'mute' | 'kick' | 'warn';
}