diff --git a/projects/gameboard-ui/src/app/admin/components/admin-enroll-team-modal/admin-enroll-team-modal.component.html b/projects/gameboard-ui/src/app/admin/components/admin-enroll-team-modal/admin-enroll-team-modal.component.html
index ffa90e92..fbca8423 100644
--- a/projects/gameboard-ui/src/app/admin/components/admin-enroll-team-modal/admin-enroll-team-modal.component.html
+++ b/projects/gameboard-ui/src/app/admin/components/admin-enroll-team-modal/admin-enroll-team-modal.component.html
@@ -1,25 +1,69 @@
-
+
+
+
+
+ Use the box above to search for the user(s) you want to enroll in this game.
+
+
+
+
+
+
+
+
{{ user.name }}
+
{{ user.id | slice:0:8}}
+
+
+
diff --git a/projects/gameboard-ui/src/app/admin/components/admin-enroll-team-modal/admin-enroll-team-modal.component.ts b/projects/gameboard-ui/src/app/admin/components/admin-enroll-team-modal/admin-enroll-team-modal.component.ts
index aa5747fd..b79c0071 100644
--- a/projects/gameboard-ui/src/app/admin/components/admin-enroll-team-modal/admin-enroll-team-modal.component.ts
+++ b/projects/gameboard-ui/src/app/admin/components/admin-enroll-team-modal/admin-enroll-team-modal.component.ts
@@ -1,7 +1,12 @@
+import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
+import { Observable, Observer, Subject, debounceTime, filter, firstValueFrom, switchMap, tap } from 'rxjs';
+import { fa } from "@/services/font-awesome.service";
import { TeamService } from '@/api/team.service';
+import { ApiUser } from '@/api/user-models';
+import { UserService } from '@/api/user.service';
import { ModalConfirmService } from '@/services/modal-confirm.service';
-import { Component, OnInit } from '@angular/core';
-import { firstValueFrom } from 'rxjs';
+import { TypeaheadMatch } from 'ngx-bootstrap/typeahead';
+import { AdminEnrollTeamResponse } from '@/api/teams.models';
@Component({
selector: 'app-admin-enroll-team-modal',
@@ -9,40 +14,82 @@ import { firstValueFrom } from 'rxjs';
styleUrls: ['./admin-enroll-team-modal.component.scss']
})
export class AdminEnrollTeamModalComponent implements OnInit {
+ @ViewChild("searchBox") searchBoxRef?: ElementRef
;
+
game?: {
id: string;
name: string;
- isTeamGame: boolean;
+ minTeamSize: number;
+ maxTeamSize?: number;
};
+ onConfirm?: (createdTeam: AdminEnrollTeamResponse) => Promise | void;
+
+ private _search$ = new Subject();
- protected addUserId = "";
+ protected canAddAsTeam = false;
protected errors: any[] = [];
+ protected fa = fa;
+ protected isTeamGame = false;
protected isWorking = false;
+ protected searchTerm = "";
+ protected selectedUsers: ApiUser[] = [];
+ protected typeaheadSearch$ = new Observable((observer: Observer) => observer.next(this.searchTerm)).pipe(
+ filter(s => s?.length >= 3),
+ switchMap(search => this.userService.list({
+ eligibleForGameId: this.game!.id,
+ excludeIds: [...this.selectedUsers.map(u => u.id)],
+ term: search,
+ })),
+ );
constructor(
private modalConfirmService: ModalConfirmService,
- private teamService: TeamService) { }
+ private teamService: TeamService,
+ private userService: UserService) { }
ngOnInit(): void {
if (!this.game)
throw new Error("Can't resolve the game.");
+
+ this.isTeamGame = !this.game.maxTeamSize || this.game.maxTeamSize >= 2;
}
protected close() {
this.modalConfirmService.hide();
}
- protected async handleAddClick(userId: string) {
+ protected async handleAddClick() {
this.errors = [];
if (!this.game?.id)
return;
+ if (!this.selectedUsers.length)
+ return;
+
try {
- await firstValueFrom(this.teamService.adminEnroll({ userIds: [this.addUserId], gameId: this.game.id }));
+ const userIds = this.selectedUsers.map(u => u.id);
+ const result = await firstValueFrom(this.teamService.adminEnroll({ userIds, gameId: this.game.id }));
+
+ if (this.onConfirm)
+ await this.onConfirm(result);
+
this.modalConfirmService.hide();
}
catch (err: any) {
this.errors.push(err);
}
}
+
+ protected handleTypeaheadSelect(apiUserResult: TypeaheadMatch) {
+ if (!this.selectedUsers.some(u => u.id === apiUserResult.item.id)) {
+ this.selectedUsers.push(apiUserResult.item);
+ this.selectedUsers.sort((a, b) => a.approvedName.localeCompare(b.approvedName));
+ }
+
+ this.searchTerm = "";
+ }
+
+ protected search(input: string) {
+ this._search$.next(input.length >= 3 ? input : "");
+ }
}
diff --git a/projects/gameboard-ui/src/app/admin/player-registrar/player-registrar.component.ts b/projects/gameboard-ui/src/app/admin/player-registrar/player-registrar.component.ts
index ec026d74..a92f08c6 100644
--- a/projects/gameboard-ui/src/app/admin/player-registrar/player-registrar.component.ts
+++ b/projects/gameboard-ui/src/app/admin/player-registrar/player-registrar.component.ts
@@ -227,11 +227,10 @@ export class PlayerRegistrarComponent {
});
}
- unenroll(model: Player): void {
+ async unenroll(model: Player): Promise {
this.isLoading = true;
- this.api.unenroll(model.id, true).pipe(first()).subscribe(_ => {
- this.refresh$.next(true);
- });
+ await firstValueFrom(this.teamService.unenroll({ teamId: model.teamId }));
+ this.refresh$.next(true);
}
update(model: Player): void {
@@ -293,7 +292,12 @@ export class PlayerRegistrarComponent {
content: AdminEnrollTeamModalComponent,
context: {
game: game,
+ onConfirm: result => {
+ this.toastService.showMessage(`Enrolled ${result.name}`);
+ this.refresh$.next(true);
+ }
},
+ modalClasses: ["modal-xl"]
});
}
@@ -304,7 +308,7 @@ export class PlayerRegistrarComponent {
protected confirmReset(request: TeamAdminContextMenuSessionResetRequest) {
this.modalConfirmService.openConfirm({
bodyContent: `
- Are you sure you want to reset the session for ${request.player.approvedName}${this.game.allowTeam ? " (and their team)" : ""}?
+ Are you sure you want to reset the session for ${this.game.allowTeam ? " team" : ""} ${request.player.approvedName}?
${(!request.unenrollTeam ? "" : `
They'll also be unenrolled from the game.`)}
`,
@@ -317,7 +321,7 @@ export class PlayerRegistrarComponent {
protected confirmUnenroll(player: Player) {
this.modalConfirmService.openConfirm({
- bodyContent: `Are you sure you want to unenroll ${player.approvedName}${this.game.allowTeam ? " (and their team)" : ""}?`,
+ bodyContent: `Are you sure you want to unenroll ${this.game.allowTeam ? " team " : ""}${player.approvedName}?`,
title: `Unenroll ${player.approvedName}?`,
onConfirm: () => {
this.unenroll(player);
diff --git a/projects/gameboard-ui/src/app/api/team.service.ts b/projects/gameboard-ui/src/app/api/team.service.ts
index e557d5e6..7b4af36f 100644
--- a/projects/gameboard-ui/src/app/api/team.service.ts
+++ b/projects/gameboard-ui/src/app/api/team.service.ts
@@ -4,7 +4,7 @@ import { Observable, Subject, map, tap } from "rxjs";
import { SessionEndRequest, SessionExtendRequest, Team } from "./player-models";
import { AdminEnrollTeamRequest, AdminEnrollTeamResponse, AdminExtendTeamSessionResponse, ResetTeamSessionRequest } from "./teams.models";
import { ApiUrlService } from "@/services/api-url.service";
-import { ApiDateTimeService } from "@/services/api-date-time.service";
+import { unique } from "../../tools";
@Injectable({ providedIn: 'root' })
export class TeamService {
@@ -18,11 +18,11 @@ export class TeamService {
public teamSessionReset$ = this._teamSessionReset$.asObservable();
constructor(
- private apiDates: ApiDateTimeService,
private apiUrl: ApiUrlService,
private http: HttpClient) { }
adminEnroll(request: AdminEnrollTeamRequest): Observable {
+ request.userIds = unique(request.userIds);
return this.http.post(this.apiUrl.build("admin/team"), request);
}
@@ -32,6 +32,12 @@ export class TeamService {
);
}
+ unenroll(request: { teamId: string }) {
+ return this.http.post(this.apiUrl.build(`team/${request.teamId}/session`), {
+ unenrollTeam: true
+ });
+ }
+
public get(teamId: string) {
return this.http.get(this.apiUrl.build(`/team/${teamId}`));
}
@@ -63,6 +69,12 @@ export class TeamService {
return this.updateSession(model);
}
+ public resetSession(teamId: string, request: ResetTeamSessionRequest): Observable {
+ return this.http.post(this.apiUrl.build(`/team/${teamId}/session`), request).pipe(
+ tap(_ => this._teamSessionReset$.next(teamId))
+ );
+ }
+
private updateSession(request: SessionExtendRequest | SessionEndRequest): Observable {
return this.http.put(this.apiUrl.build("/team/session"), request).pipe(
tap(_ => this._teamSessionsChanged$.next([request.teamId])),
@@ -70,9 +82,4 @@ export class TeamService {
);
}
- public resetSession(teamId: string, request: ResetTeamSessionRequest): Observable {
- return this.http.post(this.apiUrl.build(`/team/${teamId}/session`), request).pipe(
- tap(_ => this._teamSessionReset$.next(teamId))
- );
- }
}
diff --git a/projects/gameboard-ui/src/app/core/components/avatar/avatar.component.html b/projects/gameboard-ui/src/app/core/components/avatar/avatar.component.html
new file mode 100644
index 00000000..173713ef
--- /dev/null
+++ b/projects/gameboard-ui/src/app/core/components/avatar/avatar.component.html
@@ -0,0 +1 @@
+sponsor-avatar works!
diff --git a/projects/gameboard-ui/src/app/core/components/avatar/avatar.component.scss b/projects/gameboard-ui/src/app/core/components/avatar/avatar.component.scss
new file mode 100644
index 00000000..d0edfce4
--- /dev/null
+++ b/projects/gameboard-ui/src/app/core/components/avatar/avatar.component.scss
@@ -0,0 +1,14 @@
+@use "sass:map";
+@import "../../../../scss/player-avatars";
+
+.avatar-container {
+ aspect-ratio: 1 / 1;
+ background: no-repeat center center;
+ background-color: #dadada;
+ background-size: cover;
+ border: dashed 1px #dadada;
+ border-radius: 50%;
+ clip-path: circle(50% at 50% 50%);
+ display: inline-block;
+ object-fit: cover;
+}
diff --git a/projects/gameboard-ui/src/app/core/components/avatar/avatar.component.ts b/projects/gameboard-ui/src/app/core/components/avatar/avatar.component.ts
new file mode 100644
index 00000000..302c782f
--- /dev/null
+++ b/projects/gameboard-ui/src/app/core/components/avatar/avatar.component.ts
@@ -0,0 +1,24 @@
+import { AvatarSize } from '@/core/models/avatar';
+import { Component, ElementRef, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
+import { SafeUrl } from '@angular/platform-browser';
+
+@Component({
+ selector: 'app-avatar',
+ styleUrls: ['./avatar.component.scss'],
+ template: `
+
+ `,
+})
+export class AvatarComponent implements OnChanges {
+ @Input() imageUrl?: SafeUrl;
+ @Input() size: AvatarSize = "medium";
+ @Input() tooltip = "";
+
+ @ViewChild("searchBox") searchBox?: ElementRef;
+
+ protected sizeClass = "avatar-size-medium";
+
+ ngOnChanges(changes: SimpleChanges): void {
+ this.sizeClass = `avatar-size-${this.size}`;
+ }
+}
diff --git a/projects/gameboard-ui/src/app/core/components/player-avatar/player-avatar.component.ts b/projects/gameboard-ui/src/app/core/components/player-avatar/player-avatar.component.ts
index f9e6549f..6ca2a322 100644
--- a/projects/gameboard-ui/src/app/core/components/player-avatar/player-avatar.component.ts
+++ b/projects/gameboard-ui/src/app/core/components/player-avatar/player-avatar.component.ts
@@ -8,10 +8,10 @@ import { SafeUrl } from '@angular/platform-browser';
selector: 'app-player-avatar',
template: `
+
`,
styleUrls: ['./player-avatar.component.scss']
})
diff --git a/projects/gameboard-ui/src/app/core/core.module.ts b/projects/gameboard-ui/src/app/core/core.module.ts
index 4be1d621..fb2732b5 100644
--- a/projects/gameboard-ui/src/app/core/core.module.ts
+++ b/projects/gameboard-ui/src/app/core/core.module.ts
@@ -34,6 +34,8 @@ import { ApiDatePipe } from './pipes/api-date.pipe';
import { ApiUrlPipe } from './pipes/api-url.pipe';
import { ArrayContainsPipe } from './pipes/array-contains.pipe';
import { AssetPathPipe } from './pipes/asset-path.pipe';
+import { AutofocusDirective } from './directives/autofocus.directive';
+import { AvatarComponent } from './components/avatar/avatar.component';
import { BigStatComponent } from './components/big-stat/big-stat.component';
import { CamelspacePipe } from './pipes/camelspace.pipe';
import { ChallengeResultColorPipe } from './pipes/challenge-result-color.pipe';
@@ -46,11 +48,12 @@ import { ConfirmButtonComponent } from '@/core/components/confirm-button/confirm
import { CountdownColorPipe } from './pipes/countdown-color.pipe';
import { CountdownComponent } from './components/countdown/countdown.component';
import { CountdownPipe } from './pipes/countdown.pipe';
-import { DateToCountdownPipe } from './pipes/date-to-countdown.pipe';
import { CumulativeTimeClockComponent } from './components/cumulative-time-clock/cumulative-time-clock.component';
+import { DateToCountdownPipe } from './pipes/date-to-countdown.pipe';
import { DateTimeIsPastPipe } from './pipes/datetime-is-past.pipe';
import { DatetimeToDatePipe } from './pipes/datetime-to-date.pipe';
import { DateToDatetimePipe } from './pipes/date-to-datetime.pipe';
+import { DelimitedPipe } from './pipes/delimited.pipe';
import { DoughnutChartComponent } from './components/doughnut-chart/doughnut-chart.component';
import { DropzoneComponent } from './components/dropzone/dropzone.component';
import { ErrorDivComponent } from './components/error-div/error-div.component';
@@ -112,6 +115,8 @@ const PUBLIC_DECLARATIONS = [
ApiUrlPipe,
ArrayContainsPipe,
AssetPathPipe,
+ AutofocusDirective,
+ AvatarComponent,
BigStatComponent,
CamelspacePipe,
ChallengeResultColorPipe,
@@ -126,6 +131,7 @@ const PUBLIC_DECLARATIONS = [
DateToDatetimePipe,
DateTimeIsPastPipe,
DatetimeToDatePipe,
+ DelimitedPipe,
DoughnutChartComponent,
DropzoneComponent,
ErrorDivComponent,
@@ -147,13 +153,13 @@ const PUBLIC_DECLARATIONS = [
PlayerAvatarListComponent,
PlayerStatusComponent,
NumbersToPercentage,
+ QueryParamModelDirective,
RelativeUrlsPipe,
RenderLinksInTextComponent,
SpinnerComponent,
- UrlRewritePipe,
ToggleSwitchComponent,
TrimPipe,
- QueryParamModelDirective,
+ UrlRewritePipe,
ClockPipe,
CountdownPipe,
CountdownColorPipe,
@@ -203,12 +209,12 @@ const RELAYED_MODULES = [
RouterModule,
TabsModule,
TooltipModule,
- TypeaheadModule,
+ TypeaheadModule
];
@NgModule({
declarations: [
- ...PUBLIC_DECLARATIONS,
+ ...PUBLIC_DECLARATIONS
],
imports: [
CommonModule,
@@ -223,6 +229,7 @@ const RELAYED_MODULES = [
},
}),
PopoverModule.forRoot(),
+ TypeaheadModule.forRoot(),
...RELAYED_MODULES
],
exports: [
diff --git a/projects/gameboard-ui/src/app/core/directives/autofocus.directive.ts b/projects/gameboard-ui/src/app/core/directives/autofocus.directive.ts
new file mode 100644
index 00000000..88b2288d
--- /dev/null
+++ b/projects/gameboard-ui/src/app/core/directives/autofocus.directive.ts
@@ -0,0 +1,13 @@
+import { AfterViewInit, Directive, ElementRef } from '@angular/core';
+
+@Directive({ selector: '[appAutofocus]' })
+export class AutofocusDirective implements AfterViewInit {
+ constructor(private ref: ElementRef) { }
+
+ ngAfterViewInit(): void {
+ // i hate that this works
+ setTimeout(() => {
+ this.ref?.nativeElement?.focus();
+ }, 0);
+ }
+}
diff --git a/projects/gameboard-ui/src/app/core/directives/copy-on-click.directive.ts b/projects/gameboard-ui/src/app/core/directives/copy-on-click.directive.ts
index eebbafeb..770dae73 100644
--- a/projects/gameboard-ui/src/app/core/directives/copy-on-click.directive.ts
+++ b/projects/gameboard-ui/src/app/core/directives/copy-on-click.directive.ts
@@ -13,7 +13,6 @@ export class CopyOnClickDirective implements AfterViewInit {
ngAfterViewInit() {
const existingOnClick = this.elementRef.nativeElement.onclick;
-
this.elementRef.nativeElement.onclick = () => {
if (existingOnClick)
existingOnClick();
diff --git a/projects/gameboard-ui/src/app/core/models/avatar.ts b/projects/gameboard-ui/src/app/core/models/avatar.ts
new file mode 100644
index 00000000..644168fc
--- /dev/null
+++ b/projects/gameboard-ui/src/app/core/models/avatar.ts
@@ -0,0 +1 @@
+export type AvatarSize = 'tiny' | 'small' | 'medium' | 'large';
diff --git a/projects/gameboard-ui/src/app/core/pipes/delimited.pipe.ts b/projects/gameboard-ui/src/app/core/pipes/delimited.pipe.ts
new file mode 100644
index 00000000..0ce2c025
--- /dev/null
+++ b/projects/gameboard-ui/src/app/core/pipes/delimited.pipe.ts
@@ -0,0 +1,12 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({ name: 'delimited' })
+export class DelimitedPipe implements PipeTransform {
+
+ transform(value: string, delimiter = ","): string[] {
+ if (!value)
+ return [];
+
+ return value.split(delimiter).map(item => item.trim());
+ }
+}
diff --git a/projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.html b/projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.html
index 9e9f55d1..d9099c9c 100644
--- a/projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.html
+++ b/projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.html
@@ -4,7 +4,7 @@
-