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) { -

- Server icon - {{ 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 + + + +
+ @if(instance != null) { +

+ Server icon + {{ 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 }}

+
+

Contact Us

+

Owner: {{ instance.contactInfo.adminName }}

-

Contact Us

-

Owner: {{ instance.contactInfo.adminName }}

+ @if (instance.contactInfo.adminDiscordUsername != null) { +

Owner Discord username: {{ instance.contactInfo.adminDiscordUsername }}

+ } + @else { +

No Discord username of the owner

+ } + + @if (instance.contactInfo.discordServerInvite != null) { +

Discord server invite: + + {{ instance.contactInfo.discordServerInvite }} + +

+ } + @else { +

No Discord server invite

+ } - @if (instance.contactInfo.adminDiscordUsername != null) { -

Owner Discord username: {{ instance.contactInfo.adminDiscordUsername }}

- } - @else { -

No Discord username of the owner

- } - - @if (instance.contactInfo.discordServerInvite != null) { -

Discord server invite: - - {{ instance.contactInfo.discordServerInvite }} +

Email address: + + {{instance.contactInfo.emailAddress }} + +

+
+ } + @else if (statisticsDownloadFailed) { +

Failed to download instance metadata.

+ } + @else { +

Downloading instance metadata...

+ } +
+
+ + + + Statistics -

- } - @else { -

No Discord server invite

- } - -

Email address: - - {{instance.contactInfo.emailAddress }} - -

-
-} +
+ +
+ @if(statistics != null) { +

Registered users: {{statistics.totalUsers}}

+

Published levels: {{statistics.totalLevels}}

+

Modded levels: {{statistics.moddedLevels}}

+

Uploaded photos: {{statistics.totalPhotos}}

+

Events occurred: {{statistics.totalEvents}}

+
+

Active users: {{statistics.activeUsers}}

+

People online now: {{statistics.currentIngamePlayersCount}}

+

Active rooms: {{statistics.currentRoomCount}}

+
+

API requests: {{statistics.requestStatistics.apiRequests}}

+

Game API requests: {{statistics.requestStatistics.gameRequests}}

+ } + @else if (statisticsDownloadFailed) { +

Failed to download instance statistics.

+ } + @else { +

Downloading instance statistics...

+ } +
+
+
+

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 + + + +
+ @if(statistics != null) { +

Registered users: {{statistics.totalUsers}}

+

Published levels: {{statistics.totalLevels}}

+

Uploaded photos: {{statistics.totalPhotos}}

+

Events occurred: {{statistics.totalEvents}}

+
+

Active users: {{statistics.activeUsers}}

+

People online now: {{statistics.currentIngamePlayersCount}}

+

Active rooms: {{statistics.currentRoomCount}}

+ } + @else if (statisticsDownloadFailed) { +

Failed to download instance statistics.

+ } + @else { +

Downloading instance statistics...

+ } +
+
+
+ + + +

Announcements

+
+
+

Create

+
+ + + + +
+
+
+

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

-
-
-

Create

-
+ +
+
+

Create

+ @@ -69,10 +70,10 @@
-
-
+ +

Preview

From 549da2bf98160c31f6113d12724e78ed744dbcc6 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Wed, 8 Apr 2026 15:55:04 +0200 Subject: [PATCH 08/12] Confirmation dialogs for posting and deleting announcements --- .../items/announcement.component.ts | 27 +++++++++++++++---- .../pages/mod-panel/mod-panel.component.html | 10 +++++-- .../pages/mod-panel/mod-panel.component.ts | 15 ++++++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/app/components/items/announcement.component.ts b/src/app/components/items/announcement.component.ts index ef8c65c..257048e 100644 --- a/src/app/components/items/announcement.component.ts +++ b/src/app/components/items/announcement.component.ts @@ -2,17 +2,19 @@ import {Component, EventEmitter, Input, Output} from '@angular/core'; import {Announcement} from "../../api/types/announcement"; import {FaIconComponent} from "@fortawesome/angular-fontawesome"; -import {faBullhorn, faTrash} from "@fortawesome/free-solid-svg-icons"; +import {faBullhorn, faSignOutAlt, 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'; +import { ConfirmationDialogComponent } from "../ui/confirmation-dialog.component"; @Component({ selector: 'app-announcement', imports: [ FaIconComponent, - ButtonComponent + ButtonComponent, + ConfirmationDialogComponent ], template: `
@@ -23,12 +25,19 @@ import { RefreshApiError } from '../../api/refresh-api-error';
@if (showDeleteButton) { - + }

{{data.text}}

+ + @defer (when showDeletionDialog) { @if (showDeletionDialog) { + + + + + }} ` }) export class AnnouncementComponent { @@ -36,12 +45,19 @@ export class AnnouncementComponent { @Input() showDeleteButton: boolean = false; @Output() deleted = new EventEmitter; + protected showDeletionDialog: boolean = false; + constructor(protected client: ClientService, protected banner: BannerService) { } - delete() { - if (this.data.announcementId.length == 0) return; + protected toggleDeletionDialog(visibility: boolean) { + this.showDeletionDialog = visibility; + } + + protected delete() { + if (this.data.announcementId.length == 0) return; // fake announcement which doesn't exist on the server + this.toggleDeletionDialog(false); this.client.deleteAnnouncementByUuid(this.data.announcementId).subscribe({ error: error => { @@ -57,4 +73,5 @@ export class AnnouncementComponent { protected readonly faBullhorn = faBullhorn; protected readonly faTrash = faTrash; + protected readonly faSignOutAlt = faSignOutAlt; } diff --git a/src/app/pages/mod-panel/mod-panel.component.html b/src/app/pages/mod-panel/mod-panel.component.html index cde7a03..0667e0e 100644 --- a/src/app/pages/mod-panel/mod-panel.component.html +++ b/src/app/pages/mod-panel/mod-panel.component.html @@ -68,7 +68,7 @@
- +
@@ -103,10 +103,16 @@
} -

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) { +
+ + posted + + +
+ } @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, }; }