Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9c0449e
Initial level edit page
Toastbrot236 Oct 24, 2025
6380f08
Improve level editing page, remove preview
Toastbrot236 Oct 24, 2025
cd2d57b
Fix change detection
Toastbrot236 Oct 24, 2025
499706c
page adjustments, over-engineered game button
Toastbrot236 Oct 24, 2025
aeb16c8
Use and show a max length on text inputs
Toastbrot236 Oct 24, 2025
eab02f8
Refactor dropdown menu into own component, textarea default row count
Toastbrot236 Oct 25, 2025
eeee4a4
Also use app-dropdown-menu in FancyHeaderButtonsComponent
Toastbrot236 Oct 25, 2025
d0653b5
Add Edit button to level buttons, dropdown menu positioning fix
Toastbrot236 Oct 25, 2025
a067d12
Remove non-slug level edit route
Toastbrot236 Oct 25, 2025
1232ab9
Fix determining whether to send level updates as publisher or curator…
Toastbrot236 Oct 25, 2025
2fec4b3
level edit page GUI improvements
Toastbrot236 Oct 25, 2025
8eb8adc
Fix slug for level edit routes
Toastbrot236 Oct 25, 2025
e7c4327
Align remaining char number on textarea
Toastbrot236 Oct 25, 2025
e39d0ba
Implement level delete button
Toastbrot236 Oct 25, 2025
43e4441
Allow buttons to have no text, make more button on level details page…
Toastbrot236 Oct 25, 2025
d665732
Level deletion prompt
Toastbrot236 Oct 25, 2025
2e7bc7b
Correct server url back
Toastbrot236 Oct 26, 2025
643b9ea
More accurately determine whether to send admin or non-admin level ed…
Toastbrot236 Oct 26, 2025
71d93aa
Fix re-opening delete dialog after closing dialog itself (e.g. pressi…
Toastbrot236 Nov 1, 2025
94f27e6
Remove delete dialog margin workaround, properly close search dialog
Toastbrot236 Nov 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/app/api/client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {Contest} from "./types/contests/contest";
import {Score} from "./types/levels/score";
import { LevelRelations } from './types/levels/level-relations';
import { Asset } from './types/asset';
import { LevelUpdateRequest } from './types/levels/level-update-request';

export const defaultPageSize: number = 40;

Expand Down Expand Up @@ -55,6 +56,18 @@ export class ClientService extends ApiImplementation {
getLevelById(id: number) {
return this.http.get<Level>(`/levels/id/${id}`);
}

updateLevelById(id: number, data: LevelUpdateRequest, isCurator: boolean) {
return this.http.patch<Level>(`${isCurator ? '/admin' : ''}/levels/id/${id}`, data);
}

updateLevelIconById(id: number, hash: string, isCurator: boolean) {
return this.http.patch<Level>(`${isCurator ? '/admin' : ''}/levels/id/${id}`, {iconHash: hash});
}

deleteLevelById(id: number, isModerator: boolean) {
return this.http.delete<Level>(`${isModerator ? '/admin' : ''}/levels/id/${id}`);
}

getScoresForLevel(id: number, scoreType: number, skip: number, count: number = defaultPageSize, params: Params | null = null) {
return this.http.get<ListWithData<Score>>(`/scores/${id}/${scoreType}`, {params: this.setPageQuery(params, skip, count)});
Expand Down Expand Up @@ -135,6 +148,14 @@ export class ClientService extends ApiImplementation {
return this.http.post<Response>(`/levels/id/${id}/setAsOverride`, null);
}

teamPickLevel(id: number) {
return this.http.post<Response>(`/admin/levels/id/${id}/teamPick`, null);
}

unTeamPickLevel(id: number) {
return this.http.post<Response>(`/admin/levels/id/${id}/removeTeamPick`, null);
}

uploadAsset(hash: string, data: ArrayBuffer) {
return this.http.post<Asset>(`/assets/${hash}`, data);
}
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/types/levels/level-update-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { GameVersion } from "../game-version";

export interface LevelUpdateRequest {
title: string | undefined;
description: string | undefined;

gameVersion: GameVersion | undefined;
isReUpload: boolean | undefined;
originalPublisher: string | undefined;
}
14 changes: 9 additions & 5 deletions src/app/api/types/levels/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ export interface Level {
title: string;
description: string;
iconHash: string;
publisher: User | undefined;
originalPublisher: string | undefined;
isReUpload: boolean;
teamPicked: boolean;
dateTeamPicked: Date;
gameVersion: number;
score: number;
slotType: number;
publishDate: Date;
updateDate: Date;

booRatings: number;
yayRatings: number;
hearts: number;
totalPlays: number;
uniquePlays: number;
publisher: User | undefined;
teamPicked: boolean;
gameVersion: number;
score: number;
slotType: number;
}
9 changes: 9 additions & 0 deletions src/app/api/types/users/user-roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export enum UserRoles {
Admin = 127,
Moderator = 96,
Curator = 64,
Trusted = 1,
User = 0,
Restricted = -126,
Banned = 127,
}
6 changes: 6 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export const routes: Routes = [
},
...alias("level/:id/:slug", "slot/:id/:slug"),
...alias("level/:id", "slot/:id",),
{
path: 'level/:id/:slug/edit',
loadComponent: () => import('./pages/level-edit/level-edit.component').then(x => x.LevelEditComponent),
data: {title: "Edit Level"},
},
...alias("level/:id/:slug/edit", "slot/:id/:slug/edit"),
{
path: 'photos',
loadComponent: () => import('./pages/photo-listing/photo-listing.component').then(x => x.PhotoListingComponent),
Expand Down
9 changes: 7 additions & 2 deletions src/app/components/ui/dialog.component.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import {Component, ElementRef, OnDestroy, OnInit, ViewChild} from '@angular/core';
import {Component, ElementRef, OnDestroy, OnInit, Output, ViewChild, EventEmitter} from '@angular/core';

@Component({
selector: 'app-dialog',
imports: [],
template: `
<dialog #dialog class="backdrop:backdrop-brightness-50 text-foreground bg-red bg-opacity-0 overflow-y-clip" tabindex="-1">
<dialog (close)="close()" #dialog class="backdrop:backdrop-brightness-50 flex flex-row flex-grow text-foreground bg-container-background bg-opacity-0 overflow-y-clip" tabindex="-1">
<ng-content></ng-content>
</dialog>
`,
styles: ``
})
export class DialogComponent implements OnInit, OnDestroy {
@ViewChild('dialog', { static: true }) dialog!: ElementRef<HTMLDialogElement>;
@Output() onDialogClose = new EventEmitter;

ngOnInit(): void {
this.dialog.nativeElement.showModal();
}

close(): void {
this.onDialogClose.emit();
}

ngOnDestroy(): void {
this.dialog.nativeElement.close();
}
Expand Down
13 changes: 8 additions & 5 deletions src/app/components/ui/form/button.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,27 @@ import { NgClass } from "@angular/common";
NgClass
],
template: `
<button class="rounded px-4 py-1.5 hover:brightness-110 active:brightness-95 transition-[filter] disabled:grayscale"
[ngClass]="color" [type]=type [disabled]="!enabled">
<button class="flex flex-row justify-center rounded px-4 py-1.5 hover:brightness-110 active:brightness-95 transition-[filter] disabled:grayscale"
[ngClass]="color + ' ' + width" [type]=type [disabled]="!enabled">
@if (icon) {
<fa-icon [icon]="icon" [ngClass]="text && text.length > 0 ? 'mr-1' : ''"></fa-icon>
<fa-icon class="right-1" [icon]="icon" [ngClass]="text && text.length > 0 ? 'mr-2' : ''"></fa-icon>
}
@if (text) {
<div class="flex flex-row flex-grow justify-center"> {{ text }} </div>
}
{{ text }}
</button>
`
})
export class ButtonComponent {
// metadata
@Input({required: true}) text: string = "Button";
@Input() text: string | undefined;
@Input() icon: IconProp | undefined;
@Input() color: string = "bg-secondary";

@Input() type: "submit" | "reset" | "button" = "button";

@Input() enabled: boolean = true;
@Input() width: string = "";

// actions
@Input() routerLink: any[] | string | null | undefined
Expand Down
30 changes: 30 additions & 0 deletions src/app/components/ui/form/dropdown-menu.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {Component, Injectable, Input} from '@angular/core';
import {ReactiveFormsModule} from "@angular/forms";
import { NgClass } from "@angular/common";

@Component({
selector: 'app-dropdown-menu',
imports: [
ReactiveFormsModule,
NgClass
],
template: `
<div class="flex flex-row content-center space-x-1 group relative">
<ng-content select="[trigger]"></ng-content>
<div class="absolute z-1 flex flex-col gap-y-1.5 px-5 py-2.5 rounded bg-header-background
border-4 border-backdrop border-solid"
[ngClass]="(showMenu ? '' : 'hidden ') + offsets + ' w-' + width">
<ng-content select="[content]"></ng-content>
</div>
</div>
`
})
@Injectable({
providedIn: 'root'
})
export class DropdownMenuComponent {
@Input() offsets: string = ""
@Input({required: true}) width: number = 0;

@Input() showMenu: boolean = false;
}
6 changes: 3 additions & 3 deletions src/app/components/ui/form/radio-button.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import {FormGroup, ReactiveFormsModule} from "@angular/forms";
ReactiveFormsModule
],
template: `
<div [formGroup]="form" class="min-w-full flex flex-row content-center justify-start rounded-md px-2 py-1 hover-within:outline-2 hover-within:outline hover-within:outline-secondary-bright max-w-fit transition-[outline]">
<input type="radio" [id]=id [formControlName]="ctrlName" [name]="ctrlName" [value]=value class="outline-hidden bg-teritary placeholder:text-gentle placeholder:italic" [required]="required">
<div [formGroup]="form" class="cursor-pointer min-w-full flex flex-row content-center justify-start rounded-md px-2 py-1 hover-within:outline-2 hover-within:outline hover-within:outline-secondary-bright max-w-fit transition-[outline]">
<input type="radio" [id]=id [formControlName]="ctrlName" [name]="ctrlName" [value]=value class="cursor-pointer outline-hidden bg-teritary placeholder:text-gentle placeholder:italic" [required]="required">

@if (label.length > 0) {
<label [for]=id class="text-base hyphens-manual ml-3">{{label}}</label>
<label [for]=id class="text-base hyphens-manual ml-3 cursor-pointer">{{label}}</label>
}
</div>
`
Expand Down
21 changes: 16 additions & 5 deletions src/app/components/ui/form/textarea.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import {FormGroup, ReactiveFormsModule} from "@angular/forms";
@Component({
selector: 'app-textarea',
imports: [
FaIconComponent,
ReactiveFormsModule
],
FaIconComponent,
ReactiveFormsModule
],
template: `
@if (label.length > 0) {
<label [for]=ctrlName class="text-sm">{{label}}</label>
}
<div [formGroup]="form" class="min-w-full flex group rounded-md px-4 py-1.5 bg-teritary focus-within:outline-2 focus-within:outline focus-within:outline-secondary-bright max-w-fit text-nowrap transition-[outline]">
<fa-icon [icon]="icon" class="text-gentle mr-2 group-focus-within:text-secondary-bright transition-colors"></fa-icon>
<textarea [id]=ctrlName [formControlName]="ctrlName" [placeholder]="placeholder" class="grow min-h-20 outline-hidden wrap-break-word bg-teritary placeholder:text-gentle placeholder:italic" [required]="required"></textarea>
<div class="flex flex-col align-center mr-2 gap-y-2">
<fa-icon [icon]="icon" class="flex flex-row justify-center mt-1 text-gentle group-focus-within:text-secondary-bright transition-colors"></fa-icon>
@if (showMaxLength == true) {
<p>{{maxLength - (form.get(ctrlName)?.value?.length ?? 0)}}</p>
}
</div>
<textarea [id]=ctrlName [formControlName]="ctrlName" [maxLength]="maxLength" [placeholder]="placeholder" [rows]="defRows"
class="grow min-w-10 min-h-20 outline-hidden wrap-break-word bg-teritary placeholder:text-gentle placeholder:italic" [required]="required"></textarea>
</div>
`
})
Expand All @@ -28,4 +34,9 @@ export class TextAreaComponent {
@Input({required: true}) ctrlName: string = "";

@Input() required: boolean = true;

@Input() maxLength: number = 4096;
@Input() showMaxLength: boolean = false;

@Input() defRows: number = 4;
}
14 changes: 10 additions & 4 deletions src/app/components/ui/form/textbox.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import {FormGroup, ReactiveFormsModule} from "@angular/forms";
@Component({
selector: 'app-textbox',
imports: [
FaIconComponent,
ReactiveFormsModule
],
FaIconComponent,
ReactiveFormsModule,
],
template: `
@if (label.length > 0) {
<label [for]=ctrlName class="text-sm">{{label}}</label>
}
<div [formGroup]="form" class="min-w-full flex group rounded-full px-4 py-1.5 bg-teritary focus-within:outline-2 focus-within:outline focus-within:outline-secondary-bright max-w-fit text-nowrap transition-[outline]">
<fa-icon [icon]="icon" class="text-gentle mr-2 group-focus-within:text-secondary-bright transition-colors"></fa-icon>
<input [type]="type" [id]=ctrlName [formControlName]="ctrlName" [placeholder]="placeholder" class="grow outline-hidden bg-teritary placeholder:text-gentle placeholder:italic" [required]="required">
<input [type]="type" [maxlength]="maxLength" [id]=ctrlName [formControlName]="ctrlName" [placeholder]="placeholder" class="grow outline-hidden bg-teritary placeholder:text-gentle placeholder:italic" [required]="required">
@if (showMaxLength == true) {
<p>{{maxLength - (form.get(ctrlName)?.value?.length ?? 0)}}</p>
}
</div>
`
})
Expand All @@ -30,4 +33,7 @@ export class TextboxComponent {

@Input() required: boolean = true;
@Input() type: string = "text";

@Input() maxLength: number = 256;
@Input() showMaxLength: boolean = false;
}
31 changes: 14 additions & 17 deletions src/app/components/ui/layouts/fancy-header-buttons.component.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,29 @@
import { Component, Input, TemplateRef, ViewChild, ViewContainerRef } from "@angular/core";
import { ButtonComponent } from "../form/button.component";
import { NgClass } from "@angular/common";
import {faEllipsisV} from "@fortawesome/free-solid-svg-icons";
import {faEllipsisV, faXmark} from "@fortawesome/free-solid-svg-icons";
import { DropdownMenuComponent } from "../form/dropdown-menu.component";

