Guided Tour added

This commit is contained in:
gpapavgeri 2020-07-30 18:29:20 +03:00
parent 70d2d456ee
commit b36621877e
26 changed files with 1361 additions and 13 deletions

View File

@ -27,7 +27,8 @@
"src/styles.scss",
"src/assets/scss/material-dashboard.scss",
"src/assets/css/demo.css",
"node_modules/cookieconsent/build/cookieconsent.min.css"
"node_modules/cookieconsent/build/cookieconsent.min.css",
"src/app/library/guided-tour/guided-tour-base-theme.scss"
],
"scripts": [
"node_modules/cookieconsent/build/cookieconsent.min.js",

View File

@ -32,6 +32,7 @@
"moment-timezone": "^0.5.26",
"ngx-cookie-service": "^2.2.0",
"ngx-cookieconsent": "^2.2.3",
"ngx-guided-tour": "^1.1.10",
"rxjs": "^6.3.2",
"tinymce": "^5.1.6",
"tslib": "^1.10.0",

View File

@ -24,3 +24,5 @@
</div>
<app-notification *ngIf="!onlySplash"></app-notification>
<router-outlet *ngIf="onlySplash"></router-outlet>
<ngx-guided-tour></ngx-guided-tour>

View File

@ -32,6 +32,7 @@ import { BaseHttpService } from './core/services/http/base-http.service';
import { ConfigurationService } from './core/services/configuration/configuration.service';
import { Oauth2DialogModule } from './ui/misc/oauth2-dialog/oauth2-dialog.module';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS, MatFormFieldDefaultOptions } from '@angular/material';
import { GuidedTourModule } from './library/guided-tour/guided-tour.module';
// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient, appConfig: ConfigurationService) {
@ -105,7 +106,8 @@ const appearance: MatFormFieldDefaultOptions = {
NavbarModule,
SidebarModule,
NgcCookieConsentModule.forRoot(cookieConfig),
Oauth2DialogModule
Oauth2DialogModule,
GuidedTourModule.forRoot()
],
declarations: [
AppComponent,

View File

@ -0,0 +1,132 @@
$tour-zindex: 1081 !default;
$tour-step-color: #ffffff !default;
$tour-text-color: #231f1f !default;
$tour-next-button-color: #007bff !default;
$tour-next-button-hover: #0069d9 !default;
$tour-back-button-color: #007bff !default;
$tour-next-text-color: #ffffff !default;
$tour-next-text-hover: #ffffff !default;
$tour-skip-link-color: #5e5e5e !default;
$tour-orb-color: #625aff !default;
$tour-shadow-color: #4c4c4c !default;
body.tour-open {
overflow: hidden;
}
@mixin tour-triangle($direction, $color: currentColor, $size: 1rem) {
@if not index(top right bottom left, $direction) {
@error 'Direction must be either `top`, `right`, `bottom` or `left`.';
}
$opposite-direction: top;
@if $direction==top {
$opposite-direction: bottom;
}
@if $direction==bottom {
$opposite-direction: top;
}
@if $direction==right {
$opposite-direction: left;
}
@if $direction==left {
$opposite-direction: right;
}
width: 0;
height: 0;
content: '';
z-index: 2;
border-#{$opposite-direction}: $size solid $color;
$perpendicular-borders: $size solid transparent;
@if $direction==top or $direction==bottom {
border-left: $perpendicular-borders;
border-right: $perpendicular-borders;
}
@else if $direction==right or $direction==left {
border-bottom: $perpendicular-borders;
border-top: $perpendicular-borders;
}
}
ngx-guided-tour {
.guided-tour-user-input-mask {
z-index: $tour-zindex;
}
.guided-tour-spotlight-overlay {
z-index: $tour-zindex + 1;
}
.tour-orb {
z-index: $tour-zindex - 2;
background-color: $tour-orb-color;
box-shadow: 0 0 0.3rem 0.1rem $tour-orb-color;
.tour-orb-ring {
&::after {
border: 1rem solid $tour-orb-color;
box-shadow: 0 0 0.1rem 0.1rem $tour-orb-color;
}
}
}
.tour-step {
z-index: $tour-zindex + 2;
&.tour-bottom, &.tour-bottom-right, &.tour-bottom-left {
.tour-arrow::before {
@include tour-triangle(top, $tour-step-color);
}
}
&.tour-top, &.tour-top-right, &.tour-top-left {
.tour-arrow::before {
@include tour-triangle(bottom, $tour-step-color);
}
}
&.tour-left {
.tour-arrow::before {
@include tour-triangle(right, $tour-step-color);
}
}
&.tour-right {
.tour-arrow::before {
@include tour-triangle(left, $tour-step-color);
}
}
.tour-block {
color: $tour-text-color;
background-color: $tour-step-color;
box-shadow: 0 0.4rem 0.6rem $tour-shadow-color;
}
.tour-buttons {
button.skip-button {
color: $tour-skip-link-color;
}
.back-button {
color: $tour-back-button-color;
}
.next-button {
background-color: $tour-next-button-color;
color: $tour-next-text-color;
&:hover {
background-color: $tour-next-button-hover;
color: $tour-next-text-hover;
}
}
}
}
}

View File

@ -0,0 +1,86 @@
<!-- <div *ngIf="currentTourStep && selectedElementRect && isOrbShowing"y
class="tour-orb tour-{{ currentTourStep.orientation }}"
(mouseenter)="handleOrb()"
[style.top.px]="orbTopPosition"
[style.left.px]="orbLeftPosition"
[style.transform]="orbTransform">
<div class="tour-orb-ring"></div>
</div> -->
<div *ngIf="currentTourStep && !isOrbShowing">
<div class="guided-tour-user-input-mask" (click)="backdropClick($event)"></div>
<div class="guided-tour-spotlight-overlay" [style.top.px]="overlayTop" [style.left.px]="overlayLeft"
[style.height.px]="overlayHeight" [style.width.px]="overlayWidth">
</div>
</div>
<div *ngIf="currentTourStep && !isOrbShowing">
<div #tourStep *ngIf="currentTourStep" class="tour-step tour-{{ currentTourStep.orientation }}" [ngClass]="{
'page-tour-step': !currentTourStep.selector
}" [style.top.px]="(currentTourStep.selector && selectedElementRect ? topPosition : null)"
[style.left.px]="(currentTourStep.selector && selectedElementRect ? leftPosition : null)"
[style.width.px]="(currentTourStep.selector && selectedElementRect ? calculatedTourStepWidth : null)"
[style.transform]="(currentTourStep.selector && selectedElementRect ? transform : null)">
<div *ngIf="currentTourStep.selector" class="tour-arrow"></div>
<div class="tour-block">
<!-- <div *ngIf="
progressIndicatorLocation === progressIndicatorLocations.TopOfTourBlock
&& !guidedTourService.onResizeMessage" class="tour-progress-indicator">
<ng-container *ngTemplateOutlet="progress"></ng-container>
</div> -->
<h3 class="tour-title" *ngIf="currentTourStep.title && currentTourStep.selector">
{{currentTourStep.title}}
</h3>
<h2 class="tour-title" *ngIf="currentTourStep.title && !currentTourStep.selector">
{{ currentTourStep.title }}
</h2>
<!-- <div class="tour-content" [innerHTML]="currentTourStep.content"></div> -->
<div class="tour-buttons">
<button *ngIf="!guidedTourService.onResizeMessage" class="next-button"
(click)="guidedTourService.nextStep()">
{{ nextText }}
</button>
<button *ngIf="!guidedTourService.onResizeMessage" (click)="guidedTourService.skipTour()"
class="skip-button link-button">
{{ skipText }}
</button>
<!-- <button *ngIf="!guidedTourService.onResizeMessage"
(click)="guidedTourService.skipTour()"
class="skip-button link-button">
{{ skipText }}
</button> -->
<!-- <button *ngIf="!guidedTourService.onLastStep && !guidedTourService.onResizeMessage"
class="next-button"
(click)="guidedTourService.nextStep()">
{{ nextText }}
<ng-container *ngIf="progressIndicatorLocation === progressIndicatorLocations.InsideNextButton">
<ng-container *ngTemplateOutlet="progress"></ng-container>
</ng-container>
</button> -->
<!-- <button *ngIf="guidedTourService.onLastStep"
class="next-button"
(click)="guidedTourService.nextStep()">
{{ doneText }}
</button> -->
<!-- <button *ngIf="guidedTourService.onResizeMessage"
class="next-button"
(click)="guidedTourService.resetTour()">
{{ closeText }}
</button> -->
<!-- <button *ngIf="!guidedTourService.onFirstStep && !guidedTourService.onResizeMessage"
class="back-button link-button"
(click)="guidedTourService.backStep()">
{{ backText }}
</button> -->
</div>
<div class="argos-present-img"></div>
</div>
</div>
</div>
<!-- <ng-template #progress>
<ng-container *ngTemplateOutlet="
progressIndicator || defaultProgressIndicator;
context: { currentStepNumber: guidedTourService.currentTourStepDisplay, totalSteps: guidedTourService.currentTourStepCount }
"></ng-container>
</ng-template>
<ng-template #defaultProgressIndicator let-currentStepNumber="currentStepNumber" let-totalSteps="totalSteps">
<ng-container *ngIf="progressIndicatorLocation === progressIndicatorLocations.InsideNextButton">&nbsp;</ng-container>{{ currentStepNumber }}/{{ totalSteps }}
</ng-template> -->

