From 5f503b39bbdec422ac6fdc4c01cc956877c66ec5 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 22 Jul 2025 16:36:35 -0400 Subject: [PATCH 01/34] Initial work on challenge collections (admin side only) --- package-lock.json | 8 +- package.json | 2 +- .../src/app/admin/admin.module.ts | 16 +- .../games-table-view.component.ts | 32 +-- .../observe-view/observe-view.component.ts | 2 +- .../challenge-group-list.component.html | 43 +++ .../challenge-group-list.component.scss | 11 + .../challenge-group-list.component.ts | 84 ++++++ ...allenge-group-upsert-dialog.component.html | 60 +++++ ...allenge-group-upsert-dialog.component.scss | 7 + ...challenge-group-upsert-dialog.component.ts | 96 +++++++ .../challenge-group.component.html | 246 ++++++++++++++++++ .../challenge-group.component.scss | 46 ++++ .../challenge-group.component.ts | 217 +++++++++++++++ .../practice-content.component.html | 1 + .../practice-content.component.scss | 13 + .../practice-content.component.ts | 11 + .../admin/practice/practice.component.html | 9 +- .../gameboard-ui/src/app/app.component.html | 3 + projects/gameboard-ui/src/app/app.module.ts | 2 + .../console-page/console-page.component.html | 4 +- .../console-page/console-page.component.scss | 6 +- .../console-page/console-page.component.ts | 11 +- .../consoles/pipes/console-id-to-url.pipe.ts | 1 - .../gameboard-ui/src/app/core/core.module.ts | 4 +- .../core/directives/autofocus.directive.ts | 4 +- .../core/pipes/markdown-placeholder.pipe.ts | 2 +- .../src/app/core/pipes/pluralizer.pipe.ts | 5 +- .../src/app/core/pipes/relative-image.pipe.ts | 8 +- .../feedback-template-picker.component.ts | 22 +- ...-session-availability-warning.component.ts | 18 +- .../gameboard-ui/src/app/game/game.module.ts | 2 + .../app-layout/app-layout.component.html | 2 - .../app-layout/app-layout.component.ts | 36 +-- .../challenge-group-card.component.html | 20 ++ .../challenge-group-card.component.scss | 28 ++ .../challenge-group-card.component.ts | 19 ++ ...ice-challenge-state-summary.component.html | 8 +- .../pipes/challenge-group-card-image.pipe.ts | 16 ++ .../gameboard-ui/src/app/prac/prac.module.ts | 1 + .../src/app/prac/practice.models.ts | 82 +++++- ...sponsor-challenge-performance.component.ts | 8 +- .../src/app/reports/reports.module.ts | 4 + .../src/app/services/practice.service.ts | 43 ++- .../src/app/services/router.service.ts | 18 +- .../components/spinner/spinner.component.ts | 16 +- .../admin-system-notifications.component.ts | 12 +- .../system-notifications.module.ts | 7 +- .../game-card/game-card.component.ts | 10 +- .../src/app/utility/utility.module.ts | 4 + .../practice-challenge-group/default-card.svg | 101 +++++++ projects/gameboard-ui/src/styles.scss | 8 + .../src/tools/object-tools.lib.ts | 31 +++ tsconfig.json | 14 +- 54 files changed, 1333 insertions(+), 151 deletions(-) create mode 100644 projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.html create mode 100644 projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.ts create mode 100644 projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.html create mode 100644 projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.ts create mode 100644 projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.html create mode 100644 projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.ts create mode 100644 projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.html create mode 100644 projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.ts create mode 100644 projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.html create mode 100644 projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.scss create mode 100644 projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.ts create mode 100644 projects/gameboard-ui/src/app/prac/pipes/challenge-group-card-image.pipe.ts create mode 100644 projects/gameboard-ui/src/assets/img/practice-challenge-group/default-card.svg diff --git a/package-lock.json b/package-lock.json index fed78cfcb..32f71395f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@angular/platform-browser": "^19.2.14", "@angular/platform-browser-dynamic": "^19.2.14", "@angular/router": "^19.2.14", - "@cmusei/console-forge": "^0.19.1", + "@cmusei/console-forge": "^0.20.1", "@fortawesome/angular-fontawesome": "^1.0.0", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", @@ -3760,9 +3760,9 @@ "optional": true }, "node_modules/@cmusei/console-forge": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@cmusei/console-forge/-/console-forge-0.19.1.tgz", - "integrity": "sha512-TgDsBX2r35HerMCxrIimvlcDPzEfOUUQxNdVCqiZaq84dpyxPBCeVBp5VIi47Oy7+UYprpHmxRWoUGmVzpB3hw==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@cmusei/console-forge/-/console-forge-0.20.1.tgz", + "integrity": "sha512-1Y70eji4Z0PZpnNi4sPHhOthsAr0KH313OjbHam+mpLDl7Bw0KG4IirHJ2+ImtGEtSfhL+eR079V03B5zXLw7g==", "dependencies": { "@picocss/pico": "^2.1.1", "tslib": "^2.3.0" diff --git a/package.json b/package.json index f4ed06efd..a507379b4 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@angular/platform-browser": "^19.2.14", "@angular/platform-browser-dynamic": "^19.2.14", "@angular/router": "^19.2.14", - "@cmusei/console-forge": "^0.19.1", + "@cmusei/console-forge": "^0.20.1", "@fortawesome/angular-fontawesome": "^1.0.0", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", diff --git a/projects/gameboard-ui/src/app/admin/admin.module.ts b/projects/gameboard-ui/src/app/admin/admin.module.ts index 5daac0367..022bf1ac7 100644 --- a/projects/gameboard-ui/src/app/admin/admin.module.ts +++ b/projects/gameboard-ui/src/app/admin/admin.module.ts @@ -98,6 +98,10 @@ import { StatusLightComponent } from '@/core/components/status-light/status-ligh import { ConsoleTileComponent } from '@cmusei/console-forge'; import { PracticeObserveComponent } from './practice/practice-observe/practice-observe.component'; import { ObserveViewComponent } from './components/observe-view/observe-view.component'; +import { PracticeContentComponent } from './practice/practice-content/practice-content.component'; +import { PluralizerPipe } from '@/core/pipes/pluralizer.pipe'; +import { ChallengeGroupComponent } from './practice/challenge-group/challenge-group.component'; +import { ChallengeGroupListComponent } from './practice/challenge-group-list/challenge-group-list.component'; @NgModule({ declarations: [ @@ -193,9 +197,18 @@ import { ObserveViewComponent } from './components/observe-view/observe-view.com }, { path: "practice", component: PracticeComponent, children: [ + { + path: "content", + component: PracticeContentComponent, + title: "Practice Content", + children: [ + { path: "", component: ChallengeGroupListComponent, title: "Practice Content", pathMatch: "full" }, + { path: "group/:id", component: ChallengeGroupComponent, title: "Challenge Group" } + ] + }, { path: "settings", component: PracticeSettingsComponent, title: "Practice Settings" }, { path: "observe", component: PracticeObserveComponent, title: "Practice Observe" }, - { path: "", pathMatch: "full", redirectTo: "settings" }, + { path: "", pathMatch: "full", redirectTo: "content" }, ] }, { path: 'registrar/sponsors', component: SponsorBrowserComponent, title: "Sponsors | Admin" }, @@ -233,6 +246,7 @@ import { ObserveViewComponent } from './components/observe-view/observe-view.com MarkdownPlaceholderPipe, ObserveViewComponent, PackageUploadComponent, + PluralizerPipe, SafeUrlPipe, SessionExtensionGameEndWarningComponent, SpinnerComponent, diff --git a/projects/gameboard-ui/src/app/admin/components/games-table-view/games-table-view.component.ts b/projects/gameboard-ui/src/app/admin/components/games-table-view/games-table-view.component.ts index 8c6b029d2..1f22310aa 100644 --- a/projects/gameboard-ui/src/app/admin/components/games-table-view/games-table-view.component.ts +++ b/projects/gameboard-ui/src/app/admin/components/games-table-view/games-table-view.component.ts @@ -1,29 +1,31 @@ import { Component, inject, input, output } from '@angular/core'; +import { Router } from '@angular/router'; import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; import { firstValueFrom } from 'rxjs'; +import { GameToGameCenterLinkPipe } from '@/admin/pipes/game-to-game-center-link.pipe'; import { Game, ListGamesResponseGame } from '@/api/game-models'; import { CoreModule } from '@/core/core.module'; import { GameToMetadataTextPipe } from '@/core/pipes/game-to-metadata-text.pipe'; -import { fa } from "@/services/font-awesome.service"; import { GameInfoBubblesComponent } from '@/standalone/games/components/game-info-bubbles/game-info-bubbles.component'; -import { GameToGameCenterLinkPipe } from '@/admin/pipes/game-to-game-center-link.pipe'; import { GameService } from '@/api/game.service'; -import { RouterService } from '@/services/router.service'; import { ThemeBgDirective } from '@/core/directives/theme-bg.directive'; -import { Router } from '@angular/router'; +import { PluralizerPipe } from '@/core/pipes/pluralizer.pipe'; +import { fa } from "@/services/font-awesome.service"; +import { RouterService } from '@/services/router.service'; @Component({ - selector: 'app-games-table-view', - templateUrl: './games-table-view.component.html', - styleUrl: './games-table-view.component.scss', - imports: [ - BsDropdownModule, - CoreModule, - GameInfoBubblesComponent, - GameToGameCenterLinkPipe, - GameToMetadataTextPipe, - ThemeBgDirective - ] + selector: 'app-games-table-view', + templateUrl: './games-table-view.component.html', + styleUrl: './games-table-view.component.scss', + imports: [ + BsDropdownModule, + CoreModule, + GameInfoBubblesComponent, + GameToGameCenterLinkPipe, + GameToMetadataTextPipe, + PluralizerPipe, + ThemeBgDirective + ] }) export class GamesTableViewComponent { cloneRequest = output(); diff --git a/projects/gameboard-ui/src/app/admin/components/observe-view/observe-view.component.ts b/projects/gameboard-ui/src/app/admin/components/observe-view/observe-view.component.ts index 0967b9476..551fce5da 100644 --- a/projects/gameboard-ui/src/app/admin/components/observe-view/observe-view.component.ts +++ b/projects/gameboard-ui/src/app/admin/components/observe-view/observe-view.component.ts @@ -116,7 +116,7 @@ export class ObserveViewComponent { credentials: { accessTicket: console.accessTicket }, url: console.url }; - const consoleAppUrl = this.routerService.buildVmConsoleUrl(console.consoleId.challengeId, console.consoleId.name).toString(); + const consoleAppUrl = this.routerService.buildVmConsoleUrl(console.consoleId.challengeId, console.consoleId.name, false, true).toString(); if (teamsToPush.has(console.team.id)) { teamsToPush.get(console.team.id)!.consoles.push({ diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.html b/projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.html new file mode 100644 index 000000000..d06e39a1b --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.html @@ -0,0 +1,43 @@ + + +
+ +
+ +@if (existingGroupsResource.isLoading()) +{ +Loading challenge groups... +} +@else if (!existingGroups()?.length) +{ +
+
+ No collections yet. Hit the "Add Collection" button to start structuring your challenges into handy + collections! +
+} + +
+ @for (group of existingGroups(); track group.id) + { + +
+ + View + + + +
+
+ } +
+ + + + diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.scss b/projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.scss new file mode 100644 index 000000000..1e8542883 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.scss @@ -0,0 +1,11 @@ +.practice-content-container { + align-items: stretch; + flex-wrap: wrap; + gap: 1rem; +} + +app-challenge-group-card { + display: block; + height: auto; + flex: 0 0 30%; +} diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.ts b/projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.ts new file mode 100644 index 000000000..e62203b0e --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group-list/challenge-group-list.component.ts @@ -0,0 +1,84 @@ +import { CoreModule } from '@/core/core.module'; +import { ChallengeGroupCardComponent } from '@/prac/components/challenge-group-card/challenge-group-card.component'; +import { CreatePracticeChallengeGroupRequest, ListChallengeGroupsResponseGroup, UpdateChallengeGroupRequest } from '@/prac/practice.models'; +import { fa } from '@/services/font-awesome.service'; +import { ModalConfirmService } from '@/services/modal-confirm.service'; +import { PracticeService } from '@/services/practice.service'; +import { ErrorDivComponent } from '@/standalone/core/components/error-div/error-div.component'; +import { SpinnerComponent } from '@/standalone/core/components/spinner/spinner.component'; +import { Component, computed, inject, model, resource, TemplateRef, viewChild } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { ChallengeGroupUpsertDialogComponent, UpsertChallengeGroup } from '../challenge-group-upsert-dialog/challenge-group-upsert-dialog.component'; + +@Component({ + selector: 'app-challenge-group-list', + imports: [ + FontAwesomeModule, + CoreModule, + ChallengeGroupCardComponent, + ChallengeGroupUpsertDialogComponent, + SpinnerComponent, + ErrorDivComponent + ], + templateUrl: './challenge-group-list.component.html', + styleUrl: './challenge-group-list.component.scss' +}) +export class ChallengeGroupListComponent { + private readonly modalService = inject(ModalConfirmService); + private readonly practiceService = inject(PracticeService); + + protected errors: any[] = []; + protected fa = fa; + protected editGroup = model(); + protected existingGroupsResource = resource({ + loader: () => this.practiceService.challengeGroupList() + }); + protected existingGroups = computed(() => this.existingGroupsResource.value()); + protected readonly upsertGroupModalTemplate = viewChild>("upsertGroupModal"); + + protected handleOpenCreateDialog() { + if (!this.upsertGroupModalTemplate()) { + this.errors.push("Couldn't resolve the group dialog."); + } + + this.editGroup.update(() => undefined); + this.modalService.openTemplate(this.upsertGroupModalTemplate()!); + } + + protected handleOpenDeleteDialog(group: ListChallengeGroupsResponseGroup) { + const subcollectionsText = group.childGroups.length > 0 ? " Its subcollections will also be deleted." : ""; + + this.modalService.openConfirm({ + title: "Delete Collection", + subtitle: group.name, + bodyContent: `Are you sure you want to delete the collection **${group.name}**?${subcollectionsText}\n\nAny challenges it contains will still be available in the Practice Area.`, + renderBodyAsMarkdown: true, + onConfirm: async () => { + await this.practiceService.challengeGroupDelete(group.id); + this.existingGroupsResource.reload(); + } + }); + } + + protected handleOpenEditDialog(group: ListChallengeGroupsResponseGroup) { + if (!this.upsertGroupModalTemplate()) { + this.errors.push("Couldn't resolve the group dialog."); + } + + this.editGroup.update(() => ({ + ...group, + previousImageUrl: group.imageUrl, + })); + this.modalService.openTemplate(this.upsertGroupModalTemplate()!); + } + + protected async handleGroupSaved(value: CreatePracticeChallengeGroupRequest | UpdateChallengeGroupRequest) { + this.errors = []; + try { + this.existingGroupsResource.reload(); + } + catch (err) { + this.errors.push(err); + } + } +} diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.html b/projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.html new file mode 100644 index 000000000..e0d1d8f17 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.html @@ -0,0 +1,60 @@ + + + +
+
+ + +
+ +
+ + +
+ +
+ + + + +
+ +
+ +
+ @if (previewImageUrl()) + { + Uploaded image (for group card) + } + @else if (isEditing() && !this.previewImageUrl() && this.upsertGroupForm.value.previousImageUrl) + { + + } +
+ +
+ +
+ +
+
+
+ +
+ + +
+
+
diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.scss b/projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.scss new file mode 100644 index 000000000..c05f51e35 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.scss @@ -0,0 +1,7 @@ +.preview-group-card-image { + max-height: 200px; +} + +input[type="checkbox"] { + text-align: left; +} diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.ts b/projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.ts new file mode 100644 index 000000000..bbd38b256 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group-upsert-dialog/challenge-group-upsert-dialog.component.ts @@ -0,0 +1,96 @@ +import { Component, computed, effect, inject, input, output, resource, signal } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { CoreModule } from '@/core/core.module'; +import { MarkdownPlaceholderPipe } from '@/core/pipes/markdown-placeholder.pipe'; +import { CreatePracticeChallengeGroupRequest, UpdateChallengeGroupRequest } from '@/prac/practice.models'; +import { PracticeService } from '@/services/practice.service'; +import { ErrorDivComponent } from '@/standalone/core/components/error-div/error-div.component'; +import { RelativeImagePipe } from '@/core/pipes/relative-image.pipe'; + +export interface UpsertChallengeGroup { + id: string; + name: string; + description: string; + isFeatured: boolean; + previousImageUrl?: string; + parentGroupId?: string; +} + +@Component({ + selector: 'app-challenge-group-upsert-dialog', + imports: [ + CoreModule, + ErrorDivComponent, + MarkdownPlaceholderPipe, + RelativeImagePipe + ], + templateUrl: './challenge-group-upsert-dialog.component.html', + styleUrl: './challenge-group-upsert-dialog.component.scss' +}) +export class ChallengeGroupUpsertDialogComponent { + public group = input>(); + public saved = output(); + + private readonly practiceService = inject(PracticeService); + protected errors: any[] = []; + protected readonly isEditing = computed(() => !!this.upsertGroupForm.value.id); + protected previewImageUrl = signal(null); + protected upsertGroupForm = new FormGroup({ + id: new FormControl(""), + name: new FormControl("", Validators.required), + description: new FormControl("", Validators.required), + isFeatured: new FormControl(false), + image: new FormControl(null), + parentGroupId: new FormControl(null), + previousImageUrl: new FormControl(""), + }); + protected existingGroupsResource = resource({ loader: () => this.practiceService.challengeGroupList() }); + protected existingGroups = computed(() => this.existingGroupsResource.value()); + + constructor() { + effect(() => { + // incoming edit request + const group = this.group(); + if (group) { + this.upsertGroupForm.patchValue(group); + this.previewImageUrl.update(() => null); + } + }); + } + + protected async handleConfirm() { + this.errors = []; + + try { + if (this.upsertGroupForm.value.id) { + await this.practiceService.challengeGroupUpdate(this.upsertGroupForm.value as UpdateChallengeGroupRequest); + this.saved.emit(this.upsertGroupForm.value as UpdateChallengeGroupRequest); + } else { + await this.practiceService.challengeGroupCreate(this.upsertGroupForm.value as CreatePracticeChallengeGroupRequest); + this.saved.emit(this.upsertGroupForm.value as CreatePracticeChallengeGroupRequest); + } + this.existingGroupsResource.reload(); + } + catch (err) { + this.errors.push(err); + } + } + + protected handleImagePicked(event: Event) { + const inputElement = event?.target as HTMLInputElement; + if (!inputElement || !inputElement.files) { + this.previewImageUrl.update(() => null); + this.upsertGroupForm.patchValue({ image: null }); + return; + } + + const file = inputElement.files[0]; + this.previewImageUrl.update(() => URL.createObjectURL(file)); + this.upsertGroupForm.patchValue({ image: file || null }); + } + + protected handleRevertToDefaultImage() { + this.upsertGroupForm.patchValue({ image: null, previousImageUrl: null }); + this.previewImageUrl.update(() => null); + } +} diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.html b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.html new file mode 100644 index 000000000..5230bf035 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.html @@ -0,0 +1,246 @@ +@if(groupResource.isLoading()) +{ +Loading group... +} + +@if (groupResource.hasValue()) +{ +
+
+
+ + + @if (groupResource.value().parentGroup?.id) + { + + } + + + + + + +
+
+ +
+ + @if (canAddSubCollections()) + { + @if(groupResource.value().childGroups.length == 0 && groupResource.value().group.challenges.length == 0) + { + +
About challenge collections
+ + A collection can technically contain both subcollections and challenges. In + practice, we recommend choosing one or the other. For example, you add all the challenges from a specific + event to this collection, or you might instead create subcollections by difficulty (e.g. "Easy", "Medium", + and "Hard") which contain the challenges. +
+ } +
+

Subcollections

+ + @if (groupResource.value().childGroups.length) + { +
+ +
+
+ @for (childGroup of groupResource.value().childGroups; track childGroup.id) + { + +
+ {{ childGroup.challenges.length }} {{ "challenge" | pluralizer:childGroup.challenges.length }} +
+ View + + +
+ } +
+ } + @else + { +
+ This collection doesn't have any subcollections. + Add one now! +
+ } +
+ } + +
+

+ Challenges + {{ groupResource.value().group.challenges.length ? " (" + groupResource.value().group.challenges.length + + ")" : "" + }} +

+ + @if (groupResource.value().group.challenges.length) + { +
+ +
+ +
+
+ } + @else + { +
+ This collection doesn't have any challenges yet. + Add some now! +
+ } + + @for (childGroup of groupResource.value().childGroups; track childGroup.id) + { +
+
+ } +
+
+
+} + + + + +
+
+ + +
+
+ + +
+ + @if (gamesResource.value()?.length) + { +
+ + +
+ } +
+ + @if (addChallengesByValue() === "challenge") + { + + NOTE: To be available in the Practice Area, a challenge's game must be in Practice Mode. + + + } + @else if (addChallengesByValue() === "tag") + { + + } + @else if (addChallengesByValue() === "game") + { + + } +
+
+ + + + + + + + + + +
+ {{ context.group.name }} header image +
{{ context.isThisGroup ? "In this collection" : context.group.name }}
+
+ + @if (context.group.challenges.length) + { + + + + + + + + + + @for (challenge of context.group.challenges; track challenge.id) + { + + + + + + + } + +
ChallengeTimes Launched/CompletedLast Launch + +
{{ challenge.name }}{{ challenge.countLaunched }} / {{ challenge.countCompleted }}{{ challenge.lastLaunched | friendlyDateAndTime }} + +
+ } + @else + { +
+ This subcollection doesn't have any challenges. +
+ } +
diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.scss b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.scss new file mode 100644 index 000000000..896f58c8b --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.scss @@ -0,0 +1,46 @@ +@import "../../../../scss/variables"; + +.main-group-card { + width: 300px; +} + +app-challenge-group-card { + display: block; +} + +.child-groups-container app-challenge-group-card { + width: 30%; +} + +.layout-container { + display: flex; + gap: 1.5rem; + + .quasi-sidebar { + flex: 0 1 auto; + + .quasi-sidebar-content { + top: 14px; + position: sticky; + } + } +} + +.group-challenges-header { + background-color: $body-bg; + + img { + aspect-ratio: 16/9; + max-height: 3rem; + object-fit: cover; + } + + h5 { + margin: 0; + padding: 0; + } +} + +.challenge-title-cell { + width: 40%; +} diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.ts b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.ts new file mode 100644 index 000000000..f4e893530 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.ts @@ -0,0 +1,217 @@ +import { Component, computed, effect, inject, model, resource, signal, TemplateRef, viewChild } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { firstValueFrom, map } from 'rxjs'; +import { CoreModule } from '@/core/core.module'; +import { PracticeService } from '@/services/practice.service'; +import { SpinnerComponent } from '@/standalone/core/components/spinner/spinner.component'; +import { ModalConfirmService } from '@/services/modal-confirm.service'; +import { ChallengesAddToGroupRequest, GetPracticeChallengeGroupResponseChallenge, GetPracticeChallengeGroupResponseGroup, PracticeChallengeView } from '@/prac/practice.models'; +import { ChallengeGroupCardComponent } from '@/prac/components/challenge-group-card/challenge-group-card.component'; +import { fa } from '@/services/font-awesome.service'; +import { AppTitleService } from '@/services/app-title.service'; +import { ChallengeGroupUpsertDialogComponent, UpsertChallengeGroup } from '../challenge-group-upsert-dialog/challenge-group-upsert-dialog.component'; +import { PluralizerPipe } from '@/core/pipes/pluralizer.pipe'; +import { ChallengeGroupCardImagePipe } from '@/prac/pipes/challenge-group-card-image.pipe'; +import { GameService } from '@/api/game.service'; +import { PlayerMode } from '@/api/player-models'; +import { SimpleEntity } from '@/api/models'; +import { ToastService } from '@/utility/services/toast.service'; + +@Component({ + selector: 'app-challenge-group', + imports: [ + CoreModule, + ChallengeGroupCardComponent, + ChallengeGroupCardImagePipe, + ChallengeGroupUpsertDialogComponent, + PluralizerPipe, + SpinnerComponent, + ], + templateUrl: './challenge-group.component.html', + styleUrl: './challenge-group.component.scss' +}) +export class ChallengeGroupComponent { + private readonly activeRoute = inject(ActivatedRoute); + private readonly gamesService = inject(GameService); + private readonly groupId = toSignal(this.activeRoute.paramMap.pipe(map(p => p.get("id")))); + private readonly modalService = inject(ModalConfirmService); + private readonly practiceService = inject(PracticeService); + private readonly router = inject(Router); + private readonly title = inject(AppTitleService); + private readonly toasts = inject(ToastService); + + protected readonly addChallengeModalTemplate = viewChild>("addChallengeModal"); + protected readonly addChildGroupModalTemplate = viewChild>("addChildGroupModal"); + protected readonly editGroupModalTemplate = viewChild>("editGroupModal"); + + protected readonly challengesResource = resource({ loader: async () => this.practiceService.challengesList() }); + protected readonly gamesResource = resource({ + loader: async () => { + return firstValueFrom(this.gamesService.list().pipe( + map(games => games + .filter(g => g.playerMode == PlayerMode.practice) + .map(g => ({ id: g.id, name: g.name } as SimpleEntity)) + .sort((a, b) => a.name.localeCompare(b.name)) + ) + )); + } + }); + protected readonly groupResource = resource({ + request: () => ({ id: this.groupId() }), + loader: ({ request }) => this.practiceService.challengeGroupGet(request.id!) + }); + protected readonly settingsResource = resource({ loader: () => firstValueFrom(this.practiceService.getSettings()) }); + + protected readonly addChallengesByValue = model<"challenge" | "game" | "tag">("challenge"); + protected readonly canAddSubCollections = computed(() => !this.groupResource.value()?.parentGroup); + protected readonly challenges = computed(() => { + // the API will reject duplicates, but hide any challenges already in the group + const challenges = this.challengesResource.value(); + const group = this.groupResource.value(); + + if (!challenges?.length) { + return []; + } + + return challenges.filter(c => group?.group.challenges.map(c => c.id).indexOf(c.id) === -1); + }); + protected readonly creatingChildGroup = computed(() => ({ parentGroupId: this.group()?.id })); + protected readonly editingGroup = signal(undefined); + protected readonly fa = fa; + protected readonly group = computed(() => this.groupResource.value()?.group); + protected readonly selectedChallenge = model(); + protected readonly selectedGame = model(); + protected readonly selectedTag = model(); + protected readonly tags = computed(() => { + const settings = this.settingsResource.value(); + return settings?.suggestedSearches?.sort() || []; + }); + + constructor() { + // need to update the title based on the groupResource + effect(() => { + const group = this.groupResource.value(); + this.title.set(group?.group.name || "Practice Challenge Group"); + }); + + // when challenges, games, and tags are loaded, automatically select the first one (so the add from x dropdowns + // aren't blank) + effect(() => { + const challenges = this.challengesResource.value(); + if (challenges?.length) { + this.selectedChallenge.update(() => challenges[0]); + } + }); + effect(() => { + const games = this.gamesResource.value(); + if (games?.length) { + this.selectedGame.update(() => games[0]); + } + }); + effect(() => { + const tags = this.tags(); + if (tags.length) { + this.selectedTag.update(() => tags[0]); + } + }); + } + + protected async handleAddChallenges() { + if (!this.groupId()) { + throw new Error("Can't resolve the groupId."); + } + + const addChallengesRequest: ChallengesAddToGroupRequest = {}; + switch (this.addChallengesByValue()) { + case "challenge": + addChallengesRequest.addBySpecIds = [this.selectedChallenge()!.id]; + break; + case "game": + addChallengesRequest.addByGameId = this.selectedGame()!.id; + break; + case "tag": + addChallengesRequest.addByTag = this.selectedTag(); + break; + } + + const addedSpecIds = await this.practiceService.challengesAddToGroup(this.groupId()!, addChallengesRequest); + this.toasts.showMessage(`Added **${addedSpecIds.addedChallengeSpecIds.length} challenges** to the **${this.group()?.name || ""}** group.`); + this.groupResource.reload(); + } + + protected handleOpenAddSubCollectionModal() { + if (!this.addChildGroupModalTemplate()) { + throw new Error("Couldn't resolve the template."); + } + + this.modalService.openTemplate(this.addChildGroupModalTemplate()!); + } + + protected handleOpenEditCollectionModal(group: GetPracticeChallengeGroupResponseGroup, parentGroupId?: string) { + if (!this.editGroupModalTemplate()) { + throw new Error("Couldn't resolve the template."); + } + + this.editingGroup.update(() => { + return { + ...group, + parentGroupId: parentGroupId, + previousImageUrl: group.imageUrl + }; + }); + + this.modalService.openTemplate(this.editGroupModalTemplate()!); + } + + protected handleDeleteCollectionModal(group: GetPracticeChallengeGroupResponseGroup, hasChildGroups: boolean) { + const childGroupsText = !hasChildGroups ? "" : " Its subcollections will also be deleted."; + this.modalService.openConfirm({ + title: "Delete Collection", + subtitle: group.name, + bodyContent: `Are you sure you want to delete the collection **${group.name}**?${childGroupsText}\n\nChallenges in the collection will still be available in the Practice Area.`, + renderBodyAsMarkdown: true, + onConfirm: async () => { + await this.practiceService.challengeGroupDelete(group.id); + + if (group.id === this.groupResource.value()?.group?.id) { + // if the deleted group is this page's group, return to the group list screen + this.router.navigateByUrl("/admin/practice/content"); + } else { + // otherwise, just reload + this.groupResource.reload(); + } + } + }); + } + + protected handleGroupSaved() { + this.groupResource.reload(); + } + + protected handleOpenAddChallengeModal() { + if (!this.addChallengeModalTemplate()) { + throw new Error("Couldn't resolve the modal."); + } + + this.modalService.openTemplate(this.addChallengeModalTemplate()!); + } + + protected handleOpenRemoveChallengeModal(groupId: string, challenge: GetPracticeChallengeGroupResponseChallenge) { + if (!this.groupId()) { + throw new Error("Can't resolve the groupId."); + } + + this.modalService.openConfirm({ + title: "Remove Challenge", + subtitle: this.groupResource.value()?.group?.name, + bodyContent: `Remove **${challenge.name}** from this collection? (It'll still be available in the Practice Area.)`, + onConfirm: async () => { + await this.practiceService.challengesRemoveFromGroup({ challengeGroupId: groupId, challengeSpecIds: [challenge.id] }); + this.groupResource.reload(); + }, + confirmButtonText: "Remove", + renderBodyAsMarkdown: true + }); + } +} diff --git a/projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.html b/projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.html new file mode 100644 index 000000000..0680b43f9 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.html @@ -0,0 +1 @@ + diff --git a/projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.scss b/projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.scss new file mode 100644 index 000000000..d84031e09 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.scss @@ -0,0 +1,13 @@ +.practice-content-container { + flex-wrap: wrap; + gap: 1rem; +} + +app-challenge-group-card { + display: block; + flex: 0 0 30%; +} + +.preview-group-card-image { + max-height: 200px; +} diff --git a/projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.ts b/projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.ts new file mode 100644 index 000000000..481b94d1a --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/practice/practice-content/practice-content.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-practice-content', + imports: [RouterOutlet], + templateUrl: './practice-content.component.html', + styleUrl: './practice-content.component.scss' +}) +export class PracticeContentComponent { +} diff --git a/projects/gameboard-ui/src/app/admin/practice/practice.component.html b/projects/gameboard-ui/src/app/admin/practice/practice.component.html index b6dd30b04..6e86768ca 100644 --- a/projects/gameboard-ui/src/app/admin/practice/practice.component.html +++ b/projects/gameboard-ui/src/app/admin/practice/practice.component.html @@ -8,8 +8,8 @@

Practice Area

diff --git a/projects/gameboard-ui/src/app/app.component.html b/projects/gameboard-ui/src/app/app.component.html index bab4cea07..3a44da804 100644 --- a/projects/gameboard-ui/src/app/app.component.html +++ b/projects/gameboard-ui/src/app/app.component.html @@ -1,5 +1,8 @@
+ + +
diff --git a/projects/gameboard-ui/src/app/app.module.ts b/projects/gameboard-ui/src/app/app.module.ts index ad7f717d8..a209fc45f 100644 --- a/projects/gameboard-ui/src/app/app.module.ts +++ b/projects/gameboard-ui/src/app/app.module.ts @@ -36,6 +36,7 @@ import { markedOptionsFactory } from './core/config/marked.config'; import { MarkdownModule, MARKED_OPTIONS, provideMarkdown } from 'ngx-markdown'; import { ThemeBgDirective } from './core/directives/theme-bg.directive'; import { environment } from '../environments/environment'; +import { MessageBoardComponent } from './utility/components/message-board/message-board.component'; @NgModule({ declarations: [ @@ -60,6 +61,7 @@ import { environment } from '../environments/environment'; ProgressbarModule.forRoot(), // standalones + MessageBoardComponent, ThemeBgDirective, UserNavItemComponent ], diff --git a/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.html b/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.html index b31d05f2f..06a71862d 100644 --- a/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.html +++ b/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.html @@ -12,7 +12,7 @@ }
-
+
@if (expiresAt()) {
@@ -22,7 +22,7 @@ @if(consoleIsViewOnly()) { -
+
View Only
} diff --git a/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.scss b/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.scss index 69b035d46..8e78084f1 100644 --- a/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.scss +++ b/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.scss @@ -33,11 +33,15 @@ cf-console { background-color: rgba(0, 0, 0, 0.4); padding: 8px; font-size: 2rem; + text-align: center; } .is-view-only { + border: 2px solid red; + } + + .view-only-warning { background-color: red; - border-radius: 6px; color: #fff; flex: 0 1 auto; font-weight: bold; diff --git a/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.ts b/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.ts index 2628450ea..f86acdf78 100644 --- a/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.ts +++ b/projects/gameboard-ui/src/app/consoles/components/console-page/console-page.component.ts @@ -1,8 +1,7 @@ -import { AfterViewInit, Component, effect, HostListener, inject, model, signal, Signal, viewChild } from '@angular/core'; +import { AfterViewInit, Component, HostListener, inject, model, signal, Signal, viewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { toSignal } from "@angular/core/rxjs-interop"; import { ConsoleComponent, ConsoleComponentConfig, ConsoleConnectionStatus } from "@cmusei/console-forge"; -import { DateTime } from 'luxon'; import { interval, map } from 'rxjs'; import { UserActivityListenerComponent } from '../user-activity-listener/user-activity-listener.component'; import { ConsolesService } from '@/api/consoles.service'; @@ -48,6 +47,12 @@ export class ConsolePageComponent implements AfterViewInit { protected readonly consoleIsViewOnly = model(false); protected readonly enableActivityListener = toSignal(this.route.queryParamMap.pipe(map(qps => qps?.get("l") === "true"))); + // NOTE: this query param doesn't solely dictate whether the user can interact with the console - they still have to have logical permission to do that + // (expressed in the "isViewOnly" property of the response from the API below). This param just forces view-only even if the user _could_ + // interact with this console otherwise (say, if they opened it from the admin observe mode, but it's their own console they're observing.) This forces + // the user to intentionally interact with a player-facing console in order to start messing with it. + protected readonly forceViewOnly = toSignal(this.route.queryParamMap.pipe(map(qps => qps?.get("viewOnly") === "true"))); + // we have to wrap the value in an RXJS timer thing to get it to count down correctly (we should maybe reevaluate the countdown pipe) private _expiresAtTimestamp?: number; protected expiresAt = toSignal(interval(1000).pipe(map(() => { @@ -104,7 +109,7 @@ export class ConsolePageComponent implements AfterViewInit { const consoleState = consoleData.consoleState; this.title.set(`${consoleState.id.name} :: Console${consoleData.isViewOnly ? ' [view only]' : ''}`); - this.consoleIsViewOnly.update(() => consoleData.isViewOnly); + this.consoleIsViewOnly.update(() => !!this.forceViewOnly() || consoleData.isViewOnly); this._expiresAtTimestamp = consoleData.expiresAt?.toMillis(); this.consoleConfig.update(() => ({ diff --git a/projects/gameboard-ui/src/app/consoles/pipes/console-id-to-url.pipe.ts b/projects/gameboard-ui/src/app/consoles/pipes/console-id-to-url.pipe.ts index d9ac553af..345b3db51 100644 --- a/projects/gameboard-ui/src/app/consoles/pipes/console-id-to-url.pipe.ts +++ b/projects/gameboard-ui/src/app/consoles/pipes/console-id-to-url.pipe.ts @@ -1,6 +1,5 @@ import { ConsoleId } from '@/api/consoles.models'; import { inject, Pipe, PipeTransform } from '@angular/core'; -import { UrlTree } from '@angular/router'; import { RouterService } from '@/services/router.service'; @Pipe({ name: 'consoleIdToUrl' }) diff --git a/projects/gameboard-ui/src/app/core/core.module.ts b/projects/gameboard-ui/src/app/core/core.module.ts index 0bf1110ab..5ac7de005 100644 --- a/projects/gameboard-ui/src/app/core/core.module.ts +++ b/projects/gameboard-ui/src/app/core/core.module.ts @@ -181,9 +181,7 @@ const PUBLIC_DECLARATIONS = [ FriendlyTimePipe, MinPipe, ModalContentComponent, - PluralizerPipe, RenderLinksInTextComponent, - RelativeImagePipe, RelativeToAbsoluteHrefPipe, RelativeUrlsPipe, SelectPagerComponent, @@ -248,6 +246,8 @@ const RELAYED_MODULES = [ // standalones CountdownPipe, IfHasPermissionDirective, + PluralizerPipe, + RelativeImagePipe, SpinnerComponent, StringArrayJoinPipe, ToSupportCodePipe diff --git a/projects/gameboard-ui/src/app/core/directives/autofocus.directive.ts b/projects/gameboard-ui/src/app/core/directives/autofocus.directive.ts index 7386a2fd1..a5bec13dc 100644 --- a/projects/gameboard-ui/src/app/core/directives/autofocus.directive.ts +++ b/projects/gameboard-ui/src/app/core/directives/autofocus.directive.ts @@ -1,8 +1,8 @@ import { AfterViewInit, Directive, ElementRef } from '@angular/core'; @Directive({ - selector: '[appAutofocus]', - standalone: false + selector: '[appAutofocus]', + standalone: false }) export class AutofocusDirective implements AfterViewInit { constructor(private ref: ElementRef) { } diff --git a/projects/gameboard-ui/src/app/core/pipes/markdown-placeholder.pipe.ts b/projects/gameboard-ui/src/app/core/pipes/markdown-placeholder.pipe.ts index 01c026bd9..25495bd7f 100644 --- a/projects/gameboard-ui/src/app/core/pipes/markdown-placeholder.pipe.ts +++ b/projects/gameboard-ui/src/app/core/pipes/markdown-placeholder.pipe.ts @@ -5,7 +5,7 @@ import { Pipe, PipeTransform } from '@angular/core'; standalone: true }) export class MarkdownPlaceholderPipe implements PipeTransform { - transform(header: string): string { + transform(header?: string): string { const paragraphs: string[] = []; if (header) { diff --git a/projects/gameboard-ui/src/app/core/pipes/pluralizer.pipe.ts b/projects/gameboard-ui/src/app/core/pipes/pluralizer.pipe.ts index 80c2bca93..39fae160e 100644 --- a/projects/gameboard-ui/src/app/core/pipes/pluralizer.pipe.ts +++ b/projects/gameboard-ui/src/app/core/pipes/pluralizer.pipe.ts @@ -1,9 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; -@Pipe({ - name: 'pluralizer', - standalone: false -}) +@Pipe({ name: 'pluralizer' }) export class PluralizerPipe implements PipeTransform { transform(label: string, count: number = 0, addE: boolean = false): string { if (!label) diff --git a/projects/gameboard-ui/src/app/core/pipes/relative-image.pipe.ts b/projects/gameboard-ui/src/app/core/pipes/relative-image.pipe.ts index fa4e03971..14a71ef11 100644 --- a/projects/gameboard-ui/src/app/core/pipes/relative-image.pipe.ts +++ b/projects/gameboard-ui/src/app/core/pipes/relative-image.pipe.ts @@ -1,16 +1,14 @@ import { ConfigService } from '@/utility/config.service'; import { Pipe, PipeTransform } from '@angular/core'; -@Pipe({ - name: 'relativeImage', - standalone: false -}) +@Pipe({ name: 'relativeImage' }) export class RelativeImagePipe implements PipeTransform { constructor(private config: ConfigService) { } transform(value?: string): string | null { - if (!value) + if (!value) { return value || null; + } return `${this.config.imagehost}/${value}`; } diff --git a/projects/gameboard-ui/src/app/feedback/components/feedback-template-picker/feedback-template-picker.component.ts b/projects/gameboard-ui/src/app/feedback/components/feedback-template-picker/feedback-template-picker.component.ts index 5ba07334c..06eb56989 100644 --- a/projects/gameboard-ui/src/app/feedback/components/feedback-template-picker/feedback-template-picker.component.ts +++ b/projects/gameboard-ui/src/app/feedback/components/feedback-template-picker/feedback-template-picker.component.ts @@ -10,6 +10,7 @@ import { ModalConfirmService } from '@/services/modal-confirm.service'; import { ToastService } from '@/utility/services/toast.service'; import { FeedbackSubmissionFormComponent } from "../feedback-submission-form/feedback-submission-form.component"; import { UnsubscriberService } from '@/services/unsubscriber.service'; +import { PluralizerPipe } from '@/core/pipes/pluralizer.pipe'; interface UpsertFeedbackTemplateForm { id: string | null | undefined; @@ -19,16 +20,17 @@ interface UpsertFeedbackTemplateForm { } @Component({ - selector: 'app-feedback-template-picker', - imports: [ - CommonModule, - ReactiveFormsModule, - FontAwesomeModule, - CoreModule, - FeedbackSubmissionFormComponent - ], - templateUrl: './feedback-template-picker.component.html', - styleUrls: ['./feedback-template-picker.component.scss'] + selector: 'app-feedback-template-picker', + imports: [ + CommonModule, + ReactiveFormsModule, + FontAwesomeModule, + CoreModule, + FeedbackSubmissionFormComponent, + PluralizerPipe + ], + templateUrl: './feedback-template-picker.component.html', + styleUrls: ['./feedback-template-picker.component.scss'] }) export class FeedbackTemplatePickerComponent implements OnInit { @Input() labelText?: string; diff --git a/projects/gameboard-ui/src/app/game/components/game-session-availability-warning/game-session-availability-warning.component.ts b/projects/gameboard-ui/src/app/game/components/game-session-availability-warning/game-session-availability-warning.component.ts index eaa64c099..529dec716 100644 --- a/projects/gameboard-ui/src/app/game/components/game-session-availability-warning/game-session-availability-warning.component.ts +++ b/projects/gameboard-ui/src/app/game/components/game-session-availability-warning/game-session-availability-warning.component.ts @@ -5,16 +5,18 @@ import { UnsubscriberService } from '@/services/unsubscriber.service'; import { interval, startWith } from 'rxjs'; import { GameSessionAvailibilityResponse } from '@/api/game-models'; import { CoreModule } from '@/core/core.module'; +import { PluralizerPipe } from '@/core/pipes/pluralizer.pipe'; @Component({ - selector: 'app-game-session-availability-warning', - imports: [ - CommonModule, - CoreModule, - ], - providers: [UnsubscriberService], - templateUrl: './game-session-availability-warning.component.html', - styleUrls: ['./game-session-availability-warning.component.scss'] + selector: 'app-game-session-availability-warning', + imports: [ + CommonModule, + CoreModule, + PluralizerPipe, + ], + providers: [UnsubscriberService], + templateUrl: './game-session-availability-warning.component.html', + styleUrls: ['./game-session-availability-warning.component.scss'] }) export class GameSessionAvailabilityWarningComponent implements OnInit, OnDestroy { @Input() game?: { id: string; sessionAvailabilityWarningThreshold?: number }; diff --git a/projects/gameboard-ui/src/app/game/game.module.ts b/projects/gameboard-ui/src/app/game/game.module.ts index fe9fda478..817d11ea4 100644 --- a/projects/gameboard-ui/src/app/game/game.module.ts +++ b/projects/gameboard-ui/src/app/game/game.module.ts @@ -43,6 +43,7 @@ import { GameSessionAvailabilityWarningComponent } from "./components/game-sessi import { CountdownPipe } from '@/core/pipes/countdown.pipe'; import { VmLinkComponent } from '@/standalone/games/components/vm-link/vm-link.component'; import { ConsoleIdToUrlPipe } from '@/consoles/pipes/console-id-to-url.pipe'; +import { PluralizerPipe } from '@/core/pipes/pluralizer.pipe'; const MODULE_DECLARATIONS = [ ContinueToGameboardButtonComponent, @@ -90,6 +91,7 @@ const MODULE_DECLARATIONS = [ CountdownPipe, ErrorDivComponent, FeedbackSubmissionFormComponent, + PluralizerPipe, SafeUrlPipe, SpinnerComponent, FeedbackSubmissionFormComponent, diff --git a/projects/gameboard-ui/src/app/layouts/app-layout/app-layout.component.html b/projects/gameboard-ui/src/app/layouts/app-layout/app-layout.component.html index a216741a7..8e236a568 100644 --- a/projects/gameboard-ui/src/app/layouts/app-layout/app-layout.component.html +++ b/projects/gameboard-ui/src/app/layouts/app-layout/app-layout.component.html @@ -9,8 +9,6 @@
- -
diff --git a/projects/gameboard-ui/src/app/layouts/app-layout/app-layout.component.ts b/projects/gameboard-ui/src/app/layouts/app-layout/app-layout.component.ts index 1415de37d..fd002b8f7 100644 --- a/projects/gameboard-ui/src/app/layouts/app-layout/app-layout.component.ts +++ b/projects/gameboard-ui/src/app/layouts/app-layout/app-layout.component.ts @@ -10,33 +10,23 @@ import { MessageBoardComponent } from '@/utility/components/message-board/messag import { LayoutService } from '@/utility/layout.service'; @Component({ - selector: 'app-app-layout', - imports: [ - // angular dependencies - CommonModule, - RouterModule, - // gb dependencies - AppNavComponent, - GameboardSignalRHubsComponent, - MessageBoardComponent, - SponsorSelectBannerComponent, - SystemNotificationsModule, - ], - templateUrl: './app-layout.component.html', - styleUrl: './app-layout.component.scss' + selector: 'app-app-layout', + imports: [ + // angular dependencies + CommonModule, + RouterModule, + // gb dependencies + AppNavComponent, + GameboardSignalRHubsComponent, + SponsorSelectBannerComponent, + ], + templateUrl: './app-layout.component.html', + styleUrl: './app-layout.component.scss' }) export class AppLayoutComponent { // services private readonly layoutService = inject(LayoutService); // state - protected stickyMenu$: Observable; - - constructor() { - this.stickyMenu$ = this.layoutService.stickyMenu$; - } - - ngOnInit() { - - } + protected stickyMenu$: Observable = this.layoutService.stickyMenu$; } diff --git a/projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.html b/projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.html new file mode 100644 index 000000000..b48c3436a --- /dev/null +++ b/projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.html @@ -0,0 +1,20 @@ +
+ @if (challengeGroup().isFeatured) + { +
+ Featured +
+ } + + + +
+

{{ challengeGroup().name }}

+

+
+ + +
diff --git a/projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.scss b/projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.scss new file mode 100644 index 000000000..881158d28 --- /dev/null +++ b/projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.scss @@ -0,0 +1,28 @@ +.card { + height: 100%; +} + +img { + aspect-ratio: 16/9; + object-fit: cover; + height: 240px; +} + +.card-header { + font-weight: bold; + text-transform: uppercase; +} + +.card-text, +.card-text * { + display: -webkit-box; + line-clamp: 2; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: ellipsis; +} + +.card-body { + flex: 1 0 auto !important; +} diff --git a/projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.ts b/projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.ts new file mode 100644 index 000000000..51f6f27b7 --- /dev/null +++ b/projects/gameboard-ui/src/app/prac/components/challenge-group-card/challenge-group-card.component.ts @@ -0,0 +1,19 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, input } from '@angular/core'; +import { MarkdownPipe } from 'ngx-markdown'; +import { PracticeChallengeGroupDto } from '@/prac/practice.models'; +import { ChallengeGroupCardImagePipe } from '@/prac/pipes/challenge-group-card-image.pipe'; + +@Component({ + selector: 'app-challenge-group-card', + imports: [ + AsyncPipe, + ChallengeGroupCardImagePipe, + MarkdownPipe, + ], + templateUrl: './challenge-group-card.component.html', + styleUrl: './challenge-group-card.component.scss' +}) +export class ChallengeGroupCardComponent { + challengeGroup = input.required(); +} diff --git a/projects/gameboard-ui/src/app/prac/components/practice-challenge-state-summary/practice-challenge-state-summary.component.html b/projects/gameboard-ui/src/app/prac/components/practice-challenge-state-summary/practice-challenge-state-summary.component.html index 6fa0c2144..525aa6083 100644 --- a/projects/gameboard-ui/src/app/prac/components/practice-challenge-state-summary/practice-challenge-state-summary.component.html +++ b/projects/gameboard-ui/src/app/prac/components/practice-challenge-state-summary/practice-challenge-state-summary.component.html @@ -18,15 +18,11 @@ - - + Score + Time Remaining - - Score - Time Remaining - {{activeChallenge.scoreAndAttemptsState.score}} diff --git a/projects/gameboard-ui/src/app/prac/pipes/challenge-group-card-image.pipe.ts b/projects/gameboard-ui/src/app/prac/pipes/challenge-group-card-image.pipe.ts new file mode 100644 index 000000000..f60374ea6 --- /dev/null +++ b/projects/gameboard-ui/src/app/prac/pipes/challenge-group-card-image.pipe.ts @@ -0,0 +1,16 @@ +import { inject, Pipe, PipeTransform } from '@angular/core'; +import { ConfigService } from '@/utility/config.service'; + +@Pipe({ name: 'challengeGroupCardImage' }) +export class ChallengeGroupCardImagePipe implements PipeTransform { + private readonly configService = inject(ConfigService); + private readonly defaultCardImage = "/assets/img/practice-challenge-group/default-card.svg"; + + transform(value?: string): string { + if (!value) { + return this.defaultCardImage; + } + + return `${this.configService.imagehost}/${value}`; + } +} diff --git a/projects/gameboard-ui/src/app/prac/prac.module.ts b/projects/gameboard-ui/src/app/prac/prac.module.ts index 8808a875d..7e5a0eba8 100644 --- a/projects/gameboard-ui/src/app/prac/prac.module.ts +++ b/projects/gameboard-ui/src/app/prac/prac.module.ts @@ -57,6 +57,7 @@ import { UserPracticeSummaryComponent } from './components/user-practice-summary ErrorDivComponent, InfoBubbleComponent, PlayComponent, + PluralizerPipe, SpinnerComponent, ToPracticeCertificateLinkPipe, UserPracticeSummaryComponent diff --git a/projects/gameboard-ui/src/app/prac/practice.models.ts b/projects/gameboard-ui/src/app/prac/practice.models.ts index 2348a6a2e..0f760dae5 100644 --- a/projects/gameboard-ui/src/app/prac/practice.models.ts +++ b/projects/gameboard-ui/src/app/prac/practice.models.ts @@ -1,6 +1,84 @@ -import { GameCardContext } from "@/api/game-models"; -import { PagedArray, Search, TimestampRange } from "@/api/models"; import { DateTime } from "luxon"; +import { GameCardContext } from "@/api/game-models"; +import { PagedArray, Search, SimpleEntity, TimestampRange } from "@/api/models"; + +export interface ChallengesAddToGroupRequest { + addBySpecIds?: string[]; + addByGameId?: string; + addByTag?: string; +} + +export interface ChallengesAddToGroupResponse { + addedChallengeSpecIds: string[]; +} + +export interface CreatePracticeChallengeGroupRequest { + name: string; + description: string; + image?: File; + isFeatured: boolean; + parentGroupId?: string; +} + +export interface GetPracticeChallengeGroupResponse { + group: GetPracticeChallengeGroupResponseGroup; + parentGroup?: SimpleEntity; + childGroups: GetPracticeChallengeGroupResponseGroup[]; +} + +export interface GetPracticeChallengeGroupResponseChallenge { + id: string; + name: string; + countCompleted: number; + countLaunched: number; + lastLaunched?: DateTime; +} + +export interface GetPracticeChallengeGroupResponseGroup { + id: string; + name: string; + description: string; + imageUrl: string; + isFeatured: boolean; + challenges: GetPracticeChallengeGroupResponseChallenge[]; +} + +export interface PracticeChallengeGroupDto { + id: string; + name: string; + description: string; + imageUrl?: string; + isFeatured: boolean; + parentGroupId?: string; +} + +export interface ListChallengesRequest { + searchTerm?: string; +} + +export interface ListChallengeGroupsResponse { + groups: ListChallengeGroupsResponseGroup[]; +} + +export interface ListChallengeGroupsResponseGroup { + id: string; + name: string; + description: string; + challengeCount: number; + imageUrl?: string; + isFeatured: boolean; + parentGroupId?: string; + childGroups: ListChallengeGroupsResponseGroup[] +} + +export interface UpdateChallengeGroupRequest { + id: string; + name: string; + description: string; + image?: File; + isFeatured: boolean; + parentGroupId?: string; +} export interface SearchPracticeChallengesRequest { filter: Search; diff --git a/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/sponsor-challenge-performance/sponsor-challenge-performance.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/sponsor-challenge-performance/sponsor-challenge-performance.component.ts index 7f958bb3f..564ee5510 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/sponsor-challenge-performance/sponsor-challenge-performance.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/sponsor-challenge-performance/sponsor-challenge-performance.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; +import { BsModalRef } from 'ngx-bootstrap/modal'; import { PracticeModeReportSponsorPerformance } from '../practice-mode-report.models'; import { SimpleEntity } from '@/api/models'; -import { BsModalRef } from 'ngx-bootstrap/modal'; export interface SponsorChallengePerformanceModalContext extends Partial { challenge: SimpleEntity; @@ -9,9 +9,9 @@ export interface SponsorChallengePerformanceModalContext extends Partial { - return this.http.get(this.apiUrl.build('/practice/games')); + public async challengeGroupCreate(request: CreatePracticeChallengeGroupRequest): Promise { + const asFormData = toFormData(request, "request"); + return await firstValueFrom(this.http.post(this.apiUrl.build("practice/challenge-group"), asFormData)); } - public searchChallenges(request: SearchPracticeChallengesRequest): Observable { - return this.http.get(this.apiUrl.build('/practice', { ...request.filter, userProgress: request.userProgress })); + public challengeGroupDelete(id: string): Promise { + return firstValueFrom(this.http.delete(this.apiUrl.build(`practice/challenge-group/${id}`))); + } + + public async challengeGroupGet(id: string): Promise { + return await firstValueFrom(this.http.get(this.apiUrl.build(`practice/challenge-group/${id}`))); + } + + public async challengeGroupList(): Promise { + const response = await firstValueFrom(this.http.get(this.apiUrl.build("practice/challenge-group/list"))); + return response.groups; + } + + public async challengeGroupUpdate(request: UpdateChallengeGroupRequest): Promise { + const asFormData = toFormData(request, "request"); + return await firstValueFrom(this.http.put(this.apiUrl.build("practice/challenge-group"), asFormData)); + } + + public async challengesAddToGroup(challengeGroupId: string, request: ChallengesAddToGroupRequest): Promise { + return await firstValueFrom(this.http.post(this.apiUrl.build(`practice/challenge-group/${challengeGroupId}/challenges`), request)); + } + + public async challengesRemoveFromGroup(request: { challengeGroupId: string, challengeSpecIds: string[] }): Promise { + return await firstValueFrom(this.http.delete(this.apiUrl.build(`practice/challenge-group/${request.challengeGroupId}/challenges`), { body: { challengeSpecIds: request.challengeSpecIds } })); + } + + public challengesList(request?: ListChallengesRequest): Promise { + return firstValueFrom(this.http.get(this.apiUrl.build("practice/challenge/list", request))); + } + + public searchChallenges(request?: SearchPracticeChallengesRequest): Observable { + return this.http.get(this.apiUrl.build('/practice', { ...request?.filter, userProgress: request?.userProgress })); } private async updateIsEnabled() { diff --git a/projects/gameboard-ui/src/app/services/router.service.ts b/projects/gameboard-ui/src/app/services/router.service.ts index 8884f687f..82a4e7be0 100644 --- a/projects/gameboard-ui/src/app/services/router.service.ts +++ b/projects/gameboard-ui/src/app/services/router.service.ts @@ -1,13 +1,12 @@ -import { Injectable, OnDestroy } from '@angular/core'; +import { Injectable, } from '@angular/core'; import { NavigationEnd, ActivatedRoute, Params, Router, UrlTree } from '@angular/router'; -import { Subscription, filter } from 'rxjs'; +import { filter } from 'rxjs'; import { ReportKey } from '@/reports/reports-models'; import { PlayerMode } from '@/api/player-models'; import { ConfigService } from '@/utility/config.service'; import { UserService as LocalUser } from '@/utility/user.service'; import { slug } from "@/../tools/functions"; import { GameCenterTab } from '@/admin/components/game-center/game-center.models'; -import { SimpleEntity } from '@/api/models'; import { ObjectService } from './object.service'; export interface QueryParamsUpdate { @@ -16,9 +15,7 @@ export interface QueryParamsUpdate { } @Injectable({ providedIn: 'root' }) -export class RouterService implements OnDestroy { - private _navEndSub?: Subscription; - +export class RouterService { constructor( private config: ConfigService, private localUser: LocalUser, @@ -143,7 +140,7 @@ export class RouterService implements OnDestroy { return this.router.navigateByUrl(this.router.parseUrl(`/support/tickets/${highlightTicketKey}`)); } - public buildVmConsoleUrl(challengeId: string, vmName: string, isPractice = false) { + public buildVmConsoleUrl(challengeId: string, vmName: string, enableActivityListener = false, forceViewOnly = false) { if (!vmName || !challengeId) { throw new Error(`Can't launch a VM console without a challengeId.`); } @@ -153,7 +150,8 @@ export class RouterService implements OnDestroy { fullscreen: true, challengeId, console: vmName, - l: isPractice ? true : undefined + l: enableActivityListener ? true : undefined, + viewOnly: forceViewOnly ? true : undefined } }); } @@ -230,8 +228,4 @@ export class RouterService implements OnDestroy { private buildAppUrlWithQueryParams(queryParams: any, ...urlBits: string[]) { return this.router.createUrlTree([this.config.basehref || "", ...urlBits], { queryParams: { ...queryParams } }); } - - ngOnDestroy(): void { - this._navEndSub?.unsubscribe(); - } } diff --git a/projects/gameboard-ui/src/app/standalone/core/components/spinner/spinner.component.ts b/projects/gameboard-ui/src/app/standalone/core/components/spinner/spinner.component.ts index a94728717..1f74bca61 100644 --- a/projects/gameboard-ui/src/app/standalone/core/components/spinner/spinner.component.ts +++ b/projects/gameboard-ui/src/app/standalone/core/components/spinner/spinner.component.ts @@ -5,9 +5,9 @@ import { CommonModule } from '@angular/common'; import { Component, input } from '@angular/core'; @Component({ - selector: 'app-spinner', - imports: [CommonModule], - template: ` + selector: 'app-spinner', + imports: [CommonModule], + template: `
@@ -16,7 +16,7 @@ import { Component, input } from '@angular/core'; + xml:space="preserve" [class.default-theme]="!color()"> @@ -53,10 +53,10 @@ import { Component, input } from '@angular/core';

`, - styles: [ - ".spinner-component { display: flex; align-items: center; justify-content: center; width: 100%; text-align: center; margin: 0 auto; }", - "h1 { font-size: 0.85rem !important; font-weight: bold; text-transform: uppercase; }" - ] + styles: [ + ".spinner-component { display: flex; align-items: center; justify-content: center; width: 100%; text-align: center; margin: 0 auto; }", + "h1 { font-size: 0.85rem !important; font-weight: bold; text-transform: uppercase; }" + ] }) export class SpinnerComponent { public color = input("#41ad57"); diff --git a/projects/gameboard-ui/src/app/system-notifications/components/admin-system-notifications/admin-system-notifications.component.ts b/projects/gameboard-ui/src/app/system-notifications/components/admin-system-notifications/admin-system-notifications.component.ts index 18c9e83e8..bf19dd16a 100644 --- a/projects/gameboard-ui/src/app/system-notifications/components/admin-system-notifications/admin-system-notifications.component.ts +++ b/projects/gameboard-ui/src/app/system-notifications/components/admin-system-notifications/admin-system-notifications.component.ts @@ -2,17 +2,17 @@ import { Component, OnInit } from '@angular/core'; import { Observable, Subject, firstValueFrom, startWith, switchMap } from 'rxjs'; import { AdminViewSystemNotification } from '@/system-notifications/system-notifications.models'; import { ModalConfirmService } from '@/services/modal-confirm.service'; -import { CreateEditSystemNotificationModalComponent, CreatedEditSystemNotificationModalContext } from '../create-edit-system-notification-modal/create-edit-system-notification-modal.component'; +import { CreateEditSystemNotificationModalComponent } from '../create-edit-system-notification-modal/create-edit-system-notification-modal.component'; import { SystemNotificationsService } from '@/system-notifications/system-notifications.service'; import { AppTitleService } from '@/services/app-title.service'; import { UnsubscriberService } from '@/services/unsubscriber.service'; @Component({ - selector: 'app-notifications', - templateUrl: './admin-system-notifications.component.html', - styleUrls: ['./admin-system-notifications.component.scss'], - providers: [UnsubscriberService], - standalone: false + selector: 'app-notifications', + templateUrl: './admin-system-notifications.component.html', + styleUrls: ['./admin-system-notifications.component.scss'], + providers: [UnsubscriberService], + standalone: false }) export class AdminSystemNotificationsComponent implements OnInit { private _forceLoad$ = new Subject(); diff --git a/projects/gameboard-ui/src/app/system-notifications/system-notifications.module.ts b/projects/gameboard-ui/src/app/system-notifications/system-notifications.module.ts index 0d92b0b4b..1923ca6f2 100644 --- a/projects/gameboard-ui/src/app/system-notifications/system-notifications.module.ts +++ b/projects/gameboard-ui/src/app/system-notifications/system-notifications.module.ts @@ -8,22 +8,25 @@ import { CoreModule } from '@/core/core.module'; import { SystemNotificationsComponent } from './components/system-notifications/system-notifications.component'; import { NotificationTypeToAlertTypePipe } from './pipes/notification-type-to-alert-type.pipe'; import { MarkdownPlaceholderPipe } from '@/core/pipes/markdown-placeholder.pipe'; +import { PluralizerPipe } from '@/core/pipes/pluralizer.pipe'; const DECLARED = [ AdminSystemNotificationsComponent, CreateEditSystemNotificationModalComponent, + NotificationTypeToAlertTypePipe, SystemNotificationTypeToTextPipe, SystemNotificationsComponent ]; @NgModule({ - declarations: [...DECLARED, NotificationTypeToAlertTypePipe], + declarations: [...DECLARED], imports: [ CommonModule, FormsModule, CoreModule, // standalones - MarkdownPlaceholderPipe + MarkdownPlaceholderPipe, + PluralizerPipe, ], exports: [...DECLARED] }) diff --git a/projects/gameboard-ui/src/app/utility/components/game-card/game-card.component.ts b/projects/gameboard-ui/src/app/utility/components/game-card/game-card.component.ts index baf941b30..b61a54aac 100644 --- a/projects/gameboard-ui/src/app/utility/components/game-card/game-card.component.ts +++ b/projects/gameboard-ui/src/app/utility/components/game-card/game-card.component.ts @@ -2,8 +2,6 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { BoardGame } from '@/api/board-models'; -import { Game } from '@/api/game-models'; import { fa } from '@/services/font-awesome.service'; export type GameCardGame = { @@ -16,10 +14,10 @@ export type GameCardGame = { } @Component({ - selector: 'app-game-card', - templateUrl: './game-card.component.html', - styleUrls: ['./game-card.component.scss'], - standalone: false + selector: 'app-game-card', + templateUrl: './game-card.component.html', + styleUrls: ['./game-card.component.scss'], + standalone: false }) export class GameCardComponent { @Input() game?: GameCardGame; diff --git a/projects/gameboard-ui/src/app/utility/utility.module.ts b/projects/gameboard-ui/src/app/utility/utility.module.ts index 692f87b38..0686d6e2a 100644 --- a/projects/gameboard-ui/src/app/utility/utility.module.ts +++ b/projects/gameboard-ui/src/app/utility/utility.module.ts @@ -25,6 +25,7 @@ import { ProgressbarModule } from 'ngx-bootstrap/progressbar'; import { GameInfoBubblesComponent } from "../standalone/games/components/game-info-bubbles/game-info-bubbles.component"; import { SpinnerComponent } from '@/standalone/core/components/spinner/spinner.component'; import { ErrorDivComponent } from '@/standalone/core/components/error-div/error-div.component'; +import { RelativeImagePipe } from '@/core/pipes/relative-image.pipe'; const components = [ ClipspanComponent, @@ -50,8 +51,11 @@ const components = [ ProgressbarModule, RouterModule, CoreModule, + + // standalones ErrorDivComponent, GameInfoBubblesComponent, + RelativeImagePipe, SpinnerComponent ], }) diff --git a/projects/gameboard-ui/src/assets/img/practice-challenge-group/default-card.svg b/projects/gameboard-ui/src/assets/img/practice-challenge-group/default-card.svg new file mode 100644 index 000000000..71c681cc3 --- /dev/null +++ b/projects/gameboard-ui/src/assets/img/practice-challenge-group/default-card.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/gameboard-ui/src/styles.scss b/projects/gameboard-ui/src/styles.scss index b6ce27c5f..eb2b79065 100644 --- a/projects/gameboard-ui/src/styles.scss +++ b/projects/gameboard-ui/src/styles.scss @@ -499,6 +499,14 @@ th[align="left"] { font-weight: bold !important; } +.gap-2 { + gap: 0.75rem !important; +} + +.gap-4 { + gap: 1.5rem !important; +} + .rounded-circle { aspect-ratio: 1/1; } diff --git a/projects/gameboard-ui/src/tools/object-tools.lib.ts b/projects/gameboard-ui/src/tools/object-tools.lib.ts index d47be8c50..20da128c5 100644 --- a/projects/gameboard-ui/src/tools/object-tools.lib.ts +++ b/projects/gameboard-ui/src/tools/object-tools.lib.ts @@ -61,3 +61,34 @@ export function getAllProperties(obj: any) { return allProps.sort(); } + +/** + * Serializes an object to FormData. Particularly useful for objects that + * can't be serialized to JSON for submission to the API (commonly, objects that have File + * properties). For these, we typically have the api accept multipart/form-data and serialize + * the object into a form with two values: the file, and the rest of the object. + * + * NOTE: As implemented, does not work for complex objects (with object properties). + * + * @param obj An object to serialize to FormData. + */ +export function toFormData(obj: T, formPropertyName: string): FormData { + const formData = new FormData(); + + let k: keyof typeof obj; + for (k in obj) { + const value = obj[k]; + + if (value === undefined || value === null) { + continue; + } + else if (value instanceof Blob) { + formData.append(`${formPropertyName}.${String(k)}`, value); + } + if (obj[k]) { + formData.append(`${formPropertyName}.${String(k)}`, String(value)); + } + } + + return formData; +} diff --git a/tsconfig.json b/tsconfig.json index 52d779572..8284978e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,11 +7,7 @@ "experimentalDecorators": true, "forceConsistentCasingInFileNames": true, "importHelpers": true, - "lib": [ - "es2018", - "dom", - "DOM.Iterable" - ], + "lib": ["ES2019", "dom", "DOM.Iterable"], "module": "es2020", "moduleResolution": "node", "noFallthroughCasesInSwitch": true, @@ -19,12 +15,8 @@ "noImplicitReturns": true, "outDir": "./dist/out-tsc", "paths": { - "@/*": [ - "./projects/gameboard-ui/src/app/*" - ], - "gameboard-consoles": [ - "./dist/gameboard-consoles" - ] + "@/*": ["./projects/gameboard-ui/src/app/*"], + "gameboard-consoles": ["./dist/gameboard-consoles"] }, "sourceMap": true, "strict": true, From 524bf748c54a609fdf7830aa35f98a5126e748f8 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 24 Jul 2025 15:21:10 -0400 Subject: [PATCH 02/34] Dev on challenge collections --- .../challenge-group.component.html | 29 +++++---- .../challenge-group.component.ts | 60 ++++++++++++------- .../prac/pipes/practice-challenge-url.pipe.ts | 10 ++++ .../gameboard-ui/src/app/prac/prac.module.ts | 1 + .../src/app/prac/practice.models.ts | 10 ++++ .../src/app/services/practice.service.ts | 6 +- .../src/app/services/router.service.ts | 21 +++---- 7 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 projects/gameboard-ui/src/app/prac/pipes/practice-challenge-url.pipe.ts diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.html b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.html index 5230bf035..c89d42398 100644 --- a/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.html +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.html @@ -35,6 +35,8 @@
+ + @if (canAddSubCollections()) { @@ -88,12 +90,7 @@

Subcollections

}
-

- Challenges - {{ groupResource.value().group.challenges.length ? " (" + groupResource.value().group.challenges.length - + ")" : "" - }} -

+

Challenges

@if (groupResource.value().group.challenges.length) { @@ -174,9 +171,11 @@

@else if (addChallengesByValue() === "tag") { } @@ -206,7 +205,10 @@

{{ context.group.name }} header image -
{{ context.isThisGroup ? "In this collection" : context.group.name }}
+
+ {{ context.isThisGroup ? "In this collection" : context.group.name }} + ({{ context.group.challenges.length }}) +
@if (context.group.challenges.length) @@ -225,7 +227,14 @@

{{ context.isThisGroup ? "In this collection" : context.group.name }}
@for (challenge of context.group.challenges; track challenge.id) { - {{ challenge.name }} + + +
{{ challenge.game.name }}
+ {{ challenge.countLaunched }} / {{ challenge.countCompleted }} {{ challenge.lastLaunched | friendlyDateAndTime }} diff --git a/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.ts b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.ts index f4e893530..2c76378a3 100644 --- a/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.ts +++ b/projects/gameboard-ui/src/app/admin/practice/challenge-group/challenge-group.component.ts @@ -17,6 +17,8 @@ import { GameService } from '@/api/game.service'; import { PlayerMode } from '@/api/player-models'; import { SimpleEntity } from '@/api/models'; import { ToastService } from '@/utility/services/toast.service'; +import { ErrorDivComponent } from "@/standalone/core/components/error-div/error-div.component"; +import { PracticeChallengeUrlPipe } from '@/prac/pipes/practice-challenge-url.pipe'; @Component({ selector: 'app-challenge-group', @@ -25,8 +27,11 @@ import { ToastService } from '@/utility/services/toast.service'; ChallengeGroupCardComponent, ChallengeGroupCardImagePipe, ChallengeGroupUpsertDialogComponent, + ErrorDivComponent, PluralizerPipe, + PracticeChallengeUrlPipe, SpinnerComponent, + ErrorDivComponent ], templateUrl: './challenge-group.component.html', styleUrl: './challenge-group.component.scss' @@ -45,7 +50,8 @@ export class ChallengeGroupComponent { protected readonly addChildGroupModalTemplate = viewChild>("addChildGroupModal"); protected readonly editGroupModalTemplate = viewChild>("editGroupModal"); - protected readonly challengesResource = resource({ loader: async () => this.practiceService.challengesList() }); + protected readonly challengesResource = resource({ loader: () => this.practiceService.challengesList() }); + protected readonly challengeTagsResource = resource({ loader: () => this.practiceService.challengeTagsList() }); protected readonly gamesResource = resource({ loader: async () => { return firstValueFrom(this.gamesService.list().pipe( @@ -78,15 +84,12 @@ export class ChallengeGroupComponent { }); protected readonly creatingChildGroup = computed(() => ({ parentGroupId: this.group()?.id })); protected readonly editingGroup = signal(undefined); + protected errors: any[] = []; protected readonly fa = fa; protected readonly group = computed(() => this.groupResource.value()?.group); protected readonly selectedChallenge = model(); protected readonly selectedGame = model(); protected readonly selectedTag = model(); - protected readonly tags = computed(() => { - const settings = this.settingsResource.value(); - return settings?.suggestedSearches?.sort() || []; - }); constructor() { // need to update the title based on the groupResource @@ -110,9 +113,9 @@ export class ChallengeGroupComponent { } }); effect(() => { - const tags = this.tags(); - if (tags.length) { - this.selectedTag.update(() => tags[0]); + const tagsResponse = this.challengeTagsResource.value(); + if (tagsResponse?.challengeTags.length) { + this.selectedTag.update(() => tagsResponse.challengeTags![0].tag); } }); } @@ -136,8 +139,13 @@ export class ChallengeGroupComponent { } const addedSpecIds = await this.practiceService.challengesAddToGroup(this.groupId()!, addChallengesRequest); - this.toasts.showMessage(`Added **${addedSpecIds.addedChallengeSpecIds.length} challenges** to the **${this.group()?.name || ""}** group.`); - this.groupResource.reload(); + + if (addedSpecIds.addedChallengeSpecIds.length) { + this.toasts.showMessage(`Added **${addedSpecIds.addedChallengeSpecIds.length}** challenge(s) to the **${this.group()?.name || ""}** group.`); + this.groupResource.reload(); + } else { + this.errors.push("No challenges were added. A challenge can only appear in a collection once, so if the challenge(s) you added are already present, this is normal."); + } } protected handleOpenAddSubCollectionModal() { @@ -172,14 +180,20 @@ export class ChallengeGroupComponent { bodyContent: `Are you sure you want to delete the collection **${group.name}**?${childGroupsText}\n\nChallenges in the collection will still be available in the Practice Area.`, renderBodyAsMarkdown: true, onConfirm: async () => { - await this.practiceService.challengeGroupDelete(group.id); - - if (group.id === this.groupResource.value()?.group?.id) { - // if the deleted group is this page's group, return to the group list screen - this.router.navigateByUrl("/admin/practice/content"); - } else { - // otherwise, just reload - this.groupResource.reload(); + try { + this.errors = []; + await this.practiceService.challengeGroupDelete(group.id); + + if (group.id === this.groupResource.value()?.group?.id) { + // if the deleted group is this page's group, return to the group list screen + this.router.navigateByUrl("/admin/practice/content"); + } else { + // otherwise, just reload + this.groupResource.reload(); + } + } + catch (err) { + this.errors.push(err); } } }); @@ -207,8 +221,14 @@ export class ChallengeGroupComponent { subtitle: this.groupResource.value()?.group?.name, bodyContent: `Remove **${challenge.name}** from this collection? (It'll still be available in the Practice Area.)`, onConfirm: async () => { - await this.practiceService.challengesRemoveFromGroup({ challengeGroupId: groupId, challengeSpecIds: [challenge.id] }); - this.groupResource.reload(); + try { + this.errors = []; + await this.practiceService.challengesRemoveFromGroup({ challengeGroupId: groupId, challengeSpecIds: [challenge.id] }); + this.groupResource.reload(); + } + catch (err) { + this.errors.push(err); + } }, confirmButtonText: "Remove", renderBodyAsMarkdown: true diff --git a/projects/gameboard-ui/src/app/prac/pipes/practice-challenge-url.pipe.ts b/projects/gameboard-ui/src/app/prac/pipes/practice-challenge-url.pipe.ts new file mode 100644 index 000000000..c6f823cc8 --- /dev/null +++ b/projects/gameboard-ui/src/app/prac/pipes/practice-challenge-url.pipe.ts @@ -0,0 +1,10 @@ +import { inject, Pipe, PipeTransform } from '@angular/core'; +import { RouterService } from '@/services/router.service'; + +@Pipe({ name: 'practiceChallengeUrl' }) +export class PracticeChallengeUrlPipe implements PipeTransform { + private readonly routerService = inject(RouterService); + transform(challenge: { id: string; name?: string }): string { + return this.routerService.getPracticeChallengeUrl(challenge); + } +} diff --git a/projects/gameboard-ui/src/app/prac/prac.module.ts b/projects/gameboard-ui/src/app/prac/prac.module.ts index 7e5a0eba8..cbb30fd47 100644 --- a/projects/gameboard-ui/src/app/prac/prac.module.ts +++ b/projects/gameboard-ui/src/app/prac/prac.module.ts @@ -45,6 +45,7 @@ import { UserPracticeSummaryComponent } from './components/user-practice-summary { path: "", component: PracticePageComponent, children: [ { path: ":specId/:slug", component: PracticeSessionComponent }, + { path: ":specId", component: PracticeSessionComponent }, { path: "", pathMatch: 'full', component: PracticeChallengeListComponent } ] } diff --git a/projects/gameboard-ui/src/app/prac/practice.models.ts b/projects/gameboard-ui/src/app/prac/practice.models.ts index 0f760dae5..12b90603b 100644 --- a/projects/gameboard-ui/src/app/prac/practice.models.ts +++ b/projects/gameboard-ui/src/app/prac/practice.models.ts @@ -12,6 +12,15 @@ export interface ChallengesAddToGroupResponse { addedChallengeSpecIds: string[]; } +export interface ChallengeTagsListResponse { + challengeTags: ChallengeTagsListResponseTag[]; +} + +export interface ChallengeTagsListResponseTag { + tag: string; + challengeCount: number; +} + export interface CreatePracticeChallengeGroupRequest { name: string; description: string; @@ -29,6 +38,7 @@ export interface GetPracticeChallengeGroupResponse { export interface GetPracticeChallengeGroupResponseChallenge { id: string; name: string; + game: SimpleEntity; countCompleted: number; countLaunched: number; lastLaunched?: DateTime; diff --git a/projects/gameboard-ui/src/app/services/practice.service.ts b/projects/gameboard-ui/src/app/services/practice.service.ts index 6fe535b95..3468c2cf2 100644 --- a/projects/gameboard-ui/src/app/services/practice.service.ts +++ b/projects/gameboard-ui/src/app/services/practice.service.ts @@ -4,7 +4,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, firstValueFrom, map } from 'rxjs'; import { ApiUrlService } from './api-url.service'; -import { CreatePracticeChallengeGroupRequest, PracticeChallengeGroupDto, ListChallengeGroupsResponse, ListChallengeGroupsResponseGroup, PracticeModeSettings, PracticeSession, SearchPracticeChallengesRequest, SearchPracticeChallengesResult, UpdateChallengeGroupRequest, UserPracticeSummary, GetPracticeChallengeGroupResponse, PracticeChallengeView, ListChallengesRequest, ChallengesAddToGroupRequest, ChallengesAddToGroupResponse } from '@/prac/practice.models'; +import { CreatePracticeChallengeGroupRequest, PracticeChallengeGroupDto, ListChallengeGroupsResponse, ListChallengeGroupsResponseGroup, PracticeModeSettings, PracticeSession, SearchPracticeChallengesRequest, SearchPracticeChallengesResult, UpdateChallengeGroupRequest, UserPracticeSummary, GetPracticeChallengeGroupResponse, PracticeChallengeView, ListChallengesRequest, ChallengesAddToGroupRequest, ChallengesAddToGroupResponse, ChallengeTagsListResponse } from '@/prac/practice.models'; import { LogService } from './log.service'; import { toFormData } from '../../tools/object-tools.lib'; @@ -80,6 +80,10 @@ export class PracticeService { return firstValueFrom(this.http.get(this.apiUrl.build("practice/challenge/list", request))); } + public challengeTagsList(): Promise { + return firstValueFrom(this.http.get(this.apiUrl.build("practice/challenge-tags"))); + } + public searchChallenges(request?: SearchPracticeChallengesRequest): Observable { return this.http.get(this.apiUrl.build('/practice', { ...request?.filter, userProgress: request?.userProgress })); } diff --git a/projects/gameboard-ui/src/app/services/router.service.ts b/projects/gameboard-ui/src/app/services/router.service.ts index 82a4e7be0..c7b149802 100644 --- a/projects/gameboard-ui/src/app/services/router.service.ts +++ b/projects/gameboard-ui/src/app/services/router.service.ts @@ -23,16 +23,6 @@ export class RouterService { public route: ActivatedRoute, private router: Router) { } - public getCurrentPathBase(): string { - const urlTree = this.router.parseUrl(this.router.url); - urlTree.queryParams = {}; - return urlTree.toString(); - } - - public getCurrentQueryParams(): Params { - return this.route.queryParams; - } - public goHome(): void { this.router.navigateByUrl("/"); } @@ -88,6 +78,13 @@ export class RouterService { return this.buildAppUrlWithQueryParams(params, "practice"); } + public getPracticeChallengeUrl(challenge: { id: string; name?: string }) { + if (challenge.name) { + return this.router.createUrlTree(["practice", challenge.id, slug(challenge.name)]).toString(); + } + return this.router.createUrlTree(["practice", challenge.id]).toString(); + } + public getProfileUrl() { return this.router.createUrlTree(["user", "profile"]).toString(); } @@ -120,8 +117,8 @@ export class RouterService { return this.router.navigateByUrl("/user/certificates/practice"); } - public toPracticeChallenge(challengeSpec: { id: string, name: string }) { - return this.router.navigateByUrl(`/practice/${challengeSpec.id}/${slug(challengeSpec.name)}`); + public toPracticeChallenge(challengeSpec: { id: string; name?: string }) { + return this.router.navigateByUrl(this.getPracticeChallengeUrl(challengeSpec)); } public toCertificatePrintable(mode: PlayerMode, challengeSpecOrGameId: string) { From f82833581e645c4786ed54699f25ecc4e6f0a7e2 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 24 Jul 2025 16:07:06 -0400 Subject: [PATCH 03/34] Add missing VMWare assets and fix base href for consoles --- angular.json | 2 + .../src/app/services/router.service.ts | 2 +- .../vmware-wmks/css/extended-keypad.css | 318 ++++++++ .../assets/vendor/vmware-wmks/css/main-ui.css | 174 +++++ .../vendor/vmware-wmks/css/trackpad.css | 192 +++++ .../vendor/vmware-wmks/css/wmks-all.css | 684 ++++++++++++++++++ .../vendor/vmware-wmks/img/touch_sprite.png | Bin 0 -> 24870 bytes .../vmware-wmks/img/touch_sprite_feedback.png | Bin 0 -> 17962 bytes .../vendor/vmware-wmks/test/Gruntfile.js | 22 + .../assets/vendor/vmware-wmks/test/README.md | 54 ++ .../vmware-wmks/test/config/karma.conf.js | 95 +++ .../vendor/vmware-wmks/test/package.json | 26 + .../vendor/vmware-wmks/test/spec/basic.js | 465 ++++++++++++ .../vendor/vmware-wmks/test/spec/core.js | 578 +++++++++++++++ .../vendor/vmware-wmks/test/spec/mobile.js | 209 ++++++ .../vendor/vmware-wmks/test/view/index.html | 7 + .../src/assets/vendor/vmware-wmks/wmks.min.js | 8 + 17 files changed, 2835 insertions(+), 1 deletion(-) create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/extended-keypad.css create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/main-ui.css create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/trackpad.css create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/wmks-all.css create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/img/touch_sprite.png create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/img/touch_sprite_feedback.png create mode 100755 projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/Gruntfile.js create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/README.md create mode 100755 projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/config/karma.conf.js create mode 100755 projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/package.json create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/spec/basic.js create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/spec/core.js create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/spec/mobile.js create mode 100755 projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/view/index.html create mode 100644 projects/gameboard-ui/src/assets/vendor/vmware-wmks/wmks.min.js diff --git a/angular.json b/angular.json index 43646a51f..41721f73e 100644 --- a/angular.json +++ b/angular.json @@ -37,6 +37,7 @@ } ], "styles": [ + "projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/main-ui.css", "node_modules/toastify-js/src/toastify.css", "node_modules/vis-timeline/styles/vis-timeline-graph2d.css", "projects/gameboard-ui/src/styles.scss" @@ -53,6 +54,7 @@ } }, "scripts": [ + "projects/gameboard-ui/src/assets/vendor/vmware-wmks/wmks.min.js", "node_modules/marked/marked.min.js", "node_modules/emoji-toolkit/lib/js/joypixels.min.js" ], diff --git a/projects/gameboard-ui/src/app/services/router.service.ts b/projects/gameboard-ui/src/app/services/router.service.ts index c7b149802..1bee224cf 100644 --- a/projects/gameboard-ui/src/app/services/router.service.ts +++ b/projects/gameboard-ui/src/app/services/router.service.ts @@ -142,7 +142,7 @@ export class RouterService { throw new Error(`Can't launch a VM console without a challengeId.`); } - return this.router.createUrlTree(["c", "console"], { + return this.router.createUrlTree([this.config.basehref, "c", "console"], { queryParams: { fullscreen: true, challengeId, diff --git a/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/extended-keypad.css b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/extended-keypad.css new file mode 100644 index 000000000..6e5b1ebd5 --- /dev/null +++ b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/extended-keypad.css @@ -0,0 +1,318 @@ +/****************************************************************************** + * Copyright 2013 VMware, Inc. All rights reserved. + *****************************************************************************/ + +/* + * extended-keypad.css + * + * Defines style for the virtual keys on the control pane. + */ + +.ctrl-pane-wrapper { + width: 290px !important; /* Needed as the default is a bit larger than this */ + border: 1px solid #333 !important; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; + background: rgb(170,171,182); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* W3C */ +} + +.fnKey-pane-wrapper { + width: 427px; + border: 1px solid #333; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; + background: #c1c4d1; /* Old browsers */ + background: -webkit-linear-gradient(top, #c1c4d1 0%,#b0b1bd 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #c1c4d1 0%,#b0b1bd 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #c1c4d1 0%,#b0b1bd 100%); /* IE10+ */ + background: linear-gradient(top, #c1c4d1 0%, #b0b1bd 100%); /* W3C */ + position: absolute; + padding: 0; + -moz-box-shadow: 0px 5px 7px rgba(0,0,0,.5); + -webkit-box-shadow: 0px 5px 7px rgba(0,0,0,.5); + box-shadow: 0px 5px 7px rgba(0,0,0,.5); +} + +.fnKey-pane-wrapper-down { + width: 427px; + border: 1px solid #333; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; + background: #6e6e77; /* Old browsers */ + background: -webkit-linear-gradient(top, #6e6e77 0%,#656565 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #6e6e77 0%,#656565 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #6e6e77 0%,#656565 100%); /* IE10+ */ + background: linear-gradient(top, #6e6e77 0%, #656565 100%); /* W3C */ + position: absolute; + padding: 0; + -moz-box-shadow: 0px 5px 7px rgba(0,0,0,.5); + -webkit-box-shadow: 0px 5px 7px rgba(0,0,0,.5); + box-shadow: 0px 5px 7px rgba(0,0,0,.5); +} + +/* Hide jquery ui title bar. */ +.ctrl-pane-wrapper .ui-dialog-titlebar { + border-top: 1px solid #ccc; + border-left: 1px solid #aaa; + border-right: 1px solid #aaa; + border-bottom: 0; + padding: .6em .8em 0 .8em; + background: none !important; + -moz-border-radius-topleft: 5px; -webkit-border-top-left-radius: 5px; -khtml-border-top-left-radius: 5px; border-top-left-radius: 5px; + -moz-border-radius-topright: 5px; -webkit-border-top-right-radius: 5px; -khtml-border-top-right-radius: 5px; border-top-right-radius: 5px; +} + +/* Replace jquery ui title bar close icon. */ +.ctrl-pane-wrapper .ui-dialog-titlebar-close { + margin-top: -9px; + border: 0 !important; + background: none !important; +} + +/* Background-image is defined along with touch-sprite in 1 place. */ +.ctrl-pane-wrapper .ui-dialog-titlebar-close .ui-icon { + background-position: -9px -239px; + background-repeat: no-repeat; +} + +.ctrl-pane-wrapper .ui-dialog-titlebar-close .ui-icon:active { + background-position-x: -24px; + background-repeat: no-repeat; +} + +/* The grabber icon indicating the dialog could be moved around */ +.ctrl-pane-wrapper .ui-dialog-titlebar .ui-dialog-title { + background-position: -10px -255px; + background-repeat: no-repeat; + width: 40px; + height: 14px; + margin: 0 0 0 42%; +} + +.ctrl-pane-wrapper .ui-dialog-titlebar .ui-dialog-title:active { + background-position-x: -52px; +} + +.ctrl-pane-wrapper .ui-dialog-content { + background: none !important; + padding: 0 0; + border-style: solid; + border-color: #aaaaaa; + border-width: 0 1px 1px 1px; + -moz-border-radius-bottomleft: 5px; -webkit-border-bottom-left-radius: 5px; -khtml-border-bottom-left-radius: 5px; border-bottom-left-radius: 5px; + -moz-border-radius-bottomright: 5px; -webkit-border-bottom-right-radius: 5px; -khtml-border-bottom-right-radius: 5px; border-bottom-right-radius: 5px; +} + +.fnKey-inner-border-helper { + position: relative; + background: none !important; + border-style: solid; + border-color: #d5d5d5; + border-width: 1px; + -moz-border-radius: 5px; -webkit-border-radius: 5px; -khtml-border-radius: 5px; border-radius: 5px; + pointer-events:none; +} + +.ctrl-pane-wrapper .ctrl-pane { + padding: 3px 0 3px 6px; + height: 140px; + width: 280px; +} + +.ctrl-pane .baseKey { + float: left; + border: 0; + padding: 0; + width: 57px; + height: 57px; + margin: 6px; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; + font-family: "HelveticaNeue", "Helvetica Neue", "HelveticaNeue", "Helvetica Neue", 'TeXGyreHeros', "Helvetica", "Tahoma", "Geneva", "Arial", sans-serif; + font-size: 18px; + text-shadow: 0 1px 1px #eeeeee; + -moz-box-shadow: 0px 1px 3px rgba(0, 0, 0, .7); + -webkit-box-shadow: 0px 1px 3px rgba(0,0,0,.7); + box-shadow: 0px 1px 3px rgba(0,0,0,.7); +} + +.ctrl-pane .ctrl-key-top-row { + background: -webkit-linear-gradient(top, #fff 0%,#f3f5fb 2%,#d2d2d8 98%,#999 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #fff 0%,#f3f5fb 2%,#d2d2d8 98%,#999 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #fff 0%,#f3f5fb 2%,#d2d2d8 98%,#999 100%); /* IE10+ */ + background: linear-gradient(top, #fff 0%,#f3f5fb 2%,#d2d2d8 98%,#999 100%); /* W3C */ +} + +.ctrl-pane .ctrl-key-bottom-row { + background: -webkit-linear-gradient(top, #fff 0%,#e1e1e3 2%,#d1d1d4 50%,#bebec3 98%,#838387 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #fff 0%,#e1e1e3 2%,#d1d1d4 50%,#bebec3 98%,#838387 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #fff 0%,#e1e1e3 2%,#d1d1d4 50%,#bebec3 98%,#838387 100%); /* IE10+ */ + background: linear-gradient(top, #fff 0%,#e1e1e3 2%,#d1d1d4 50%,#bebec3 98%,#838387 100%); /* W3C */ +} + +.ctrl-pane .up-position .fn-key-top-row { + color:#333; + background: #ffffff; /* Old browsers */ + background: -webkit-linear-gradient(top, #ffffff 0%,#f7f7f7 2%,#dcdde3 96%,#999999 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#f7f7f7 2%,#dcdde3 96%,#999999 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#f7f7f7 2%,#dcdde3 96%,#999999 100%); /* IE10+ */ + background: linear-gradient(top, #ffffff 0%,#f7f7f7 2%,#dcdde3 96%,#999999 100%); /* W3C */ +} + +.ctrl-pane .up-position .fn-key-bottom-row { + color:#333; + background: #ffffff; /* Old browsers */ + background: -webkit-linear-gradient(top, #ffffff 0%,#f3f5fb 2%,#d2d2d8 98%,#999999 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#f3f5fb 2%,#d2d2d8 98%,#999999 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#f3f5fb 2%,#d2d2d8 98%,#999999 100%); /* IE10+ */ + background: linear-gradient(top, #ffffff 0%,#f3f5fb 2%,#d2d2d8 98%,#999999 100%); /* W3C */ +} + +.ctrl-pane .down-position .fn-key-top-row { + color:#333; + background: #ffffff; /* Old browsers */ + background: -webkit-linear-gradient(top, #ffffff 0%,#e1e1e3 4%,#d1d1d4 45%,#b7b8bd 98%,#838387 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#e1e1e3 4%,#d1d1d4 45%,#b7b8bd 98%,#838387 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#e1e1e3 4%,#d1d1d4 45%,#b7b8bd 98%,#838387 100%); /* IE10+ */ + background: linear-gradient(top, #ffffff 0%,#e1e1e3 4%,#d1d1d4 45%,#b7b8bd 98%,#838387 100%); /* W3C */ +} + +.ctrl-pane .down-position .fn-key-bottom-row { + color:#333; + background: #ffffff; /* Old browsers */ + background: -webkit-linear-gradient(top, #ffffff 0%,#d9dadd 4%,#c8c8cd 45%,#b0b0b7 98%,#838387 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#d9dadd 4%,#c8c8cd 45%,#b0b0b7 98%,#838387 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#d9dadd 4%,#c8c8cd 45%,#b0b0b7 98%,#838387 100%); /* IE10+ */ + background: linear-gradient(top, #ffffff 0%,#d9dadd 4%,#c8c8cd 45%,#b0b0b7 98%,#838387 100%); /* W3C */ +} + +.ctrl-pane .fn-key-top-row { + margin: 12px 6px 6px 6px; +} + +.ctrl-pane .border-key-top-left .fn-key-top-row { + margin: 12px 6px 6px 12px; +} + +.ctrl-pane .border-key-top-right .fn-key-top-row { + margin: 12px 12px 6px 6px; +} + +.ctrl-pane .fn-key-bottom-row { + margin: 5px 6px 12px 6px; +} + +.ctrl-pane .border-key-bottom-left .fn-key-bottom-row { + margin: 5px 6px 12px 12px; +} + +.ctrl-pane .border-key-bottom-right .fn-key-bottom-row { + margin: 5px 12px 12px 6px; +} + +.ctrl-pane .ctrl-key-top-row:active, .ctrl-pane .fn-key-top-row:active, +.ctrl-pane .ctrl-key-bottom-row:active, .ctrl-pane .fn-key-bottom-row:active { + background: #bbbbbb; + background: -webkit-linear-gradient(bottom, #888888 25%, #CCCCCC 68%); + background: -ms-linear-gradient(bottom, #888888 25%, #CCCCCC 68%); + background: -o-linear-gradient(bottom, #888888 25%, #CCCCCC 68%); + background: linear-gradient(bottom, #888888 25%, #CCCCCC 68%); +} + +.ctrl-pane .ctrl-key-top-row div, .ctrl-pane .ctrl-key-bottom-row div, +.ctrl-pane .fn-key-top-row div, .ctrl-pane .fn-key-bottom-row div { + width: 100%; + text-align: center; + padding-top: 17px; + overflow-x: hidden; +} + +/* Highlight selected modifier key */ +.ctrl-pane .ab-modifier-key-down { + color: #4D8DFF; +} + +.ctrl-pane .baseKey img { /* use .touch-sprite for image */ + background-repeat: no-repeat; + width: 57px; + height: 57px; + border: 0; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; +} + +.ctrl-pane .baseKey .right-arrow { + background-position: -242px -182px; +} + +.ctrl-pane .baseKey .left-arrow { + background-position: -126px -182px; +} + +.ctrl-pane .baseKey .up-arrow { + background-position: -299px -182px; +} + +.ctrl-pane .baseKey .down-arrow { + background-position: -183px -182px; +} + +.ctrl-pane .baseKey .more-keys { + background-position: -10px -182px; +} + +/* Ctrl - pane flip transition. */ +.ctrl-pane.flip-container { + perspective: 1000; + -webkit-perspective: 1000; + -moz-perspective: 1000; + -ms-perspective: 1000; +} + + /* flip the ctrl-pane when this class toggles. */ +.flip-container.perform-flip .flipper { + transform: rotateY(180deg); + -webkit-transform: rotateY(180deg); + -moz-transform: rotateY(180deg); + -ms-transform: rotateY(180deg); +} + +/* flip speed goes here */ +.flip-container .flipper { + transition: 0.6s; + transform-style: preserve-3d; + -webkit-transition: 0.6s; + -webkit-transform-style: preserve-3d; + -moz-transition: 0.6s; + -moz-transform-style: preserve-3d; + -ms-transition: 0.6s; + -ms-transform-style: preserve-3d; + position: relative; +} + +/* hide back of pane during swap */ +.flip-container .front, .flip-container .back { + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + -moz-backface-visibility: hidden; + -ms-backface-visibility: hidden; + position: absolute; + top: 0; + left: 0; +} + +/* front pane, placed above back */ +.flip-container .front { + z-index: 200; +} + +/* back, initially hidden pane */ +.flip-container .back { + transform: rotateY(180deg); + -webkit-transform: rotateY(180deg); + -moz-transform: rotateY(180deg); + -ms-transform: rotateY(180deg); +} + +#fnMasterKey { + letter-spacing: -1px +} \ No newline at end of file diff --git a/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/main-ui.css b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/main-ui.css new file mode 100644 index 000000000..bf2c28c95 --- /dev/null +++ b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/main-ui.css @@ -0,0 +1,174 @@ +/****************************************************************************** + * Copyright 2013 VMware, Inc. All rights reserved. + *****************************************************************************/ + +/* + * main-ui.css + * + * Defines style for the wmks ui widgets. + * + * Use CSS3 for touch devices as jquery effects break when browser handles + * orientation changes, or page bouncing. + * + * TODO: Need to handle Retina mode for iPad. + */ + +/* + * jQuery UI Dialog + */ +.ui-dialog { + padding: 0; + box-shadow: 0px 5px 7px rgba(0,0,0,.5); +} + +.ui-dialog .ui-dialog-titlebar { + padding: .8em .8em; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.ui-dialog .ui-dialog-titlebar-close { + right: .4em; + margin-top: -11px; +} + +.ui-widget-content { + border: 0; + background: #ffffff; + color: #333333; +} + +.ui-widget-header a { + color: #333333; +} + + +/* Touch feedback indicator */ +.ui-touch-feedback-icon { + background-image: url('../img/touch_sprite_feedback.png'); + width: 300px; + height: 120px; + position: absolute; + left: -9999px; + top: -9999px; + z-index: 2; +} + +.feedback-container { + z-index: 2; + position: absolute; + display: none; +} + +.feedback-container.cursor-icon { + background: url('../img/touch_sprite_feedback.png') -260px -15px no-repeat; + width: 17px; + height: 23px; +} + +.feedback-container.tap-icon { + background: url('../img/touch_sprite_feedback.png') -300px -15px no-repeat; + width: 36px; + height: 36px; +} + +.feedback-container.drag-icon { + background: url('../img/touch_sprite_feedback.png') -10px -10px no-repeat; + width: 100px; + height: 100px; +} + +.feedback-container.pulse-icon { + background: url('../img/touch_sprite_feedback.png') -111px -10px no-repeat; + width: 100px; + height: 100px; +} + +.feedback-container.scroll-icon { + background: url('../img/touch_sprite_feedback.png') -212px -10px no-repeat; + width: 27px; + height: 100px; +} + +.trackPad-cursor { + background: none !important; +} + +.trackPad-cursor.cursorIcon{ + opacity: 0; +} + +.cursor-icon-shadow { + transform-origin: 0 0 ; + -webkit-transform-origin: 0 0 ; + -moz-transform-origin: 0 0 ; + -ms-transform-origin: 0 0 ; +} + +/* CSS3 feedback indicator animation. Keep it simple (uses lower cpu cycles) + as there may be multiple animation requests made in quick successions. */ +.animate-feedback-indicator { + display: block; + opacity: 0; + animation-name: showfadeout; + animation-duration: 350ms; + -webkit-animation-name: showfadeout; + -webkit-animation-duration: 350ms; + -moz-animation-name: showfadeout; + -moz-animation-duration: 350ms; + -ms-animation-name: showfadeout; + -ms-animation-duration: 350ms; +} + +@-webkit-keyframes showfadeout { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +@-moz-keyframes showfadeout { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +@-ms-keyframes showfadeout { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +.animate-double-feedback-indicator { + display: block; + opacity: 0; + animation-name: showdoublefadeout; + animation-duration: 400ms; + -webkit-animation-name: showdoublefadeout; + -webkit-animation-duration: 400ms; + -moz-animation-name: showdoublefadeout; + -moz-animation-duration: 400ms; + -ms-animation-name: showdoublefadeout; + -ms-animation-duration: 400ms; +} + +@-webkit-keyframes showdoublefadeout { + 0% { opacity: 1; } + 40% { opacity: 0; } + 70% { opacity: 1; } + 100% { opacity: 0; } +} + +@-moz-keyframes showdoublefadeout { + 0% { opacity: 1; } + 40% { opacity: 0; } + 70% { opacity: 1; } + 100% { opacity: 0; } +} + +@-ms-keyframes showdoublefadeout { + 0% { opacity: 1; } + 40% { opacity: 0; } + 70% { opacity: 1; } + 100% { opacity: 0; } +} + +#relativepadLeft { + height:200px; border:1px solid black; +} diff --git a/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/trackpad.css b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/trackpad.css new file mode 100644 index 000000000..efccae414 --- /dev/null +++ b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/trackpad.css @@ -0,0 +1,192 @@ +/****************************************************************************** + * Copyright 2013 VMware, Inc. All rights reserved. + *****************************************************************************/ + +/* + * trackpad.css + * + * Defines style for the trackpad widget. + */ + +/* + * jQuery UI Dialog 1.8.16 + */ +.ui-dialog { + padding: 0; + box-shadow: 0px 5px 7px rgba(0,0,0,.5); +} + +.ui-dialog .ui-dialog-titlebar { + padding: .8em .8em; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.ui-dialog .ui-dialog-titlebar-close { + right: .4em; + margin-top: -11px; +} + +.ui-widget-content { + border: 0; + background: #ffffff; + color: #333333; +} + +.ui-widget-header a { + color: #333333; +} + +/* + * Touch sprite is loaded in a single class (as we have disabled caching images). + * We do this for the iOS case, due to extreme limitations in terms of image size. + * This form of grouped declaration forces all these definitions to load the same + * sprite. (This is also loaded upfront for the navbar so its always visible). + * For details see PR - 978390. + */ +.trackpad-wrapper .ui-dialog-titlebar-close .ui-icon, +.trackpad-wrapper .ui-dialog-titlebar .ui-dialog-title, +.touch-sprite { + background-image: url('../img/touch_sprite.png'); +} + +/* Replace jquery ui title bar close icon. */ +.trackpad-wrapper .ui-dialog-titlebar-close { + margin-top: -9px; + border: 0 !important; + background: none !important; +} + +.trackpad-wrapper .ui-dialog-titlebar-close { + margin-top: -11px; +} + +/* Background-image is defined along with touch-sprite in 1 place. */ +.trackpad-wrapper .ui-dialog-titlebar-close .ui-icon { + background-position: -9px -239px; + background-repeat: no-repeat; +} + +.trackpad-wrapper .ui-dialog-titlebar-close .ui-icon:active { + background-position-x: -24px; + background-repeat: no-repeat; +} + +/* The grabber icon indicating the dialog could be moved around */ +.trackpad-wrapper .ui-dialog-titlebar .ui-dialog-title { + background-position: -10px -255px; + background-repeat: no-repeat; + width: 40px; + height: 14px; + margin: 0 0 0 42%; +} +.trackpad-wrapper .ui-dialog-titlebar .ui-dialog-title:active { + background-position-x: -52px; +} + +.trackpad-wrapper { + width: 289px !important; /* As this is less than the default value */ + border: 1px solid #333 !important; + background: none !important; + border-radius: 6px; + box-shadow: 0px 4px 9px rgba(0,0,0,.6); +} + +.trackpad-wrapper .ui-dialog-titlebar { + border-top: 1px solid #ccc; + border-left: 1px solid #aaa; + border-right: 1px solid #aaa; + border-bottom: 0; + padding: .5em .8em .4em .8em; + background: rgb(175,176,187); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(175,176,187,.93) 0%,rgba(170,171,182,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(175,176,187,.93) 0%,rgba(170,171,182,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(175,176,187,.93) 0%,rgba(170,171,182,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(175,176,187,.93) 0%,rgba(170,171,182,.93) 100%); /* W3C */ + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +.trackpad-wrapper .trackpad-container { + padding: 0 !important; +} + +.trackpad-wrapper .left-border { + background: rgb(170,171,182); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* W3C */ + margin-top: -1px; + float: left; + width: 12px; + height: 209px; + border: 0; +} + +.trackpad-wrapper .touch-area { + background: rgba(255,255,255,0.8); + background: -webkit-linear-gradient(-70deg, rgba(255,255,255,0.8) 0%, rgba(238,238,240,0.8) 22%, rgba(210,210,216,0.8) 71%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(-70deg, rgba(255,255,255,0.8) 0%, rgba(238,238,240,0.8) 22%, rgba(210,210,216,0.8) 71%); /* Opera 11.10+ */ + background: -ms-linear-gradient(-70deg, rgba(255,255,255,0.8) 0%, rgba(238,238,240,0.8) 22%, rgba(210,210,216,0.8) 71%); /* IE10+ */ + background: linear-gradient(110deg, rgba(255,255,255,0.8) 0%, rgba(238,238,240,0.8) 22%, rgba(210,210,216,0.8) 71%); /* W3C */ + border: 1px solid #555; + box-shadow: 0 2px 6px 1px #888 inset; + float: left; + width: 263px; + height: 206px; +} + +.trackpad-wrapper .right-border { + background: rgb(170,171,182); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* W3C */ + margin-top: -1px; + float: left; + width: 12px; + height: 209px; + border: 0; + } + +.trackpad-wrapper .bottom-border { + background: rgb(123,123,133); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(123,123,133,.93) 0%,rgba(110,110,119,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(123,123,133,.93) 0%,rgba(110,110,119,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(123,123,133,.93) 0%,rgba(110,110,119,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(123,123,133,.93) 0%,rgba(110,110,119,.93) 100%); /* W3C */ + width: 289px; + height: 73px; + margin-top: 208px; + border: 0; +} + +.trackpad-wrapper .button-left, .trackpad-wrapper .button-right { + background: rgb(255,255,255); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(255,255,255,.7) 0%,rgba(225,225,227,.7) 3%,rgba(204,204,204,.7) 45%,rgba(190,190,195,.7) 96%,rgba(131,131,135,.7) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(255,255,255,.7) 0%,rgba(225,225,227,.7) 3%,rgba(204,204,204,.7) 45%,rgba(190,190,195,.7) 96%,rgba(131,131,135,.7) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(255,255,255,.7) 0%,rgba(225,225,227,.7) 3%,rgba(204,204,204,.7) 45%,rgba(190,190,195,.7) 96%,rgba(131,131,135,.7) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(255,255,255,.7) 0%,rgba(225,225,227,.7) 3%,rgba(204,204,204,.7) 45%,rgba(190,190,195,.7) 96%,rgba(131,131,135,.7) 100%); /* W3C */ + border-radius: 6px; + box-shadow: 0 2px 5px #333; + float: left; + width: 126px; + height: 47px; +} + +.trackpad-wrapper .button-left { + margin: 12px 0px auto 12px; +} + +.trackpad-wrapper .button-right { + margin: 12px; +} + +.trackpad-wrapper .button-left.button-highlight, +.trackpad-wrapper .button-right.button-highlight { + background: -webkit-linear-gradient(top, rgba(170,171,182,.7) 0%,rgba(123,123,133,.7) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(170,171,182,.7) 0%,rgba(123,123,133,.7) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(170,171,182,.7) 0%,rgba(123,123,133,.7) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(170,171,182,.7) 0%,rgba(123,123,133,.7) 100%); /* W3C */ +} diff --git a/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/wmks-all.css b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/wmks-all.css new file mode 100644 index 000000000..21c3894a6 --- /dev/null +++ b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/css/wmks-all.css @@ -0,0 +1,684 @@ +/****************************************************************************** + * Copyright 2013 VMware, Inc. All rights reserved. + *****************************************************************************/ + +/* + * main-ui.css + * + * Defines style for the wmks ui widgets. + * + * Use CSS3 for touch devices as jquery effects break when browser handles + * orientation changes, or page bouncing. + * + * TODO: Need to handle Retina mode for iPad. + */ + +/* + * jQuery UI Dialog + */ +.ui-dialog { + padding: 0; + box-shadow: 0px 5px 7px rgba(0,0,0,.5); +} + +.ui-dialog .ui-dialog-titlebar { + padding: .8em .8em; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.ui-dialog .ui-dialog-titlebar-close { + right: .4em; + margin-top: -11px; +} + +.ui-widget-content { + border: 0; + background: #ffffff; + color: #333333; +} + +.ui-widget-header a { + color: #333333; +} + + +/* Touch feedback indicator */ +.ui-touch-feedback-icon { + background-image: url('../img/touch_sprite_feedback.png'); + width: 300px; + height: 120px; + position: absolute; + left: -9999px; + top: -9999px; + z-index: 2; +} + +.feedback-container { + z-index: 2; + position: absolute; + display: none; +} + +.feedback-container.cursor-icon { + background: url('../img/touch_sprite_feedback.png') -260px -15px no-repeat; + width: 17px; + height: 23px; +} + +.feedback-container.tap-icon { + background: url('../img/touch_sprite_feedback.png') -300px -15px no-repeat; + width: 36px; + height: 36px; +} + +.feedback-container.drag-icon { + background: url('../img/touch_sprite_feedback.png') -10px -10px no-repeat; + width: 100px; + height: 100px; +} + +.feedback-container.pulse-icon { + background: url('../img/touch_sprite_feedback.png') -111px -10px no-repeat; + width: 100px; + height: 100px; +} + +.feedback-container.scroll-icon { + background: url('../img/touch_sprite_feedback.png') -212px -10px no-repeat; + width: 27px; + height: 100px; +} + +.trackPad-cursor { + background: none !important; +} + +.trackPad-cursor.cursorIcon{ + opacity: 0; +} + +.cursor-icon-shadow { + transform-origin: 0 0 ; + -webkit-transform-origin: 0 0 ; + -moz-transform-origin: 0 0 ; + -ms-transform-origin: 0 0 ; +} + +/* CSS3 feedback indicator animation. Keep it simple (uses lower cpu cycles) + as there may be multiple animation requests made in quick successions. */ +.animate-feedback-indicator { + display: block; + opacity: 0; + animation-name: showfadeout; + animation-duration: 350ms; + -webkit-animation-name: showfadeout; + -webkit-animation-duration: 350ms; + -moz-animation-name: showfadeout; + -moz-animation-duration: 350ms; + -ms-animation-name: showfadeout; + -ms-animation-duration: 350ms; +} + +@-webkit-keyframes showfadeout { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +@-moz-keyframes showfadeout { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +@-ms-keyframes showfadeout { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +.animate-double-feedback-indicator { + display: block; + opacity: 0; + animation-name: showdoublefadeout; + animation-duration: 400ms; + -webkit-animation-name: showdoublefadeout; + -webkit-animation-duration: 400ms; + -moz-animation-name: showdoublefadeout; + -moz-animation-duration: 400ms; + -ms-animation-name: showdoublefadeout; + -ms-animation-duration: 400ms; +} + +@-webkit-keyframes showdoublefadeout { + 0% { opacity: 1; } + 40% { opacity: 0; } + 70% { opacity: 1; } + 100% { opacity: 0; } +} + +@-moz-keyframes showdoublefadeout { + 0% { opacity: 1; } + 40% { opacity: 0; } + 70% { opacity: 1; } + 100% { opacity: 0; } +} + +@-ms-keyframes showdoublefadeout { + 0% { opacity: 1; } + 40% { opacity: 0; } + 70% { opacity: 1; } + 100% { opacity: 0; } +} + +#relativepadLeft { + height:200px; border:1px solid black; +} +/****************************************************************************** + * Copyright 2013 VMware, Inc. All rights reserved. + *****************************************************************************/ + +/* + * trackpad.css + * + * Defines style for the trackpad widget. + */ + +/* + * jQuery UI Dialog 1.8.16 + */ +.ui-dialog { + padding: 0; + box-shadow: 0px 5px 7px rgba(0,0,0,.5); +} + +.ui-dialog .ui-dialog-titlebar { + padding: .8em .8em; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.ui-dialog .ui-dialog-titlebar-close { + right: .4em; + margin-top: -11px; +} + +.ui-widget-content { + border: 0; + background: #ffffff; + color: #333333; +} + +.ui-widget-header a { + color: #333333; +} + +/* + * Touch sprite is loaded in a single class (as we have disabled caching images). + * We do this for the iOS case, due to extreme limitations in terms of image size. + * This form of grouped declaration forces all these definitions to load the same + * sprite. (This is also loaded upfront for the navbar so its always visible). + * For details see PR - 978390. + */ +.trackpad-wrapper .ui-dialog-titlebar-close .ui-icon, +.trackpad-wrapper .ui-dialog-titlebar .ui-dialog-title, +.touch-sprite { + background-image: url('../img/touch_sprite.png'); +} + +/* Replace jquery ui title bar close icon. */ +.trackpad-wrapper .ui-dialog-titlebar-close { + margin-top: -9px; + border: 0 !important; + background: none !important; +} + +.trackpad-wrapper .ui-dialog-titlebar-close { + margin-top: -11px; +} + +/* Background-image is defined along with touch-sprite in 1 place. */ +.trackpad-wrapper .ui-dialog-titlebar-close .ui-icon { + background-position: -9px -239px; + background-repeat: no-repeat; +} + +.trackpad-wrapper .ui-dialog-titlebar-close .ui-icon:active { + background-position-x: -24px; + background-repeat: no-repeat; +} + +/* The grabber icon indicating the dialog could be moved around */ +.trackpad-wrapper .ui-dialog-titlebar .ui-dialog-title { + background-position: -10px -255px; + background-repeat: no-repeat; + width: 40px; + height: 14px; + margin: 0 0 0 42%; +} +.trackpad-wrapper .ui-dialog-titlebar .ui-dialog-title:active { + background-position-x: -52px; +} + +.trackpad-wrapper { + width: 289px !important; /* As this is less than the default value */ + border: 1px solid #333 !important; + background: none !important; + border-radius: 6px; + box-shadow: 0px 4px 9px rgba(0,0,0,.6); +} + +.trackpad-wrapper .ui-dialog-titlebar { + border-top: 1px solid #ccc; + border-left: 1px solid #aaa; + border-right: 1px solid #aaa; + border-bottom: 0; + padding: .5em .8em .4em .8em; + background: rgb(175,176,187); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(175,176,187,.93) 0%,rgba(170,171,182,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(175,176,187,.93) 0%,rgba(170,171,182,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(175,176,187,.93) 0%,rgba(170,171,182,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(175,176,187,.93) 0%,rgba(170,171,182,.93) 100%); /* W3C */ + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +.trackpad-wrapper .trackpad-container { + padding: 0 !important; +} + +.trackpad-wrapper .left-border { + background: rgb(170,171,182); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* W3C */ + margin-top: -1px; + float: left; + width: 12px; + height: 209px; + border: 0; +} + +.trackpad-wrapper .touch-area { + background: rgba(255,255,255,0.8); + background: -webkit-linear-gradient(-70deg, rgba(255,255,255,0.8) 0%, rgba(238,238,240,0.8) 22%, rgba(210,210,216,0.8) 71%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(-70deg, rgba(255,255,255,0.8) 0%, rgba(238,238,240,0.8) 22%, rgba(210,210,216,0.8) 71%); /* Opera 11.10+ */ + background: -ms-linear-gradient(-70deg, rgba(255,255,255,0.8) 0%, rgba(238,238,240,0.8) 22%, rgba(210,210,216,0.8) 71%); /* IE10+ */ + background: linear-gradient(110deg, rgba(255,255,255,0.8) 0%, rgba(238,238,240,0.8) 22%, rgba(210,210,216,0.8) 71%); /* W3C */ + border: 1px solid #555; + box-shadow: 0 2px 6px 1px #888 inset; + float: left; + width: 263px; + height: 206px; +} + +.trackpad-wrapper .right-border { + background: rgb(170,171,182); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* W3C */ + margin-top: -1px; + float: left; + width: 12px; + height: 209px; + border: 0; + } + +.trackpad-wrapper .bottom-border { + background: rgb(123,123,133); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(123,123,133,.93) 0%,rgba(110,110,119,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(123,123,133,.93) 0%,rgba(110,110,119,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(123,123,133,.93) 0%,rgba(110,110,119,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(123,123,133,.93) 0%,rgba(110,110,119,.93) 100%); /* W3C */ + width: 289px; + height: 73px; + margin-top: 208px; + border: 0; +} + +.trackpad-wrapper .button-left, .trackpad-wrapper .button-right { + background: rgb(255,255,255); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(255,255,255,.7) 0%,rgba(225,225,227,.7) 3%,rgba(204,204,204,.7) 45%,rgba(190,190,195,.7) 96%,rgba(131,131,135,.7) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(255,255,255,.7) 0%,rgba(225,225,227,.7) 3%,rgba(204,204,204,.7) 45%,rgba(190,190,195,.7) 96%,rgba(131,131,135,.7) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(255,255,255,.7) 0%,rgba(225,225,227,.7) 3%,rgba(204,204,204,.7) 45%,rgba(190,190,195,.7) 96%,rgba(131,131,135,.7) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(255,255,255,.7) 0%,rgba(225,225,227,.7) 3%,rgba(204,204,204,.7) 45%,rgba(190,190,195,.7) 96%,rgba(131,131,135,.7) 100%); /* W3C */ + border-radius: 6px; + box-shadow: 0 2px 5px #333; + float: left; + width: 126px; + height: 47px; +} + +.trackpad-wrapper .button-left { + margin: 12px 0px auto 12px; +} + +.trackpad-wrapper .button-right { + margin: 12px; +} + +.trackpad-wrapper .button-left.button-highlight, +.trackpad-wrapper .button-right.button-highlight { + background: -webkit-linear-gradient(top, rgba(170,171,182,.7) 0%,rgba(123,123,133,.7) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(170,171,182,.7) 0%,rgba(123,123,133,.7) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(170,171,182,.7) 0%,rgba(123,123,133,.7) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(170,171,182,.7) 0%,rgba(123,123,133,.7) 100%); /* W3C */ +} +/****************************************************************************** + * Copyright 2013 VMware, Inc. All rights reserved. + *****************************************************************************/ + +/* + * extended-keypad.css + * + * Defines style for the virtual keys on the control pane. + */ + +.ctrl-pane-wrapper { + width: 290px !important; /* Needed as the default is a bit larger than this */ + border: 1px solid #333 !important; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; + background: rgb(170,171,182); /* Old browsers */ + background: -webkit-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* IE10+ */ + background: linear-gradient(top, rgba(170,171,182,.93) 0%,rgba(123,123,133,.93) 100%); /* W3C */ +} + +.fnKey-pane-wrapper { + width: 427px; + border: 1px solid #333; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; + background: #c1c4d1; /* Old browsers */ + background: -webkit-linear-gradient(top, #c1c4d1 0%,#b0b1bd 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #c1c4d1 0%,#b0b1bd 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #c1c4d1 0%,#b0b1bd 100%); /* IE10+ */ + background: linear-gradient(top, #c1c4d1 0%, #b0b1bd 100%); /* W3C */ + position: absolute; + padding: 0; + -moz-box-shadow: 0px 5px 7px rgba(0,0,0,.5); + -webkit-box-shadow: 0px 5px 7px rgba(0,0,0,.5); + box-shadow: 0px 5px 7px rgba(0,0,0,.5); +} + +.fnKey-pane-wrapper-down { + width: 427px; + border: 1px solid #333; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; + background: #6e6e77; /* Old browsers */ + background: -webkit-linear-gradient(top, #6e6e77 0%,#656565 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #6e6e77 0%,#656565 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #6e6e77 0%,#656565 100%); /* IE10+ */ + background: linear-gradient(top, #6e6e77 0%, #656565 100%); /* W3C */ + position: absolute; + padding: 0; + -moz-box-shadow: 0px 5px 7px rgba(0,0,0,.5); + -webkit-box-shadow: 0px 5px 7px rgba(0,0,0,.5); + box-shadow: 0px 5px 7px rgba(0,0,0,.5); +} + +/* Hide jquery ui title bar. */ +.ctrl-pane-wrapper .ui-dialog-titlebar { + border-top: 1px solid #ccc; + border-left: 1px solid #aaa; + border-right: 1px solid #aaa; + border-bottom: 0; + padding: .6em .8em 0 .8em; + background: none !important; + -moz-border-radius-topleft: 5px; -webkit-border-top-left-radius: 5px; -khtml-border-top-left-radius: 5px; border-top-left-radius: 5px; + -moz-border-radius-topright: 5px; -webkit-border-top-right-radius: 5px; -khtml-border-top-right-radius: 5px; border-top-right-radius: 5px; +} + +/* Replace jquery ui title bar close icon. */ +.ctrl-pane-wrapper .ui-dialog-titlebar-close { + margin-top: -9px; + border: 0 !important; + background: none !important; +} + +/* Background-image is defined along with touch-sprite in 1 place. */ +.ctrl-pane-wrapper .ui-dialog-titlebar-close .ui-icon { + background-position: -9px -239px; + background-repeat: no-repeat; +} + +.ctrl-pane-wrapper .ui-dialog-titlebar-close .ui-icon:active { + background-position-x: -24px; + background-repeat: no-repeat; +} + +/* The grabber icon indicating the dialog could be moved around */ +.ctrl-pane-wrapper .ui-dialog-titlebar .ui-dialog-title { + background-position: -10px -255px; + background-repeat: no-repeat; + width: 40px; + height: 14px; + margin: 0 0 0 42%; +} + +.ctrl-pane-wrapper .ui-dialog-titlebar .ui-dialog-title:active { + background-position-x: -52px; +} + +.ctrl-pane-wrapper .ui-dialog-content { + background: none !important; + padding: 0 0; + border-style: solid; + border-color: #aaaaaa; + border-width: 0 1px 1px 1px; + -moz-border-radius-bottomleft: 5px; -webkit-border-bottom-left-radius: 5px; -khtml-border-bottom-left-radius: 5px; border-bottom-left-radius: 5px; + -moz-border-radius-bottomright: 5px; -webkit-border-bottom-right-radius: 5px; -khtml-border-bottom-right-radius: 5px; border-bottom-right-radius: 5px; +} + +.fnKey-inner-border-helper { + position: relative; + background: none !important; + border-style: solid; + border-color: #d5d5d5; + border-width: 1px; + -moz-border-radius: 5px; -webkit-border-radius: 5px; -khtml-border-radius: 5px; border-radius: 5px; + pointer-events:none; +} + +.ctrl-pane-wrapper .ctrl-pane { + padding: 3px 0 3px 6px; + height: 140px; + width: 280px; +} + +.ctrl-pane .baseKey { + float: left; + border: 0; + padding: 0; + width: 57px; + height: 57px; + margin: 6px; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; + font-family: "HelveticaNeue", "Helvetica Neue", "HelveticaNeue", "Helvetica Neue", 'TeXGyreHeros', "Helvetica", "Tahoma", "Geneva", "Arial", sans-serif; + font-size: 18px; + text-shadow: 0 1px 1px #eeeeee; + -moz-box-shadow: 0px 1px 3px rgba(0, 0, 0, .7); + -webkit-box-shadow: 0px 1px 3px rgba(0,0,0,.7); + box-shadow: 0px 1px 3px rgba(0,0,0,.7); +} + +.ctrl-pane .ctrl-key-top-row { + background: -webkit-linear-gradient(top, #fff 0%,#f3f5fb 2%,#d2d2d8 98%,#999 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #fff 0%,#f3f5fb 2%,#d2d2d8 98%,#999 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #fff 0%,#f3f5fb 2%,#d2d2d8 98%,#999 100%); /* IE10+ */ + background: linear-gradient(top, #fff 0%,#f3f5fb 2%,#d2d2d8 98%,#999 100%); /* W3C */ +} + +.ctrl-pane .ctrl-key-bottom-row { + background: -webkit-linear-gradient(top, #fff 0%,#e1e1e3 2%,#d1d1d4 50%,#bebec3 98%,#838387 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #fff 0%,#e1e1e3 2%,#d1d1d4 50%,#bebec3 98%,#838387 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #fff 0%,#e1e1e3 2%,#d1d1d4 50%,#bebec3 98%,#838387 100%); /* IE10+ */ + background: linear-gradient(top, #fff 0%,#e1e1e3 2%,#d1d1d4 50%,#bebec3 98%,#838387 100%); /* W3C */ +} + +.ctrl-pane .up-position .fn-key-top-row { + color:#333; + background: #ffffff; /* Old browsers */ + background: -webkit-linear-gradient(top, #ffffff 0%,#f7f7f7 2%,#dcdde3 96%,#999999 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#f7f7f7 2%,#dcdde3 96%,#999999 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#f7f7f7 2%,#dcdde3 96%,#999999 100%); /* IE10+ */ + background: linear-gradient(top, #ffffff 0%,#f7f7f7 2%,#dcdde3 96%,#999999 100%); /* W3C */ +} + +.ctrl-pane .up-position .fn-key-bottom-row { + color:#333; + background: #ffffff; /* Old browsers */ + background: -webkit-linear-gradient(top, #ffffff 0%,#f3f5fb 2%,#d2d2d8 98%,#999999 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#f3f5fb 2%,#d2d2d8 98%,#999999 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#f3f5fb 2%,#d2d2d8 98%,#999999 100%); /* IE10+ */ + background: linear-gradient(top, #ffffff 0%,#f3f5fb 2%,#d2d2d8 98%,#999999 100%); /* W3C */ +} + +.ctrl-pane .down-position .fn-key-top-row { + color:#333; + background: #ffffff; /* Old browsers */ + background: -webkit-linear-gradient(top, #ffffff 0%,#e1e1e3 4%,#d1d1d4 45%,#b7b8bd 98%,#838387 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#e1e1e3 4%,#d1d1d4 45%,#b7b8bd 98%,#838387 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#e1e1e3 4%,#d1d1d4 45%,#b7b8bd 98%,#838387 100%); /* IE10+ */ + background: linear-gradient(top, #ffffff 0%,#e1e1e3 4%,#d1d1d4 45%,#b7b8bd 98%,#838387 100%); /* W3C */ +} + +.ctrl-pane .down-position .fn-key-bottom-row { + color:#333; + background: #ffffff; /* Old browsers */ + background: -webkit-linear-gradient(top, #ffffff 0%,#d9dadd 4%,#c8c8cd 45%,#b0b0b7 98%,#838387 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #ffffff 0%,#d9dadd 4%,#c8c8cd 45%,#b0b0b7 98%,#838387 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #ffffff 0%,#d9dadd 4%,#c8c8cd 45%,#b0b0b7 98%,#838387 100%); /* IE10+ */ + background: linear-gradient(top, #ffffff 0%,#d9dadd 4%,#c8c8cd 45%,#b0b0b7 98%,#838387 100%); /* W3C */ +} + +.ctrl-pane .fn-key-top-row { + margin: 12px 6px 6px 6px; +} + +.ctrl-pane .border-key-top-left .fn-key-top-row { + margin: 12px 6px 6px 12px; +} + +.ctrl-pane .border-key-top-right .fn-key-top-row { + margin: 12px 12px 6px 6px; +} + +.ctrl-pane .fn-key-bottom-row { + margin: 5px 6px 12px 6px; +} + +.ctrl-pane .border-key-bottom-left .fn-key-bottom-row { + margin: 5px 6px 12px 12px; +} + +.ctrl-pane .border-key-bottom-right .fn-key-bottom-row { + margin: 5px 12px 12px 6px; +} + +.ctrl-pane .ctrl-key-top-row:active, .ctrl-pane .fn-key-top-row:active, +.ctrl-pane .ctrl-key-bottom-row:active, .ctrl-pane .fn-key-bottom-row:active { + background: #bbbbbb; + background: -webkit-linear-gradient(bottom, #888888 25%, #CCCCCC 68%); + background: -ms-linear-gradient(bottom, #888888 25%, #CCCCCC 68%); + background: -o-linear-gradient(bottom, #888888 25%, #CCCCCC 68%); + background: linear-gradient(bottom, #888888 25%, #CCCCCC 68%); +} + +.ctrl-pane .ctrl-key-top-row div, .ctrl-pane .ctrl-key-bottom-row div, +.ctrl-pane .fn-key-top-row div, .ctrl-pane .fn-key-bottom-row div { + width: 100%; + text-align: center; + padding-top: 17px; + overflow-x: hidden; +} + +/* Highlight selected modifier key */ +.ctrl-pane .ab-modifier-key-down { + color: #4D8DFF; +} + +.ctrl-pane .baseKey img { /* use .touch-sprite for image */ + background-repeat: no-repeat; + width: 57px; + height: 57px; + border: 0; + -moz-border-radius: 6px; -webkit-border-radius: 6px; -khtml-border-radius: 6px; border-radius: 6px; +} + +.ctrl-pane .baseKey .right-arrow { + background-position: -242px -182px; +} + +.ctrl-pane .baseKey .left-arrow { + background-position: -126px -182px; +} + +.ctrl-pane .baseKey .up-arrow { + background-position: -299px -182px; +} + +.ctrl-pane .baseKey .down-arrow { + background-position: -183px -182px; +} + +.ctrl-pane .baseKey .more-keys { + background-position: -10px -182px; +} + +/* Ctrl - pane flip transition. */ +.ctrl-pane.flip-container { + perspective: 1000; + -webkit-perspective: 1000; + -moz-perspective: 1000; + -ms-perspective: 1000; +} + + /* flip the ctrl-pane when this class toggles. */ +.flip-container.perform-flip .flipper { + transform: rotateY(180deg); + -webkit-transform: rotateY(180deg); + -moz-transform: rotateY(180deg); + -ms-transform: rotateY(180deg); +} + +/* flip speed goes here */ +.flip-container .flipper { + transition: 0.6s; + transform-style: preserve-3d; + -webkit-transition: 0.6s; + -webkit-transform-style: preserve-3d; + -moz-transition: 0.6s; + -moz-transform-style: preserve-3d; + -ms-transition: 0.6s; + -ms-transform-style: preserve-3d; + position: relative; +} + +/* hide back of pane during swap */ +.flip-container .front, .flip-container .back { + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + -moz-backface-visibility: hidden; + -ms-backface-visibility: hidden; + position: absolute; + top: 0; + left: 0; +} + +/* front pane, placed above back */ +.flip-container .front { + z-index: 200; +} + +/* back, initially hidden pane */ +.flip-container .back { + transform: rotateY(180deg); + -webkit-transform: rotateY(180deg); + -moz-transform: rotateY(180deg); + -ms-transform: rotateY(180deg); +} + +#fnMasterKey { + letter-spacing: -1px +} \ No newline at end of file diff --git a/projects/gameboard-ui/src/assets/vendor/vmware-wmks/img/touch_sprite.png b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/img/touch_sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..9209d836cd3b19ba74c69ff287734043c79be2b1 GIT binary patch literal 24870 zcmbTdbyQqU^Da7AfDqh*ySu{#cPF^JySpYp(4Zl>dvFNu?(Xgog1f`ryzlq@&RKVz zb?#bs{$bCa?&|95uIlRNDZ)O>OCY|-eGdYG5TztVl|UdUVh{*|9_}6R<`cs_82E$Z zBBtS@Y;We`Zs=qR5;CzjG9{L>H8eL>GBq^ubR0D01$tRns%W@q$jO3@?QIzi-`X&G z*g62cK_FfM4+leIYf~3uBU5urJ3i9W=5|tIOA|g)bq+aZIR_C_3rk5aCsSoFc@<+X zYhxZ0QUQKqUJo!Jz}D2okl4f4#?Be+!AJU!Trlwb_L_;5_@5>&)_kP@Dy1R!nOMZ$ z$&{Fbk(t4mnUjT>lbw-;otcN7hn|>~nT45&g_Vh!i-Cn1%)td_W+DF1hZGpi$;1q- zBr5)&v4C%Uq!unN4qzrGcXxM2cQ!_QCvzqi9v&VhW>zLvRtBI2gR`fdi=hXDoio{g zC5W0j8#`G#xLDfT5x+?^G_rSf;Ufh!{dWqs4svq;BiPRQKZXKK#^hn>z{JAH%w%i( zHm`qLJG&^E{x3EDkFA|mJRMA#luVuNU7d`9^)Ms*Z)d=E|M!938UosY6`U-AMKQDy zwKsOPHMMh*66GTWeql7RGyzP+&CJ5W#m>yd%FM~l!otJC%p=YvA|@on!@?@U!Tw(v z|EInz9Ad&^tm4d^Y@9+YEMlxeY~o^K!lELqob0ST971gW?JH&H>|$tVZ2I4EErD_W z?JFk!f9(qvaWXY@v3F9jx3~H44)|XFAPsBPawK?HIBqJG^zbKuTZ1PQryG z2ZJB9R-0Ut;YePeF2}DUBO^7C2|`|G_zSWA798&?KO)!8Hb6sw{!xfnaD`k@twPWC zmwE+Zz0bpyxRXmk2VS3bS!v? z07y|SfL!r0ami82<24W#xf~7lx6R@OkJ1#~M3NST)$%MDZn@5utx?7&R7|o^KHa4O zdUv*FM{k^41@zAx&+NXcj0qI$wR)v$nttNoa7BFf52(zMh$a-d_G%R~!K!oI7Ow(n zqGDsuhlB-A7b>_b%LD~|`-Z0Wv(9$8DIc=g{ZtQqnKGt7mQ2E=*LtoL2kEDeUN&c# z77u29th_B`Z6o)}n>8ImKvLn+(d)d{bM)oOlS{SM@rexDec&{O!nrD=-tcH?7&wGn z0lebltecxGp}?&SufH^QtEu*FjrSwTY`;QdzJ7(QCv2@69?NlTN)#>Qeo(VU>dMO_@%Y@GRUS+iGO0We z`@BBit_XQ-g;A|+K`0t{PsJD1RLLJZ9Dr2~2+7W0FZV{b{UoCBEm6mH3F*tGtBm`P z0C^G(*52Fw(c_C%#uDUFEf-nvSb#woa#CRPR7=!C5Z=H4;ML0i8LmXH73{g6?mps} zr(Y)`EUb~A#&+QKki${QTMSZcDb>0RkW3{HOC=1OFD78r-H+OKy*bjnIIdrRk7=n? zC{M7`D<2mZht?ToX=Y}omCR~NS!X>j?|9mi!RM`)eXo#r-(PvG2=NsP`E5mzW+_RJ z)>tNr+W;KBful~+j3wn8Zr8LC-39sVS(6dr^ zSQWFZPfSg%DCOz^)&DSNYoUWtIk1)fJbR(UeohIj7y=y+Q$Jg)X zJeSX1dcHux3TtN4>m0tReC7tBl(3{|<#>HKPc@RlWp6jRLd^u6(K`dNwzj4nL4z%x zmrh;4Czv@!!a!VH{9@hzbu>~=FG-pqcF9!$lh}!_+3LH`?%YuAy9f8Aqfyl@+spWY z#+T~qYP~`MB#E@#+g57p4Jcm$H{vlbM|tPk)Vap?n0e0bbQaMMYZ(43qHi@paT@oBj1yiMgfA|?yZ?3#qK??I>b5#T0wTrPKM)~9k-V{Ood_x zcNUWY6a|%crgD`X-=OE>?hJPJ_lLvX0Y^gZ8SBZsYDyB~;qo}!P_iE;=u$u-kWDf% zFob{?>FE zN}E>dCt{d_wcCjuS|RS%)WrNNYZ#Iz*IbrUc?3+YS5wH@+1b(Bs95zDclem)uCqZz z0%V^gw6s#XpECU(4wUpI-WK??WOtt+>AJEv`bkS4`cPtVkLN4iE13>#1gJ@DzYr5hZqZaf@MrLZU7 zNlQ0s;U`F1}Y|9dRTDrcrT z+We7eG%PH&ztW}LZDqU!ELJnrnwYOZqyisvT)HR|n@3TV1!W$tHs9vO#Kgeo7(XmO zTpwZ=lhmzJJpG=tL3&dK6br$Gp;nle$E#6mH7hR6om!C%92&Nqb}Iu`xDwxq(@aqQ+}_=U7y6>Sf{irEh^iX`C&2&#ruU0CanonVsWXa|4cBpe%LQqO&TD4A(x2Ht@mwR%IrB1s;Z*dnm zs+)o&zV2q5;pp0;?Vh+ms?2CUF=Y^mG|9ss?b_ShA!&z>GktmmH%4IKa zz23&hMPJcrpi$?3x<+u)G{;Brw7}DQ_~y0~1t85yz;8d&p`$uw#EhkXN`$M&O58o~ zC-SchgHt{KswAAe;+dy z3KRMP7_S2sMp9T_LYnV=-u@u_epGs00LseqAg)JeUDbz2DgWf+5vA zDH!ON6;Yawc=(5waI8LHR|+O{%QSz>nEClWO{*w1>Jg_YlWFsUERFcjTp1pZ?*luP z=uVIQLS?q+A}R?9N!nSFdPTIC8)9%94z6+J&(v?u%27F#vbT|&{xgVHZ zEG&v{ZGby%i-y*34HgJmeb9It0JH(8ir2-~*%1U)i#28FQhcXD{hbNX*iu7_qZjs! zaOXV?)_8KmKTSKlCo~UjtT=My?~!Y#P;DkyFn|=6?DifJ_}K#;B4dTy*Hf>)?ro8P z&zam;-bTlC`T3xi-}{N~n=b#p-dOhd{Z9fjFN97Hywd8)m8Exy+r?$2l#ie&Jv zq<${mjme{$#o%li$NN9hA$|bWdBy#=gX)TU2 z2}7H~^{EMlaxFA!XL?;Y_lr#$-!F&l@Ziq3jG0kDe*@x87{nIF_xki9FeTET8Rqb7 zc%GA!z{+EjZ(-2XO#JVAx4QGlY=#?ZRQvlXNiEKyR~1b?S}(ic2dt!g;I*)V-&gYhu&@f2JIcx~1dcO#e@}|> zs2$c9N)h=qqAxI~(R+R&(SVw`Nz^Q87qY?_QH3A$ysUln#yfLG62=lUAf3^>eJHCv zS)2^+ubwb#s6sXe=#Wzk04q(9{_+$|A!i?WA9GdGP^jj6ar2njtI;A@j5#GBYw=N- zY{1wzBz|r@?&FO=Bhgq{p(jVa=gU>X4YC*GJc(}O`$i4!u=zoFeQ(y`A0l)SrLrQp z5-Hshim^YsgNSr*??pEE&gKO6&o{*m$Lir-t@B@x^r;4$5;7fz2{L6VRsT}N&}&7} z&vctiKR3pv-7z23Ab$Clb_(V!nUy%OE#>j?lXDX^IZewN=m=`7_!hSq@3`@{z8%?1 z%q{5sEv$P-r?9NHW#-pndy50;s%DqmbPxIu-rF-@MxxhzO(U7`UFsA(#nTgi-c7VYk|a5N5kh5)GAqt@gt&a zO9qy&;s+!-4UN?P5pbPR#Uy%1Fc?lPL3!FNUL7g+%*XmYujmx^xz(6dry+!;QAy}l zt?l*L&B7sc+wbql`ONItS%0JWo}tJk_nQVqbb<>{kfYlCH=>VAw_M@P?WSnAn7=1f zb|weLT11NO{!}l7O9aBlVF=&!s}$Fa8r%e-pgn(9(un`zSfK4sq|o{_y{F6mM?GiL zkk9!(4E_tR-N!IZ4-@zn7akJrM=%P64}+W}hF*lZZ~VrZ&<93arseE(QoDnv znB4XCE3JYzWekho|`))jyj}bl$N*UQejU>emKKrI2=ze6M73d>qnWJt&uJ&yPPvHuQaf ziF}!-Q^;j6c06c3t+WNF1pHHAp9Es-_Zhk(w<0IRc$tN_~YYkJ`SDK|vLqfI@uoYouoM$H&cZZd@aETG?=3 zBFbAL^YqlM#En%lo@&<59haN6a$ml2M6Iy6++Gt!#+njwo!t^F>q4OuYvRWIbRyf@8Ozc{)sO{IvRn1n)6&lz z&y*(@2;yu`k|By;c#tshazk{{lX@N9gmh2i=a0KiITsY1X+M3gqB<^!y!>=i)KV=g z7uk{emR~wRrnfpiF_8Y%N{aQhB-6~=y${urvEGJuW) z<8z5oJV3qC{6l%(rDRClULtN8R|2)>h^DrJ5lLV)i`iRO8Fa9D(dpPSt;zA_G&p)gv3LS_N~A1vJLT>~w|lL>1-Q|A`>iPb~{ zhr7nZ&(MMj>5&sXh~LN*kVh3L)@gI6g3F3;qTpL24^|nOs@G9*fO(t0$&-Di9G>g# z*1>Qo<44V0y=(BRC)bJ!NqG_aCsbGd*X=|+E8t-3mESCnGE>z$C%J}`lxQ<@)v}wR zHJ*$-$rEE#?_6(L`I{Uk%n=Qmh<%ag9SK?E0|pF4lb%ZkX|b zlCrwyL@P)_G=ZX~?nNw8ajq4g{-xBhoBIpfkN2^(*J!1C`nX8ExCIffQf+XPTFP1a zTEY3V%Zay5lag3id!|gy6UALf<|n8e51$g$?y4G1@za%C2;b&u`#vV`qRe{^#a$LV zD7Z?P-qYZ!#o@u9&ygrrm@a+%J40rv&|ai*yj=+^UQ@}XQ-e1#T0DbDQAYq~+Amijz5BWtC@Fo-A7 zU{<=Gz8s`IFJ!f+)@+)X+)L{+d!hSQnAVfu@KR)Pt~WsuOP4hbv{=NfZ#E>tAm&(h z^KaMM_cL{pa|;SK@rxD?V@5?t$U=G^h9 zPKl3S>jsbIxpPKdx0j=i_Vvoy5xa6z#O||M)N%Ag>OS~Ikf1MB2P@)drsj3^sOwl( zL^x%u#3(sp6Ed;>*hjfBg4KS6m@TPy3}dJQsGITZqeSC%J6(jn&IR3fkQ_xCmLl5{ zGjhZS^lh*(p@pfCm}vdatTv(H>@!Pm;dG-GGVeLwH^=Edyn&eBrM@Dry3j3uSEYfA zvd|78jtZ9`;yLcCj?(=RQ{G5fgk>R~OXMKEdRar6bU@Va9M6-rcE$5*z1PD;PtRmktxZl5*4^^ylRFzXvV@-rPt9$&+S4))jwq4pn3Hzg-Ai9M z`XZ&FUaC)xdW8n}zRX@_RcQZss2vhj%b&g1`fGNn+i8-**>~M%z+MJ&{u>BQ&V#bP zK~Z;|#LRdsw?`!bN_q|MN8h9NQRJOuNh(L>l8(q!SW(BK&m`8rbouX1g)Brr^n{!m z+&D>!NqT&kXQz!I`nqWL1gGZ_+JeKm%@%8ff)E{d{`_4>kxe*zJldmLuJcfxo;k>Y zpo&80stESYYJ!x%yabP8$yEZqEKoBq16KTBpH(Bp$*Rn3(laymF=wVij|(idYT5nP{}l`! zDj_K?DGA5^@k38al`DNzX8Lx?Ok8ooWv4G)|LL;o%FsUzmxu?3hi~84YR{+*N3OR5 z0BTx&H?9pqgothP$dg9lb@0;HDpJ8SjZsw%=+SB0EJ8v6%&hne!OKP zQAxEpLikjcmRJHXI^*!jG&V$wZH$`L(C+Ni8~|6N$lean3&m<`BjY=_mA9DQ0-Tc; z_lv_G=#zV}{2hUv%mEc}C^v#~0GiE)!{^10eFlKLv8Ka6gOymif&>Mue2WOM^9>0j zHj#PH1Y>fCI3v34jUlS8ir5S4zG?BI69m3PByIJ+-BTM$VhMb@+m0@+=;UGGqXsZJ zX?%EA{V&JBMeQ@Bf1`Oof#GRO?#vc@84<2A8DY)!Hn-!YD)UUW%uN%3TGP?zm*%fcK_OX!B1GAEzqSX_*bCj^JkF=SMMr+v ze!iD#AD5R$&I)jSPTPIoO@!E)qkGAsdAoWYudd^I4VSqcHbB;Ey+H_t7Qa-Ah1a|e z890@&uoY5}>81hRBpK9{pAdg|C|BX*`xsDvF+_fMUoHa%!kVpSX>ao5F3nty z7AEtLj~A)}FH-=rG!FeEtLHUbWndlDt#@~X1BxPY zC*y3s`3WOkpY+W&*R>-WLwXdn?8@zcP(sSl;RXsaD+ju!5+3#`<+ah`WhG*+yb1E&HR+# znXu*c8?Zg?dzWz{Qt3fNA_zM3g={GkZ1uWsk|&a!UKE)dwgJ*mHvl z+IaNX6%A4lc)kh(fYVC1taCl3JT~8lt4WyVCMD8phq< z5c2@`A`c)2JW4?Bda2qz2AK=|7U$}-QgS+CK{da44@R#B&b;J~xZzNYl^2 z_FS3RV9@&AkqYB+&8ARhsH{s+P9%p35db=;`~{GE98q#gJ3(h3IUF_wt@VSF0t_}Q z==IyuzPGU`m!lY(5{t2oTiLm(sfSSYy$t38%LaDOoA&Z4Q@>b{9mQtUiAne=Ctq}} z*b#7wfO3Ql>X?T1a9sRWGs&z$q5SPxlwtDQU1dP8q2oO}(xsWHf$8Md;C1a-=S!V` zhjRi+=gC^DOxxWS!lETWk?UVv&+F%+gU|k&XUG?Rk{`{>%HP&R0y(_1zX%|Y&AAsy z8qO60m_+K-Y@I%Yr$TH!kL)rwJb->>zBK!-TWvC^_pscjr=#`_U@4==2M0Y~Nc%n^vEQM(`_M*iJdDnlb5@tUZ20fqs(;28D%c z1Ri*YnXEBU=%0J6RqfcqD_y1RE(pv7rE zFMgz8i1_gqqRlfrzoqf}F4-h9>6?8e&!{(nLup?T$ZtPOVMkk^eV&HC!z~%{6{2fo zq!1^)sVKKQ*Ed z|IN8l|1@&@pJCD=3`cOlh={#^+y}}i)^hJXwSDvr@6I+~E`mQ#F8*nIzR`w5`ObF<9N^vH zXf|Z=-_zL9gD&cY)2(~oA9tf3#cdTbXQoL!WOY{8fH2j#4uq`^bjT;|fFCzsM2OPh z-}48d`&3mULF6!=1%N((2nxB$@C1(FgBH+zSMUxIaJF85ZEq`?XcqJBOCld^?hkLiNX`WFe&?&mLQrSZ9*8~VK7(Eb~6oWe^Jzcs9NUD;$- zv4hJ$%J+}JOqM6Vs9Xdv-+a~vI3%0X%)-RPiy-?*xHI+-E>F zXY|}U`}GWOHA~c5bE}*E$uB_V++2JPpj%sl&BAJ!hkD~FxY_5vdR=cH1lcoPTC81P zel#QqB)d}UZN7~|eZ|U!4eODSAMA8pT-d3&aXD=Up25n42Br;;+kLoAMUBN%`N-Ei z&U-}ib?}-Wmq)D%aU)%JI4XIiEV852L-^Uuksa-~dk` zFtu#dV!F2K*;`{?e#MV}s=zVKr_{Q=@_jn*PpW_t3|KHk!$dqk5<5AXm!uR03g*n6 zTWjvCSU(G)EA)Oi#Hc8a`g7t}^!RWS;I2O}OOF4^Et1XpYbPcI8U}{wQk|^~kkwPt zF`y#=sY7KmL*uRiAvMW2&CxU z47#fU8Iq~At`;j(Rd`ZYRr_3ya@oh{jQvSDK!${Xe9A|=0k&f_KEF?riJ95Y2d~(^ z68(1md!QI?piH|SzW_*WYrBuRomZ=e`m#krxPiOUD6o(f$(Q6F!YUp160k}S$@>Q6 zLVMQ+-4{qdBAJ;Mcz!^MbQQQ+a?;T>`yQ>bC7QEClpioPC0 zKmZ!>hAc!3igvd7K1y0y{Zc+i`#%3mm9E;a^*Or3(a|vvxYQxlZ%Y%QQ84&WS4gH9KL!8SwF&n0zi&GUx4DD)kTE4>jreOCIo!h4; zAI`{AD~2>3_h{M8!)2&7s?y|nk*GTd@k*@-^;#>{G4zYub^m4ATcG-5a7o-Uh;3QD zZX77GI$yr50DNTzV_n+e>W`v?BBO<4`$||dAHtalkXgGH4d_vR`SL~Y)-^RDEa4bP z^ZN=VKrNyHm7BjSbeqM2JD8;1dRtiv$gtAy4DO(t(Tp@+a zGlcJpKs3@$Nd}~cATWn>YLO76U0hA?!+8|7!7MlH&8}cAzI95X^$cytYndN&?FOFO z_nN^i0I;y~$Neax{Cl>Tj2uuW*kBGiL?)#qCl4a0r2JLqFZ^P&+|e!pdoep`%3{d~ z#H$QHIG$}?4JlimTF05ChK9k+BDrkN7_I6NcHl_d$R~mWO0W9EP$CVh8M_bzC;`YZ4EQ!KCnZ%Zh~2*&=JrjC2v#iqnAb;hraw2rX71r z8kVN2{7j&~VLc%+dte!J=8yQCs7u;hPd%a<`3YK*D)s|tSel85rUB@4w zHF{ftYnTpEEKrv#c4?-EMTzoJ_J|suj*gCv?o-cO0i=E}9TjOF<}(Bh4b8q}m4q>R zEs4@3F>vq5MELNbbe}qk3_`SibnnqXzPSskbu!`;IT&Tw75};;78g-)#W}EJ*LdiRd@z#Ny z|09qR3(%s5+!j^d^Jf^FPUPI79LsPh?I=p$3T<{iobi_KUP7$|PGNe_U4_ei{yS<4 z092rs1Y&!}G=yh{q(YOtNN%V4Q*`~=WGd*yxBsjFz^-?YK9=SdV&2W2W1xEgAqt%S zr?v$YU_LRJE~iyEEq=KK0rgG?5dooQX`ckBb#w*7?ySQb)Oz>6%X?+gTg#3Yq|^8U za`*<)UnZgDcWbQvSH@d8$9ruZoqtM4f!aEpOzW?2?DHME#aEb?%?GFs97u=%)@A=s z6*n9T;zVZ3;J533n*Vf!6qRafYWR=LELQ$`_~&3$`&B=;o}P=jIp62Oo=-CT$*K^A z0DT@%5g`Ak1Fu{Fl-9Y=OZ+`W0C#%`Ok@sI%Wl+cnWDz8r%&1BOOxRPVP?L^^1zo&OFAI7X{!BMf z{|i*367|l2c~hDc z{lagG+(m*63y;5=J{DR1Z{50I}TG;S)uKeEllTRC%TicMe6O-uXu^~$?B&KxpR}aDw0*vr_ zM@RllaqE6d!h3RF5n_XdYUre*-Uc7_003?db_WX`q;%d1PfQEfbmg7wb31K$AxL(U z9fba2IqA-T5aH+8TU;}A)Wp?msN{{)%%HcgL$toHWUX?^D^lh?gS^TcfXdl_3oE z`wC>M8HAF!wbkn(njgOp{%}1`wPV~I^QY>v#drs%~6u&^= z&?|!yZDdg!pXqt0 z0g$oZ^n}EQN*EX+%aE;f>p|XPj;i?hfgB!qvQ5+8GJm zhQY{R-^W9;7WRS5{g}cx+fu(Yg|5`1lZ8NT-2NE6ERkjg@*%4D8?<&E`%q!Snk$tgCF7T!F^23nw$#8 zq{sK-v1;3|>y!A-V%ZH{S!5AJ@9S~fyT9nz`9f5c3c|JTF9+QpaU=p1xS9{^=+kRs zyFZn+`UsT*dArGt2ZpymxF5vlaIRN-FCT{>6M4<}FVGk(e7S6Y>)kB$v;kPq9w{?b z8wF*Bf{Ys676XmtE!<@^(BW?0!<5O1ON9p_>Ob6rVA(V09|MPbzmK^2Rj{=yEJDIE zQN0;=PWWC-f8xF3WDPf?*eG6j8z&Z+n}3V@-K1kJPG%UcRMVFJx}ELssPON2#fkJ? zY9pD_FRk8*Cd!IGZON|^x$10059zY-%fQ!+B>XOoT>UPxrF!GBcLt*-E0#AN1!BkQ z8PWJw!5FpRW4C-T^V>nRjxpGd?(Z-rZB!BT2VUIA2YrnAK!$;aYYX zR$&X!?Mie7l>h@F$Ox4L3l3>JqWo4^6S7|#Ooa87t6YzKo#C}X)I)wwxB4NzIOrOG z5G}<3=blmnrB6_Z)Yymt>SAh~oDV}lx8yN?rTXOR?CCe#R)J>Yq$BqvKcd3Mfe!+KKC}&s~4oa5}FJuPciC&QBb--4y|6&PQxi5ysb;wOw4=~yc58)fX-;hbE_pGl3 ze~>mw?5y&!;iHAKk9iNF;_~e@10pbON&k*WPR!)rHH4<n*WRJn zgUBmZqeo^~iCDu!AQT@tbgY(AJI;lYsHHC{=FX*QTfPcA`h=|gbj{@C@e8E}i2IzL zN-U5QW6J@wfer2HxcJMj(Zp^hD0+FY#AVkM3(I6p=WNILd#NufD?(^(v-}7my|O1a zJRQ0T&rm|t$Kdk;&)BY)^|M}l&KZ4{MG$MmwKn`awl)V|MP@|ZXlS0Ln<+FJW7-5aVm zl+L>nf98!Nfuv(>Ph`uFX5;fEILpovfT-nCRw+C0NG4YyTl}77} z%Xv1l7hmT^jv!E9n3P{hl-it*?mlr%XJCHc`NTQWUG1MLH=z6LMrJ6ZkbgafLFrT?=rcj6U4^*g9 zC{SBIqxHEtQLD&fmksJ0ctkMMyM99RoWxA<5RAz$;3H@MfdP*klW?m*BRwu8uEI>g zWY@ZDS<>o$W+4a^Qf!-DhnpDhJ&C~R4O!3kIf@>EMN!FPA;qC`z$MNDQ`8aQG>>2CDa$M}$C7t(XY)Yw1aj@8g1Nl&Hh-E8S-GJMdR6O3UMBdh+V zK{TwCpHYaWq7HPp8T1Xlf7{9M#*1sPV~QIKhUszGr@g<8Cfm7sAsWm^oFF=RM~IU3 zZfj3j-cvZwG0cBlAwJN1$~v3W9vL(A4aLRT`sxk){uQscUF=Z)N-9+Auf>hCw}y*w z{{aLDQdCYay`@OyVA@{qMZ36q=f(Trv|}LYU9Vh)3q=bF#X|`6Hx!Y4tvs7%tvsn_ zb7Dc>fDH&1?xaR-h956o)^q~e=jv$#oX9D}89j$(l)z~deaF$y>*Dx@`o$eNMEm=? ziGPNthAiJYkIKiKB|Qp~R^6gHn9$*_+y(TUTjTNmU-6480*ffbdWfwd`nf6Dg;0*2 zt;ljYw^^|ooa&rEt6yPBddGhV#}`0{E@ki^5aiMA-pFqGfX=iblyDaUPv^?4`xAzk zp69N&CpmCA3@{hb;_$S>@6uccUTU%_BsyeinhYhj$S0ioH9NfH0B+$}~~+ zcrCLs22vd+h?Dash}w?)r+B;c+fZn|I0eA(#zW>L+2W#;U<2E_bIL*^a&A+5UvqAA z2VLf~rdta{c}8*#&IxT909e-hAuj`|(>Tu7>G%$A*O!q5EdvDz@gu8t7``Ni!}CfN zJ#-1L?Zt`P!J9gWoIxS0FC-07ykvqwBp$vCP)1I~D5ah>J8)P~vHK8{GG<`d4yMsX!|%Ukl3jJ!Mcge?PlEGCN_rW&j@}ui+q91rHnR#YjCs; zHh@oroyCNIE&7whDQ!!|+vJlfeBf5o%#v zepOETN3Pd70K@D4vP%~j-3BgZ%1)j7)mn}ea~Uc>KCc|NO z4Sl}RHfbvD`{k4L+>}bo3|KhgkB~vV3hKY`yVyn~Th^!lGAgWRK7gX_h(MW`#9U(p znb`PPixl*vO^u(w!de-)LK;-gclv0Mj?- zcw79mtDeX1eFy*}!2>i{AH9nhayccx-5N91=1c4GZz)#(nCsQLzIuMLNRl5trt{SS zO+SA!l-A(^rfIZ&RB9>8T*miHo*k8AXa9g{* z!#0nL@9)E*55IOhS%_IapdE~S6)C@)EWVk7Y!g#6dlyEFZj0JiPQbB$?7-=7ZK}fR zU+XxZs2Pcrhwn+5UfV_xSlm!#h&CH=zxx3_wdFI0KF!Qkz+FZm@|qtAB8Rr(N)F7k zz5Lv`d&mi2&xL9bEN(k+nE5K$_VL+A=b;R*#KW=b=Mu?H`Bsg!;IW~V0?Vdioe}2L zVQW4)do}~tNYQNa*!?Ti7{Tx}d*im0{-^V~-rzCPkv}<-)FevuQ;Rn-q1#|Ifnc8+ z-da5^M!#&*xi*c)m@(}MnwE7!!{vtrlg`K7!6uPnEKGMpBca-$keYjB$)?N?L~MaB zUaXo4PqSu^WMYH~68>Y#*AH`3e+1V&1T@~Gl;|N*vd+O1A}QWU2=_bZ!-TGF<^HG+ z8NTK99olJsG2D%c7=*6>a7F?lY}sOU^BRo&Xj0Q11{&tFk`OOlQg9pb40*cfu6Jbj z04M^BscXxrr-a+k;x4`wk?|AReV6;4m8#8B`kAQ5E_klkDUMv)GRc^25=U~sQcvZx zb970b^Fw}lkF>`}_Yy#PO@_@)o0mmR_d%d)bikc%&sjK6@p3MF@04-Ij`S;l&Y}}P zeKO)(pKK`$L*58kP1nGekoa*LR6ZTVwLG1{e+BVI?bH;Q)|-!Fs%o>vARI!{s*w>; zGEtT3CyS3A>q#&^ztA=M-7qoc4%MELIU;jX%`RP&7KG1^V(Rd)rO*FeN=y^@?Tx5g zT6i5_c2y*&r1!$nPmTqjDS%P__lEXpkP9ble+52De6@-L9Lt_gJaF|Cp`k#h+CgD zw|W`=pJu*19Lg^KTOmuzFj>Qlv1aU9vVOVNBV`K_ zS+ZsudxUASZ|@m-e(!T#?{&S`_5SyszwVj)+~+>)_j~U1{d_(p@0lQN48DVcQGUG7 zuf%sIHBa!w(OyI%QI4XG5fSXm~S~TCt z?3;)>b6m&mix$rVZ?8)kY4fWWb&X6n$&y}@(a6Gj`uEBTHMca?F%UX@Iu)x0n1`9% z-C94iuY8O)dPOt;)H0LGDErE$d7?_IT6Nd{FMZFqA+lv2P(Q0row**rJGuD6?Jjje|o?n@bU7#)h(5-2nD5J!B;KMV5e>79GggcEu!vrKV_$&az+*q{0hT=Unp&q1)Oi3_i6SWP^NRlF{^O?jt!1 zZ&|oI7flbnkurMf#YUn}S915Zw%5yZ?YC_5JJ&9iypxWjQKb<0b!v8OrR`A9(oKm6 zf%dyPTvi{J=a#$k%s2${OEPccS$@y=<$Y~OUj=|q;9}fhla-aI-wm2qpxCs9`wS?m zunt*zG+lEumyP~4JLv(_@Wuc7mh|eT1&F6)B_|<&IcC+7h#75?pZXn}s>gpp5?f*V z&=robDZOlDk^jAJY~-Vk`+>X;$H$!~;47!#%)dEFL?L&E7QI&(U z<(WMRRi)q{J1TknU))a^g67z+)a?0;xsZu~ibES>C zD?y+H0;d*rRMLGlWUxBQ5;)oX%ZWP9B$W_jaEo?dFmF#fuwp5UspJ-Xx);Vc81Vi> z@w8WK^LC0%kgI{x>iEzQ?b@X-SA_%oE$ORCbODpUi>SXhKiZ%z*zIXF+cRJJriSE` z=0i3!Lm1fMl%a8P51F=l%ft#^)%MpVvwRiey(lQQWa$QjaP|P-TbYpho`#2y!ShD_ zv)X_hQTDA$6eYx314vQP(^3|`ptB=*xs?vy9q(hYEsD`3*Fh1J(QQdoi$r~Gw??z4KZkU z5yo4yw*DoJG~Wx$+GaviYNCX!T)4He`{cx`u9LK`W zRt^@J=|DwLEihGzzUa&s>*AyU3w6QP8PFilS=2i66|@B?3m>|D_gwfnqZNe?zo{)P zGIv405O%*5rQTkw<(*$Uez0%NF55L3AI}2aYJM6(|E}EA?IG=|Y>^ngk@?_H&W7!p zLMg4FflPavIwX7n%S+$lR!q(Cf`eYG@&2XYT*POF&<(%YOszI=c9zj4h9BLBkB!uy zG55Zk)t;Uk_2i4IUe(b-Nnw322x`ntChuSTAQio+kgkkKuewA}#XDvWt=*BAsNy4}2} zFCA+MH^$~!PoFO27`$DHbkyG8TEx6N1GHo|NPOt&svx>NJ20Mc~qp#_e-yIWB>8m@7BmiNv2tr zRdo{$>X%;BbZZ(r!U~I`SqvH_i-n%0#nW#yU7Ij{-*d*xvjq1l9UHD-m_C}WsFIpq ztLp4c5x)J|U?cLDqHzzC$OOvmXqU%G*x*O>RjSSK$U*eJmoCJp7`cR-iiP>T zCLu`!Ae;)RIsB57PyPoBW_o9{z}baFScHNCJBSwkvg$>5n73vax-`u$?~uY6;Vsn^ z#y`FEN7FfL>=}jckc6<+*HNV)<*QdwJ2J_RPYlx)x%Vdt=EskuRo$rZiXzZ*Mg%_ zk|~iB-5~mq>wlw@U`d|v2b4li{vr9lXjuIxdiwwJ{J(-(!2F#&2EaOh)BpMY{}Idr z`cifovFvjCdEN|a${q{91mt|K&=Vg652ew=0QjM8(TXiLd>&)Ha2`QMyaRzNA#q%h z_bktyAr&ZNeUc$(j^>`WBi;hV`C_*zDJh3(@Y5F&=nSo6=@+p5Bq?Qtspg20sWnSm zg2>NFK|lLIcsRB=zU89>PZw)G`>dADY-c@nK*1c|zR$*aj2&l9-xARi9j|ePl~}xM zIGpWt8x5-j|NW7Q2R41V8Z1Cx$A4+JtgI~1m=1cFp(RuTV6(K&BNeUA4d+-T-a3df zQ!4@lmZ#(LKVHkcheHkxRrK2?>gwuqK!W2zm0d4_u($S+?<}LB`kO+I*MkE|xUj>R ze1>~pU!j5rg!AOO^H+zR%o)!ot_s623p=7SUKbqXg2DA2UG zNapu3PFQ~xxf(MVia-UGkwx7%e9PF4okro7Kg?=+Etc>B!vtMD^nS+OrgC1(U~ z03hUPYisM~*82~>JS&iJnUD|YFjR~=lUSxmH7U&{D?9pUrLren3;O-ty}~bODk$5% zsG~QEsOZr7Gh}2mPQzvQrvZS_hp;@ZY-MHDoTC=E`{!#@Y<`E$&+AWG(tIX^wm51t zA=d)G5&GIP7FzC>Io<~fI}O|lEeJHgx`HbC){@|P{I^yQ_5k1Kyk@A#2ow%vax#b- zK72g)m+cKjf{jF+ZFz78YNu7m2q}W+&Yv%l0KnW8z+VvO5&f3YHV+V9ijp4lT~dIU zLhzz<$3(`bn)}-y0D+r#?Hbo-CT3gPI4wS^$$I@%QGLql<*@r_$(RnNaqZf*wF``2 zmM5BVeG+>Glq0cx2;#uxoz4wD*d+&~Cm)PN*C>-N7AMdOAlB!JYE=ci`hbjNZBZ|x z6?*h0`Hpke>({Tpjo6xiIQYD^@6=<8KSVs^Pf)3&dzT0vSgVN?{jM#bxK(Xba>r|61jps{0?9gWHsgL1UU`Nc z6T?lfZz#Zt)TS2nV?*vTQJ>Ent`CwoCf^%aX;Puz6^Rc3Jbx$QvK zQ7^s$c424#>Gdry$zs(kAd+m--sWTD8h^0LySR1!b^h5N;GD&qX$XMY!iem#%8`uNTBP>z7aiX*ShAx`m8gMU>UMrY1&)&pg~;2l>VHYoQ2fX z)XJt`!y+Lm6km~zce&Pxjs*g*M%q7Wc@>)(vs|cGWLSQd0X_@JPeIQQ_sTqjevM)y z00qK1i$Y)?YS=0-E}jVG1gPi+VP)37AhvqcQLwY|xrU8Flg=PBF!?$oDvVG2H&>dW`F8tQh z%!mRl5eekc*O$rNIRvQX*zk-xZfTcfmG)nNMb?v>JOF_)adIs-q`Z)@r+&ZG0V#|vhRZ6pt4O#{kyCZ6QQFY7fpU>TN=`RQzPbxns$2D_tjKbt? zKsibwB$hYPK%dBo&=Lu~4tjl|@^nio9ho}lFsiF@h583jD?n<(qyO~i__=hhcQ#xjI)@*^Cf;gDR8-rud{fQAktB8*t z`{KVBpwcJl|CbGf(z(!C)xhZ|Os7wilIAR0UBMOsRjUUk(b6{U@#?$HxVhi>m^_W> zyTq{rEm?z)kt&m`@a*^x427vRrW2mHe$B0her z^IDEubeAiSpGqCO6HpFd>p3~u*_m_w&-?qzn1Cg8l6C_2KrIz%p!1Tb*a*@T7nuy5 zK~GV@)+$~PPIkxSJ@C9)dh#pNLzhp9y|r$OH3Pc@cKwgR2f|2$l9#+Xa=<`tYbFbj z-!W0YlprM|Q@adg&6o#)MHB`kqKxqV;}IqaahZ;xm}SGBtF5iA8Bqk2uoH&~SYIfbAc+973SK-hEA2+(eh-fpX3 zXi@X`-|YYh?H+^>=-9;ezj_8~3~(#XUV!br0pPXCMuxVwZwde%+e&U`m1@!7LKszd@OdVQ?M<&_0kA#wt6;gK(D z>T;H!LZksA+fFAvEzLg|Fj&0%floRv;kDDtOicWil|di3!P(ai!Gw1{VDC=)IZyy?YVvS*rydq@Oly6!XF@^*}X86qOR4d z#+23UXKXxL03_#V6ns|MQd7KiU}9Ei?0;riBj5onYPYIg+2fOw+nY_b2=1^IIePq+ zO8G{+7o&NeX0ikDVy1#O<$%6uw_c#D{bg}+ora^sJyT;TzV;o$LT#0#!@ygq;Qj5^ zE0TY!s1#yabURC#-=w{xL#7;18l77Nf(PDR)kD+hXlcoP9gIMu^^)E^efo5+ZvuFr zmMZ&x!9Ji|m>=fy<9%b^Ia=D%@VkHK8wpu^F%Tu?dthr@lIuxNo7yP?+XLG}|Ao1^ zARThe%l4yy7s!3*afLhRP5u7v%&Y(SczbUy=)IO`F8KT1@o6jmD$QDNj@pOd`ZfT# zUILkxH|C1&=Et5-%~R%$w$=5B`bNx zH&8u0I*G=UJnzqWDsPeen{S`NSLwJE5)x<9d> z(Pl>eaLj6qyET)xU_FFLKqsbiXCX=K_nRUU{IP3B%p8pVw|^;DI+Z!mwN0F-p*yia zs^pm4Sv3z{G8dQ>j#gmSZ-3`Kci*ql38NB`BmQ^9NhJ7{S{MRy&dm%C6&U?m#;lh? zzeLBCK?PlE=R%J6M!*B^omMgJl~(VPKT^#|b6e)(YC6<=_gYXSB_Hb)*_C#Zjl@nb zg!#rtE6z{6d&v0xpN>PAJp}>reNX$d9ltI8sCJA;^s`jZm`^-*%|>G(Zf0bJ-K{I!`1 z37ou23Ku=XN(58I!uYR`G`;|T^5jY1`9&PB;SS;y0)y6$94|Kd@KZ+yfri2)gte@B zV4SFHA+yja8O@d6Vj8>vZzDp0Y~zk&}}rKPS&Hro`TMxIch+dqbSP z(L@4UfryqxEg9?TdpkQhjzvb%C>nrP|H^G&b~CVHp{Gl{t|`?l9-#Dm`-=ApGpN+3 zhY5T8L?IUmq!P1mRD4DD9Dj9eRD8D{dLRiC*B=<+ku{P$5`c@@wwy?V^65-{C7$aM zhqg*s$rQqQhaOMYT6$K_M+|G?tz%A_!k4~~yDq+U8my$b8&>;7Wxt zkZTyq&guQ#sY)i@S5`aU3s`DWn4o|L5E#fxJU#dXBga4y+%K6BbO=0McPTAz zEBo}*-1pZ5O4QM_kj^`$$HR6S%+(M@Y6uI0TZLP>=|*SPzO3WvTqSPh76NLR&7Fz3 z7dk$T*p4J|gDZ#nm2*m7dr782rp{S@xlmR_{y)W>1;wbW(9y}mt_89IRh#Y-;Z0wq z|EWABBmx0emu12_L{JH#)BbdN=s-986I@<@c{~4X*LQH zV2fA4KKS1q6n8SCwo#kQ&xr$g-!K%_>l;#&T;}o#Njx?=13E_(4lW9*MM;>ri~~)o SNVLFD5{(-=DkaL6q5lg$`Kx{a literal 0 HcmV?d00001 diff --git a/projects/gameboard-ui/src/assets/vendor/vmware-wmks/img/touch_sprite_feedback.png b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/img/touch_sprite_feedback.png new file mode 100644 index 0000000000000000000000000000000000000000..fbdb0a3fc5f5efb5e1ce44a97e4d66fc11b209b2 GIT binary patch literal 17962 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 XjF1U;qs4)naTz>a{an^LB{Ts5i& 0) + s += ", "; + s += uInt8Array[i]; + } + s += "]"; + return s; + }; + + var wmks, needDestroy; + + beforeEach(function(done) { + wmks = WMKS.createWMKS("wmksContainer", {}); + wmks.register(WMKS.CONST.Events.CONNECTION_STATE_CHANGE, function(event, data) { + if (data.state == WMKS.CONST.ConnectionState.CONNECTED) { + setTimeout(function() { + done(); + }, 1); + } + }); + wmks.connect(url); + }); + + afterEach(function(done) { + + if (wmks && needDestroy) + wmks.destroy(); + done(); + }); + + it("sendKeyCodes", function() { + var sendSpy = WMKS.send.calls; + sendSpy.reset(); + wmks.sendKeyCodes([71],[0x22]); + var sentData = new Uint8Array(sendSpy.argsFor(0)[0]); + expect(sentData).toEqual(new Uint8Array([127, 0, 0, 8, 0, 34, 1, 0])); + sentData = new Uint8Array(sendSpy.argsFor(1)[0]); + expect(sentData).toEqual(new Uint8Array([127, 0, 0, 8, 0, 34, 0, 0])); + }); + + it("sendInputString in one line", function() { + //notice here, some character such as caps, would involve the shift key + var result = [ + [127, 0, 0, 8, 0, 2, 1, 0], + [127, 0, 0, 8, 0, 2, 0, 0], + [127, 0, 0, 8, 0, 42, 1, 0], + [127, 0, 0, 8, 0, 30, 1, 0], + [127, 0, 0, 8, 0, 30, 0, 0], + [127, 0, 0, 8, 0, 42, 0, 0] + ]; + var sendSpy = WMKS.send.calls; + sendSpy.reset(); + wmks.sendInputString("1A"); + var count = expect(sendSpy.count()).toBe(6); + for (var i = 0; i < 6; i++) { + var sentData = new Uint8Array(sendSpy.argsFor(i)[0]); + expect(sentData).toEqual(new Uint8Array(result[i])); + } + }); + + it("sendInputString in multiple line", function() { + var result = [ + [127, 0, 0, 8, 0, 2, 1, 0], + [127, 0, 0, 8, 0, 2, 0, 0], + [127, 0, 0, 8, 0, 28, 1, 0], + [127, 0, 0, 8, 0, 28, 0, 0], + [127, 0, 0, 8, 0, 2, 1, 0], + [127, 0, 0, 8, 0, 2, 0, 0] + ]; + var sendSpy = WMKS.send.calls; + sendSpy.reset(); + wmks.sendInputString("1\n1"); + var count = expect(sendSpy.count()).toBe(6); + for (var i = 0; i < 6; i++) { + var sentData = new Uint8Array(sendSpy.argsFor(i)[0]); + expect(sentData).toEqual(new Uint8Array(result[i])); + } + }); + + it("sendCAD", function() { + var result = [ + [127, 0, 0, 8, 0, 29, 1, 0], + [127, 0, 0, 8, 0, 56, 1, 0], + [127, 0, 0, 8, 1, 83, 1, 0], + [127, 0, 0, 8, 0, 29, 0, 0], + [127, 0, 0, 8, 0, 56, 0, 0], + [127, 0, 0, 8, 1, 83, 0, 0] + ]; + var sendSpy = WMKS.send.calls; + sendSpy.reset(); + wmks.sendCAD(); + expect(sendSpy.count()).toEqual(6); + for (var i = 0; i < 6; i++) { + var sentData = new Uint8Array(sendSpy.argsFor(i)[0]); + expect(sentData).toEqual(new Uint8Array(result[i])); + } + needDestroy = true; + }); + + }); +}); \ No newline at end of file diff --git a/projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/spec/mobile.js b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/spec/mobile.js new file mode 100644 index 000000000..45e5c0f26 --- /dev/null +++ b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/spec/mobile.js @@ -0,0 +1,209 @@ +/********************************************************* + * Copyright (C) 2015 VMware, Inc. All rights reserved. + *********************************************************/ +/* + * This file include mobile related test suites + * - enable/disable keyboard + * - enable/disable trackpad + * - enable/disable extendedkeypad + * - show/hide keyboar + * - toggle trackpad extendedkeypad + * - trigger toggle keyboard,trackpad,extendedkeypad event + * + */ +describe("webmks mobile", function() { + var wmks, deviceType = WMKS.CONST.InputDeviceType, toggleKeyboard; + + beforeEach(function() { + jasmine.getFixtures().fixturesPath = 'base/view/'; + loadFixtures("index.html"); + + spyOn(WMKS.BROWSER, "isTouchDevice").and.returnValue(true); + spyOn(WMKS.BROWSER, "isAndroid").and.returnValue(true); + spyOn(WMKS.BROWSER, "isLinux").and.returnValue(true); + + var isMobile = WMKS.BROWSER.isTouchDevice(); + expect(isMobile).toBe(true); + var opts = { + allowMobileKeyboardInput: false, + allowMobileExtendedKeypad: false, + allowMobileTrackpad: false + }; + wmks = WMKS.createWMKS("wmksContainer", opts); + toggleKeyboard = sinon.spy(WMKS.widgetProto.toggleKeyboard); + + }); + afterEach(function() { + if (wmks) wmks.destroy(); + }); + + it("enable keyboard", function() { + + wmks.enableInputDevice(deviceType.KEYBOARD); + var inputDivNum = $("#input-proxy").length; + expect(inputDivNum).toBe(1); + }); + + it("disable keyboard", function() { + spyOn(wmks.wmksData._touchHandler, "removeMobileFeature").and.callThrough(); + wmks.enableInputDevice(deviceType.KEYBOARD); + var inputDivNum = $("#input-proxy").length; + expect(inputDivNum).toBe(1); + wmks.disableInputDevice(deviceType.KEYBOARD); + var calls = wmks.wmksData._touchHandler.removeMobileFeature.calls; + expect(calls.count()).toBe(2); + expect(calls.argsFor(0)[0]).toBe(WMKS.CONST.TOUCH.FEATURE.SoftKeyboard); + }); + + it("show keyboard", function() { + wmks.enableInputDevice(deviceType.KEYBOARD); + wmks.showKeyboard(); + expect(document.activeElement.id).toEqual("input-proxy"); + + }); + + it("hide keyboard", function() { + var bak = WMKS.CONST.TOUCH.minKeyboardToggleTime; + WMKS.CONST.TOUCH.minKeyboardToggleTime = -1; + wmks.enableInputDevice(deviceType.KEYBOARD); + wmks.showKeyboard(); + expect(toggleKeyboard.withArgs(true).calledOnce); + + wmks.hideKeyboard(); + expect(toggleKeyboard.withArgs(false).calledOnce); + WMKS.CONST.TOUCH.minKeyboardToggleTime = bak; + }); + + it("can trigger show keyboard event", function(done) { + var eventType = WMKS.CONST.Events.TOGGLE; + + expect(Object.keys(wmks.eventHandlers).length).toBe(0); + var vncDecoder = wmks.wmksData._vncDecoder; + var handlers = { + handler: function(event, data) { + expect(data.type).toBe("KEYBOARD"); + expect(data.visibility).toBe(true); + } + }; + spyOn(handlers, "handler").and.callThrough(); + wmks.register(eventType, handlers.handler); + + WMKS.CONST.TOUCH.minKeyboardToggleTime = -1; + wmks.enableInputDevice(deviceType.KEYBOARD); + wmks.showKeyboard(); + expect(toggleKeyboard.withArgs(true).calledOnce); + wmks.unregister(eventType); + done(); + }); + + it("enable extendKeyboard", function() { + wmks.enableInputDevice(deviceType.EXTENDED_KEYBOARD); + var num = $("#ctrlPanePopup").length; + expect(num).toBe(1); + }); + + it("disable extendKeyboard", function() { + wmks.enableInputDevice(deviceType.EXTENDED_KEYBOARD); + var num = $("#ctrlPanePopup").length; + expect(num).toBe(1); + wmks.disableInputDevice(deviceType.EXTENDED_KEYBOARD); + num = $("#ctrlPanePopup").length; + expect(num).toBe(0); + }); + + it("toggle extendKeyboard", function() { + wmks.enableInputDevice(deviceType.EXTENDED_KEYBOARD); + var vis = $("#ctrlPaneWidget").css("display"); + expect(vis).toBe("none"); + wmks.toggleExtendedKeypad({ + minToggleTime: -1 + }); + vis = $("#ctrlPaneWidget").css("display"); + expect(vis).not.toBe("none"); + wmks.toggleExtendedKeypad({ + minToggleTime: -1 + }); + vis = $("#ctrlPaneWidget").css("display"); + expect(vis).toBe("none"); + }); + + it("can trigger show extendKeyboard event", function(done) { + var eventType = WMKS.CONST.Events.TOGGLE; + + expect(Object.keys(wmks.eventHandlers).length).toBe(0); + var vncDecoder = wmks.wmksData._vncDecoder; + var handlers = { + handler: function(event, data) { + expect(data.type).toBe("EXTENDED_KEYPAD"); + expect(data.visibility).toBe(true); + } + }; + spyOn(handlers, "handler").and.callThrough(); + wmks.register(eventType, handlers.handler); + + wmks.enableInputDevice(deviceType.EXTENDED_KEYBOARD); + wmks.toggleExtendedKeypad({ + minToggleTime: -1 + }); + var cnt = handlers.handler.calls.count(); + expect(cnt).toBe(1); + wmks.unregister(eventType); + done(); + }); + + it("enable trackpad", function() { + wmks.enableInputDevice(deviceType.TRACKPAD); + var num = $(".trackpad-wrapper").length; + expect(num).toBe(1); + }); + + it("disable trackpad", function() { + wmks.enableInputDevice(deviceType.TRACKPAD); + var num = $(".trackpad-wrapper").length; + expect(num).toBe(1); + wmks.disableInputDevice(deviceType.TRACKPAD); + num = $(".trackpad-wrapper").length; + expect(num).toBe(0); + }); + + it("toggle trackpad", function() { + wmks.enableInputDevice(deviceType.TRACKPAD); + var vis = $(".trackpad-wrapper").css("display"); + expect(vis).toBe("none"); + //here set the option minToggleTime as -1 to make this test case can call toggle 2 twice + wmks.toggleTrackpad({ + minToggleTime: -1 + }); + vis = $(".trackpad-wrapper").css("display"); + expect(vis).not.toBe("none"); + wmks.toggleTrackpad({ + minToggleTime: -1 + }); + vis = $(".trackpad-wrapper").css("display"); + expect(vis).toBe("none"); + }); + + it("can trigger show extendKeyboard event", function(done) { + var eventType = WMKS.CONST.Events.TOGGLE; + + expect(Object.keys(wmks.eventHandlers).length).toBe(0); + var vncDecoder = wmks.wmksData._vncDecoder; + var handlers = { + handler: function(event, data) { + expect(data.type).toBe("TRACKPAD"); + expect(data.visibility).toBe(true); + } + }; + spyOn(handlers, "handler").and.callThrough(); + wmks.register(eventType, handlers.handler); + + wmks.enableInputDevice(deviceType.TRACKPAD); + wmks.toggleTrackpad({ + minToggleTime: -1 + }); + var cnt = handlers.handler.calls.count(); + expect(cnt).toBe(1); + wmks.unregister(eventType); + done(); + }); +}); \ No newline at end of file diff --git a/projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/view/index.html b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/view/index.html new file mode 100755 index 000000000..3c1eb7d45 --- /dev/null +++ b/projects/gameboard-ui/src/assets/vendor/vmware-wmks/test/view/index.html @@ -0,0 +1,7 @@ + + + + +