diff --git a/src/app/api/client.service.ts b/src/app/api/client.service.ts index 425dfcd5..494b80f1 100644 --- a/src/app/api/client.service.ts +++ b/src/app/api/client.service.ts @@ -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; @@ -55,6 +56,18 @@ export class ClientService extends ApiImplementation { getLevelById(id: number) { return this.http.get(`/levels/id/${id}`); } + + updateLevelById(id: number, data: LevelUpdateRequest, isCurator: boolean) { + return this.http.patch(`${isCurator ? '/admin' : ''}/levels/id/${id}`, data); + } + + updateLevelIconById(id: number, hash: string, isCurator: boolean) { + return this.http.patch(`${isCurator ? '/admin' : ''}/levels/id/${id}`, {iconHash: hash}); + } + + deleteLevelById(id: number, isModerator: boolean) { + return this.http.delete(`${isModerator ? '/admin' : ''}/levels/id/${id}`); + } getScoresForLevel(id: number, scoreType: number, skip: number, count: number = defaultPageSize, params: Params | null = null) { return this.http.get>(`/scores/${id}/${scoreType}`, {params: this.setPageQuery(params, skip, count)}); @@ -135,6 +148,14 @@ export class ClientService extends ApiImplementation { return this.http.post(`/levels/id/${id}/setAsOverride`, null); } + teamPickLevel(id: number) { + return this.http.post(`/admin/levels/id/${id}/teamPick`, null); + } + + unTeamPickLevel(id: number) { + return this.http.post(`/admin/levels/id/${id}/removeTeamPick`, null); + } + uploadAsset(hash: string, data: ArrayBuffer) { return this.http.post(`/assets/${hash}`, data); } diff --git a/src/app/api/types/levels/level-update-request.ts b/src/app/api/types/levels/level-update-request.ts new file mode 100644 index 00000000..04b85e87 --- /dev/null +++ b/src/app/api/types/levels/level-update-request.ts @@ -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; +} \ No newline at end of file diff --git a/src/app/api/types/levels/level.ts b/src/app/api/types/levels/level.ts index 22b2c3ef..e56ec3b3 100644 --- a/src/app/api/types/levels/level.ts +++ b/src/app/api/types/levels/level.ts @@ -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; } diff --git a/src/app/api/types/users/user-roles.ts b/src/app/api/types/users/user-roles.ts new file mode 100644 index 00000000..685c53e6 --- /dev/null +++ b/src/app/api/types/users/user-roles.ts @@ -0,0 +1,9 @@ +export enum UserRoles { + Admin = 127, + Moderator = 96, + Curator = 64, + Trusted = 1, + User = 0, + Restricted = -126, + Banned = 127, +} diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index f66bc763..2b8a3d53 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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), diff --git a/src/app/components/ui/dialog.component.ts b/src/app/components/ui/dialog.component.ts index 1bd33b06..8a894e45 100644 --- a/src/app/components/ui/dialog.component.ts +++ b/src/app/components/ui/dialog.component.ts @@ -1,10 +1,10 @@ -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: ` - + `, @@ -12,11 +12,16 @@ import {Component, ElementRef, OnDestroy, OnInit, ViewChild} from '@angular/core }) export class DialogComponent implements OnInit, OnDestroy { @ViewChild('dialog', { static: true }) dialog!: ElementRef; + @Output() onDialogClose = new EventEmitter; ngOnInit(): void { this.dialog.nativeElement.showModal(); } + close(): void { + this.onDialogClose.emit(); + } + ngOnDestroy(): void { this.dialog.nativeElement.close(); } diff --git a/src/app/components/ui/form/button.component.ts b/src/app/components/ui/form/button.component.ts index a0f6e326..b8ddd9b9 100644 --- a/src/app/components/ui/form/button.component.ts +++ b/src/app/components/ui/form/button.component.ts @@ -10,24 +10,27 @@ import { NgClass } from "@angular/common"; NgClass ], template: ` - ` }) 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 diff --git a/src/app/components/ui/form/dropdown-menu.component.ts b/src/app/components/ui/form/dropdown-menu.component.ts new file mode 100644 index 00000000..cf7d1ee6 --- /dev/null +++ b/src/app/components/ui/form/dropdown-menu.component.ts @@ -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: ` +
+ +
+ +
+
+ ` +}) +@Injectable({ + providedIn: 'root' +}) +export class DropdownMenuComponent { + @Input() offsets: string = "" + @Input({required: true}) width: number = 0; + + @Input() showMenu: boolean = false; +} diff --git a/src/app/components/ui/form/radio-button.component.ts b/src/app/components/ui/form/radio-button.component.ts index 1d23051b..78417cae 100644 --- a/src/app/components/ui/form/radio-button.component.ts +++ b/src/app/components/ui/form/radio-button.component.ts @@ -7,11 +7,11 @@ import {FormGroup, ReactiveFormsModule} from "@angular/forms"; ReactiveFormsModule ], template: ` -
- +
+ @if (label.length > 0) { - + }
` diff --git a/src/app/components/ui/form/textarea.component.ts b/src/app/components/ui/form/textarea.component.ts index b75c46c1..816e9349 100644 --- a/src/app/components/ui/form/textarea.component.ts +++ b/src/app/components/ui/form/textarea.component.ts @@ -6,16 +6,22 @@ import {FormGroup, ReactiveFormsModule} from "@angular/forms"; @Component({ selector: 'app-textarea', imports: [ - FaIconComponent, - ReactiveFormsModule - ], + FaIconComponent, + ReactiveFormsModule +], template: ` @if (label.length > 0) { }
- - +
+ + @if (showMaxLength == true) { +

{{maxLength - (form.get(ctrlName)?.value?.length ?? 0)}}

+ } +
+
` }) @@ -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; } diff --git a/src/app/components/ui/form/textbox.component.ts b/src/app/components/ui/form/textbox.component.ts index 3f1953ff..9499774b 100644 --- a/src/app/components/ui/form/textbox.component.ts +++ b/src/app/components/ui/form/textbox.component.ts @@ -7,16 +7,19 @@ import {FormGroup, ReactiveFormsModule} from "@angular/forms"; @Component({ selector: 'app-textbox', imports: [ - FaIconComponent, - ReactiveFormsModule - ], + FaIconComponent, + ReactiveFormsModule, +], template: ` @if (label.length > 0) { }
- + + @if (showMaxLength == true) { +

{{maxLength - (form.get(ctrlName)?.value?.length ?? 0)}}

+ }
` }) @@ -30,4 +33,7 @@ export class TextboxComponent { @Input() required: boolean = true; @Input() type: string = "text"; + + @Input() maxLength: number = 256; + @Input() showMaxLength: boolean = false; } diff --git a/src/app/components/ui/layouts/fancy-header-buttons.component.ts b/src/app/components/ui/layouts/fancy-header-buttons.component.ts index e6266145..9bdbe967 100644 --- a/src/app/components/ui/layouts/fancy-header-buttons.component.ts +++ b/src/app/components/ui/layouts/fancy-header-buttons.component.ts @@ -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: ` - -
-
-
-
-
-
-
+ + +
+
+
+
`, styles: `` }) @@ -70,4 +66,5 @@ export class FancyHeaderButtonsComponent { } protected readonly faEllipsisV = faEllipsisV; + protected readonly faXmark = faXmark; } \ No newline at end of file diff --git a/src/app/components/ui/layouts/fancy-header-level-buttons.component.ts b/src/app/components/ui/layouts/fancy-header-level-buttons.component.ts index 27ff6c4f..3867d468 100644 --- a/src/app/components/ui/layouts/fancy-header-level-buttons.component.ts +++ b/src/app/components/ui/layouts/fancy-header-level-buttons.component.ts @@ -11,17 +11,21 @@ import { faBellSlash, faHeart, faHeartCrack, + faPencil, faPlay } from "@fortawesome/free-solid-svg-icons"; import { FancyHeaderButtonsComponent } from "./fancy-header-buttons.component"; import { areGameVersionsCompatible } from "../../../helpers/game-versioning"; +import { UserRoles } from "../../../api/types/users/user-roles"; +import { Router } from "@angular/router"; +import { SlugPipe } from "../../../pipes/slug.pipe"; @Component({ selector: 'app-fancy-header-level-buttons', imports: [ - ButtonOrNavItemComponent, - FancyHeaderButtonsComponent - ], + ButtonOrNavItemComponent, + FancyHeaderButtonsComponent, +], template: ` + + + + + @if (buttonsInitialized) { @@ -88,14 +105,25 @@ export class FancyHeaderLevelButtonsComponent { @ViewChild('playNowButtonTemplate') playNowButtonTemplateRef!: TemplateRef; @ViewChild('queueButtonTemplate') queueButtonTemplateRef!: TemplateRef; @ViewChild('heartButtonTemplate') heartButtonTemplateRef!: TemplateRef; + @ViewChild('editButtonTemplate') editButtonTemplateRef!: TemplateRef; buttonTemplateRefs: TemplateRef[] = []; ownUserRoom: Room | undefined; buttonsInitialized: boolean = false; + levelTitleSlug: string = "title"; - constructor(private client: ClientService, private bannerService: BannerService) {} + constructor(private client: ClientService, private bannerService: BannerService, + protected router: Router, private slug: SlugPipe) {} ngAfterViewInit() { + const isPublisher: boolean = this.level.publisher != null && this.level.publisher.userId == this.ownUser.userId; + this.levelTitleSlug = this.slug.transform(this.level.title); + + // Edit button at the top, if level is published by the user + if (isPublisher) { + this.buttonTemplateRefs.push(this.editButtonTemplateRef); + } + // Play Now button, if level is compatible with the game currently played by the player this.ownUserRoom = this.ownUser.activeRoom; if (this.ownUserRoom != undefined && areGameVersionsCompatible(this.level.gameVersion, this.ownUserRoom.game)) { @@ -110,6 +138,11 @@ export class FancyHeaderLevelButtonsComponent { // Heart Button this.buttonTemplateRefs.push(this.heartButtonTemplateRef); + // Edit button further below, if the user is a curator or above and not already the publisher aswell + if (!isPublisher && this.ownUser.role >= UserRoles.Curator) { + this.buttonTemplateRefs.push(this.editButtonTemplateRef); + } + this.buttonsInitialized = true; } @@ -150,4 +183,5 @@ export class FancyHeaderLevelButtonsComponent { protected readonly faBellSlash = faBellSlash; protected readonly faHeart = faHeart; protected readonly faHeartCrack = faHeartCrack; + protected readonly faPencil = faPencil; } \ No newline at end of file diff --git a/src/app/overlays/search.component.ts b/src/app/overlays/search.component.ts index 9a10d6a1..6b6b2d4e 100644 --- a/src/app/overlays/search.component.ts +++ b/src/app/overlays/search.component.ts @@ -39,7 +39,7 @@ import {debounceTime} from "rxjs"; @defer (when show) { @if (show) { - +
@if (!(layout.isMobile | async)) { diff --git a/src/app/pages/level-edit/level-edit.component.html b/src/app/pages/level-edit/level-edit.component.html new file mode 100644 index 00000000..3d609442 --- /dev/null +++ b/src/app/pages/level-edit/level-edit.component.html @@ -0,0 +1,110 @@ +@if (level && ownUser && !isUserPublisher && !isUserCurator) +{ +

You may not edit this level. Go away.

+} +@else if (level && ownUser) { +
+
+ +

(Level ID: {{ level.levelId }})

+
+ +
+
+ +
+
+
+ + + +
+
+ +
+ +
+
+ + +
+ +
+ +
+ +
+
+
+ + @if (isUserCurator) { + + +
+ + +
+

Game version:

+ + + +
+ + + + + + + +
+
+
+ +
+
+
+ +

+ since +

+
+ +
+ + +
+
+ } +
+ + + @defer (when showDeletionPrompt) { @if (showDeletionPrompt) { + +
+ +
+ + +
+
+
+ }} +} diff --git a/src/app/pages/level-edit/level-edit.component.ts b/src/app/pages/level-edit/level-edit.component.ts new file mode 100644 index 00000000..b429d56b --- /dev/null +++ b/src/app/pages/level-edit/level-edit.component.ts @@ -0,0 +1,357 @@ +import {Component, Inject, PLATFORM_ID} from '@angular/core'; +import {Level} from "../../api/types/levels/level"; +import {ClientService} from "../../api/client.service"; +import {ActivatedRoute, Router} from "@angular/router"; +import { AsyncPipe, isPlatformBrowser, NgClass, NgIf } from "@angular/common"; +import {LevelAvatarComponent} from "../../components/ui/photos/level-avatar.component"; +import {GamePipe} from "../../pipes/game.pipe"; +import {LayoutService} from "../../services/layout.service"; +import {DividerComponent} from "../../components/ui/divider.component"; +import {AuthenticationService} from "../../api/authentication.service"; +import { ExtendedUser } from '../../api/types/users/extended-user'; +import { UserRoles } from '../../api/types/users/user-roles'; +import { PageTitleComponent } from "../../components/ui/text/page-title.component"; +import { ButtonComponent } from "../../components/ui/form/button.component"; +import { faCertificate, faChevronDown, faChevronUp, faClone, faFloppyDisk, faPencil, faSignOutAlt, faTrash, faUser } from '@fortawesome/free-solid-svg-icons'; +import { TextboxComponent } from '../../components/ui/form/textbox.component'; +import { FormControl, FormGroup } from '@angular/forms'; +import { LevelUpdateRequest } from '../../api/types/levels/level-update-request'; +import { BannerService } from '../../banners/banner.service'; +import { RefreshApiError } from '../../api/refresh-api-error'; +import { sha1Async } from '../../helpers/crypto'; +import { CheckboxComponent } from "../../components/ui/form/checkbox.component"; +import { GameVersion } from '../../api/types/game-version'; +import { FaIconComponent } from "@fortawesome/angular-fontawesome"; +import { RadioButtonComponent } from "../../components/ui/form/radio-button.component"; +import { TextAreaComponent } from "../../components/ui/form/textarea.component"; +import { DateComponent } from "../../components/ui/info/date.component"; +import { DropdownMenuComponent } from "../../components/ui/form/dropdown-menu.component"; +import { SlugPipe } from '../../pipes/slug.pipe'; +import { DialogComponent } from "../../components/ui/dialog.component"; + +@Component({ + selector: 'app-level-edit', + imports: [ + LevelAvatarComponent, + AsyncPipe, + DividerComponent, + PageTitleComponent, + ButtonComponent, + TextboxComponent, + NgIf, + CheckboxComponent, + FaIconComponent, + RadioButtonComponent, + GamePipe, + TextAreaComponent, + DateComponent, + DropdownMenuComponent, + DialogComponent +], + providers: [ + SlugPipe + ], + templateUrl: './level-edit.component.html' +}) +export class LevelEditComponent { + level: Level | undefined | null; + + protected readonly isBrowser: boolean; + protected isMobile: boolean = false; + protected ownUser: ExtendedUser | undefined; + + settingsForm = new FormGroup({ + title: new FormControl(), + description: new FormControl(), + }); + + curatorForm = new FormGroup({ + isReupload: new FormControl(), + originalPublisher: new FormControl(), + isTeamPicked: new FormControl(), + gameVersion: new FormControl(0), + }); + + iconHash: string = "0"; + hasTitleChanged: boolean = false; + hasDescriptionChanged: boolean = false; + + hasReuploadChanged: boolean = false; + hasOriginalPublisherChanged: boolean = false; + hasTeamPickedChanged: boolean = false; + hasGameChanged: boolean = false; + + hasPendingChangesTotal: boolean = false; + hasPendingChangesLevel: boolean = false; + hasPendingCuratorChanges: boolean = false; + + showDeletionPrompt: boolean = false; + showGameMenu: boolean = false; + gameButtonColor: string = "bg-secondary"; + + isUserCurator: boolean = false; + isUserModerator: boolean = false; + isUserPublisher: boolean = false; + + constructor(private client: ClientService, protected banner: BannerService, route: ActivatedRoute, + protected layout: LayoutService, private auth: AuthenticationService, + @Inject(PLATFORM_ID) platformId: Object, private router: Router, private slug: SlugPipe) + { + this.isBrowser = isPlatformBrowser(platformId); + + route.params.subscribe(params => { + const id: number = +params['id']; + this.client.getLevelById(id).subscribe(data => { + if(!this.level && data) { + this.level = data; + this.updateInputs(data); + + if(this.isBrowser) { + window.history.replaceState({}, '', `/level/${data.levelId}/${this.slug.transform(data.title)}/edit`); + } + + this.auth.user.subscribe(user => { + if(user) { + this.ownUser = user; + this.isUserPublisher = data.publisher != null && user.userId == data.publisher?.userId; + this.isUserCurator = user.role >= UserRoles.Curator; + this.isUserModerator = user.role >= UserRoles.Moderator; + } + }); + } + }); + }); + + this.layout.isMobile.subscribe(v => this.isMobile = v); + } + + checkTitleChanges() { + this.hasTitleChanged = this.settingsForm.controls.title.getRawValue() != this.level?.title; + this.doesPageHavePendingChanges(); + } + + checkDescriptionChanges() { + this.hasDescriptionChanged = this.settingsForm.controls.description.getRawValue() != this.level?.description; + this.doesPageHavePendingChanges(); + } + + setGame(input: GameVersion) { + this.curatorForm.controls.gameVersion.setValue(input); + this.hasGameChanged = input != this.level!.gameVersion; + this.doesPageHavePendingChanges(); + this.setGameButtonColor(input); + } + + setGameButtonColor(game: GameVersion) { + switch (game) { + case 0: this.gameButtonColor = "bg-dark-pink"; break; + case 1: this.gameButtonColor = "bg-dark-green"; break; + case 2: this.gameButtonColor = "bg-orange"; break; + case 3: this.gameButtonColor = "bg-purple"; break; + case 4: this.gameButtonColor = "bg-light-blue"; break; + case 6: this.gameButtonColor = "bg-blue"; break; + default: this.gameButtonColor = "bg-tertiary"; break; + } + } + + checkIsReuploadChanges() { + this.hasReuploadChanged = this.curatorForm.controls.isReupload.getRawValue() != this.level?.isReUpload; + this.doesPageHavePendingChanges(); + } + + checkOriginalPublisherChanges() { + this.hasOriginalPublisherChanged = this.curatorForm.controls.originalPublisher.getRawValue() != (this.level?.originalPublisher ?? ""); + this.doesPageHavePendingChanges(); + } + + checkTeamPickChanges() { + this.hasTeamPickedChanged = this.curatorForm.controls.isTeamPicked.getRawValue() != this.level?.teamPicked; + this.doesPageHavePendingChanges(); + } + + doesPageHavePendingChanges() { + this.hasPendingChangesTotal = + this.doesLevelHavePendingChanges() + || this.hasTeamPickedChanged; + } + + doesLevelHavePendingChanges(): boolean { + return this.hasPendingChangesLevel = + this.hasTitleChanged + || this.hasDescriptionChanged + || this.doesLevelHavePendingCuratorSettingChanges(); + } + + doesLevelHavePendingCuratorSettingChanges(): boolean { + return this.hasPendingCuratorChanges = + this.hasGameChanged + || this.hasReuploadChanged + || this.hasOriginalPublisherChanged; + } + + updateInputs(level: Level) { + this.hasPendingChangesTotal = false; + this.hasPendingChangesLevel = false; + this.hasPendingCuratorChanges = false; + + this.settingsForm.controls.title.setValue(level.title); + this.settingsForm.controls.description.setValue(level.description); + + this.curatorForm.controls.gameVersion.setValue(level.gameVersion); + this.curatorForm.controls.isTeamPicked.setValue(level.teamPicked); + this.curatorForm.controls.isReupload.setValue(level.isReUpload); + this.curatorForm.controls.originalPublisher.setValue(level.originalPublisher); + + this.setGameButtonColor(level.gameVersion); + + this.hasTitleChanged = false; + this.hasDescriptionChanged = false; + this.hasTeamPickedChanged = false; + this.hasReuploadChanged = false; + this.hasOriginalPublisherChanged = false; + this.hasGameChanged = false; + } + + async gameButtonClick() { + this.showGameMenu = !this.showGameMenu; + } + + uploadChanges() { + if (!this.hasPendingChangesTotal) return; + + if (this.hasTeamPickedChanged) this.updateTeamPick(); + if (this.hasPendingChangesLevel) this.updateLevel(); + this.hasPendingChangesTotal = false; + } + + updateTeamPick() { + if (this.curatorForm.controls.isTeamPicked.getRawValue()) { + this.client.teamPickLevel(this.level!.levelId).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.error("Failed to team pick level", apiError == null ? error.message : apiError.message); + }, + next: response => { + this.hasTeamPickedChanged = false; + this.level!.teamPicked = true; + this.level!.dateTeamPicked = new Date(); + this.banner.success("Level team picked!", "The level was successfully team picked."); + } + }); + } + else { + this.client.unTeamPickLevel(this.level!.levelId).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.error("Failed to remove team pick", apiError == null ? error.message : apiError.message); + }, + next: response => { + this.hasTeamPickedChanged = false; + this.level!.teamPicked = false; + this.banner.success("Team pick removed!", "The level is no longer team picked."); + } + }); + } + } + + updateLevel() { + let request: LevelUpdateRequest = { + title: this.settingsForm.controls.title.getRawValue(), + description: this.settingsForm.controls.description.getRawValue(), + + gameVersion: this.curatorForm.controls.gameVersion.getRawValue()!, + isReUpload: this.curatorForm.controls.isReupload.getRawValue(), + originalPublisher: this.curatorForm.controls.originalPublisher.getRawValue(), + }; + + // Only make an admin request if the curator has either changed any curator options on the level, + // or this is another user's level. This way they may still edit the metadata of their own level + // without having that be counted as moderation action. + this.client.updateLevelById(this.level!.levelId, request, this.hasPendingCuratorChanges || !this.isUserPublisher).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.error("Failed to update the level", apiError == null ? error.message : apiError.message); + }, + next: response => { + this.banner.success("Level updated!", "The level was successfully updated."); + + // Update level data + this.level = response; + this.updateInputs(response); + } + }); + } + + async uploadIcon($event: any) { + const file: File = $event.target.files[0]; + console.log(file); + + const data: ArrayBuffer = await file.arrayBuffer(); + const hash: string = await sha1Async(data); + + this.client.uploadAsset(hash, data).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.warn("Failed to upload new icon", apiError == null ? error.message : apiError.message); + }, + next: _ => { + this.updateLevelIcon(hash); + } + }); + } + + updateLevelIcon(hash: string) { + this.client.updateLevelIconById(this.level!.levelId, hash, !this.isUserPublisher).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.error("Failed to update the level icon", apiError == null ? error.message : apiError.message); + }, + next: response => { + this.banner.success("Level updated!", "The level icon was successfully updated."); + + // Update level data + this.level = response; + } + }); + } + + avatarErr(img: EventTarget | null): void { + if(!(img instanceof HTMLImageElement)) return; + img.srcset = "/assets/missingLevel.svg"; + } + + async deleteButtonClick() { + this.showDeletionPrompt = !this.showDeletionPrompt; + } + + async closeDeleteDialog() { + this.showDeletionPrompt = false; + } + + delete() { + if (this.level == undefined) return; + + this.client.deleteLevelById(this.level.levelId, !this.isUserPublisher).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.error("Failed to delete the level", apiError == null ? error.message : apiError.message); + }, + next: response => { + this.banner.success("Level deleted!", "The level was successfully deleted."); + + // Navigate (TODO: redirect to page before the details page, or atleast last viewed level category) + this.router.navigate(['/levels']); + } + }); + } + + protected readonly faFloppyDisk = faFloppyDisk; + protected readonly faPencil = faPencil; + protected readonly faTrash = faTrash; + protected readonly faUser = faUser; + protected readonly faCertificate = faCertificate; + protected readonly faClone = faClone; + protected readonly faChevronDown = faChevronDown; + protected readonly faChevronUp = faChevronUp; + protected readonly faSignOutAlt = faSignOutAlt; +} diff --git a/tailwind.config.js b/tailwind.config.js index 6f11369e..165fc15e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -21,9 +21,14 @@ const defaultColors = { "red": "#E52E2E", "green": "#52BC24", + "dark-green": "#4a9e27ff", + "light-blue": "#2d92e5ff", "blue": "#2D43E5", "yellow": "#F2AA00", + "orange": "#f8640eff", + "dark-pink": "#f145a4ff", "pink": "#ff68f4", + "purple": "#A13DE3", "rank-gold": "#FFD234", "rank-silver": "#f9f4f9",