View File

@ -0,0 +1,235 @@
ngx-guided-tour {
.guided-tour-user-input-mask {
position: fixed;
top: 0;
left: 0;
display: block;
height: 100%;
width: 100%;
max-height: 100vh;
text-align: center;
opacity: 0;
}
.guided-tour-spotlight-overlay {
position: fixed;
box-shadow: 0 0 0 9999px rgba(0,0,0,.7), 0 0 1.5rem rgba(0,0,0,.5);
border-radius: 44px;
}
.tour-orb {
position: fixed;
width: 20px;
height: 20px;
border-radius: 50%;
.tour-orb-ring {
width: 35px;
height: 35px;
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s linear infinite;
&:after {
content: '';
display: inline-block;
height: 100%;
width: 100%;
border-radius: 50%;
}
}
@keyframes pulse {
from {
transform: translate(-50%, -50%) scale(0.45);
opacity: 1.0;
}
to {
transform: translate(-50%, -50%) scale(1);
opacity: 0.0;
}
}
}
.tour-step {
position: fixed;
&.page-tour-step {
max-width: 1043px;
width: 50%;
left: 50%;
top: 50%;
border-radius: 5px;
transform: translate(-50%, -50%)
}
&.tour-bottom, &.tour-bottom-right, &.tour-bottom-left {
.tour-arrow::before {
position: absolute;
}
.tour-block {
margin-top: 10px;
}
}
&.tour-top, &.tour-top-right, &.tour-top-left {
margin-bottom: 10px;
.tour-arrow::before {
position: absolute;
bottom: 0;
}
.tour-block {
margin-bottom: 10px;
}
}
&.tour-bottom , &.tour-top {
.tour-arrow::before {
transform: translateX(-50%);
left: 50%;
}
}
&.tour-bottom-right, &.tour-top-right {
.tour-arrow::before {
transform: translateX(-100%);
left: calc(100% - 5px);
}
}
&.tour-bottom-left, &.tour-top-left {
.tour-arrow::before {
left: 5px;
}
}
&.tour-left {
.tour-arrow::before {
position: absolute;
left: 100%;
transform: translateX(-100%);
top: 5px;
}
.tour-block {
margin-right: 10px;
}
}
&.tour-right {
.tour-arrow::before {
position: absolute;
left: 0;
top: 5px;
}
.tour-block {
margin-left: 10px;
}
}
.tour-block {
padding: 15px 25px;
height: 348px;
border-radius: 5px;
}
.tour-progress-indicator {
padding-bottom: 15px;
}
.tour-title {
font-weight: lighter !important;
font-size: 16px !important;
padding-left: 65px;
padding-top: 28px;
padding-right: 156px;
// padding-bottom: 20px;
text-align: left;
color: #212121;
line-height: 26px;
white-space:pre-line;
height: 182px;
}
h3.tour-title {
font-size: 20px;
}
h2.tour-title {
font-size: 30px;
}
.tour-content {
min-height: 80px;
padding-bottom: 30px;
font-size: 15px;
}
.tour-buttons {
overflow: hidden; // clearfix
padding: 40px 156px 25px 65px;
button.link-button {
padding-left: 0;
font-size: 15px;
font-weight: bold;
max-width: none !important;
cursor: pointer;
text-align: center;
white-space: nowrap;
vertical-align: middle;
border: 1px solid transparent;
line-height: 1.5;
background-color: transparent;
position: relative;
outline: none;
padding: 0 15px;
-webkit-appearance: button;
}
button.skip-button.link-button {
padding: 0;
border-left: 0;
float: right;
width: 133px;
height: 40px;
border: 1px solid #129D99;
background: #FFFFFF 0% 0% no-repeat padding-box;
color: #129D99;
// text-align: center;
}
.back-button {
float: right;
}
.next-button {
cursor: pointer;
float: left;
border: none;
outline: none;
padding: 10px 0px;;
width: 101px;
background: #129D99 0% 0% no-repeat padding-box;
}
button.skip-button.link-button, .next-button {
font-size: 14px;
font-weight: bold;
letter-spacing: 0.35px;
height: 40px;
box-shadow: 0px 3px 6px #1E202029;
border-radius: 30px;
}
}
.argos-present-img {
background: url("../../../assets/splash/assets/img/argos\ present.png") no-repeat;
width: 176px;
height: 220px;
position: relative;
top: -205px;
left: 820px;
border-top: none;
}
}
}

