diff --git a/src/app/api/client.service.ts b/src/app/api/client.service.ts index 494b80f1..2d7d2bb0 100644 --- a/src/app/api/client.service.ts +++ b/src/app/api/client.service.ts @@ -16,6 +16,7 @@ import {Contest} from "./types/contests/contest"; import {Score} from "./types/levels/score"; import { LevelRelations } from './types/levels/level-relations'; import { Asset } from './types/asset'; +import { Statistics } from './types/statistics'; import { LevelUpdateRequest } from './types/levels/level-update-request'; export const defaultPageSize: number = 40; @@ -26,6 +27,7 @@ export const defaultPageSize: number = 40; export class ClientService extends ApiImplementation { private readonly instance: LazySubject; private readonly categories: LazySubject>; + private statistics: LazySubject; private usersCache: User[] = []; @@ -34,7 +36,9 @@ export class ClientService extends ApiImplementation { this.instance = new LazySubject(() => this.http.get("/instance")); this.instance.tryLoad(); - this.categories = new LazySubject>(() => this.http.get>("/levels?includePreviews=true")) + this.categories = new LazySubject>(() => this.http.get>("/levels?includePreviews=true")); + + this.statistics = this.getStatisticsInternal(); } getInstance() { @@ -45,6 +49,18 @@ export class ClientService extends ApiImplementation { return this.categories.asObservable(); } + private getStatisticsInternal() { + return this.statistics = new LazySubject(() => this.http.get("/statistics")); + } + + getStatistics(ignoreCache: boolean = false) { + if (ignoreCache) { + this.statistics = this.getStatisticsInternal(); + } + + return this.statistics.asObservable(); + } + getRoomListing() { return this.http.get>("/rooms"); } diff --git a/src/app/api/types/asset-config-flags.ts b/src/app/api/types/asset-config-flags.ts new file mode 100644 index 00000000..214b25e9 --- /dev/null +++ b/src/app/api/types/asset-config-flags.ts @@ -0,0 +1,17 @@ +export interface AssetConfigFlags { + // For some reason these are serialized as capitalized by Refresh, unlike most other attributes. + 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; +} diff --git a/src/app/api/types/contact-info.ts b/src/app/api/types/contact-info.ts new file mode 100644 index 00000000..3afb914e --- /dev/null +++ b/src/app/api/types/contact-info.ts @@ -0,0 +1,6 @@ +export interface ContactInfo { + adminName: string; + emailAddress: string; + discordServerInvite: string; + adminDiscordUsername: string; +} diff --git a/src/app/api/types/instance.ts b/src/app/api/types/instance.ts index fbcabe52..a94a11a2 100644 --- a/src/app/api/types/instance.ts +++ b/src/app/api/types/instance.ts @@ -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; } diff --git a/src/app/api/types/request-statistics.ts b/src/app/api/types/request-statistics.ts index b561e479..6f9c5e79 100644 --- a/src/app/api/types/request-statistics.ts +++ b/src/app/api/types/request-statistics.ts @@ -1,6 +1,5 @@ export interface RequestStatistics { totalRequests: number apiRequests: number - legacyApiRequests: number gameRequests: number } diff --git a/src/app/api/types/statistics.ts b/src/app/api/types/statistics.ts index d4c6aa00..b40de9c4 100644 --- a/src/app/api/types/statistics.ts +++ b/src/app/api/types/statistics.ts @@ -2,6 +2,7 @@ import {RequestStatistics} from "./request-statistics"; export interface Statistics { totalLevels: number + moddedLevels: number totalUsers: number activeUsers: number totalPhotos: number diff --git a/src/app/app.component.html b/src/app/app.component.html index b963056a..61eb5c3b 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,12 +1,18 @@ -@if(!(layout.isMobile | async)) { - -} @else { - -} -@defer (when bannerService.banners.length > 0) { - -} -
- +
+
+ @if(!(layout.isMobile | async)) { + + } @else { + + } + @defer (when bannerService.banners.length > 0) { + + } +
+ +
+
+ +
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index a516d8ec..f789f88d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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', [ diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 2b8a3d53..ba884a6c 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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. { diff --git a/src/app/components/ui/divider.component.ts b/src/app/components/ui/divider.component.ts index 9ce5fe5c..c4650ee6 100644 --- a/src/app/components/ui/divider.component.ts +++ b/src/app/components/ui/divider.component.ts @@ -1,12 +1,12 @@ -import { Component } from '@angular/core'; +import { Component, Input } from '@angular/core'; @Component({ selector: 'app-divider', imports: [], template: ` -
+
` }) export class DividerComponent { - + @Input() color: String = "bg-divider"; } diff --git a/src/app/components/ui/footer.component.ts b/src/app/components/ui/footer.component.ts new file mode 100644 index 00000000..7523f876 --- /dev/null +++ b/src/app/components/ui/footer.component.ts @@ -0,0 +1,208 @@ +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: ` + + ` +}) +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; +} diff --git a/src/app/components/ui/header/header.component.ts b/src/app/components/ui/header/header.component.ts index 2a900da6..c7616765 100644 --- a/src/app/components/ui/header/header.component.ts +++ b/src/app/components/ui/header/header.component.ts @@ -1,28 +1,15 @@ import { Component } from '@angular/core'; - import {NavbarItemComponent} from "./navbar-item.component"; import {Router} from "@angular/router"; import {faSignInAlt} from "@fortawesome/free-solid-svg-icons"; - - - +import {VerticalDividerComponent} from "../vertical-divider.component"; import {LayoutService} from "../../../services/layout.service"; import {NavbarCategoryComponent} from "./navbar-category.component"; - import {SearchComponent} from "../../../overlays/search.component"; - - import {HeaderMeComponent} from "./header-me.component"; import {HeaderLogoComponent} from "./header-logo.component"; import {navTree, rightNavTree} from './navtypes'; -@Component({ - selector: 'header-vertical-divider', - imports: [], - template: `
` -}) -class VerticalDividerComponent {} - @Component({ selector: 'app-header', imports: [ @@ -38,7 +25,7 @@ class VerticalDividerComponent {} class="flex items-center bg-header-background gap-x-2.5 sm:gap-x-1 px-5 leading-none sticky top-0 left-0 w-full z-1000"> - +