Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
20de2a1
Refactor vertical divider into a seperate file, allow divider color t…
Toastbrot236 Dec 26, 2025
ca526ee
Allow client to request instance statistics
Toastbrot236 Dec 26, 2025
4f40e19
typo correction
Toastbrot236 Dec 26, 2025
8668f32
Add a footer
Toastbrot236 Dec 26, 2025
4d1ef2c
Footer stuff
Toastbrot236 Dec 26, 2025
8a9c7f9
Have footer stick to bottom of page if the inner page is too short
Toastbrot236 Dec 26, 2025
d503e1f
Ensure footer elements are not too far from each other for too wide w…
Toastbrot236 Dec 26, 2025
00afc68
Show server's icon in the footer
Toastbrot236 Dec 26, 2025
e5ffa08
Other minor footer improvements
Toastbrot236 Dec 26, 2025
0282618
Expose class which can properly wrap and break words
Toastbrot236 Feb 7, 2026
b1eb4a5
Stretch footer areas with equal spacing, expose server license name, …
Toastbrot236 Feb 7, 2026
27c33f5
Cache statistics and refresh if wanted
Toastbrot236 Feb 7, 2026
e65b5ff
Add stats, and add/switch around other info on the footer
Toastbrot236 Feb 7, 2026
959f3d9
Add blocked asset flag attributes to instance
Toastbrot236 Feb 7, 2026
1cc0293
Forgot to push AssetConfigFlags
Toastbrot236 Feb 7, 2026
3206f95
Improve presentation of server software info on the footer
Toastbrot236 Feb 7, 2026
cf57f5e
Add missing attribute, remove bogus attribute
Toastbrot236 Feb 7, 2026
a090b6b
Add an instance info page
Toastbrot236 Feb 7, 2026
20a9535
Use link color on footer and instance page, make link color stick out…
Toastbrot236 Feb 7, 2026
fbeff27
Add server source and contact data to instance page
Toastbrot236 Feb 7, 2026
0f1147e
Add instance page nav item
Toastbrot236 Feb 7, 2026
9b2cef0
Remove/use unused attributes
Toastbrot236 Feb 7, 2026
7a28810
Merge branch 'main' into insta
Toastbrot236 Feb 7, 2026
File filter

Filter by extension

Filter by extension

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