View File

@ -0,0 +1,392 @@
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild, ViewEncapsulation, TemplateRef, Inject } from '@angular/core';
import { fromEvent, Subscription } from 'rxjs';
import { DOCUMENT } from '@angular/common';
import { Orientation, TourStep, ProgressIndicatorLocation } from './guided-tour.constants';
import { GuidedTourService } from './guided-tour.service';
import { WindowRefService } from "./windowref.service";
@Component({
selector: 'ngx-guided-tour',
templateUrl: './guided-tour.component.html',
styleUrls: ['./guided-tour.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class GuidedTourComponent implements AfterViewInit, OnDestroy {
@Input() public topOfPageAdjustment ?= 0;
@Input() public tourStepWidth ?= 1043;
@Input() public minimalTourStepWidth ?= 900;
@Input() public skipText ?= 'Leave Tour';
@Input() public nextText ?= 'Got it!';
@Input() public doneText ?= 'Done';
@Input() public closeText ?= 'Close';
@Input() public backText ?= 'Back';
@Input() public progressIndicatorLocation?: ProgressIndicatorLocation = ProgressIndicatorLocation.InsideNextButton;
@Input() public progressIndicator?: TemplateRef<any> = undefined;
@ViewChild('tourStep', { static: false }) public tourStep: ElementRef;
public highlightPadding = 4;
public currentTourStep: TourStep = null;
public selectedElementRect: DOMRect = null;
public isOrbShowing = false;
public progressIndicatorLocations = ProgressIndicatorLocation;
private resizeSubscription: Subscription;
private scrollSubscription: Subscription;
constructor(
public guidedTourService: GuidedTourService,
private windowRef: WindowRefService,
@Inject(DOCUMENT) private dom: any
) { }
private get maxWidthAdjustmentForTourStep(): number {
return this.tourStepWidth - this.minimalTourStepWidth;
}
private get widthAdjustmentForScreenBound(): number {
if (!this.tourStep) {
return 0;
}
let adjustment = 0;
if (this.calculatedLeftPosition < 0) {
adjustment = -this.calculatedLeftPosition;
}
if (this.calculatedLeftPosition > this.windowRef.nativeWindow.innerWidth - this.tourStepWidth) {
adjustment = this.calculatedLeftPosition - (this.windowRef.nativeWindow.innerWidth - this.tourStepWidth);
}
return Math.min(this.maxWidthAdjustmentForTourStep, adjustment);
}
public get calculatedTourStepWidth() {
return this.tourStepWidth - this.widthAdjustmentForScreenBound;
}
public ngAfterViewInit(): void {
this.guidedTourService.guidedTourCurrentStepStream.subscribe((step: TourStep) => {
this.currentTourStep = step;
if (step && step.selector) {
const selectedElement = this.dom.querySelector(step.selector);
if (selectedElement) {
this.handleOrb();
this.scrollToAndSetElement();
} else {
this.selectedElementRect = null;
}
} else {
this.selectedElementRect = null;
}
});
this.guidedTourService.guidedTourOrbShowingStream.subscribe((value: boolean) => {
this.isOrbShowing = value;
});
this.resizeSubscription = fromEvent(this.windowRef.nativeWindow, 'resize').subscribe(() => {
this.updateStepLocation();
});
this.scrollSubscription = fromEvent(this.windowRef.nativeWindow, 'scroll').subscribe(() => {
this.updateStepLocation();
});
}
public ngOnDestroy(): void {
this.resizeSubscription.unsubscribe();
this.scrollSubscription.unsubscribe();
}
public scrollToAndSetElement(): void {
this.updateStepLocation();
// Allow things to render to scroll to the correct location
setTimeout(() => {
if (!this.isOrbShowing && !this.isTourOnScreen()) {
if (this.selectedElementRect && this.isBottom()) {
// Scroll so the element is on the top of the screen.
const topPos = ((this.windowRef.nativeWindow.scrollY + this.selectedElementRect.top) - this.topOfPageAdjustment)
- (this.currentTourStep.scrollAdjustment ? this.currentTourStep.scrollAdjustment : 0)
+ this.getStepScreenAdjustment();
try {
this.windowRef.nativeWindow.scrollTo({
left: null,
top: topPos,
behavior: 'smooth'
});
} catch (err) {
if (err instanceof TypeError) {
this.windowRef.nativeWindow.scroll(0, topPos);
} else {
throw err;
}
}
} else {
// Scroll so the element is on the bottom of the screen.
const topPos = (this.windowRef.nativeWindow.scrollY + this.selectedElementRect.top + this.selectedElementRect.height)
- this.windowRef.nativeWindow.innerHeight
+ (this.currentTourStep.scrollAdjustment ? this.currentTourStep.scrollAdjustment : 0)
- this.getStepScreenAdjustment();
try {
this.windowRef.nativeWindow.scrollTo({
left: null,
top: topPos,
behavior: 'smooth'
});
} catch (err) {
if (err instanceof TypeError) {
this.windowRef.nativeWindow.scroll(0, topPos);
} else {
throw err;
}
}
}
}
});
}
public handleOrb(): void {
this.guidedTourService.activateOrb();
if (this.currentTourStep && this.currentTourStep.selector) {
this.scrollToAndSetElement();
}
}
private isTourOnScreen(): boolean {
return this.tourStep
&& this.elementInViewport(this.dom.querySelector(this.currentTourStep.selector))
&& this.elementInViewport(this.tourStep.nativeElement);
}
private elementInViewport(element: HTMLElement): boolean {
let top = element.offsetTop;
const height = element.offsetHeight;
while (element.offsetParent) {
element = (element.offsetParent as HTMLElement);
top += element.offsetTop;
}
if (this.isBottom()) {
return (
top >= (this.windowRef.nativeWindow.pageYOffset
+ this.topOfPageAdjustment
+ (this.currentTourStep.scrollAdjustment ? this.currentTourStep.scrollAdjustment : 0)
+ this.getStepScreenAdjustment())
&& (top + height) <= (this.windowRef.nativeWindow.pageYOffset + this.windowRef.nativeWindow.innerHeight)
);
} else {
return (
top >= (this.windowRef.nativeWindow.pageYOffset + this.topOfPageAdjustment - this.getStepScreenAdjustment())
&& (top + height + (this.currentTourStep.scrollAdjustment ? this.currentTourStep.scrollAdjustment : 0)) <= (this.windowRef.nativeWindow.pageYOffset + this.windowRef.nativeWindow.innerHeight)
);
}
}
public backdropClick(event: Event): void {
if (this.guidedTourService.preventBackdropFromAdvancing) {
event.stopPropagation();
} else {
this.guidedTourService.nextStep();
}
}
public updateStepLocation(): void {
if (this.currentTourStep && this.currentTourStep.selector) {
const selectedElement = this.dom.querySelector(this.currentTourStep.selector);
if (selectedElement && typeof selectedElement.getBoundingClientRect === 'function') {
this.selectedElementRect = (selectedElement.getBoundingClientRect() as DOMRect);
} else {
this.selectedElementRect = null;
}
} else {
this.selectedElementRect = null;
}
}
private isBottom(): boolean {
return this.currentTourStep.orientation
&& (this.currentTourStep.orientation === Orientation.Bottom
|| this.currentTourStep.orientation === Orientation.BottomLeft
|| this.currentTourStep.orientation === Orientation.BottomRight);
}
public get topPosition(): number {
const paddingAdjustment = this.getHighlightPadding();
if (this.isBottom()) {
return this.selectedElementRect.top + this.selectedElementRect.height + paddingAdjustment;
}
return this.selectedElementRect.top - this.getHighlightPadding();
}
public get orbTopPosition(): number {
if (this.isBottom()) {
return this.selectedElementRect.top + this.selectedElementRect.height;
}
if (
this.currentTourStep.orientation === Orientation.Right
|| this.currentTourStep.orientation === Orientation.Left
) {
return (this.selectedElementRect.top + (this.selectedElementRect.height / 2));
}
return this.selectedElementRect.top;
}
private get calculatedLeftPosition(): number {
const paddingAdjustment = this.getHighlightPadding();
if (
this.currentTourStep.orientation === Orientation.TopRight
|| this.currentTourStep.orientation === Orientation.BottomRight
) {
return (this.selectedElementRect.right - this.tourStepWidth);
}
if (
this.currentTourStep.orientation === Orientation.TopLeft
|| this.currentTourStep.orientation === Orientation.BottomLeft
) {
return (this.selectedElementRect.left);
}
if (this.currentTourStep.orientation === Orientation.Left) {
return this.selectedElementRect.left - this.tourStepWidth - paddingAdjustment;
}
if (this.currentTourStep.orientation === Orientation.Right) {
return (this.selectedElementRect.left + this.selectedElementRect.width + paddingAdjustment);
}
return (this.selectedElementRect.right - (this.selectedElementRect.width / 2) - (this.tourStepWidth / 2));
}
public get leftPosition(): number {
if (this.calculatedLeftPosition >= 0) {
return this.calculatedLeftPosition;
}
const adjustment = Math.max(0, -this.calculatedLeftPosition)
const maxAdjustment = Math.min(this.maxWidthAdjustmentForTourStep, adjustment);
return this.calculatedLeftPosition + maxAdjustment;
}
public get orbLeftPosition(): number {
if (
this.currentTourStep.orientation === Orientation.TopRight
|| this.currentTourStep.orientation === Orientation.BottomRight
) {
return this.selectedElementRect.right;
}
if (
this.currentTourStep.orientation === Orientation.TopLeft
|| this.currentTourStep.orientation === Orientation.BottomLeft
) {
return this.selectedElementRect.left;
}
if (this.currentTourStep.orientation === Orientation.Left) {
return this.selectedElementRect.left;
}
if (this.currentTourStep.orientation === Orientation.Right) {
return (this.selectedElementRect.left + this.selectedElementRect.width);
}
return (this.selectedElementRect.right - (this.selectedElementRect.width / 2));
}
public get transform(): string {
if (
!this.currentTourStep.orientation
|| this.currentTourStep.orientation === Orientation.Top
|| this.currentTourStep.orientation === Orientation.TopRight
|| this.currentTourStep.orientation === Orientation.TopLeft
) {
return 'translateY(-100%)';
}
return null;
}
public get orbTransform(): string {
if (
!this.currentTourStep.orientation
|| this.currentTourStep.orientation === Orientation.Top
|| this.currentTourStep.orientation === Orientation.Bottom
|| this.currentTourStep.orientation === Orientation.TopLeft
|| this.currentTourStep.orientation === Orientation.BottomLeft
) {
return 'translateY(-50%)';
}
if (
this.currentTourStep.orientation === Orientation.TopRight
|| this.currentTourStep.orientation === Orientation.BottomRight
) {
return 'translate(-100%, -50%)';
}
if (
this.currentTourStep.orientation === Orientation.Right
|| this.currentTourStep.orientation === Orientation.Left
) {
return 'translate(-50%, -50%)';
}
return null;
}
public get overlayTop(): number {
if (this.selectedElementRect) {
return this.selectedElementRect.top - this.getHighlightPadding();
}
return 0;
}
public get overlayLeft(): number {
if (this.selectedElementRect) {
return this.selectedElementRect.left - this.getHighlightPadding();
}
return 0;
}
public get overlayHeight(): number {
if (this.selectedElementRect) {
return this.selectedElementRect.height + (this.getHighlightPadding() * 2);
}
return 0;
}
public get overlayWidth(): number {
if (this.selectedElementRect) {
return (this.selectedElementRect.width + (this.getHighlightPadding() * 2)) * 0.95;
}
return 0;
}
private getHighlightPadding(): number {
let paddingAdjustment = this.currentTourStep.useHighlightPadding ? this.highlightPadding : 0;
if (this.currentTourStep.highlightPadding) {
paddingAdjustment = this.currentTourStep.highlightPadding;
}
return paddingAdjustment;
}
// This calculates a value to add or subtract so the step should not be off screen.
private getStepScreenAdjustment(): number {
if (
this.currentTourStep.orientation === Orientation.Left
|| this.currentTourStep.orientation === Orientation.Right
) {
return 0;
}
const scrollAdjustment = this.currentTourStep.scrollAdjustment ? this.currentTourStep.scrollAdjustment : 0;
const tourStepHeight = typeof this.tourStep.nativeElement.getBoundingClientRect === 'function' ? this.tourStep.nativeElement.getBoundingClientRect().height : 0;
const elementHeight = this.selectedElementRect.height + scrollAdjustment + tourStepHeight;
if ((this.windowRef.nativeWindow.innerHeight - this.topOfPageAdjustment) < elementHeight) {
return elementHeight - (this.windowRef.nativeWindow.innerHeight - this.topOfPageAdjustment);
}
return 0;
}
}

View File

@ -0,0 +1,75 @@
export interface TourStep {
/** Selector for element that will be highlighted */
selector?: string;
/** Tour title text */
title?: string;
/** Tour step text */
content: string;
/** Where the tour step will appear next to the selected element */
orientation?: Orientation | OrientationConfiguration[];
/** Action that happens when the step is opened */
action?: () => void;
/** Action that happens when the step is closed */
closeAction?: () => void;
/** Skips this step, this is so you do not have create multiple tour configurations based on user settings/configuration */
skipStep?: boolean;
/** Adds some padding for things like sticky headers when scrolling to an element */
scrollAdjustment?: number;
/** Adds default padding around tour highlighting. Does not need to be true for highlightPadding to work */
useHighlightPadding?: boolean;
/** Adds padding around tour highlighting in pixels, this overwrites the default for this step. Is not dependent on useHighlightPadding being true */
highlightPadding?: number;
}
export interface GuidedTour {
/** Identifier for tour */
tourId: string;
/** Use orb to start tour */
useOrb?: boolean;
/** Steps fo the tour */
steps: TourStep[];
/** Function will be called when tour is skipped */
skipCallback?: (stepSkippedOn: number) => void;
/** Function will be called when tour is completed */
completeCallback?: () => void;
/** Minimum size of screen in pixels before the tour is run, if the tour is resized below this value the user will be told to resize */
minimumScreenSize?: number;
/** Dialog shown if the window width is smaller than the defined minimum screen size. */
resizeDialog?: {
/** Resize dialog title text */
title?: string;
/** Resize dialog text */
content: string;
}
/**
* Prevents the tour from advancing by clicking the backdrop.
* This should only be set if you are completely sure your tour is displaying correctly on all screen sizes otherwise a user can get stuck.
*/
preventBackdropFromAdvancing?: boolean;
}
export interface OrientationConfiguration {
/** Where the tour step will appear next to the selected element */
orientationDirection: Orientation;
/** When this orientation configuration starts in pixels */
maximumSize?: number;
}
export class Orientation {
public static readonly Bottom = 'bottom';
public static readonly BottomLeft = 'bottom-left';
public static readonly BottomRight = 'bottom-right';
public static readonly Center = 'center';
public static readonly Left = 'left';
public static readonly Right = 'right';
public static readonly Top = 'top';
public static readonly TopLeft = 'top-left';
public static readonly TopRight = 'top-right';
}
export enum ProgressIndicatorLocation {
InsideNextButton = 'inside-next-button',
TopOfTourBlock = 'top-of-tour-block',
None = 'none',
}

View File

@ -0,0 +1,21 @@
import { GuidedTourService } from './guided-tour.service';
import { GuidedTourComponent } from './guided-tour.component';
import { NgModule, ErrorHandler, ModuleWithProviders } from '@angular/core';
import { CommonModule } from '@angular/common';
import { WindowRefService } from './windowref.service';
@NgModule({
declarations: [GuidedTourComponent],
imports: [CommonModule],
providers: [WindowRefService],
exports: [GuidedTourComponent],
entryComponents: [GuidedTourComponent],
})
export class GuidedTourModule {
public static forRoot(): ModuleWithProviders {
return {
ngModule: GuidedTourModule,
providers: [ErrorHandler, GuidedTourService],
};
}
}

View File

@ -0,0 +1,229 @@
import { debounceTime } from 'rxjs/operators';
import { ErrorHandler, Inject, Injectable } from '@angular/core';
import { Observable, Subject, fromEvent } from 'rxjs';
import { GuidedTour, TourStep, Orientation, OrientationConfiguration } from './guided-tour.constants';
import { cloneDeep } from 'lodash';
import { DOCUMENT } from "@angular/common";
import { WindowRefService } from "./windowref.service";
@Injectable()
export class GuidedTourService {
public guidedTourCurrentStepStream: Observable<TourStep>;
public guidedTourOrbShowingStream: Observable<boolean>;
private _guidedTourCurrentStepSubject = new Subject<TourStep>();
private _guidedTourOrbShowingSubject = new Subject<boolean>();
private _currentTourStepIndex = 0;
private _currentTour: GuidedTour = null;
private _onFirstStep = true;
private _onLastStep = true;
private _onResizeMessage = false;
constructor(
public errorHandler: ErrorHandler,
private windowRef: WindowRefService,
@Inject(DOCUMENT) private dom
) {
this.guidedTourCurrentStepStream = this._guidedTourCurrentStepSubject.asObservable();
this.guidedTourOrbShowingStream = this._guidedTourOrbShowingSubject.asObservable();
fromEvent(this.windowRef.nativeWindow, 'resize').pipe(debounceTime(200)).subscribe(() => {
if (this._currentTour && this._currentTourStepIndex > -1) {
if (this._currentTour.minimumScreenSize && this._currentTour.minimumScreenSize >= this.windowRef.nativeWindow.innerWidth) {
this._onResizeMessage = true;
const dialog = this._currentTour.resizeDialog || {
title: 'Please resize',
content: 'You have resized the tour to a size that is too small to continue. Please resize the browser to a larger size to continue the tour or close the tour.'
};
this._guidedTourCurrentStepSubject.next(dialog);
} else {
this._onResizeMessage = false;
this._guidedTourCurrentStepSubject.next(this.getPreparedTourStep(this._currentTourStepIndex));
}
}
});
}
public nextStep(): void {
if (this._currentTour.steps[this._currentTourStepIndex].closeAction) {
this._currentTour.steps[this._currentTourStepIndex].closeAction();
}
if (this._currentTour.steps[this._currentTourStepIndex + 1]) {
this._currentTourStepIndex++;
this._setFirstAndLast();
if (this._currentTour.steps[this._currentTourStepIndex].action) {
this._currentTour.steps[this._currentTourStepIndex].action();
// Usually an action is opening something so we need to give it time to render.
setTimeout(() => {
if (this._checkSelectorValidity()) {
this._guidedTourCurrentStepSubject.next(this.getPreparedTourStep(this._currentTourStepIndex));
} else {
this.nextStep();
}
});
} else {
if (this._checkSelectorValidity()) {
this._guidedTourCurrentStepSubject.next(this.getPreparedTourStep(this._currentTourStepIndex));
} else {
this.nextStep();
}
}
} else {
if (this._currentTour.completeCallback) {
this._currentTour.completeCallback();
}
this.resetTour();
}
}
public backStep(): void {
if (this._currentTour.steps[this._currentTourStepIndex].closeAction) {
this._currentTour.steps[this._currentTourStepIndex].closeAction();
}
if (this._currentTour.steps[this._currentTourStepIndex - 1]) {
this._currentTourStepIndex--;
this._setFirstAndLast();
if (this._currentTour.steps[this._currentTourStepIndex].action) {
this._currentTour.steps[this._currentTourStepIndex].action();
setTimeout(() => {
if (this._checkSelectorValidity()) {
this._guidedTourCurrentStepSubject.next(this.getPreparedTourStep(this._currentTourStepIndex));
} else {
this.backStep();
}
});
} else {
if (this._checkSelectorValidity()) {
this._guidedTourCurrentStepSubject.next(this.getPreparedTourStep(this._currentTourStepIndex));
} else {
this.backStep();
}
}
} else {
this.resetTour();
}
}
public skipTour(): void {
if (this._currentTour.skipCallback) {
this._currentTour.skipCallback(this._currentTourStepIndex);
}
this.resetTour();
}
public resetTour(): void {
this.dom.body.classList.remove('tour-open');
this._currentTour = null;
this._currentTourStepIndex = 0;
this._guidedTourCurrentStepSubject.next(null);
}
public startTour(tour: GuidedTour): void {
this._currentTour = cloneDeep(tour);
this._currentTour.steps = this._currentTour.steps.filter(step => !step.skipStep);
this._currentTourStepIndex = 0;
this._setFirstAndLast();
this._guidedTourOrbShowingSubject.next(this._currentTour.useOrb);
if (
this._currentTour.steps.length > 0
&& (!this._currentTour.minimumScreenSize
|| (this.windowRef.nativeWindow.innerWidth >= this._currentTour.minimumScreenSize))
) {
if (!this._currentTour.useOrb) {
this.dom.body.classList.add('tour-open');
}
if (this._currentTour.steps[this._currentTourStepIndex].action) {
this._currentTour.steps[this._currentTourStepIndex].action();
}
if (this._checkSelectorValidity()) {
this._guidedTourCurrentStepSubject.next(this.getPreparedTourStep(this._currentTourStepIndex));
} else {
this.nextStep();
}
}
}
public activateOrb(): void {
this._guidedTourOrbShowingSubject.next(false);
this.dom.body.classList.add('tour-open');
}
private _setFirstAndLast(): void {
this._onLastStep = (this._currentTour.steps.length - 1) === this._currentTourStepIndex;
this._onFirstStep = this._currentTourStepIndex === 0;
}
private _checkSelectorValidity(): boolean {
if (this._currentTour.steps[this._currentTourStepIndex].selector) {
const selectedElement = this.dom.querySelector(this._currentTour.steps[this._currentTourStepIndex].selector);
if (!selectedElement) {
this.errorHandler.handleError(
// If error handler is configured this should not block the browser.
new Error(`Error finding selector ${this._currentTour.steps[this._currentTourStepIndex].selector} on step ${this._currentTourStepIndex + 1} during guided tour: ${this._currentTour.tourId}`)
);
return false;
}
}
return true;
}
public get onLastStep(): boolean {
return this._onLastStep;
}
public get onFirstStep(): boolean {
return this._onFirstStep;
}
public get onResizeMessage(): boolean {
return this._onResizeMessage;
}
public get currentTourStepDisplay(): number {
return this._currentTourStepIndex + 1;
}
public get currentTourStepCount(): number {
return this._currentTour && this._currentTour.steps ? this._currentTour.steps.length : 0;
}
public get preventBackdropFromAdvancing(): boolean {
return this._currentTour && this._currentTour.preventBackdropFromAdvancing;
}
private getPreparedTourStep(index: number): TourStep {
return this.setTourOrientation(this._currentTour.steps[index]);
}
private setTourOrientation(step: TourStep): TourStep {
const convertedStep = cloneDeep(step);
if (
convertedStep.orientation
&& !(typeof convertedStep.orientation === 'string')
&& (convertedStep.orientation as OrientationConfiguration[]).length
) {
(convertedStep.orientation as OrientationConfiguration[]).sort((a: OrientationConfiguration, b: OrientationConfiguration) => {
if (!b.maximumSize) {
return 1;
}
if (!a.maximumSize) {
return -1;
}
return b.maximumSize - a.maximumSize;
});
let currentOrientation: Orientation = Orientation.Top;
(convertedStep.orientation as OrientationConfiguration[]).forEach(
(orientationConfig: OrientationConfiguration) => {
if (!orientationConfig.maximumSize || this.windowRef.nativeWindow.innerWidth <= orientationConfig.maximumSize) {
currentOrientation = orientationConfig.orientationDirection;
}
}
);
convertedStep.orientation = currentOrientation;
}
return convertedStep;
}
}

View File

@ -0,0 +1,38 @@
import { Inject, Injectable, PLATFORM_ID } from "@angular/core";
import { isPlatformBrowser } from "@angular/common";
function getWindow(): any {
return window;
}
function getMockWindow(): any {
return {
innerWidth: 0,
innerHeight: 0,
scrollY: 0,
scrollX: 0,
pageYOffset: 0,
pageXOffset: 0,
scroll: () => {},
scrollTo: () => {},
addEventListener: () => {},
removeEventListener: () => {},
}
}
@Injectable()
export class WindowRefService {
private readonly isBrowser: boolean = false;
get nativeWindow(): any {
if (this.isBrowser) {
return getWindow();
} else {
return getMockWindow();
}
}
constructor(@Inject(PLATFORM_ID) platformId) {
this.isBrowser = isPlatformBrowser(platformId);
}
}

View File

@ -34,6 +34,11 @@
</mat-select>
</mat-form-field>
<!-- End of Sort by -->
<!-- Guided Tour -->
<div class="center-content" [style.display]=" (!isVisible && isAuthenticated()) ? 'block' : 'none'" (click)="restartTour()">
{{ 'GENERAL.ACTIONS.TAKE-A-TOUR'| translate }}
</div>
<!-- End of Guided Tour -->
<!-- Search Filter-->
<mat-form-field appearance="outline" class="search-form ml-auto col-auto" floatLabel="never">
<mat-icon matSuffix>search</mat-icon>

View File

@ -137,6 +137,19 @@
height: 45px;
}
.center-content {
width: 100%;
min-width: 10rem;
margin: auto;
padding: 0 15px;
text-align: right;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.02rem;
color: #2D72D6;
cursor: pointer;
}
.search-form {
// font-size: 12px;
text-align: left;

View File

@ -24,6 +24,8 @@ import { MatDialog } from '@angular/material';
import { FormGroup, FormBuilder, FormControl } from '@angular/forms';
import { RecentActivityOrder } from '@app/core/common/enum/recent-activity-order';
import { EnumUtils } from '@app/core/services/utilities/enum-utils.service';
import { GuidedTourService } from '@app/library/guided-tour/guided-tour.service';
import { GuidedTour, Orientation } from '@app/library/guided-tour/guided-tour.constants';
@Component({
selector: 'app-dataset-listing-component',
@ -59,6 +61,8 @@ export class DatasetListingComponent extends BaseComponent implements OnInit, IB
scrollbar: boolean;
order = RecentActivityOrder;
dmpText: string;
datasetText: string;
constructor(
private datasetService: DatasetService,
@ -68,7 +72,9 @@ export class DatasetListingComponent extends BaseComponent implements OnInit, IB
private dmpService: DmpService,
private language: TranslateService,
private authService: AuthService,
public enumUtils: EnumUtils
public enumUtils: EnumUtils,
private authentication: AuthService,
private guidedTourService: GuidedTourService
) {
super();
}
@ -133,6 +139,27 @@ export class DatasetListingComponent extends BaseComponent implements OnInit, IB
this.scrollbar = this.hasScrollbar();
}
public dashboardTour: GuidedTour = {
tourId: 'dmp-dataset-tour',
useOrb: true,
steps: [
{
selector: '.dmp-tour',
content: 'Step 1',
orientation: Orientation.Right
},
{
selector: '.dataset-tour',
content: 'Step 2',
orientation: Orientation.Right
}
]
};
public isAuthenticated(): boolean {
return !(!this.authentication.current());
}
controlModified(): void {
// this.clearErrorModel();
// if (this.refreshCallback != null &&
@ -327,6 +354,28 @@ export class DatasetListingComponent extends BaseComponent implements OnInit, IB
return merged;
}
public setDashboardTourDmpText(): void {
this.dmpText = this.language.instant('DMP-LISTING.TEXT-INFO') + '\n\n' +
this.language.instant('DMP-LISTING.TEXT-INFO-QUESTION') + ' ' +
this.language.instant('DMP-LISTING.LINK-ZENODO') + ' ' +
this.language.instant('DMP-LISTING.GET-IDEA');
this.dashboardTour.steps[0].title = this.dmpText;
}
public setDashboardTourDatasetText(): void {
this.datasetText = this.language.instant('DATASET-LISTING.TEXT-INFO') +
this.language.instant('DATASET-LISTING.LINK-PUBLIC-DATASETS') + ' ' +
this.language.instant('DATASET-LISTING.TEXT-INFO-REST') + '\n\n' +
this.language.instant('DATASET-LISTING.TEXT-INFO-PAR');
this.dashboardTour.steps[1].title = this.datasetText;
}
public restartTour(): void {
this.setDashboardTourDmpText();
this.setDashboardTourDatasetText();
this.guidedTourService.startTour(this.dashboardTour);
}
// rowClicked(dataset: DatasetListingModel) {
// this.router.navigate(['/datasets/edit/' + dataset.id]);
// }

View File

@ -29,6 +29,11 @@
</mat-select>
</mat-form-field>
<!-- End of Sort by -->
<!-- Guided Tour -->
<div class="center-content" [style.display]="!isVisible && isAuthenticated()? 'block' : 'none'" (click)="restartTour()">
{{ 'GENERAL.ACTIONS.TAKE-A-TOUR'| translate }}
</div>
<!-- End of Guided Tour -->
<!-- Search Filter-->
<mat-form-field appearance="outline" class="search-form ml-auto col-auto pr-0" floatLabel="never">
<mat-icon matSuffix>search</mat-icon>

View File

@ -241,6 +241,19 @@
padding: 0.3rem 0rem 0.6rem 0rem !important;
}
.center-content {
width: 100%;
min-width: 10rem;
margin: auto;
padding: 0 15px;
text-align: right;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.02rem;
color: #2D72D6;
cursor: pointer;
}
// .bot-paginator {
// margin-top: auto;
// }

View File

@ -26,7 +26,8 @@ import { AuthService } from '@app/core/services/auth/auth.service';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { DmpCriteriaDialogComponent } from './criteria/dmp-criteria-dialog.component';
import { RecentActivityOrder } from '@app/core/common/enum/recent-activity-order';
import { GuidedTourService } from '@app/library/guided-tour/guided-tour.service';
import { GuidedTour, Orientation } from '@app/library/guided-tour/guided-tour.constants';
@Component({
selector: 'app-dmp-listing-component',
@ -63,6 +64,8 @@ export class DmpListingComponent extends BaseComponent implements OnInit, IBread
scrollbar: boolean;
order = RecentActivityOrder;
dmpText: string;
datasetText: string;
constructor(
private dmpService: DmpService,
@ -73,7 +76,8 @@ export class DmpListingComponent extends BaseComponent implements OnInit, IBread
private language: TranslateService,
private grantService: GrantService,
private uiNotificationService: UiNotificationService,
private authService: AuthService
private authService: AuthService,
private guidedTourService: GuidedTourService
) {
super();
}
@ -173,6 +177,26 @@ export class DmpListingComponent extends BaseComponent implements OnInit, IBread
.subscribe(x => this.refresh());
}
public dashboardTour: GuidedTour = {
tourId: 'dmp-dataset-tour',
useOrb: true,
steps: [
{
selector: '.dmp-tour',
content: 'Step 1',
orientation: Orientation.Right
},
{
selector: '.dataset-tour',
content: 'Step 2',
orientation: Orientation.Right
}
]
};
public isAuthenticated(): boolean {
return !(!this.authService.current());
}
ngAfterContentChecked(): void {
this.scrollbar = this.hasScrollbar();
}
@ -426,6 +450,28 @@ export class DmpListingComponent extends BaseComponent implements OnInit, IBread
}
return merged;
}
public setDashboardTourDmpText(): void {
this.dmpText = this.language.instant('DMP-LISTING.TEXT-INFO') + '\n\n' +
this.language.instant('DMP-LISTING.TEXT-INFO-QUESTION') + ' ' +
this.language.instant('DMP-LISTING.LINK-ZENODO') + ' ' +
this.language.instant('DMP-LISTING.GET-IDEA');
this.dashboardTour.steps[0].title = this.dmpText;
}
public setDashboardTourDatasetText(): void {
this.datasetText = this.language.instant('DATASET-LISTING.TEXT-INFO') +
this.language.instant('DATASET-LISTING.LINK-PUBLIC-DATASETS') + ' ' +
this.language.instant('DATASET-LISTING.TEXT-INFO-REST') + '\n\n' +
this.language.instant('DATASET-LISTING.TEXT-INFO-PAR');
this.dashboardTour.steps[1].title = this.datasetText;
}
public restartTour(): void {
this.setDashboardTourDmpText();
this.setDashboardTourDatasetText();
this.guidedTourService.startTour(this.dashboardTour);
}
}
// export class DmpDataSource extends DataSource<DmpListingModel> {

