src/app/test-controller/components/unithost/unithost.component.ts
styleUrls | ./unithost.component.css |
templateUrl | ./unithost.component.html |
Properties |
|
Methods |
|
HostListeners |
constructor(tcs: TestControllerService, mds: MainDataService, bs: BackendService, route: ActivatedRoute, snackBar: MatSnackBar)
|
||||||||||||||||||
Parameters :
|
window:resize |
window:resize()
|
Private adjustIframeSize |
adjustIframeSize()
|
Returns :
void
|
Private Static getEnabledNavigationTargets | ||||||||||||||||||||
getEnabledNavigationTargets(nr: number, min: number, max: number, terminationAllowed: "ON" | "OFF" | "LAST_UNIT")
|
||||||||||||||||||||
Parameters :
Returns :
VeronaNavigationTarget[]
|
Private getPlayerConfig |
getPlayerConfig()
|
Returns :
VeronaPlayerConfig
|
gotoPage | ||||||
gotoPage(navigationTarget: string)
|
||||||
Parameters :
Returns :
void
|
Private handleIncomingMessage | ||||||
handleIncomingMessage(messageEvent: MessageEvent)
|
||||||
Parameters :
Returns :
void
|
Private handleNavigationDenial | ||||||
handleNavigationDenial(navigationDenial: literal type)
|
||||||
Parameters :
Returns :
void
|
ngOnDestroy |
ngOnDestroy()
|
Returns :
void
|
ngOnInit |
ngOnInit()
|
Returns :
void
|
onKeydownInClearCodeInput | ||||||
onKeydownInClearCodeInput($event: KeyboardEvent)
|
||||||
Parameters :
Returns :
void
|
onResize |
onResize()
|
Decorators :
@HostListener('window:resize')
|
Returns :
void
|
Private open | ||||||
open(currentUnitSequenceId: number)
|
||||||
Parameters :
Returns :
void
|
Private prepareIframe |
prepareIframe()
|
Returns :
void
|
Private prepareUnit |
prepareUnit()
|
Returns :
void
|
Private runUnit |
runUnit()
|
Returns :
void
|
Private startTimerIfNecessary |
startTimerIfNecessary()
|
Returns :
void
|
verifyCodes |
verifyCodes()
|
Returns :
void
|
clearCodes |
Type : object
|
Default value : {}
|
codeRequiringTestlets |
Type : Testlet[]
|
Default value : []
|
currentPageIndex |
Type : number
|
currentUnit |
Type : UnitControllerData
|
currentUnitSequenceId |
Default value : -1
|
Private iFrameHostElement |
Type : HTMLElement
|
Private iFrameItemplayer |
Type : HTMLIFrameElement
|
Private itemplayerSessionId |
Type : string
|
Default value : ''
|
knownPages |
Type : literal type[]
|
leaveWarning |
Default value : false
|
Private pendingUnitData |
Type : PendingUnitData
|
Default value : null
|
Private postMessageTarget |
Type : Window
|
Default value : null
|
showPageNav |
Default value : false
|
Private subscriptions |
Type : literal type
|
Default value : {}
|
Public tcs |
Type : TestControllerService
|
unitNavigationTarget |
Default value : UnitNavigationTarget
|
unitsLoading$ |
Type : BehaviorSubject<LoadingProgress[]>
|
Default value : new BehaviorSubject<LoadingProgress[]>([])
|
unitsToLoadLabels |
Type : string[]
|
import { BehaviorSubject, combineLatest, Subscription } from 'rxjs';
import {
Component, HostListener, OnInit, OnDestroy
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
TestStateKey,
WindowFocusState,
PendingUnitData,
StateReportEntry,
UnitStateKey,
UnitPlayerState, LoadingProgress, UnitNavigationTarget
} from '../../interfaces/test-controller.interfaces';
import { BackendService } from '../../services/backend.service';
import { TestControllerService } from '../../services/test-controller.service';
import { MainDataService } from '../../../maindata.service';
import { VeronaNavigationDeniedReason, VeronaNavigationTarget, VeronaPlayerConfig } from '../../interfaces/verona.interfaces';
import { Testlet, UnitControllerData } from '../../classes/test-controller.classes';
declare let srcDoc;
@Component({
templateUrl: './unithost.component.html',
styleUrls: ['./unithost.component.css']
})
export class UnithostComponent implements OnInit, OnDestroy {
private iFrameHostElement: HTMLElement;
private iFrameItemplayer: HTMLIFrameElement;
private subscriptions: { [tag: string ]: Subscription } = {};
leaveWarning = false;
showPageNav = false;
currentUnitSequenceId = -1;
private itemplayerSessionId = '';
private postMessageTarget: Window = null;
private pendingUnitData: PendingUnitData = null;
knownPages: { id: string; label: string }[];
unitsLoading$: BehaviorSubject<LoadingProgress[]> = new BehaviorSubject<LoadingProgress[]>([]);
unitsToLoadLabels: string[];
currentUnit: UnitControllerData;
currentPageIndex: number;
unitNavigationTarget = UnitNavigationTarget;
clearCodes = {};
codeRequiringTestlets: Testlet[] = [];
constructor(
public tcs: TestControllerService,
private mds: MainDataService,
private bs: BackendService,
private route: ActivatedRoute,
private snackBar: MatSnackBar
) { }
ngOnInit(): void {
this.iFrameHostElement = <HTMLElement>document.querySelector('#iframe-host');
this.iFrameItemplayer = null;
this.leaveWarning = false;
setTimeout(() => {
this.subscriptions.postMessage = this.mds.postMessage$
.subscribe(messageEvent => this.handleIncomingMessage(messageEvent));
this.subscriptions.routing = this.route.params
.subscribe(params => this.open(Number(params.u)));
this.subscriptions.navigationDenial = this.tcs.navigationDenial
.subscribe(navigationDenial => this.handleNavigationDenial(navigationDenial));
});
}
ngOnDestroy(): void {
Object.values(this.subscriptions).forEach(subscription => subscription.unsubscribe());
}
private handleIncomingMessage(messageEvent: MessageEvent): void {
const msgData = messageEvent.data;
const msgType = msgData.type;
let msgPlayerId = msgData.sessionId;
if ((msgPlayerId === undefined) || (msgPlayerId === null)) {
msgPlayerId = this.itemplayerSessionId;
}
switch (msgType) {
case 'vopReadyNotification':
// TODO add apiVersion check
if (!this.pendingUnitData || this.pendingUnitData.playerId !== msgPlayerId) {
this.pendingUnitData = {
unitDefinition: '',
unitDataParts: '',
playerId: '',
currentPage: null
};
}
if (this.tcs.testMode.saveResponses) {
this.bs.updateUnitState(this.tcs.testId, this.currentUnit.unitDef.alias, [<StateReportEntry>{
key: UnitStateKey.PLAYER, timeStamp: Date.now(), content: UnitPlayerState.RUNNING
}]);
}
this.postMessageTarget = messageEvent.source as Window;
this.postMessageTarget.postMessage({
type: 'vopStartCommand',
sessionId: this.itemplayerSessionId,
unitDefinition: this.pendingUnitData.unitDefinition,
unitState: {
dataParts: { all: this.pendingUnitData.unitDataParts }
},
playerConfig: this.getPlayerConfig()
}, '*');
// TODO maybe clean up memory?
break;
case 'vopStateChangedNotification':
if (msgPlayerId === this.itemplayerSessionId) {
if (msgData.playerState) {
const { playerState } = msgData;
this.knownPages = Object.keys(playerState.validPages)
.map(id => ({ id, label: playerState.validPages[id] }));
this.currentPageIndex = this.knownPages.findIndex(page => page.id === playerState.currentPage);
if (typeof playerState.currentPage !== 'undefined') {
const pageId = playerState.currentPage;
const pageNr = this.knownPages.indexOf(playerState.currentPage) + 1;
const pageCount = this.knownPages.length;
if (this.knownPages.length > 1 && this.knownPages.indexOf(playerState.currentPage) >= 0) {
this.tcs.newUnitStateCurrentPage(
this.currentUnit.unitDef.alias,
this.currentUnitSequenceId,
pageNr,
pageId,
pageCount
);
}
}
}
const unitDbKey = this.currentUnit.unitDef.alias;
if (msgData.unitState) {
const { unitState } = msgData;
const { presentationProgress, responseProgress } = unitState;
if (presentationProgress) {
this.tcs.updateUnitStatePresentationProgress(unitDbKey, this.currentUnitSequenceId, presentationProgress);
}
if (responseProgress) {
this.tcs.newUnitStateResponseProgress(unitDbKey, this.currentUnitSequenceId, responseProgress);
}
const unitDataPartsAll = unitState?.dataParts?.all;
if (unitDataPartsAll) {
this.tcs.newUnitStateData(unitDbKey, this.currentUnitSequenceId,
unitDataPartsAll, unitState.unitStateDataType);
}
}
if (msgData.log) {
this.bs.addUnitLog(this.tcs.testId, unitDbKey, msgData.log);
}
}
break;
case 'vopUnitNavigationRequestedNotification':
if (msgPlayerId === this.itemplayerSessionId) {
// support Verona2 and Verona3 version
const target = msgData.target ? `#${msgData.target}` : msgData.targetRelative;
this.tcs.setUnitNavigationRequest(target);
}
break;
case 'vopWindowFocusChangedNotification':
if (msgData.hasFocus) {
this.tcs.windowFocusState$.next(WindowFocusState.PLAYER);
} else if (document.hasFocus()) {
this.tcs.windowFocusState$.next(WindowFocusState.HOST);
} else {
this.tcs.windowFocusState$.next(WindowFocusState.UNKNOWN);
}
break;
default:
console.log(`processMessagePost ignored message: ${msgType}`);
break;
}
}
private open(currentUnitSequenceId: number): void {
this.currentUnitSequenceId = currentUnitSequenceId;
this.tcs.currentUnitSequenceId = this.currentUnitSequenceId;
this.mds.appSubTitle$.next(`Aufgabe ${this.currentUnitSequenceId}`);
while (this.iFrameHostElement.hasChildNodes()) {
this.iFrameHostElement.removeChild(this.iFrameHostElement.lastChild);
}
this.currentPageIndex = undefined;
this.knownPages = [];
this.currentUnit = this.tcs.rootTestlet.getUnitAt(this.currentUnitSequenceId);
if (this.subscriptions.loading) {
this.subscriptions.loading.unsubscribe();
}
const unitsToLoadIds = this.currentUnit.maxTimerRequiringTestlet ?
this.tcs.rootTestlet.getAllUnitSequenceIds(this.currentUnit.maxTimerRequiringTestlet?.id) :
[currentUnitSequenceId];
const unitsToLoad = unitsToLoadIds
.map(unitSequenceId => this.tcs.getUnitLoadProgress$(unitSequenceId));
this.unitsToLoadLabels = unitsToLoadIds
.map(unitSequenceId => this.tcs.rootTestlet.getUnitAt(unitSequenceId).unitDef.title);
this.subscriptions.loading = combineLatest<LoadingProgress[]>(unitsToLoad)
.subscribe({
next: value => {
this.unitsLoading$.next(value);
},
error: err => {
this.mds.appError$.next({
label: `Unit konnte nicht geladen werden. ${err.info}`, // TODO which item failed?
description: (err.info) ? err.info : err,
category: 'PROBLEM'
});
},
complete: () => this.prepareUnit()
});
}
private prepareUnit(): void {
this.unitsLoading$.next([]);
this.tcs.currentUnitDbKey = this.currentUnit.unitDef.alias;
this.tcs.currentUnitTitle = this.currentUnit.unitDef.title;
if (this.tcs.testMode.saveResponses) {
this.bs.updateTestState(this.tcs.testId, [<StateReportEntry>{
key: TestStateKey.CURRENT_UNIT_ID, timeStamp: Date.now(), content: this.currentUnit.unitDef.alias
}]);
this.bs.updateUnitState(this.tcs.testId, this.currentUnit.unitDef.alias, [<StateReportEntry>{
key: UnitStateKey.PLAYER, timeStamp: Date.now(), content: UnitPlayerState.LOADING
}]);
}
if (this.tcs.testMode.presetCode) {
this.currentUnit.codeRequiringTestlets
.forEach(testlet => { this.clearCodes[testlet.id] = testlet.codeToEnter; });
}
this.runUnit();
}
private runUnit(): void {
this.codeRequiringTestlets = this.currentUnit.codeRequiringTestlets
.filter(testlet => !this.tcs.clearCodeTestlets.includes(testlet.id));
if (this.codeRequiringTestlets.length) {
return;
}
if (this.currentUnit.unitDef.locked) {
return;
}
this.startTimerIfNecessary();
this.itemplayerSessionId = Math.floor(Math.random() * 20000000 + 10000000).toString();
this.pendingUnitData = {
playerId: this.itemplayerSessionId,
unitDefinition: this.tcs.hasUnitDefinition(this.currentUnitSequenceId) ?
this.tcs.getUnitDefinition(this.currentUnitSequenceId) :
null,
unitDataParts: this.tcs.hasUnitStateDataParts(this.currentUnitSequenceId) ?
this.tcs.getUnitStateDataParts(this.currentUnitSequenceId) :
null,
currentPage: this.tcs.hasUnitStateCurrentPage(this.currentUnitSequenceId) ?
this.tcs.getUnitStateCurrentPage(this.currentUnitSequenceId) :
null
};
this.leaveWarning = false;
this.prepareIframe();
}
private startTimerIfNecessary(): void {
if (this.currentUnit.maxTimerRequiringTestlet === null) {
return;
}
if (this.tcs.currentMaxTimerTestletId &&
(this.currentUnit.maxTimerRequiringTestlet.id === this.tcs.currentMaxTimerTestletId)
) {
return;
}
this.tcs.startMaxTimer(
this.currentUnit.maxTimerRequiringTestlet.id,
this.currentUnit.maxTimerRequiringTestlet.maxTimeLeft
);
}
private prepareIframe(): void {
this.iFrameItemplayer = <HTMLIFrameElement>document.createElement('iframe');
this.iFrameItemplayer.setAttribute('sandbox', 'allow-forms allow-scripts allow-same-origin');
this.iFrameItemplayer.setAttribute('class', 'unitHost');
this.adjustIframeSize();
this.iFrameHostElement.appendChild(this.iFrameItemplayer);
srcDoc.set(this.iFrameItemplayer, this.tcs.getPlayer(this.currentUnit.unitDef.playerId));
}
private adjustIframeSize(): void {
this.iFrameItemplayer.setAttribute('height', String(this.iFrameHostElement.clientHeight));
}
@HostListener('window:resize')
onResize(): void {
if (this.iFrameItemplayer && this.iFrameHostElement) {
this.adjustIframeSize();
}
}
private getPlayerConfig(): VeronaPlayerConfig {
const playerConfig: VeronaPlayerConfig = {
enabledNavigationTargets: UnithostComponent.getEnabledNavigationTargets(
this.currentUnitSequenceId,
1,
this.tcs.allUnitIds.length,
this.tcs.bookletConfig.allow_player_to_terminate_test
),
logPolicy: this.tcs.bookletConfig.logPolicy,
pagingMode: this.tcs.bookletConfig.pagingMode,
stateReportPolicy: this.tcs.bookletConfig.stateReportPolicy,
unitNumber: this.currentUnitSequenceId,
unitTitle: this.tcs.currentUnitTitle,
unitId: this.currentUnit.unitDef.alias
};
if (this.pendingUnitData.currentPage && (this.tcs.bookletConfig.restore_current_page_on_return === 'ON')) {
playerConfig.startPage = this.pendingUnitData.currentPage;
}
return playerConfig;
}
private static getEnabledNavigationTargets(
nr: number, min: number, max: number,
terminationAllowed: 'ON' | 'OFF' | 'LAST_UNIT' = 'ON'
): VeronaNavigationTarget[] {
const navigationTargets = [];
if (nr < max) {
navigationTargets.push('next');
}
if (nr > min) {
navigationTargets.push('previous');
}
if (nr !== min) {
navigationTargets.push('first');
}
if (nr !== max) {
navigationTargets.push('last');
}
if (terminationAllowed === 'ON') {
navigationTargets.push('end');
}
if ((terminationAllowed === 'LAST_UNIT') && (nr === max)) {
navigationTargets.push('end');
}
return navigationTargets;
}
gotoPage(navigationTarget: string): void {
if (typeof this.postMessageTarget !== 'undefined') {
this.postMessageTarget.postMessage({
type: 'vopPageNavigationCommand',
sessionId: this.itemplayerSessionId,
target: navigationTarget
}, '*');
}
}
private handleNavigationDenial(
navigationDenial: { sourceUnitSequenceId: number; reason: VeronaNavigationDeniedReason[] }
): void {
if (navigationDenial.sourceUnitSequenceId !== this.currentUnitSequenceId) {
return;
}
this.postMessageTarget.postMessage({
type: 'vopNavigationDeniedNotification',
sessionId: this.itemplayerSessionId,
reason: navigationDenial.reason
}, '*');
}
verifyCodes(): void {
this.currentUnit.codeRequiringTestlets
.forEach(
testlet => {
if (!this.clearCodes[testlet.id]) {
return;
}
if (testlet.codeToEnter.toUpperCase().trim() === this.clearCodes[testlet.id].toUpperCase().trim()) {
this.tcs.addClearedCodeTestlet(testlet.id);
this.runUnit();
} else {
this.snackBar.open(
`Freigabewort '${this.clearCodes[testlet.id]}' für '${testlet.title}' stimmt nicht.`,
'OK',
{ duration: 3000 }
);
delete this.clearCodes[testlet.id];
}
}
);
}
onKeydownInClearCodeInput($event: KeyboardEvent): void {
if ($event.key === 'Enter') {
this.verifyCodes();
}
}
}
<div
[class]="{
'unit-host': true,
'with-header': tcs.bookletConfig.unit_screenheader !== 'OFF',
'with-title': tcs.bookletConfig.unit_title === 'ON',
'with-footer': tcs.bookletConfig.page_navibuttons === 'SEPARATE_BOTTOM',
'is-waiting': currentUnit?.unitDef?.locked || (unitsLoading$ | async).length || codeRequiringTestlets.length
}"
>
<div
*ngIf="tcs.bookletConfig.unit_title === 'ON'"
id="unit-title"
fxLayout="column"
fxLayoutAlign="center center"
>
<p>{{currentUnit?.unitDef?.title}}</p>
</div>
<div id="iframe-host">
</div>
<ng-container *ngIf="{ list: (unitsLoading$ | async) } as loadingUnits">
<div id="waiting-room" *ngIf="loadingUnits.list.length || currentUnit?.unitDef?.locked || codeRequiringTestlets.length">
<mat-card>
<mat-card-title>{{currentUnit?.unitDef?.title}}</mat-card-title>
<mat-card-subtitle *ngIf="loadingUnits.list.length > 1">
{{'Aufgabenblock wird geladen' | customtext:'booklet_loadingBlock' | async}}
</mat-card-subtitle>
<mat-card-subtitle *ngIf="loadingUnits.list.length === 1">
{{'Aufgabe wird geladen' | customtext:'booklet_loadingUnit' | async}}
</mat-card-subtitle>
<mat-card-subtitle *ngIf="currentUnit?.unitDef?.locked">
{{'Aufgabenzeit ist Abgelaufen' | customtext:'booklet_lockedBlock' | async}}
</mat-card-subtitle>
<mat-card-subtitle *ngIf="currentUnit.codeRequiringTestlets.length">
{{'Aufgabenblock erfordert ein Freigabewort' | customtext:'booklet_codeToEnterTitle' | async}}
</mat-card-subtitle>
<mat-card-content *ngIf="codeRequiringTestlets.length">
<ng-container *ngFor="let testlet of codeRequiringTestlets">
<mat-form-field style="display: block">
<mat-label>{{testlet.title || ('Block ' + (testlet.sequenceId + 1))}}</mat-label>
<input
matInput
type="text"
[(ngModel)]="clearCodes[testlet.id]"
style="text-transform:uppercase"
(keydown)="onKeydownInClearCodeInput($event)"
>
<mat-hint align="start" *ngIf="testlet.codePrompt">{{testlet.codePrompt}}</mat-hint>
<mat-hint *ngIf="!testlet.codePrompt">
{{'Bitte Freigabewort eingeben!' | customtext:'booklet_codeToEnterPrompt' | async}}
</mat-hint>
</mat-form-field>
</ng-container>
</mat-card-content>
<mat-card-content>
<ng-container *ngFor="let loading of loadingUnits.list; let index = index">
<mat-progress-bar
color="primary"
[mode]="({'UNKNOWN': 'indeterminate', 'PENDING': 'query'})[loading.progress] || 'determinate'"
[value]="loading.progress"
>
</mat-progress-bar>
<p class="progress-bar-sub-text">
{{unitsToLoadLabels[index]}}
<ng-container [ngSwitch]="loading.progress">
<span *ngSwitchCase="'UNKNOWN'">
({{'wird geladen' | customtext:'booklet_unitLoadingUnknownProgress' | async}})
</span>
<span *ngSwitchCase="'PENDING'">
({{'in der Warteschleife' | customtext:'booklet_unitLoadingPending' | async}})
</span>
<span *ngSwitchDefault>
({{loading.progress}}% {{'geladen' | customtext:'booklet_unitLoading' | async}})
</span>
</ng-container>
</p>
</ng-container>
</mat-card-content>
<mat-card-actions>
<button
*ngIf="codeRequiringTestlets.length"
mat-raised-button
color="primary"
[disabled]="(clearCodes | keyvalue)?.length < codeRequiringTestlets.length"
(click)="verifyCodes()"
>
OK
</button>
<button
*ngIf="tcs.bookletConfig.unit_navibuttons === 'OFF'"
mat-raised-button
[disabled]="tcs.currentUnitSequenceId === 0"
(click)="tcs.setUnitNavigationRequest(unitNavigationTarget.PREVIOUS)" matTooltip="Zurück" fxFlex="none"
>
<i class="material-icons">chevron_left</i>
</button>
<button
*ngIf="tcs.bookletConfig.unit_navibuttons === 'OFF'"
mat-raised-button
[disabled]="tcs.currentUnitSequenceId >= tcs.allUnitIds.length"
(click)="tcs.setUnitNavigationRequest(unitNavigationTarget.NEXT)" matTooltip="Weiter" fxFlex="none"
>
<i class="material-icons">chevron_right</i>
</button>
</mat-card-actions>
</mat-card>
</div>
</ng-container>
<div
id="page-navigation"
fxLayout="row"
fxLayoutAlign="end center"
fxLayoutGap="10px"
*ngIf="tcs.bookletConfig.page_navibuttons === 'SEPARATE_BOTTOM'"
>
<div fxLayout="row" fxLayoutAlign="space-between center" *ngIf="knownPages && knownPages.length">
<div id="page-navigation-prompt">
{{ '' | customtext:'login_pagesNaviPrompt' | async}}
</div>
<mat-button-toggle-group>
<mat-button-toggle
[disabled]="currentPageIndex <= 0"
(click)="gotoPage(knownPages[currentPageIndex - 1].id);"
fxLayout="row"
fxLayoutAlign="center center"
>
<i class="material-icons">chevron_left</i>
</mat-button-toggle>
<mat-button-toggle
*ngFor="let page of knownPages; let index = index"
fxLayout="column"
(click)="gotoPage(page.id)"
[matTooltip]="page.label"
[checked]="currentPageIndex === index"
>
{{ index + 1 }}
</mat-button-toggle>
<mat-button-toggle
[disabled]="currentPageIndex >= knownPages.length - 1"
(click)="gotoPage(knownPages[currentPageIndex + 1].id);"
fxLayout="row"
fxLayoutAlign="center center"
>
<i class="material-icons">chevron_right</i>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
</div>
</div>
./unithost.component.css
:host {
width: 100%
}
#iframe-host {
position: fixed;
width: 100%;
top: 0;
bottom: 0;
padding: 0;
background-color: white;
}
.with-header #iframe-host {
top: 65px;
}
.with-title #iframe-host {
top: 40px;
}
.with-header.with-title #iframe-host {
top: 105px;
}
.with-footer #iframe-host {
bottom: 45px;
}
#waiting-room {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
max-width: 50%;
}
#waiting-room mat-card {
margin: 4em;
}
#waiting-room .progress-bar-sub-text {
margin-top: 0.1em
}
#unit-title {
position: absolute;
width: 100%;
top: 0;
height: 39px;
font-size: 1.5em;
background-color: white;
border-bottom: solid 1px black;
white-space: nowrap;
text-overflow: clip;
overflow: hidden;
}
.is-waiting #iframe-host,
.is-waiting #unit-title {
visibility: hidden;
}
#page-navigation {
position: absolute;
width: 100%;
height: 45px;
bottom: 0;
padding: 0 16px;
font-size: 1.2em;
}
#page-navigation-prompt {
padding-right: 8px;
color: white;
}