export const defaultPageSize: number = 40;
Expand All @@ -26,6 +27,7 @@ export const defaultPageSize: number = 40;
export class ClientService extends ApiImplementation {
private readonly instance: LazySubject<Instance>;
private readonly categories: LazySubject<ListWithData<LevelCategory>>;
private statistics: LazySubject<Statistics>;

private usersCache: User[] = [];

Expand All @@ -34,7 +36,9 @@ export class ClientService extends ApiImplementation {
this.instance = new LazySubject<Instance>(() => this.http.get<Instance>("/instance"));
this.instance.tryLoad();

this.categories = new LazySubject<ListWithData<LevelCategory>>(() => this.http.get<ListWithData<LevelCategory>>("/levels?includePreviews=true"))
this.categories = new LazySubject<ListWithData<LevelCategory>>(() => this.http.get<ListWithData<LevelCategory>>("/levels?includePreviews=true"));

this.statistics = new LazySubject<Statistics>(() => this.http.get<Statistics>("/statistics"));
}

getInstance() {
Expand All @@ -45,6 +49,14 @@ export class ClientService extends ApiImplementation {
return this.categories.asObservable();
}

getStatistics(refresh: boolean) {
if (refresh) {
this.statistics = new LazySubject<Statistics>(() => this.http.get<Statistics>("/statistics"));
}

return this.statistics.asObservable();
}

getRoomListing() {
return this.http.get<ListWithData<Room>>("/rooms");
}
Expand Down
17 changes: 17 additions & 0 deletions src/app/api/types/asset-config-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface AssetConfigFlags {
// For some reason these are serialized as capitalized
Dangerous: boolean;
Media: boolean;
Modded: boolean;
}

export function blockedFlagsAsString(flags: AssetConfigFlags): String {
let flagList: String[] = [];
if (flags.Media == true) flagList.push("Media");
if (flags.Modded == true) flagList.push("Modded");
if (flags.Dangerous == true) flagList.push("Dangerous");

let flagStr: String = flagList.join(", ");
if (flagStr.length === 0) flagStr = "None";
return flagStr;
}
6 changes: 6 additions & 0 deletions src/app/api/types/contact-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface ContactInfo {
adminName: string;
emailAddress: string;
discordServerInvite: string;
adminDiscordUsername: string;
}
10 changes: 9 additions & 1 deletion src/app/api/types/instance.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import {Announcement} from "./announcement";
import { AssetConfigFlags } from "./asset-config-flags";
import { ContactInfo } from "./contact-info";
import {Contest} from "./contests/contest";

export interface Instance {
instanceName: string;
instanceDescription: string;
websiteLogoUrl: string;

softwareName: string;
softwareVersion: string;
softwareType: string;
softwareSourceUrl: string;
softwareLicenseName: string;
softwareLicenseUrl: string;

registrationEnabled: boolean;
maximumAssetSafetyLevel: number;
blockedAssetFlags: AssetConfigFlags;
blockedAssetFlagsForTrustedUsers: AssetConfigFlags;

announcements: Announcement[];
maintenanceModeEnabled: boolean;
grafanaDashboardUrl: string | null;

activeContest: Contest | null;
contactInfo: ContactInfo;
}
1 change: 0 additions & 1 deletion src/app/api/types/request-statistics.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export interface RequestStatistics {
totalRequests: number
apiRequests: number
legacyApiRequests: number
gameRequests: number
}
1 change: 1 addition & 0 deletions src/app/api/types/statistics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {RequestStatistics} from "./request-statistics";

export interface Statistics {
totalLevels: number
moddedLevels: number
totalUsers: number
activeUsers: number
totalPhotos: number
Expand Down
28 changes: 17 additions & 11 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
@if(!(layout.isMobile | async)) {
<app-header></app-header>
} @else {
<app-header-mobile></app-header-mobile>
}
@defer (when bannerService.banners.length > 0) {
<app-popup-banner-container></app-popup-banner-container>
}
<div class="container mx-auto bg-backdrop p-5 border-divider"
[@routeAnimations]="o.isActivated ? o.activatedRoute : ''">
<router-outlet #o=outlet />
<div class="flex flex-col min-h-screen">
<div class="flex-grow">
@if(!(layout.isMobile | async)) {
<app-header></app-header>
} @else {
<app-header-mobile></app-header-mobile>
}
@defer (when bannerService.banners.length > 0) {
<app-popup-banner-container></app-popup-banner-container>
}
<div class="container mx-auto bg-backdrop p-5 border-divider"
[@routeAnimations]="o.isActivated ? o.activatedRoute : ''">
<router-outlet #o=outlet />
</div>
</div>

<app-footer class="container mx-auto"></app-footer>
</div>
3 changes: 2 additions & 1 deletion src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import {animate, group, query, style, transition, trigger} from "@angular/animat
import {LayoutService} from "./services/layout.service";
import {AsyncPipe} from "@angular/common";
import {HeaderMobileComponent} from "./components/ui/header/mobile/header-mobile.component";
import { FooterComponent } from "./components/ui/footer.component";

const fadeLength: string = "100ms";

@Component({
selector: 'app-root',
imports: [RouterOutlet, HeaderComponent, PopupBannerContainerComponent, AsyncPipe, HeaderMobileComponent],
imports: [RouterOutlet, HeaderComponent, PopupBannerContainerComponent, AsyncPipe, HeaderMobileComponent, FooterComponent],
templateUrl: './app.component.html',
animations: [
trigger('routeAnimations', [
Expand Down
5 changes: 5 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export const routes: Routes = [
loadComponent: () => import('./pages/contest-listing/contest-listing.component').then(x => x.ContestListingComponent),
data: {title: "Contests"},
},
{
path: 'instance',
loadComponent: () => import('./pages/instance-info/instance-info.component').then(x => x.InstanceInfoComponent),
data: {title: "About Us"},
},
...appendDebugRoutes(),
// KEEP THIS ROUTE LAST! It handles pages that do not exist.
{
Expand Down
6 changes: 3 additions & 3 deletions src/app/components/ui/divider.component.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Component } from '@angular/core';
import { Component, Input } from '@angular/core';

@Component({
selector: 'app-divider',
imports: [],
template: `
<div class="my-3 h-[3px] rounded-sm bg-divider drop-shadow-md"></div>
<div [class]="'my-3 h-[3px] rounded-sm drop-shadow-md ' + color"></div>
`
})
export class DividerComponent {

@Input() color: String = "bg-divider";
}
202 changes: 202 additions & 0 deletions src/app/components/ui/footer.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { Component } from '@angular/core';
import { NgTemplateOutlet, NgOptimizedImage } from '@angular/common';
import { ClientService } from '../../api/client.service';
import { Instance } from '../../api/types/instance';
import { BannerService } from '../../banners/banner.service';
import { RefreshApiError } from '../../api/refresh-api-error';
import { LayoutService } from '../../services/layout.service';
import { VerticalDividerComponent } from "./vertical-divider.component";
import { faArrowUp, faCertificate, faCodeFork, faEnvelope, faPlay, faSignIn, faUser } from '@fortawesome/free-solid-svg-icons';
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { DividerComponent } from "./divider.component";
import { getWebsiteRepoUrl } from '../../helpers/data-fetching';
import { Statistics } from '../../api/types/statistics';
import { StatisticComponent } from "./info/statistic.component";
import { RouterLink } from "@angular/router";

@Component({
selector: 'app-footer',
imports: [
VerticalDividerComponent,
DividerComponent,
NgTemplateOutlet,
FaIconComponent,
NgOptimizedImage,
StatisticComponent,
RouterLink
],
template: `
<footer class="mt-10 mb-5 mx-4">
@if (instance != null) {
<ng-template #instanceInfo>
<div class="flex flex-col gap-y-1">
<p class="text-3xl">
<img [ngSrc]="instance.websiteLogoUrl" class="inline aspect-square object-cover rounded"
alt="Server icon" width="30" height="30"
(error)="iconErr($event.target)" loading="lazy">
{{ instance.instanceName }}
</p>
<p class="text-wrap"> {{instance.instanceDescription}}</p>

@if (isMobile) {
<app-divider color="bg-foreground"></app-divider>
}
@else {
<div class="pt-3"></div>
}

<div>
@if (statistics != null) {
<div class="flex flex-col gap-y-1">
<p class="text-3xl">Global Statistics</p>
<div class="flex flex-wrap gap-x-1.5">
<app-statistic [value]=statistics.totalLevels name="Total Levels" [icon]=faCertificate></app-statistic>
<app-statistic [value]=statistics.totalUsers name="Total Users" [icon]=faUser></app-statistic>
<app-statistic [value]=statistics.totalEvents name="Total Events" [icon]=faPlay></app-statistic>
<app-statistic [value]=statistics.requestStatistics.apiRequests name="API Requests" [icon]=faArrowUp></app-statistic>
</div>
<a routerLink="/instance" class="text-link hover:text-link-hover hover:underline">
More stats
</a>
</div>
}
@else {
<div class="flex flex-col">
<p class="text-2xl text-wrap">Failed to retrieve statistics</p>
<p>{{ statisticsError ?? "" }}</p>
</div>
}
</div>
</div>
</ng-template>

<ng-template #contactInfo>
<div class="flex flex-col gap-y-1">
<p class="text-3xl">Get In Touch</p>
<a [href]="'mailto:' + instance.contactInfo.emailAddress" class="text-link hover:text-link-hover hover:underline">
<fa-icon class="pr-1" [icon]="faEnvelope"></fa-icon>
Email Us ({{ instance.contactInfo.emailAddress }})
</a>
<a [href]="instance.contactInfo.discordServerInvite" class="text-link hover:text-link-hover hover:underline">
<fa-icon class="pr-1" [icon]="faSignIn"></fa-icon>
Join Our Discord Server
</a>
<p>
You can also contact <span class="italic">{{ instance.contactInfo.adminName }}</span> on Discord at <span class="italic">{{ instance.contactInfo.adminDiscordUsername }}</span>
</p>
</div>
</ng-template>

<ng-template #softwareInfo>
<div class="flex flex-col gap-y-1">
<p class="text-3xl">The Software</p>
<a [href]="websiteRepoUrl" class="text-link hover:text-link-hover hover:underline">
<fa-icon class="pr-1" [icon]="faCodeFork"></fa-icon>
Website Repository
</a>
<a [href]="instance.softwareSourceUrl" class="text-link hover:text-link-hover hover:underline">
<fa-icon class="pr-1" [icon]="faCodeFork"></fa-icon>
Server Repository
</a>
<p class="text-wrap">
Server license:
<a [href]="instance.softwareLicenseUrl" class="text-link hover:text-link-hover hover:underline">
<fa-icon class="pr-1"></fa-icon>
{{ instance.softwareLicenseName }}
</a>
</p>
<p>
Server software: <span class="italic">{{instance.softwareName}} ({{instance.softwareType}})</span>
</p>
<p>
Server version: <span class="word-wrap-and-break italic">v{{ instance.softwareVersion }}</span>
</p>
</div>
</ng-template>

@if (isMobile) {
<div class="flex flex-col gap-y-1 pb-10">
<ng-container *ngTemplateOutlet="instanceInfo"></ng-container>
<app-divider color="bg-foreground"></app-divider>
<ng-container *ngTemplateOutlet="contactInfo"></ng-container>
<app-divider color="bg-foreground"></app-divider>
<ng-container *ngTemplateOutlet="softwareInfo"></ng-container>
</div>
}
@else {
<div class="flex flex-row flex-grow gap-x-3">
<div class="w-full mb-20">
<ng-container *ngTemplateOutlet="instanceInfo"></ng-container>
</div>
<app-vertical-divider color="bg-foreground" height="h-full"></app-vertical-divider>
<div class="w-full mb-20">
<ng-container *ngTemplateOutlet="contactInfo"></ng-container>
</div>
<app-vertical-divider color="bg-foreground" height="h-full"></app-vertical-divider>
<div class="w-full mb-20">
<ng-container *ngTemplateOutlet="softwareInfo"></ng-container>
</div>
</div>
}
}
@else {
<div class="flex flex-col">
<p class="text-3xl">Failed to retrieve instance data</p>
<p>{{ instanceError ?? "" }}</p>
</div>
}
</footer>
`
})
export class FooterComponent {
protected instance: Instance | undefined;
protected instanceError: String | undefined;

protected statistics: Statistics | undefined;
protected statisticsError: String | undefined;

protected isMobile: boolean = false;
protected websiteRepoUrl: String = getWebsiteRepoUrl();
protected iconError: boolean = false;

constructor(private client: ClientService, protected banner: BannerService, protected layout: LayoutService) {
this.layout.isMobile.subscribe(v => this.isMobile = v);

client.getInstance().subscribe({
error: error => {
const apiError: RefreshApiError | undefined = error.error?.error;
this.instanceError = apiError == null ? error.message : apiError.message;
},
next: response => {
this.instance = response;

// Only get stats after instance
client.getStatistics(false).subscribe({
error: error => {
const apiError: RefreshApiError | undefined = error.error?.error;
this.statisticsError = apiError == null ? error.message : apiError.message;
},
next: response => {
this.statistics = response
}
});
}
});
}

iconErr(img: EventTarget | null): void {
if(this.iconError) return;
this.iconError = true;

if(!(img instanceof HTMLImageElement)) return;
img.srcset = "/assets/logo.svg";
}

protected readonly faEnvelope = faEnvelope;
protected readonly faSignIn = faSignIn;
protected readonly faCodeFork = faCodeFork;
protected readonly faCertificate = faCertificate;
protected readonly faPlay = faPlay;
protected readonly faUser = faUser;
protected readonly faArrowUp = faArrowUp;
}
Loading