From 0b9a48eed584898ffb6578fb59bfa2403dbf48c0 Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Wed, 8 Apr 2026 13:08:36 +0200
Subject: [PATCH 01/12] Slightly improve instance info page
---
.../instance-info.component.html | 174 ++++++++++--------
.../instance-info/instance-info.component.ts | 15 +-
2 files changed, 115 insertions(+), 74 deletions(-)
diff --git a/src/app/pages/instance-info/instance-info.component.html b/src/app/pages/instance-info/instance-info.component.html
index 464c9d2..021b624 100644
--- a/src/app/pages/instance-info/instance-info.component.html
+++ b/src/app/pages/instance-info/instance-info.component.html
@@ -1,84 +1,112 @@
-@if(instance != null) {
-
-
- {{ instance.instanceName }}
-
- {{instance.instanceDescription}}
-
+
- Server Information
- Software: {{instance.softwareName}} ({{instance.softwareType}})
- Version: v{{instance.softwareVersion}}
- License:
-
- {{ instance.softwareLicenseName }}
-
-
- Source repository:
-
- {{ instance.softwareSourceUrl }}
-
-
- Blocked assets for regular users: {{ blockedAssetFlags }}
- Blocked assets for trusted users: {{ blockedAssetFlagsForTrustedUsers }}
-
+
+
+
+
+ Instance
+
+
+
+
+
+
+
+
+ Statistics
-
- }
- @else {
- No Discord server invite
- }
-
- Email address:
-
- {{instance.contactInfo.emailAddress }}
-
-
-
-}
+
+
+
+
+
+
Website
Source repository:
{{ websiteRepoUrl }}
-
-
-
-@if(statistics != null) {
- Things!
- Registered users: {{statistics.totalUsers}}
- Published levels: {{statistics.totalLevels}}
- Modded levels: {{statistics.moddedLevels}}
- Uploaded photos: {{statistics.totalPhotos}}
- Events occurred: {{statistics.totalEvents}}
-
-
- Activity
- Active users: {{statistics.activeUsers}}
- People online now: {{statistics.currentIngamePlayersCount}}
- Active rooms: {{statistics.currentRoomCount}}
-
-
- Requests ({{statistics.requestStatistics.totalRequests}} in total)
- API requests: {{statistics.requestStatistics.apiRequests}}
- Game API requests: {{statistics.requestStatistics.gameRequests}}
-}
+
\ No newline at end of file
diff --git a/src/app/pages/instance-info/instance-info.component.ts b/src/app/pages/instance-info/instance-info.component.ts
index 7e36fb9..0af214e 100644
--- a/src/app/pages/instance-info/instance-info.component.ts
+++ b/src/app/pages/instance-info/instance-info.component.ts
@@ -9,19 +9,30 @@ import { NgOptimizedImage } from "@angular/common";
import { getWebsiteRepoUrl } from '../../helpers/data-fetching';
import { RouterLink } from "@angular/router";
import { blockedFlagsAsString } from '../../api/types/asset-config-flags';
+import { TwoPaneLayoutComponent } from "../../components/ui/layouts/two-pane-layout.component";
+import { ContainerComponent } from "../../components/ui/container.component";
+import { PaneTitleComponent } from "../../components/ui/text/pane-title.component";
+import { DividerComponent } from "../../components/ui/divider.component";
@Component({
selector: 'app-instance-info',
imports: [
PageTitleComponent,
NgOptimizedImage,
- RouterLink
+ RouterLink,
+ TwoPaneLayoutComponent,
+ ContainerComponent,
+ PaneTitleComponent,
+ DividerComponent
],
templateUrl: './instance-info.component.html'
})
export class InstanceInfoComponent {
protected instance: Instance | undefined;
+ protected instanceDownloadFailed: boolean = false;
+
protected statistics: Statistics | undefined;
+ protected statisticsDownloadFailed: boolean = false;
protected websiteRepoUrl: String = getWebsiteRepoUrl();
protected iconError: boolean = false;
@@ -32,6 +43,7 @@ export class InstanceInfoComponent {
constructor(private client: ClientService, protected banner: BannerService) {
client.getInstance().subscribe({
error: error => {
+ this.instanceDownloadFailed = true;
const apiError: RefreshApiError | undefined = error.error?.error;
this.banner.error("Failed to retrieve instance data", apiError == null ? error.message : apiError.message);
},
@@ -44,6 +56,7 @@ export class InstanceInfoComponent {
client.getStatistics(true).subscribe({
error: error => {
+ this.statisticsDownloadFailed = true;
const apiError: RefreshApiError | undefined = error.error?.error;
this.banner.error("Failed to retrieve instance statistics", apiError == null ? error.message : apiError.message);
},
From 841184924a736be0a5a4a66050c5f223a94d825c Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Wed, 8 Apr 2026 13:09:16 +0200
Subject: [PATCH 02/12] Ability to refresh and update cached instance
---
src/app/api/client.service.ts | 32 ++++++++++++++++++++++++++------
1 file changed, 26 insertions(+), 6 deletions(-)
diff --git a/src/app/api/client.service.ts b/src/app/api/client.service.ts
index f217728..363950c 100644
--- a/src/app/api/client.service.ts
+++ b/src/app/api/client.service.ts
@@ -23,6 +23,7 @@ import { AdminUserUpdateRequest } from './types/users/admin-user-update-request'
import { ExtendedUser } from './types/users/extended-user';
import { PunishUserRequest } from './types/moderation/punish-user-request';
import { PlanetInfo } from './types/users/planet-info';
+import { Announcement } from './types/announcement';
export const defaultPageSize: number = 40;
@@ -30,7 +31,7 @@ export const defaultPageSize: number = 40;
providedIn: 'root'
})
export class ClientService extends ApiImplementation {
- private readonly instance: LazySubject;
+ private instance: LazySubject;
private readonly levelCategories: LazySubject>;
private readonly userCategories: LazySubject>;
private statistics: LazySubject;
@@ -39,19 +40,30 @@ export class ClientService extends ApiImplementation {
constructor(http: HttpClient) {
super(http);
- this.instance = new LazySubject(() => this.http.get("/instance"));
- this.instance.tryLoad();
+
+ this.instance = this.getInstanceInternal();
+ this.statistics = this.getStatisticsInternal();
this.levelCategories = new LazySubject>(() => this.http.get>("/levels?includePreviews=true"));
this.userCategories = new LazySubject>(() => this.http.get>("/users?includePreviews=true"));
+ }
- this.statistics = this.getStatisticsInternal();
+ private getInstanceInternal() {
+ return this.instance = new LazySubject(() => this.http.get("/instance"));
}
- getInstance() {
+ getInstance(ignoreCache: boolean = false) {
+ if (ignoreCache) {
+ this.getInstanceInternal();
+ }
+
return this.instance.asObservable();
}
+ updateCachedInstance(instance: Instance) {
+ this.instance = new LazySubject(() => new BehaviorSubject(instance));
+ }
+
getLevelCategories() {
return this.levelCategories.asObservable();
}
@@ -62,7 +74,7 @@ export class ClientService extends ApiImplementation {
getStatistics(ignoreCache: boolean = false) {
if (ignoreCache) {
- this.statistics = this.getStatisticsInternal();
+ this.getStatisticsInternal();
}
return this.statistics.asObservable();
@@ -263,4 +275,12 @@ export class ClientService extends ApiImplementation {
deleteReviewsByUserByUuid(uuid: string) {
return this.http.delete(`/admin/users/uuid/${uuid}/reviews`);
}
+
+ postAnnouncement(announcement: Announcement) {
+ return this.http.post(`/admin/announcements`, announcement);
+ }
+
+ deleteAnnouncementByUuid(uuid: string) {
+ return this.http.delete(`/admin/announcements/${uuid}`);
+ }
}
From a03548ad48b3ab94c76359c32c1109dbdeaa2b32 Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Wed, 8 Apr 2026 13:10:53 +0200
Subject: [PATCH 03/12] Ability to delete announcements, other minor
improvements
---
.../items/announcement.component.ts | 49 +++++++++++++--
src/app/pages/landing/landing.component.html | 2 +-
src/app/pages/landing/landing.component.ts | 62 ++++++++++++-------
3 files changed, 83 insertions(+), 30 deletions(-)
diff --git a/src/app/components/items/announcement.component.ts b/src/app/components/items/announcement.component.ts
index 51e0076..ef8c65c 100644
--- a/src/app/components/items/announcement.component.ts
+++ b/src/app/components/items/announcement.component.ts
@@ -1,23 +1,60 @@
-import {Component, Input} from '@angular/core';
+import {Component, EventEmitter, Input, Output} from '@angular/core';
import {Announcement} from "../../api/types/announcement";
import {FaIconComponent} from "@fortawesome/angular-fontawesome";
-import {faBullhorn} from "@fortawesome/free-solid-svg-icons";
+import {faBullhorn, faTrash} from "@fortawesome/free-solid-svg-icons";
+import { ButtonComponent } from "../ui/form/button.component";
+import { ClientService } from '../../api/client.service';
+import { BannerService } from '../../banners/banner.service';
+import { RefreshApiError } from '../../api/refresh-api-error';
@Component({
selector: 'app-announcement',
imports: [
- FaIconComponent
+ FaIconComponent,
+ ButtonComponent
],
template: `
-
-
{{data.title}}
-
{{data.text}}
+
+
+
+ {{data.title}}
+
+
+ @if (showDeleteButton) {
+
+ }
+
+
+
{{data.text}}
`
})
export class AnnouncementComponent {
@Input({required: true}) data: Announcement = undefined!;
+ @Input() showDeleteButton: boolean = false;
+ @Output() deleted = new EventEmitter;
+
+ constructor(protected client: ClientService, protected banner: BannerService) {
+
+ }
+
+ delete() {
+ if (this.data.announcementId.length == 0) return;
+
+ this.client.deleteAnnouncementByUuid(this.data.announcementId).subscribe({
+ error: error => {
+ const apiError: RefreshApiError | undefined = error.error?.error;
+ this.banner.error("Announcement deletion failed", apiError == null ? error.message : apiError.message);
+ },
+ next: _ => {
+ this.banner.success("Announcement successfully deleted!", "");
+ this.deleted.emit();
+ }
+ });
+ }
+
protected readonly faBullhorn = faBullhorn;
+ protected readonly faTrash = faTrash;
}
diff --git a/src/app/pages/landing/landing.component.html b/src/app/pages/landing/landing.component.html
index e914258..a79698f 100644
--- a/src/app/pages/landing/landing.component.html
+++ b/src/app/pages/landing/landing.component.html
@@ -16,7 +16,7 @@ {{this.instance?.instanceName ?? 'Refresh'}}
@if (instance && instance.announcements.length > 0) {
@for (a of instance.announcements; track a.announcementId) {
-
+
}
}
diff --git a/src/app/pages/landing/landing.component.ts b/src/app/pages/landing/landing.component.ts
index ea91657..72acf5e 100644
--- a/src/app/pages/landing/landing.component.ts
+++ b/src/app/pages/landing/landing.component.ts
@@ -29,6 +29,9 @@ import {ActivityPage} from "../../api/types/activity/activity-page";
import {EventPageComponent} from "../../components/items/event-page.component";
import {repeat, Subscription} from "rxjs";
import {ContestBannerComponent} from "../../components/items/contest-banner.component";
+import { ExtendedUser } from '../../api/types/users/extended-user';
+import { AuthenticationService } from '../../api/authentication.service';
+import { UserRoles } from '../../api/types/users/user-roles';
@Component({
selector: 'app-landing',
@@ -56,29 +59,42 @@ export class LandingComponent implements OnDestroy {
private activitySubscription: Subscription | undefined;
private roomsSubscription: Subscription | undefined;
- constructor(private client: ClientService, @Inject(PLATFORM_ID) platformId: Object, changeDetector: ChangeDetectorRef) {
- client.getInstance().subscribe(data => this.instance = data);
-
- if(isPlatformBrowser(platformId)) {
- inject(NgZone).runOutsideAngular(() => {
- this.activitySubscription = this.fetchActivity()
- .pipe(repeat({delay: 5000}))
- .subscribe(data => {
- this.activity = data;
- changeDetector.detectChanges();
- });
-
- this.roomsSubscription = this.fetchRooms()
- .pipe(repeat({delay: 15000}))
- .subscribe(data => {
- this.rooms = data.data;
- changeDetector.detectChanges();
- });
- })
- }
-
- this.fetchActivity().subscribe(data => this.activity = data);
- this.fetchRooms().subscribe(data => this.rooms = data.data);
+ protected ownUser: ExtendedUser | null | undefined;
+ protected showAnnouncementDeleteButton: boolean = false;
+
+ constructor(private client: ClientService, @Inject(PLATFORM_ID) platformId: Object, changeDetector: ChangeDetectorRef, protected auth: AuthenticationService) {
+ client.getInstance().subscribe(data => this.instance = data);
+
+ if(isPlatformBrowser(platformId)) {
+ inject(NgZone).runOutsideAngular(() => {
+ this.activitySubscription = this.fetchActivity()
+ .pipe(repeat({delay: 5000}))
+ .subscribe(data => {
+ this.activity = data;
+ changeDetector.detectChanges();
+ });
+
+ this.roomsSubscription = this.fetchRooms()
+ .pipe(repeat({delay: 15000}))
+ .subscribe(data => {
+ this.rooms = data.data;
+ changeDetector.detectChanges();
+ });
+ })
+ }
+
+ this.fetchActivity().subscribe(data => this.activity = data);
+ this.fetchRooms().subscribe(data => this.rooms = data.data);
+
+ this.auth.user.subscribe(user => {
+ if (user) {
+ this.ownUser = user;
+
+ if (user.role >= UserRoles.Moderator) {
+ this.showAnnouncementDeleteButton = true;
+ }
+ }
+ });
}
ngOnDestroy(): void {
From 09dab114be5c0b30d9c83e05e26a9a8257e25fad Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Wed, 8 Apr 2026 13:11:38 +0200
Subject: [PATCH 04/12] Add a moderation panel page
---
src/app/app.routes.ts | 5 +
.../pages/mod-panel/mod-panel.component.html | 107 +++++++++++
.../pages/mod-panel/mod-panel.component.ts | 172 ++++++++++++++++++
3 files changed, 284 insertions(+)
create mode 100644 src/app/pages/mod-panel/mod-panel.component.html
create mode 100644 src/app/pages/mod-panel/mod-panel.component.ts
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index d0793d9..8c6fe26 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -120,6 +120,11 @@ export const routes: Routes = [
loadComponent: () => import('./pages/instance-info/instance-info.component').then(x => x.InstanceInfoComponent),
data: {title: "About Us"},
},
+ {
+ path: 'moderation',
+ loadComponent: () => import('./pages/mod-panel/mod-panel.component').then(x => x.ModPanelComponent),
+ data: {title: "Moderation Panel"},
+ },
...appendDebugRoutes(),
// KEEP THIS ROUTE LAST! It handles pages that do not exist.
{
diff --git a/src/app/pages/mod-panel/mod-panel.component.html b/src/app/pages/mod-panel/mod-panel.component.html
new file mode 100644
index 0000000..aad9316
--- /dev/null
+++ b/src/app/pages/mod-panel/mod-panel.component.html
@@ -0,0 +1,107 @@
+@if (ownUser) {
+
+ Hi, {{ ownUser.username }}! This is where you manage and view information about {{instance?.instanceName ?? 'this Refresh server'}}.
+
+
+
+
+
+
+ Instance
+
+
+
+
+ @if(instance != null) {
+
Software: {{instance.softwareName}} ({{instance.softwareType}})
+
Version: v{{instance.softwareVersion}}
+
+
Blocked assets for regular users: {{ blockedAssetFlags }}
+
Blocked assets for trusted users: {{ blockedAssetFlagsForTrustedUsers }}
+ }
+ @else if (instanceDownloadFailed) {
+
Failed to download instance metadata.
+ }
+ @else {
+
Downloading instance metadata...
+ }
+
+
+
+
+
+ Statistics
+
+
+
+
+
+
+
+
+
+ Announcements
+
+
+
+
Preview
+
+
+
Live Announcements
+ @if (instance == null && instanceDownloadFailed) {
+
Couldn't get announcements, because we couldn't get instance data.
+ }
+ @else if (instance == null && !instanceDownloadFailed) {
+
Loading instance data...
+ }
+ @else if (instance!.announcements.length <= 0) {
+
There are no current announcements.
+ }
+ @else {
+
+ @for (announcement of instance!.announcements; track announcement.announcementId) {
+
+ }
+
+ }
+
+
+
+ @if (this.grafanaSafeUrl != null) {
+
+ }
+
+
+
+
+ Other Actions
+ To manage users, levels, and other data, you can access administrative actions on their respective pages, usually on the page headers.
+}
\ No newline at end of file
diff --git a/src/app/pages/mod-panel/mod-panel.component.ts b/src/app/pages/mod-panel/mod-panel.component.ts
new file mode 100644
index 0000000..bb4189d
--- /dev/null
+++ b/src/app/pages/mod-panel/mod-panel.component.ts
@@ -0,0 +1,172 @@
+import { Component } from '@angular/core';
+import {ClientService} from "../../api/client.service";
+import { PageTitleComponent } from "../../components/ui/text/page-title.component";
+import { ExtendedUser } from '../../api/types/users/extended-user';
+import { AuthenticationService } from '../../api/authentication.service';
+import { UserRoles } from '../../api/types/users/user-roles';
+import { BannerService } from '../../banners/banner.service';
+import { Instance } from '../../api/types/instance';
+import { Statistics } from '../../api/types/statistics';
+import { RefreshApiError } from '../../api/refresh-api-error';
+import { blockedFlagsAsString } from '../../api/types/asset-config-flags';
+import { DividerComponent } from "../../components/ui/divider.component";
+import { FormControl, FormGroup } from '@angular/forms';
+import { AnnouncementComponent } from "../../components/items/announcement.component";
+import { Announcement } from '../../api/types/announcement';
+import { faBullhorn, faPaperPlane, faPencil } from '@fortawesome/free-solid-svg-icons';
+import { TextboxComponent } from "../../components/ui/form/textbox.component";
+import { TextAreaComponent } from "../../components/ui/form/textarea.component";
+import { ButtonComponent } from "../../components/ui/form/button.component";
+import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
+import { RouterLink } from "@angular/router";
+import { TwoPaneLayoutComponent } from "../../components/ui/layouts/two-pane-layout.component";
+import { ContainerComponent } from "../../components/ui/container.component";
+import { PaneTitleComponent } from "../../components/ui/text/pane-title.component";
+
+@Component({
+ selector: 'app-mod-panel',
+ imports: [
+ PageTitleComponent,
+ DividerComponent,
+ AnnouncementComponent,
+ TextboxComponent,
+ TextAreaComponent,
+ ButtonComponent,
+ RouterLink,
+ TwoPaneLayoutComponent,
+ ContainerComponent,
+ PaneTitleComponent
+],
+ templateUrl: './mod-panel.component.html'
+})
+export class ModPanelComponent {
+ protected ownUser: ExtendedUser | null | undefined;
+
+ protected instance: Instance | undefined;
+ protected instanceDownloadFailed: boolean = false;
+ protected blockedAssetFlags: String = "None";
+ protected blockedAssetFlagsForTrustedUsers: String = "None";
+
+ protected statistics: Statistics | undefined;
+ protected statisticsDownloadFailed: boolean = false;
+
+ protected previewAnnouncement: Announcement = {
+ announcementId: "",
+ title: "",
+ text: "",
+ };
+ protected announcementForm = new FormGroup({
+ title: new FormControl(),
+ text: new FormControl(),
+ });
+ protected isAnnouncementComplete: boolean = false;
+
+ protected grafanaSafeUrl: SafeUrl | undefined;
+
+ constructor(private auth: AuthenticationService, private client: ClientService, protected banner: BannerService, private sanitizer: DomSanitizer) {
+ this.auth.user.subscribe(user => {
+ if (user) {
+ if (user.role < UserRoles.Moderator) {
+ this.banner.error("You are not a moderator", "Get out!");
+ }
+ else {
+ this.ownUser = user;
+
+ client.getInstance().subscribe({
+ error: error => {
+ this.instanceDownloadFailed = true;
+ const apiError: RefreshApiError | undefined = error.error?.error;
+ this.banner.error("Failed to retrieve instance data", apiError == null ? error.message : apiError.message);
+ },
+ next: response => {
+ this.instance = response;
+ this.blockedAssetFlags = blockedFlagsAsString(response.blockedAssetFlags);
+ this.blockedAssetFlagsForTrustedUsers = blockedFlagsAsString(response.blockedAssetFlagsForTrustedUsers);
+
+ // initialize sanitized URL
+ let originalUrl = this.instance?.grafanaDashboardUrl;
+ if (originalUrl == null || originalUrl.length <= 0) {
+ originalUrl = "";
+ }
+
+ this.grafanaSafeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(originalUrl);
+ console.log("safe grafana url: " + this.grafanaSafeUrl);
+ }
+ });
+
+ client.getStatistics().subscribe({
+ error: error => {
+ this.statisticsDownloadFailed = true;
+ const apiError: RefreshApiError | undefined = error.error?.error;
+ this.banner.error("Failed to retrieve instance statistics", apiError == null ? error.message : apiError.message);
+ },
+ next: response => {
+ this.statistics = response;
+ }
+ });
+ }
+ }
+ });
+ }
+
+ protected checkAnnouncementTitleChanges() {
+ let currentTitle: string = this.announcementForm.controls.title.getRawValue();
+ this.previewAnnouncement.title = currentTitle;
+ this.checkAnnouncementCompleteness();
+ }
+
+ protected checkAnnouncementTextChanges() {
+ let currentText: string = this.announcementForm.controls.text.getRawValue();
+ this.previewAnnouncement.text = currentText;
+ this.checkAnnouncementCompleteness();
+ }
+
+ protected checkAnnouncementCompleteness() {
+ this.isAnnouncementComplete = this.previewAnnouncement.title.length > 0 && this.previewAnnouncement.text.length > 0;
+ }
+
+ protected resetAnnouncementInputs() {
+ this.isAnnouncementComplete = false;
+ this.announcementForm.controls.title.setValue("");
+ this.announcementForm.controls.text.setValue("");
+
+ this.previewAnnouncement = {
+ announcementId: "",
+ title: "",
+ text: "",
+ };
+ }
+
+ protected postAnnouncement() {
+ if (this.instance == null || !this.isAnnouncementComplete) return;
+
+ this.client.postAnnouncement(this.previewAnnouncement).subscribe({
+ error: error => {
+ const apiError: RefreshApiError | undefined = error.error?.error;
+ this.banner.error("Failed to post the announcement", apiError == null ? error.message : apiError.message);
+ },
+ next: response => {
+ this.resetAnnouncementInputs();
+
+ // currently the server doesn't sort the announcements, so this is fine
+ this.instance!.announcements.push(response);
+ }
+ });
+ }
+
+ protected removeAnnouncement(uuid: string) {
+ if (this.instance == null) return;
+
+ let newList: Announcement[] = [];
+ for (let announcement of this.instance.announcements) {
+ if (announcement.announcementId !== uuid) newList.push(announcement);
+ }
+
+ this.instance.announcements = newList;
+ this.client.updateCachedInstance(this.instance);
+ }
+
+ protected readonly faBullhorn = faBullhorn;
+ protected readonly faPencil = faPencil;
+ protected readonly faPaperPlane = faPaperPlane;
+}
From ef4791dc23ce376b1982cd4bf4ec7928e62508f4 Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Wed, 8 Apr 2026 13:41:56 +0200
Subject: [PATCH 05/12] Add mod panel as nav item
---
.../ui/header/header-me-menu.component.ts | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/src/app/components/ui/header/header-me-menu.component.ts b/src/app/components/ui/header/header-me-menu.component.ts
index f8f5338..b085de1 100644
--- a/src/app/components/ui/header/header-me-menu.component.ts
+++ b/src/app/components/ui/header/header-me-menu.component.ts
@@ -8,6 +8,7 @@ import {UserStatisticsComponent} from "../../items/user-statistics.component";
import {DividerComponent} from "../divider.component";
import {NavItem} from "./navtypes";
+import { UserRoles } from '../../../api/types/users/user-roles';
@Component({
selector: 'app-header-me-menu',
@@ -37,6 +38,14 @@ import {NavItem} from "./navtypes";
@for (item of topItems; track item.route) {
}
+
+ @if (isModerator) {
+
+ @for (item of specialItems; track item.route) {
+
+ }
+ }
+
@for (item of bottomItems; track item.route) {
@@ -46,6 +55,13 @@ import {NavItem} from "./navtypes";
})
export class HeaderMeMenuComponent {
@Input({required: true}) user: User = undefined!;
+ isModerator: boolean = false;
+
+ ngOnInit() {
+ if (this.user.role >= UserRoles.Moderator) {
+ this.isModerator = true;
+ }
+ }
protected topItems: NavItem[] = [
{
@@ -64,6 +80,13 @@ export class HeaderMeMenuComponent {
route: '/settings/profile'
},
];
+ protected specialItems: NavItem[] = [
+ {
+ name: 'Mod Panel',
+ icon: 'tools',
+ route: '/moderation'
+ },
+ ];
protected bottomItems: NavItem[] = [
{
name: 'Log out',
From 065684dc63b9595e5fcaed6f3c22a84b11f6a947 Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Wed, 8 Apr 2026 13:49:27 +0200
Subject: [PATCH 06/12] Properly handle empty/null grafana URL, ability to
discard announcement
---
src/app/pages/mod-panel/mod-panel.component.html | 6 +++++-
src/app/pages/mod-panel/mod-panel.component.ts | 15 ++++++++-------
2 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/src/app/pages/mod-panel/mod-panel.component.html b/src/app/pages/mod-panel/mod-panel.component.html
index aad9316..18f2dbe 100644
--- a/src/app/pages/mod-panel/mod-panel.component.html
+++ b/src/app/pages/mod-panel/mod-panel.component.html
@@ -65,7 +65,11 @@
-
+
+
diff --git a/src/app/pages/mod-panel/mod-panel.component.ts b/src/app/pages/mod-panel/mod-panel.component.ts
index bb4189d..23c48ff 100644
--- a/src/app/pages/mod-panel/mod-panel.component.ts
+++ b/src/app/pages/mod-panel/mod-panel.component.ts
@@ -13,7 +13,7 @@ import { DividerComponent } from "../../components/ui/divider.component";
import { FormControl, FormGroup } from '@angular/forms';
import { AnnouncementComponent } from "../../components/items/announcement.component";
import { Announcement } from '../../api/types/announcement';
-import { faBullhorn, faPaperPlane, faPencil } from '@fortawesome/free-solid-svg-icons';
+import { faBullhorn, faPaperPlane, faPencil, faTrash } from '@fortawesome/free-solid-svg-icons';
import { TextboxComponent } from "../../components/ui/form/textbox.component";
import { TextAreaComponent } from "../../components/ui/form/textarea.component";
import { ButtonComponent } from "../../components/ui/form/button.component";
@@ -85,12 +85,9 @@ export class ModPanelComponent {
// initialize sanitized URL
let originalUrl = this.instance?.grafanaDashboardUrl;
- if (originalUrl == null || originalUrl.length <= 0) {
- originalUrl = "";
+ if (originalUrl != null && originalUrl.length > 0) {
+ this.grafanaSafeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(originalUrl);
}
-
- this.grafanaSafeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(originalUrl);
- console.log("safe grafana url: " + this.grafanaSafeUrl);
}
});
@@ -148,8 +145,11 @@ export class ModPanelComponent {
next: response => {
this.resetAnnouncementInputs();
- // currently the server doesn't sort the announcements, so this is fine
+ // currently the server doesn't sort the announcements, so this is fine.
+ // also, TODO: implement and use a separate endpoint for retreiving announcement lists,
+ // because the instance responses usually have a cache-control header
this.instance!.announcements.push(response);
+ this.client.updateCachedInstance(this.instance!);
}
});
}
@@ -169,4 +169,5 @@ export class ModPanelComponent {
protected readonly faBullhorn = faBullhorn;
protected readonly faPencil = faPencil;
protected readonly faPaperPlane = faPaperPlane;
+ protected readonly faTrash = faTrash;
}
From 87aff0ba3236c38545703244115a827d3f6841eb Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Wed, 8 Apr 2026 14:25:30 +0200
Subject: [PATCH 07/12] Properly wrap announcement creation parts
---
src/app/pages/mod-panel/mod-panel.component.html | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/app/pages/mod-panel/mod-panel.component.html b/src/app/pages/mod-panel/mod-panel.component.html
index 18f2dbe..cde7a03 100644
--- a/src/app/pages/mod-panel/mod-panel.component.html
+++ b/src/app/pages/mod-panel/mod-panel.component.html
@@ -58,10 +58,11 @@
Announcements
-
}
-
Other Actions
To manage users, levels, and other data, you can access administrative actions on their respective pages, usually on the page headers.
+
+ @defer (when showAnnouncementPostDialog) { @if (showAnnouncementPostDialog) {
+
+
+
+
+ }}
}
\ No newline at end of file
diff --git a/src/app/pages/mod-panel/mod-panel.component.ts b/src/app/pages/mod-panel/mod-panel.component.ts
index 23c48ff..d28bcff 100644
--- a/src/app/pages/mod-panel/mod-panel.component.ts
+++ b/src/app/pages/mod-panel/mod-panel.component.ts
@@ -13,7 +13,7 @@ import { DividerComponent } from "../../components/ui/divider.component";
import { FormControl, FormGroup } from '@angular/forms';
import { AnnouncementComponent } from "../../components/items/announcement.component";
import { Announcement } from '../../api/types/announcement';
-import { faBullhorn, faPaperPlane, faPencil, faTrash } from '@fortawesome/free-solid-svg-icons';
+import { faBullhorn, faPaperPlane, faPencil, faSignOutAlt, faTrash } from '@fortawesome/free-solid-svg-icons';
import { TextboxComponent } from "../../components/ui/form/textbox.component";
import { TextAreaComponent } from "../../components/ui/form/textarea.component";
import { ButtonComponent } from "../../components/ui/form/button.component";
@@ -22,6 +22,7 @@ import { RouterLink } from "@angular/router";
import { TwoPaneLayoutComponent } from "../../components/ui/layouts/two-pane-layout.component";
import { ContainerComponent } from "../../components/ui/container.component";
import { PaneTitleComponent } from "../../components/ui/text/pane-title.component";
+import { ConfirmationDialogComponent } from "../../components/ui/confirmation-dialog.component";
@Component({
selector: 'app-mod-panel',
@@ -35,7 +36,8 @@ import { PaneTitleComponent } from "../../components/ui/text/pane-title.componen
RouterLink,
TwoPaneLayoutComponent,
ContainerComponent,
- PaneTitleComponent
+ PaneTitleComponent,
+ ConfirmationDialogComponent
],
templateUrl: './mod-panel.component.html'
})
@@ -60,6 +62,7 @@ export class ModPanelComponent {
text: new FormControl(),
});
protected isAnnouncementComplete: boolean = false;
+ protected showAnnouncementPostDialog: boolean = false;
protected grafanaSafeUrl: SafeUrl | undefined;
@@ -134,9 +137,14 @@ export class ModPanelComponent {
};
}
+ protected toggleAnnouncementPostDialog(visibility: boolean) {
+ this.showAnnouncementPostDialog = visibility;
+ }
+
protected postAnnouncement() {
if (this.instance == null || !this.isAnnouncementComplete) return;
-
+ this.toggleAnnouncementPostDialog(false);
+
this.client.postAnnouncement(this.previewAnnouncement).subscribe({
error: error => {
const apiError: RefreshApiError | undefined = error.error?.error;
@@ -170,4 +178,5 @@ export class ModPanelComponent {
protected readonly faPencil = faPencil;
protected readonly faPaperPlane = faPaperPlane;
protected readonly faTrash = faTrash;
+ protected readonly faSignOutAlt = faSignOutAlt;
}
From c4f744715ce98f68bed7b2f941bfcb14bb038e2c Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Wed, 8 Apr 2026 16:10:50 +0200
Subject: [PATCH 09/12] Revert ability to update cached instance data
---
src/app/api/client.service.ts | 23 +++++--------------
.../pages/mod-panel/mod-panel.component.ts | 9 +++-----
2 files changed, 9 insertions(+), 23 deletions(-)
diff --git a/src/app/api/client.service.ts b/src/app/api/client.service.ts
index 363950c..2a64df2 100644
--- a/src/app/api/client.service.ts
+++ b/src/app/api/client.service.ts
@@ -31,7 +31,7 @@ export const defaultPageSize: number = 40;
providedIn: 'root'
})
export class ClientService extends ApiImplementation {
- private instance: LazySubject;
+ private readonly instance: LazySubject;
private readonly levelCategories: LazySubject>;
private readonly userCategories: LazySubject>;
private statistics: LazySubject;
@@ -40,30 +40,19 @@ export class ClientService extends ApiImplementation {
constructor(http: HttpClient) {
super(http);
-
- this.instance = this.getInstanceInternal();
- this.statistics = this.getStatisticsInternal();
+ this.instance = new LazySubject(() => this.http.get("/instance"));
+ this.instance.tryLoad();
this.levelCategories = new LazySubject>(() => this.http.get>("/levels?includePreviews=true"));
this.userCategories = new LazySubject>(() => this.http.get>("/users?includePreviews=true"));
- }
- private getInstanceInternal() {
- return this.instance = new LazySubject(() => this.http.get("/instance"));
+ this.statistics = this.getStatisticsInternal();
}
- getInstance(ignoreCache: boolean = false) {
- if (ignoreCache) {
- this.getInstanceInternal();
- }
-
+ getInstance() {
return this.instance.asObservable();
}
- updateCachedInstance(instance: Instance) {
- this.instance = new LazySubject(() => new BehaviorSubject(instance));
- }
-
getLevelCategories() {
return this.levelCategories.asObservable();
}
@@ -74,7 +63,7 @@ export class ClientService extends ApiImplementation {
getStatistics(ignoreCache: boolean = false) {
if (ignoreCache) {
- this.getStatisticsInternal();
+ this.statistics = this.getStatisticsInternal();
}
return this.statistics.asObservable();
diff --git a/src/app/pages/mod-panel/mod-panel.component.ts b/src/app/pages/mod-panel/mod-panel.component.ts
index d28bcff..6fe0f25 100644
--- a/src/app/pages/mod-panel/mod-panel.component.ts
+++ b/src/app/pages/mod-panel/mod-panel.component.ts
@@ -152,12 +152,7 @@ export class ModPanelComponent {
},
next: response => {
this.resetAnnouncementInputs();
-
- // currently the server doesn't sort the announcements, so this is fine.
- // also, TODO: implement and use a separate endpoint for retreiving announcement lists,
- // because the instance responses usually have a cache-control header
this.instance!.announcements.push(response);
- this.client.updateCachedInstance(this.instance!);
}
});
}
@@ -171,7 +166,9 @@ export class ModPanelComponent {
}
this.instance.announcements = newList;
- this.client.updateCachedInstance(this.instance);
+ // Even if we update the instance cached by ClientService, the instance responses usually have a cache control header
+ // set to 1 hour, so simply refreshing the website will get us the outdated instance response again.
+ // Therefore, TODO: implement and use a separate endpoint for retreiving announcement lists.
}
protected readonly faBullhorn = faBullhorn;
From 7b3ace794c21e4ee6ad7893d44200ff2df57f51d Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Sun, 12 Apr 2026 20:54:39 +0200
Subject: [PATCH 10/12] Only use /announcements to get announcements
---
src/app/api/client.service.ts | 4 +++
src/app/api/types/instance.ts | 1 -
src/app/pages/landing/landing.component.html | 6 ++--
src/app/pages/landing/landing.component.ts | 14 +++++++++
.../pages/mod-panel/mod-panel.component.html | 23 +++++++++-----
.../pages/mod-panel/mod-panel.component.ts | 30 ++++++++++++++-----
6 files changed, 58 insertions(+), 20 deletions(-)
diff --git a/src/app/api/client.service.ts b/src/app/api/client.service.ts
index 2a64df2..8293963 100644
--- a/src/app/api/client.service.ts
+++ b/src/app/api/client.service.ts
@@ -265,6 +265,10 @@ export class ClientService extends ApiImplementation {
return this.http.delete(`/admin/users/uuid/${uuid}/reviews`);
}
+ getAllAnnouncements() {
+ return this.http.get(`/announcements`);
+ }
+
postAnnouncement(announcement: Announcement) {
return this.http.post(`/admin/announcements`, announcement);
}
diff --git a/src/app/api/types/instance.ts b/src/app/api/types/instance.ts
index a94a11a..450f0c4 100644
--- a/src/app/api/types/instance.ts
+++ b/src/app/api/types/instance.ts
@@ -19,7 +19,6 @@ export interface Instance {
blockedAssetFlags: AssetConfigFlags;
blockedAssetFlagsForTrustedUsers: AssetConfigFlags;
- announcements: Announcement[];
maintenanceModeEnabled: boolean;
grafanaDashboardUrl: string | null;
diff --git a/src/app/pages/landing/landing.component.html b/src/app/pages/landing/landing.component.html
index a79698f..5148070 100644
--- a/src/app/pages/landing/landing.component.html
+++ b/src/app/pages/landing/landing.component.html
@@ -13,10 +13,10 @@ {{this.instance?.instanceName ?? 'Refresh'}}
{{this.instance?.instanceDescription ?? 'Loading...'}}
-@if (instance && instance.announcements.length > 0) {
+@if (announcements && announcements.length > 0) {
- @for (a of instance.announcements; track a.announcementId) {
-
+ @for (a of announcements; track a.announcementId) {
+
}
}
diff --git a/src/app/pages/landing/landing.component.ts b/src/app/pages/landing/landing.component.ts
index 72acf5e..71005a5 100644
--- a/src/app/pages/landing/landing.component.ts
+++ b/src/app/pages/landing/landing.component.ts
@@ -32,6 +32,8 @@ import {ContestBannerComponent} from "../../components/items/contest-banner.comp
import { ExtendedUser } from '../../api/types/users/extended-user';
import { AuthenticationService } from '../../api/authentication.service';
import { UserRoles } from '../../api/types/users/user-roles';
+import { Announcement } from '../../api/types/announcement';
+import { RefreshApiError } from '../../api/refresh-api-error';
@Component({
selector: 'app-landing',
@@ -55,6 +57,7 @@ export class LandingComponent implements OnDestroy {
protected instance: Instance | undefined;
protected rooms: Room[] | undefined;
protected activity: ActivityPage | undefined;
+ protected announcements: Announcement[] | undefined;
private activitySubscription: Subscription | undefined;
private roomsSubscription: Subscription | undefined;
@@ -95,6 +98,17 @@ export class LandingComponent implements OnDestroy {
}
}
});
+
+ client.getAllAnnouncements().subscribe({
+ error: error => {
+ // don't log, just print to console, as this isn't that important
+ const apiError: RefreshApiError | undefined = error.error?.error;
+ console.warn("Failed to retrieve announcements: " + (apiError == null ? error.message : apiError.message));
+ },
+ next: response => {
+ this.announcements = response;
+ }
+ });
}
ngOnDestroy(): void {
diff --git a/src/app/pages/mod-panel/mod-panel.component.html b/src/app/pages/mod-panel/mod-panel.component.html
index 0667e0e..476ec53 100644
--- a/src/app/pages/mod-panel/mod-panel.component.html
+++ b/src/app/pages/mod-panel/mod-panel.component.html
@@ -78,18 +78,25 @@
Live Announcements
- @if (instance == null && instanceDownloadFailed) {
- Couldn't get announcements, because we couldn't get instance data.
- }
- @else if (instance == null && !instanceDownloadFailed) {
- Loading instance data...
+ @if (announcements == null) {
+ @if (announcementsDownloadFailed) {
+ Failed to download announcements.
+ }
+ @else {
+ Loading instance data...
+ }
}
- @else if (instance!.announcements.length <= 0) {
- There are no current announcements.
+ @else if (announcements.length <= 0) {
+ @if (announcementsDownloadFailed) {
+ There might be current announcements, reload the page to download them.
+ }
+ @else {
+ There are no current announcements.
+ }
}
@else {
- @for (announcement of instance!.announcements; track announcement.announcementId) {
+ @for (announcement of announcements; track announcement.announcementId) {
}
diff --git a/src/app/pages/mod-panel/mod-panel.component.ts b/src/app/pages/mod-panel/mod-panel.component.ts
index 6fe0f25..073de7e 100644
--- a/src/app/pages/mod-panel/mod-panel.component.ts
+++ b/src/app/pages/mod-panel/mod-panel.component.ts
@@ -46,6 +46,8 @@ export class ModPanelComponent {
protected instance: Instance | undefined;
protected instanceDownloadFailed: boolean = false;
+ protected announcements: Announcement[] | undefined;
+ protected announcementsDownloadFailed: boolean = false;
protected blockedAssetFlags: String = "None";
protected blockedAssetFlagsForTrustedUsers: String = "None";
@@ -104,6 +106,17 @@ export class ModPanelComponent {
this.statistics = response;
}
});
+
+ client.getAllAnnouncements().subscribe({
+ error: error => {
+ this.announcementsDownloadFailed = true;
+ const apiError: RefreshApiError | undefined = error.error?.error;
+ this.banner.error("Failed to retrieve announcements", apiError == null ? error.message : apiError.message);
+ },
+ next: response => {
+ this.announcements = response;
+ }
+ });
}
}
});
@@ -142,7 +155,7 @@ export class ModPanelComponent {
}
protected postAnnouncement() {
- if (this.instance == null || !this.isAnnouncementComplete) return;
+ if (!this.isAnnouncementComplete) return;
this.toggleAnnouncementPostDialog(false);
this.client.postAnnouncement(this.previewAnnouncement).subscribe({
@@ -152,23 +165,24 @@ export class ModPanelComponent {
},
next: response => {
this.resetAnnouncementInputs();
- this.instance!.announcements.push(response);
+ let newList: Announcement[] = [];
+ newList.push(response);
+
+ if (!this.announcements) this.announcements = newList
+ else this.announcements = newList.concat(this.announcements);
}
});
}
protected removeAnnouncement(uuid: string) {
- if (this.instance == null) return;
+ if (this.announcements == null) return;
let newList: Announcement[] = [];
- for (let announcement of this.instance.announcements) {
+ for (let announcement of this.announcements) {
if (announcement.announcementId !== uuid) newList.push(announcement);
}
- this.instance.announcements = newList;
- // Even if we update the instance cached by ClientService, the instance responses usually have a cache control header
- // set to 1 hour, so simply refreshing the website will get us the outdated instance response again.
- // Therefore, TODO: implement and use a separate endpoint for retreiving announcement lists.
+ this.announcements = newList;
}
protected readonly faBullhorn = faBullhorn;
From 0a984adeae31ac2ad5b394d901ccde14aea4aff2 Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Mon, 13 Apr 2026 12:48:13 +0200
Subject: [PATCH 11/12] Slightly darker yellow
---
tailwind.config.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tailwind.config.js b/tailwind.config.js
index f84760a..1615dab 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -26,7 +26,7 @@ const defaultColors = {
"dark-green": "#4a9e27ff",
"light-blue": "#2d92e5ff",
"blue": "#2D43E5",
- "yellow": "#F2AA00",
+ "yellow": "#DB9901",
"orange": "#f8640eff",
"dark-pink": "#f145a4ff",
"pink": "#ff68f4",
From e966efbdac8bf5c7146dfc06d87c50697ee10cf0 Mon Sep 17 00:00:00 2001
From: Toaster2
Date: Mon, 13 Apr 2026 12:56:05 +0200
Subject: [PATCH 12/12] Show announcement creation date
---
src/app/api/types/announcement.ts | 1 +
src/app/components/items/announcement.component.ts | 13 ++++++++++++-
src/app/pages/mod-panel/mod-panel.component.ts | 2 ++
3 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/src/app/api/types/announcement.ts b/src/app/api/types/announcement.ts
index 4fb55da..b52d42e 100644
--- a/src/app/api/types/announcement.ts
+++ b/src/app/api/types/announcement.ts
@@ -2,4 +2,5 @@ export interface Announcement {
announcementId: string;
title: string;
text: string;
+ createdAt: Date | undefined;
}
diff --git a/src/app/components/items/announcement.component.ts b/src/app/components/items/announcement.component.ts
index 257048e..689830a 100644
--- a/src/app/components/items/announcement.component.ts
+++ b/src/app/components/items/announcement.component.ts
@@ -8,13 +8,15 @@ import { ClientService } from '../../api/client.service';
import { BannerService } from '../../banners/banner.service';
import { RefreshApiError } from '../../api/refresh-api-error';
import { ConfirmationDialogComponent } from "../ui/confirmation-dialog.component";
+import { DateComponent } from "../ui/info/date.component";
@Component({
selector: 'app-announcement',
imports: [
FaIconComponent,
ButtonComponent,
- ConfirmationDialogComponent
+ ConfirmationDialogComponent,
+ DateComponent
],
template: `
@@ -30,6 +32,15 @@ import { ConfirmationDialogComponent } from "../ui/confirmation-dialog.component
{{data.text}}
+
+ @if (data.createdAt != null) {
+
+ }
@defer (when showDeletionDialog) { @if (showDeletionDialog) {
diff --git a/src/app/pages/mod-panel/mod-panel.component.ts b/src/app/pages/mod-panel/mod-panel.component.ts
index 073de7e..a456d00 100644
--- a/src/app/pages/mod-panel/mod-panel.component.ts
+++ b/src/app/pages/mod-panel/mod-panel.component.ts
@@ -58,6 +58,7 @@ export class ModPanelComponent {
announcementId: "",
title: "",
text: "",
+ createdAt: undefined,
};
protected announcementForm = new FormGroup({
title: new FormControl(),
@@ -147,6 +148,7 @@ export class ModPanelComponent {
announcementId: "",
title: "",
text: "",
+ createdAt: undefined,
};
}