@Component({
selector: 'app-fancy-header-buttons',
imports: [
ButtonComponent,
NgClass
],
ButtonComponent,
DropdownMenuComponent
],
template: `
<ng-template #moreButtonTemplate>
<app-button
text=""
[icon]="faEllipsisV"
width="w-11"
[icon]="showMenu ? faXmark : faEllipsisV"
color="bg-secondary"
(click)="moreButtonClick()">
</app-button>
</ng-template>

<div class="flex flex-row justify-end content-center space-x-1 min-w-56 group relative">
<div #firstButtonContainer></div>
<div #secondButtonContainer></div>
<div class="absolute z-1 flex flex-col gap-y-1.5 w-48 px-5 py-2.5 rounded bg-header-background
border-4 border-backdrop border-solid top-10 cursor-pointer"
[ngClass]="showMenu ? '' : 'hidden'">
<div #navItemsContainer>
</div>
</div>

<app-dropdown-menu class="flex flex-row justify-end" [showMenu]="showMenu" offsets="top-9 right-0" [width]="48">
<div trigger #firstButtonContainer></div>
<div trigger #secondButtonContainer></div>
<div content #navItemsContainer></div>
</app-dropdown-menu>
`,
styles: ``
})
Expand Down Expand Up @@ -70,4 +66,5 @@ export class FancyHeaderButtonsComponent {
}

protected readonly faEllipsisV = faEllipsisV;
protected readonly faXmark = faXmark;
}
Loading