View File

@ -5,7 +5,7 @@
<hr *ngIf="!firstGroup">
<mat-list-item routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" *ngFor="let groupMenuRoute of groupMenuItem.routes; let first = first" class="nav-item"
[ngClass]="{'mt-4': first && firstGroup}">
<a class="nav-link nav-row" [routerLink]="[groupMenuRoute.path]">
<a class="nav-link nav-row" [routerLink]="[groupMenuRoute.path]" [ngClass]="{'dmp-tour': groupMenuRoute.path == '/plans', 'dataset-tour' : groupMenuRoute.path == '/datasets'}">
<i class="material-icons icon">{{ groupMenuRoute.icon }}</i>
<i *ngIf="groupMenuRoute.path == '/plans' || groupMenuRoute.path == '/datasets'" class="material-icons icon-mask">person</i>
<span [ngClass]="{'pl-0': groupMenuRoute.path == '/plans' || groupMenuRoute.path == '/datasets'}">{{groupMenuRoute.title | translate}}</span>

View File

@ -79,7 +79,8 @@
"VIEW-ALL": "Alles anzeigen",
"SHOW-MORE": "Mehr anzeigen",
"SHOW-LESS": "Weniger anzeigen",
"LOG-IN": "Einloggen"
"LOG-IN": "Einloggen",
"TAKE-A-TOUR": "Do you need help? Take a tour.."
},
"PREPOSITIONS": {
"OF": "von"

View File

@ -81,7 +81,8 @@
"SHOW-MORE": "Show more",
"LOAD-MORE": "Load more",
"SHOW-LESS": "Show less",
"LOG-IN": "Log in"
"LOG-IN": "Log in",
"TAKE-A-TOUR": "Do you need help? Take a tour.."
},
"PREPOSITIONS": {
"OF": "of"

View File

@ -80,7 +80,8 @@
"VIEW-ALL": "Ver todo",
"SHOW-MORE": "Mostrar más",
"SHOW-LESS": "Show less",
"LOG-IN": "Iniciar sesión"
"LOG-IN": "Iniciar sesión",
"TAKE-A-TOUR": "Do you need help? Take a tour.."
},
"PREPOSITIONS": {
"OF": "de"

View File

@ -80,7 +80,8 @@
"VIEW-ALL": "Προβολή όλων",
"SHOW-MORE": "Δείτε περισσότερα",
"SHOW-LESS": "Δείτε λιγότερα",
"LOG-IN": "Σύνδεση"
"LOG-IN": "Σύνδεση",
"TAKE-A-TOUR": "Do you need help? Take a tour.."
},
"PREPOSITIONS": {
"OF": "of"
@ -1239,9 +1240,7 @@
"SEARCH": "ΑΝΑΖΗΤΗΣΗ...",
"DATA-MANAGEMENT-PLANS": "ΣΧΕΔΙΑ ΔΙΑΧΕΙΡΙΣΗΣ ΔΕΔΟΜΕΝΩΝ",
"PERSONAL-USAGE": "Προσωπική Χρήση",
"DMPS": "DMPs",
"DATASET-DESCRIPTIONS": "Περιγραφές Dataset",
"GRANTS": "Grants",
"RELATED-ORGANISATIONS": "Σχετικοί Οργανισμοί",
"DRAFTS": "Προσχέδια",
"ALL": "Όλα",

View File

@ -79,7 +79,8 @@
"VIEW-ALL": "Tümüne Gör",
"SHOW-MORE": "Daha fazla göster",
"SHOW-LESS": "Daha az göster",
"LOG-IN": "Oturum aç"
"LOG-IN": "Oturum aç",
"TAKE-A-TOUR": "Do you need help? Take a tour.."
},
"PREPOSITIONS": {
"OF": "nın"

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB