diff --git a/src/app/api/cached-list-with-data.ts b/src/app/api/cached-list-with-data.ts new file mode 100644 index 0000000..31be4d3 --- /dev/null +++ b/src/app/api/cached-list-with-data.ts @@ -0,0 +1,5 @@ +import { ListWithData } from "./list-with-data"; + +export interface CachedListWithData extends ListWithData { + totalLoads: number; +} \ No newline at end of file diff --git a/src/app/api/client.service.ts b/src/app/api/client.service.ts index f217728..15b5669 100644 --- a/src/app/api/client.service.ts +++ b/src/app/api/client.service.ts @@ -147,6 +147,10 @@ export class ClientService extends ApiImplementation { return this.http.get>(`/photos`, {params: this.createPageQuery(skip, count)}); } + getPhotosRelatedToUserUuid(userId: string, category: string, skip: number = 0, count: number = defaultPageSize) { + return this.http.get>(`/photos/${category}/uuid/${userId}`, {params: this.createPageQuery(skip, count)}); + } + getContests() { return this.http.get>("/contests"); } diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index d0793d9..a6eec10 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -17,6 +17,11 @@ export const routes: Routes = [ loadComponent: () => import('./pages/level-listing/level-listing.component').then(x => x.LevelListingComponent), data: {title: "Level Listing"} }, + { + path: 'levels/:category/user/:username', + loadComponent: () => import('./pages/level-user-listing/level-user-listing.component').then(x => x.LevelUserListingComponent), + data: {title: "Levels Related To User"} + }, { path: 'level/:id/:slug', loadComponent: () => import('./pages/level/level.component').then(x => x.LevelComponent), @@ -46,9 +51,9 @@ export const routes: Routes = [ data: {title: "Photos"}, }, { - path: 'photos', - loadComponent: () => import('./pages/photo-listing/photo-listing.component').then(x => x.PhotoListingComponent), - data: {title: "Photos"}, + path: 'photos/:category/user/:username', + loadComponent: () => import('./pages/photo-user-listing/photo-user-listing.component').then(x => x.PhotoUserListingComponent), + data: {title: "Photos Related To User"}, }, { path: 'photo/:id', diff --git a/src/app/components/ui/infinite-scroller.component.ts b/src/app/components/ui/infinite-scroller.component.ts index 59056af..d8e633b 100644 --- a/src/app/components/ui/infinite-scroller.component.ts +++ b/src/app/components/ui/infinite-scroller.component.ts @@ -42,7 +42,8 @@ export class InfiniteScrollerComponent implements AfterViewInit { nextPageIndex: number = this.pageSize + 1; total: number = 0; - totalLoads: number = 0; + @Input() totalLoads: number = 0; + @Output() incrementLoads = new EventEmitter; @Input({required: true}) set listInfo(listInfo: RefreshApiListInfo) { if(!listInfo) return; @@ -56,6 +57,7 @@ export class InfiniteScrollerComponent implements AfterViewInit { this.loadData.emit(); // tell the parent to load more data this.totalLoads++; + this.incrementLoads.emit(); } ngAfterViewInit(): void { diff --git a/src/app/pages/level-user-listing/level-user-listing.component.html b/src/app/pages/level-user-listing/level-user-listing.component.html new file mode 100644 index 0000000..d7b8c37 --- /dev/null +++ b/src/app/pages/level-user-listing/level-user-listing.component.html @@ -0,0 +1,42 @@ +@if (user) { + +
+
+ + + + +
+ + +
+
+ + ({{this.listInfo.totalItems}} in total) +
+
+
+ + @if (currentLevels.data.length > 0) { + + @for (level of this.currentLevels.data; track level.levelId) { + + + + } + + } + @else if (currentLevels.listInfo.totalItems < 0) { +

Loading levels...

+ } + @else { +

No levels yet...

+ } + + +} diff --git a/src/app/pages/level-user-listing/level-user-listing.component.ts b/src/app/pages/level-user-listing/level-user-listing.component.ts new file mode 100644 index 0000000..4e7dcc0 --- /dev/null +++ b/src/app/pages/level-user-listing/level-user-listing.component.ts @@ -0,0 +1,183 @@ +import {Component, Inject, PLATFORM_ID} from '@angular/core'; +import {ClientService, defaultPageSize} from "../../api/client.service"; +import {ActivatedRoute} from "@angular/router"; +import {PageTitleComponent} from "../../components/ui/text/page-title.component"; +import {ResponsiveGridComponent} from "../../components/ui/responsive-grid.component"; +import {Level} from "../../api/types/levels/level"; +import {LevelPreviewComponent} from "../../components/items/level-preview.component"; +import {ContainerComponent} from "../../components/ui/container.component"; +import {Scrollable} from "../../helpers/scrollable"; +import {defaultListInfo, RefreshApiListInfo} from "../../api/refresh-api-list-info"; +import {InfiniteScrollerComponent} from "../../components/ui/infinite-scroller.component"; +import { User } from '../../api/types/users/user'; +import { UserLinkComponent } from "../../components/ui/text/links/user-link.component"; +import { RadioButtonComponent } from "../../components/ui/form/radio-button.component"; +import { DropdownMenuComponent } from "../../components/ui/form/dropdown-menu.component"; +import { ButtonComponent } from "../../components/ui/form/button.component"; +import { ContainerHeaderComponent } from "../../components/ui/container-header.component"; +import { FormControl, FormGroup } from '@angular/forms'; +import { ListWithData } from '../../api/list-with-data'; +import { BannerService } from '../../banners/banner.service'; +import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'; +import { isPlatformBrowser } from '@angular/common'; +import { RefreshApiError } from '../../api/refresh-api-error'; +import { CachedListWithData } from '../../api/cached-list-with-data'; + +@Component({ + selector: 'app-level-user-listing', + imports: [ + PageTitleComponent, + ResponsiveGridComponent, + LevelPreviewComponent, + ContainerComponent, + InfiniteScrollerComponent, + UserLinkComponent, + RadioButtonComponent, + DropdownMenuComponent, + ButtonComponent, + ContainerHeaderComponent +], + templateUrl: './level-user-listing.component.html' +}) +export class LevelUserListingComponent implements Scrollable { + user: User | undefined; + levelsPublishedByUser: CachedListWithData | undefined; + levelsHeartedByUser: CachedListWithData | undefined; + currentLevels: CachedListWithData = { + data: [], + listInfo: defaultListInfo, + totalLoads: 0, + }; + + showLevelDropdown: boolean = false; + levelSelectionString: string = ""; + filterForm = new FormGroup({ + selection: new FormControl(-1) + }); + + protected readonly isBrowser: boolean; + + constructor(private client: ClientService, protected banner: BannerService, private route: ActivatedRoute, @Inject(PLATFORM_ID) platformId: Object) { + route.params.subscribe(params => { + const username: string | undefined = params['username']; + const category: string | undefined = params['category']; + + if (username != null) { + this.client.getUserByUsername(username).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.warn("Failed to get user", apiError == null ? error.message : apiError.message); + }, + next: user => { + this.user = user; + + if (category != null) { + this.levelSelectionString = category; + switch (category) { + case "byUser": + this.setLevelSelection(0); + break; + case "hearted": + this.setLevelSelection(1); + break; + default: + this.banner.warn("Cannot get levels", "Selection '" + category + "' is unknown"); + return; + } + } + } + }); + } + }); + + this.isBrowser = isPlatformBrowser(platformId); + } + + levelSelectionButtonClick() { + this.showLevelDropdown = !this.showLevelDropdown; + } + + getLevelSelectionText(selection: number): string { + switch (selection) { + case 0: return "Published by"; + case 1: return "Hearted by"; + default: return "Something by"; + } + } + + setLevelSelection(selection: number) { + if (this.user == null) return; + + let previousSelection: number = this.filterForm.controls.selection.getRawValue()!; + if (selection === previousSelection) return; + + switch (previousSelection) { + case 0: + this.levelsPublishedByUser = this.currentLevels; + break; + case 1: + this.levelsHeartedByUser = this.currentLevels; + break; + } + + let cachedList: CachedListWithData | undefined; + switch (selection) { + case 0: + this.levelSelectionString = "byUser"; + cachedList = this.levelsPublishedByUser; + break; + case 1: + this.levelSelectionString = "hearted"; + cachedList = this.levelsHeartedByUser; + break; + default: + this.banner.warn("Cannot get levels", "Selection " + selection + " is unknown"); + return; + } + + this.filterForm.controls.selection.setValue(selection); + if(this.isBrowser) { + window.history.replaceState({}, '', `/levels/${this.levelSelectionString}/user/${this.user.username}`); + } + + if (cachedList != null) { + this.currentLevels = cachedList; + return; + } + + this.currentLevels = { + data: [], + listInfo: defaultListInfo, + totalLoads: 0, + }; + this.currentLevels.totalLoads++; + this.loadData(); + } + + isLoading: boolean = false; + get listInfo() {return this.currentLevels.listInfo} + + loadData(): void { + if(!this.user) return; + + this.isLoading = true; + this.client.getLevelsInCategory(this.levelSelectionString, this.listInfo.nextPageIndex, defaultPageSize, {u: this.user.username}).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.warn("Failed to get levels", apiError == null ? error.message : apiError.message); + }, + next: list => { + this.currentLevels.data = this.currentLevels.data.concat(list.data); + this.currentLevels.listInfo = list.listInfo; + this.isLoading = false; + } + }); + } + + incrementLoads() { + this.currentLevels.totalLoads++; + } + + protected readonly faChevronDown = faChevronDown; + protected readonly faChevronUp = faChevronUp; +} diff --git a/src/app/pages/photo-user-listing/photo-user-listing.component.html b/src/app/pages/photo-user-listing/photo-user-listing.component.html new file mode 100644 index 0000000..02b5a53 --- /dev/null +++ b/src/app/pages/photo-user-listing/photo-user-listing.component.html @@ -0,0 +1,42 @@ +@if (user) { + +
+
+ + + + +
+ + +
+
+ + ({{this.currentPhotos.listInfo.totalItems}} in total) +
+
+
+ + @if (currentPhotos.data.length > 0) { + + @for (photo of this.currentPhotos.data; track photo.photoId) { + + + + } + + } + @else if (currentPhotos.listInfo.totalItems < 0) { +

Loading photos...

+ } + @else { +

No photos yet...

+ } + + +} diff --git a/src/app/pages/photo-user-listing/photo-user-listing.component.ts b/src/app/pages/photo-user-listing/photo-user-listing.component.ts new file mode 100644 index 0000000..bd8fd93 --- /dev/null +++ b/src/app/pages/photo-user-listing/photo-user-listing.component.ts @@ -0,0 +1,184 @@ +import {Component, Inject, PLATFORM_ID} from '@angular/core'; +import {ClientService, defaultPageSize} from "../../api/client.service"; +import {ActivatedRoute} from "@angular/router"; +import {PageTitleComponent} from "../../components/ui/text/page-title.component"; +import {ResponsiveGridComponent} from "../../components/ui/responsive-grid.component"; +import {ContainerComponent} from "../../components/ui/container.component"; +import {Scrollable} from "../../helpers/scrollable"; +import {defaultListInfo, RefreshApiListInfo} from "../../api/refresh-api-list-info"; +import {InfiniteScrollerComponent} from "../../components/ui/infinite-scroller.component"; +import { User } from '../../api/types/users/user'; +import { UserLinkComponent } from "../../components/ui/text/links/user-link.component"; +import { RadioButtonComponent } from "../../components/ui/form/radio-button.component"; +import { DropdownMenuComponent } from "../../components/ui/form/dropdown-menu.component"; +import { ButtonComponent } from "../../components/ui/form/button.component"; +import { ContainerHeaderComponent } from "../../components/ui/container-header.component"; +import { FormControl, FormGroup } from '@angular/forms'; +import { ListWithData } from '../../api/list-with-data'; +import { BannerService } from '../../banners/banner.service'; +import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'; +import { Photo } from '../../api/types/photos/photo'; +import { PhotoComponent } from "../../components/items/photo.component"; +import { isPlatformBrowser } from '@angular/common'; +import { RefreshApiError } from '../../api/refresh-api-error'; +import { CachedListWithData } from '../../api/cached-list-with-data'; + +@Component({ + selector: 'app-photo-user-listing', + imports: [ + PageTitleComponent, + ResponsiveGridComponent, + ContainerComponent, + InfiniteScrollerComponent, + UserLinkComponent, + RadioButtonComponent, + DropdownMenuComponent, + ButtonComponent, + ContainerHeaderComponent, + PhotoComponent +], + templateUrl: './photo-user-listing.component.html' +}) +export class PhotoUserListingComponent implements Scrollable { + user: User | undefined; + photosByUser: CachedListWithData | undefined; + photosWithUser: CachedListWithData | undefined; + currentPhotos: CachedListWithData = { + data: [], + listInfo: defaultListInfo, + totalLoads: 0, + }; + + showPhotoDropdown: boolean = false; + photoSelectionString: string = ""; + filterForm = new FormGroup({ + selection: new FormControl(-1) + }); + + protected readonly isBrowser: boolean; + + constructor(private client: ClientService, protected banner: BannerService, private route: ActivatedRoute, @Inject(PLATFORM_ID) platformId: Object) { + route.params.subscribe(params => { + const username: string | undefined = params['username']; + const category: string | undefined = params['category']; + + if (username != null) { + this.client.getUserByUsername(username).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.warn("Failed to get user", apiError == null ? error.message : apiError.message); + }, + next: user => { + this.user = user; + + if (category != null) { + this.photoSelectionString = category; + switch (category) { + case "by": + this.setPhotoSelection(0); + break; + case "with": + this.setPhotoSelection(1); + break; + default: + this.banner.warn("Cannot get photos", "Selection '" + category + "' is unknown"); + return; + } + } + } + }); + } + }); + + this.isBrowser = isPlatformBrowser(platformId); + } + + photoSelectionButtonClick() { + this.showPhotoDropdown = !this.showPhotoDropdown; + } + + getPhotoSelectionText(selection: number): string { + switch (selection) { + case 0: return "By"; + case 1: return "With"; + default: return "Something"; + } + } + + setPhotoSelection(selection: number) { + if (this.user == null) return; + + let previousSelection: number = this.filterForm.controls.selection.getRawValue()!; + if (selection === previousSelection) return; + + switch (previousSelection) { + case 0: + this.photosByUser = this.currentPhotos; + break; + case 1: + this.photosWithUser = this.currentPhotos; + break; + } + + let cachedList: CachedListWithData | undefined; + switch (selection) { + case 0: + this.photoSelectionString = "by"; + cachedList = this.photosByUser; + break; + case 1: + this.photoSelectionString = "with"; + cachedList = this.photosWithUser; + break; + default: + this.banner.warn("Cannot get photos", "Selection " + selection + " is unknown"); + return; + } + + this.filterForm.controls.selection.setValue(selection); + if(this.isBrowser) { + window.history.replaceState({}, '', `/photos/${this.photoSelectionString}/user/${this.user.username}`); + } + + if (cachedList != null) { + this.currentPhotos = cachedList; + return; + } + + this.currentPhotos = { + data: [], + listInfo: defaultListInfo, + totalLoads: 0, + }; + this.currentPhotos.totalLoads++; + this.loadData(); + } + + isLoading: boolean = false; + get listInfo() {return this.currentPhotos.listInfo} + + loadData(): void { + if(!this.user) return; + + this.isLoading = true; + this.client.getPhotosRelatedToUserUuid(this.user.userId, this.photoSelectionString, this.currentPhotos.listInfo.nextPageIndex, defaultPageSize).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.warn("Failed to get photos", apiError == null ? error.message : apiError.message); + }, + next: list => { + this.isLoading = false; + + this.currentPhotos.data = this.currentPhotos.data.concat(list.data); + this.currentPhotos.listInfo = list.listInfo; + } + }); + } + + incrementLoads() { + this.currentPhotos.totalLoads++; + } + + protected readonly faChevronDown = faChevronDown; + protected readonly faChevronUp = faChevronUp; +} diff --git a/src/app/pages/user/user.component.html b/src/app/pages/user/user.component.html index bd0c947..ac744bd 100644 --- a/src/app/pages/user/user.component.html +++ b/src/app/pages/user/user.component.html @@ -21,4 +21,83 @@ } + + + +
+ + + Levels + + ({{this.currentLevels.listInfo.totalItems}} in total) + + + + +
+ + +
+
+
+ + @if (currentLevels.data.length > 0) { + @for (level of currentLevels.data; track level.levelId; let i = $index) { +
+ + + +
+ } + } + @else if (currentLevels.listInfo.totalItems < 0) { +

Loading levels...

+ } + @else { +

No levels yet...

+ } +
+ +
+ + + Photos + + ({{this.currentPhotos.listInfo.totalItems}} in total) + + + + +
+ + +
+
+
+ + @if (currentPhotos.data.length > 0) { + @for (photo of currentPhotos.data; track photo.photoId; let i = $index) { +
+ +
+ } + } + @else if (currentPhotos.listInfo.totalItems < 0) { +

Loading photos...

+ } + @else { +

No photos yet...

+ } +
+
} diff --git a/src/app/pages/user/user.component.ts b/src/app/pages/user/user.component.ts index c362f82..db02410 100644 --- a/src/app/pages/user/user.component.ts +++ b/src/app/pages/user/user.component.ts @@ -1,8 +1,7 @@ import { Component } from '@angular/core'; import {User} from "../../api/types/users/user"; -import {TitleService} from "../../services/title.service"; import {ClientService} from "../../api/client.service"; -import {ActivatedRoute} from "@angular/router"; +import { ActivatedRoute, RouterLink } from "@angular/router"; import {DefaultPipe} from "../../pipes/default.pipe"; import { AsyncPipe } from "@angular/common"; import {UserAvatarComponent} from "../../components/ui/photos/user-avatar.component"; @@ -15,6 +14,24 @@ import { UserRelations } from '../../api/types/users/user-relations'; import { FancyHeaderUserButtonsComponent } from "../../components/ui/layouts/fancy-header-user-buttons.component"; import { ExtendedUser } from '../../api/types/users/extended-user'; import { AuthenticationService } from '../../api/authentication.service'; +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 { DropdownMenuComponent } from "../../components/ui/form/dropdown-menu.component"; +import { ButtonComponent } from "../../components/ui/form/button.component"; +import { RadioButtonComponent } from "../../components/ui/form/radio-button.component"; +import { Level } from '../../api/types/levels/level'; +import { Photo } from '../../api/types/photos/photo'; +import { FormControl, FormGroup } from '@angular/forms'; +import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'; +import { DividerComponent } from "../../components/ui/divider.component"; +import { BannerService } from '../../banners/banner.service'; +import { RefreshApiError } from '../../api/refresh-api-error'; +import { LevelPreviewComponent } from "../../components/items/level-preview.component"; +import { PhotoComponent } from "../../components/items/photo.component"; +import { DarkContainerComponent } from "../../components/ui/dark-container.component"; +import { ListWithData } from '../../api/list-with-data'; +import { defaultListInfo } from '../../api/refresh-api-list-info'; import { UserRoleComponent } from "../../components/ui/info/user-role.component"; @Component({ @@ -28,7 +45,18 @@ import { UserRoleComponent } from "../../components/ui/info/user-role.component" UserStatusComponent, UserStatisticsComponent, FancyHeaderUserButtonsComponent, - UserRoleComponent + TwoPaneLayoutComponent, + ContainerComponent, + PaneTitleComponent, + RouterLink, + DropdownMenuComponent, + ButtonComponent, + RadioButtonComponent, + DividerComponent, + LevelPreviewComponent, + PhotoComponent, + DarkContainerComponent, + UserRoleComponent, ], templateUrl: './user.component.html', styles: `` @@ -39,7 +67,35 @@ export class UserComponent { protected ownUser: ExtendedUser | undefined; protected isMobile: boolean = false; - constructor(private auth: AuthenticationService, private client: ClientService, route: ActivatedRoute, protected layout: LayoutService) { + levelsPublishedByUser: ListWithData | undefined; + levelsHeartedByUser: ListWithData | undefined; + currentLevels: ListWithData = { + data: [], + listInfo: defaultListInfo, + }; + + showLevelDropdown: boolean = false; + levelSelectionString: string = ""; + levelForm = new FormGroup({ + selection: new FormControl(0) + }); + + photosByUser: ListWithData | undefined; + photosWithUser: ListWithData | undefined; + currentPhotos: ListWithData = { + data: [], + listInfo: defaultListInfo, + }; + + showPhotoDropdown: boolean = false; + photoSelectionString: string = ""; + photoForm = new FormGroup({ + selection: new FormControl(0) + }); + + constructor(private auth: AuthenticationService, private client: ClientService, route: ActivatedRoute, protected layout: LayoutService, + protected banner: BannerService + ) { route.params.subscribe(params => { const username: string | undefined = params['username']; const uuid: string | undefined = params['uuid']; @@ -53,9 +109,130 @@ export class UserComponent { this.ownUser = user; } }); + + this.getLevels(0); + this.getPhotos(0); }); }); this.layout.isMobile.subscribe(v => this.isMobile = v); } + + levelSelectionButtonClick() { + this.showLevelDropdown = !this.showLevelDropdown; + } + + getLevels(selection: number) { + if (this.user == null) return; + let cachedList: ListWithData | undefined; + + switch (selection) { + case 0: + this.levelSelectionString = "byUser"; + cachedList = this.levelsPublishedByUser; + break; + case 1: + this.levelSelectionString = "hearted"; + cachedList = this.levelsHeartedByUser; + break; + default: + this.banner.warn("Cannot get levels", "Selection " + selection + " is unknown"); + return; + } + this.levelForm.controls.selection.setValue(selection); + + if (cachedList != null) { + this.currentLevels = cachedList; + return; + } + + this.client.getLevelsInCategory(this.levelSelectionString, 0, 5, {u: this.user.username}).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.warn("Failed to get levels", apiError == null ? error.message : apiError.message); + }, + next: levelPage => { + // cache the page + switch (selection) { + case 0: + this.levelsPublishedByUser = levelPage; + break; + case 1: + this.levelsHeartedByUser = levelPage; + break; + } + + this.currentLevels = levelPage; + } + }); + } + + getLevelSelectionText(selection: number): string { + switch (selection) { + case 0: return "Published by user"; + case 1: return "Hearted by user"; + default: return "Something by user"; + } + } + + photoSelectionButtonClick() { + this.showPhotoDropdown = !this.showPhotoDropdown; + } + + getPhotos(selection: number) { + if (this.user == null) return; + let cachedList: ListWithData | undefined; + + switch (selection) { + case 0: + this.photoSelectionString = "by"; + cachedList = this.photosByUser; + break; + case 1: + this.photoSelectionString = "with"; + cachedList = this.photosWithUser; + break; + default: + this.banner.warn("Cannot get photos", "Selection " + selection + " is unknown"); + return; + } + this.photoForm.controls.selection.setValue(selection); + + if (cachedList != null) { + this.currentPhotos = cachedList; + return; + } + + + this.client.getPhotosRelatedToUserUuid(this.user.userId, this.photoSelectionString, 0, 2).subscribe({ + error: error => { + const apiError: RefreshApiError | undefined = error.error?.error; + this.banner.warn("Failed to get photos with user", apiError == null ? error.message : apiError.message); + }, + next: photoPage => { + // cache the page + switch (selection) { + case 0: + this.photosByUser = photoPage; + break; + case 1: + this.photosWithUser = photoPage; + break; + } + + this.currentPhotos = photoPage; + } + }); + } + + getPhotoSelectionText(selection: number): string { + switch (selection) { + case 0: return "By user"; + case 1: return "With user"; + default: return "Something user"; + } + } + + protected readonly faChevronDown = faChevronDown; + protected readonly faChevronUp = faChevronUp; }