Refactor: BcfWpAttributeGroupComponent,

ViewerBridgeService, IFCViewerService + Add ViewpointsService
pull/8406/head
Aleix Suau 5 years ago committed by Wieland Lindenthal
parent 340bee2bd6
commit c8ed85f81a
  1. 4
      frontend/src/app/modules/bim/bcf/api/viewpoints/bcf-viewpoint.interface.ts
  2. 17
      frontend/src/app/modules/bim/bcf/bcf-viewer-bridge/revit-bridge.service.ts
  3. 11
      frontend/src/app/modules/bim/bcf/bcf-viewer-bridge/viewer-bridge.service.ts
  4. 4
      frontend/src/app/modules/bim/bcf/bcf-wp-attribute-group/bcf-new-wp-attribute-group.component.ts
  5. 2
      frontend/src/app/modules/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.html
  6. 234
      frontend/src/app/modules/bim/bcf/bcf-wp-attribute-group/bcf-wp-attribute-group.component.ts
  7. 86
      frontend/src/app/modules/bim/bcf/helper/viewpoints.service.ts
  8. 8
      frontend/src/app/modules/bim/bcf/openproject-bcf.module.ts
  9. 8
      frontend/src/app/modules/bim/ifc_models/ifc-base-view/event-handler/bcf-click-handler.ts
  10. 47
      frontend/src/app/modules/bim/ifc_models/ifc-viewer/ifc-viewer.service.ts

@ -3,7 +3,9 @@ export interface BcfViewpointInterface {
guid:string;
components:unknown;
bitmaps:unknown[];
snapshot:{ snapshot_type:string, snapshot_data:string };
orthogonal_camera?:unknown;
perspective_camera?:unknown;
snapshot:{ snapshot_type:string, snapshot_data:string };
clipping_planes?:unknown[];
lines?:unknown[];
}

@ -1,9 +1,11 @@
import {Injectable} from '@angular/core';
import {Injectable, Injector} from '@angular/core';
import {Observable, Subject} from "rxjs";
import {distinctUntilChanged, filter, first, mapTo} from "rxjs/operators";
import {BcfViewpointInterface} from "core-app/modules/bim/bcf/api/viewpoints/bcf-viewpoint.interface";
import {ViewerBridgeService} from "core-app/modules/bim/bcf/bcf-viewer-bridge/viewer-bridge.service";
import {input} from "reactivestates";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
declare global {
interface Window {
@ -19,7 +21,7 @@ export class RevitBridgeService extends ViewerBridgeService {
revitMessageReceived$ = this.revitMessageReceivedSource.asObservable();
constructor() {
constructor(readonly injector:Injector) {
super();
if (window.RevitBridge) {
@ -34,11 +36,11 @@ export class RevitBridgeService extends ViewerBridgeService {
}
}
viewerVisible() {
public viewerVisible() {
return this._ready$.getValueOr(false);
}
getViewpoint():Promise<any> {
public getViewpoint():Promise<any> {
const trackingId = this.newTrackingId();
this.sendMessageToRevit('ViewpointGenerationRequest', trackingId, '');
@ -62,8 +64,11 @@ export class RevitBridgeService extends ViewerBridgeService {
});
}
showViewpoint(data:BcfViewpointInterface) {
this.sendMessageToRevit('ShowViewpoint', this.newTrackingId(), JSON.stringify(data));
public showViewpoint(workPackage:WorkPackageResource, index:number) {
/* const viewPointResource = this.getViewPointResource(workPackage:WorkPackageResource, index:number);
this.getViewPointData$(viewpointHref)
.subscribe(viewpoint => this.sendMessageToRevit('ShowViewpoint', this.newTrackingId(), JSON.stringify(viewpoint))); */
}
sendMessageToRevit(messageType:string, trackingId:string, messagePayload?:any) {

@ -1,17 +1,24 @@
import {Injector, Injectable} from '@angular/core';
import {BcfViewpointInterface} from "core-app/modules/bim/bcf/api/viewpoints/bcf-viewpoint.interface";
import {Observable} from "rxjs";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
@Injectable()
export abstract class ViewerBridgeService {
constructor(readonly injector:Injector) {}
/**
* Get a viewpoint from the viewer
*/
abstract getViewpoint():Promise<BcfViewpointInterface>;
abstract getViewpoint$():Observable<BcfViewpointInterface>;
/**
* Show the given viewpoint JSON in the viewer
* @param viewpoint
*/
abstract showViewpoint(viewpoint:BcfViewpointInterface):void;
abstract showViewpoint(workPackage:WorkPackageResource, index:number):void;
/**
* Determine whether a viewer is present to ensure we can show viewpoints

@ -22,11 +22,11 @@ export class BcfNewWpAttributeGroupComponent extends BcfWpAttributeGroupComponen
}
// Disable show viewpoint functionality
showViewpoint(index:number) {
showViewpoint(workPackage:WorkPackageResource, index:number) {
return;
}
deleteViewpoint(index:number) {
deleteViewpoint(workPackage:WorkPackageResource, index:number) {
this.setViewpoints(
this.viewpoints.filter((_, i) => i !== index)
);

@ -20,7 +20,7 @@
<a *ngIf="viewerVisible && createAllowed"
[title]="text.add_viewpoint"
class="button"
(click)="saveCurrentAsViewpoint()">
(click)="saveCurrentAsViewpoint(workPackage)">
<op-icon icon-classes="button--icon icon-add"></op-icon>
<span class="button--text"> {{text.viewpoint}} </span>
</a>

@ -23,6 +23,8 @@ import {NotificationsService} from "core-app/modules/common/notifications/notifi
import {BcfViewpointInterface} from "core-app/modules/bim/bcf/api/viewpoints/bcf-viewpoint.interface";
import {WorkPackageCreateService} from "core-components/wp-new/wp-create.service";
import {BcfAuthorizationService} from "core-app/modules/bim/bcf/api/bcf-authorization.service";
import {ViewpointsService} from "core-app/modules/bim/bcf/helper/viewpoints.service";
export interface ViewpointItem {
/** The URL of the viewpoint, if persisted */
@ -132,7 +134,8 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements
readonly wpCreate:WorkPackageCreateService,
readonly notifications:NotificationsService,
readonly cdRef:ChangeDetectorRef,
readonly I18n:I18nService) {
readonly I18n:I18nService,
readonly viewpointsService:ViewpointsService) {
super();
}
@ -141,29 +144,71 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements
this.observeChanges();
}
showViewpoint(index:number) {
this
protected observeChanges() {
this.wpCache
.observe(this.workPackage.id!)
.pipe(
this.untilDestroyed()
)
.subscribe(async wp => {
this.workPackage = wp;
//this.setTopicUUIDFromWorkPackage();
const projectId = this.workPackage.project.idFromLink;
this.viewAllowed = await this.bcfAuthorization.isAllowedTo(projectId, 'project_actions', 'viewTopic');
this.createAllowed = await this.bcfAuthorization.isAllowedTo(projectId, 'topic_actions', 'createViewpoint');
if (wp.bcfViewpoints) {
this.viewpoints = wp.bcfViewpoints.map((el:HalLink) => {
return { href: el.href, snapshotURL: `${el.href}/snapshot` };
});
this.setViewpointsOnGallery(this.viewpoints);
this.loadViewpointFromRoute(this.workPackage);
}
this.cdRef.detectChanges();
});
}
protected showViewpoint(workPackage:WorkPackageResource, index:number) {
this.viewerBridge.showViewpoint(workPackage, index);
/* this
.viewpointFromIndex(index)
.get()
.subscribe(data => {
if (this.viewerVisible) {
this.viewerBridge.showViewpoint(data);
} else {
// Send a message to Revit, waiting for response
// TODO: Show feedback to the user (Trying to communicate with Revit...)
// TODO: Check if there is a 'model-loaded' event
// SKIP this on PLUGINS SCENARIO
window.location.href = this.pathHelper.bimDetailsPath(
this.workPackage.project.idFromLink,
this.workPackage.id!,
index
);
}
});
}); */
}
deleteViewpoint(index:number) {
protected deleteViewpoint(workPackage:WorkPackageResource, index:number) {
if (!window.confirm(this.text.text_are_you_sure)) {
return;
}
this
this.viewpointsService
.deleteViewPoint$(workPackage, index)
.subscribe(data => {
// Update the work package to reload the viewpoint
this.notifications.addSuccess(this.text.notice_successful_delete);
this.wpCache.require(this.workPackage.id!, true);
this.gallery.preview.close();
});
/* this
.viewpointFromIndex(index)
.delete()
.subscribe(data => {
@ -171,62 +216,47 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements
this.notifications.addSuccess(this.text.notice_successful_delete);
this.wpCache.require(this.workPackage.id!, true);
this.gallery.preview.close();
});
}); */
}
async saveCurrentAsViewpoint() {
const viewpoint = await this.viewerBridge!.getViewpoint();
await this.persistViewpoint(viewpoint);
public saveCurrentAsViewpoint(workPackage:WorkPackageResource) {
this.viewpointsService
.saveCurrentAsViewpoint$(workPackage)
.subscribe(response => {
console.log('Type this response', response);
// Update the work package to reload the viewpoint
this.notifications.addSuccess(this.text.notice_successful_create);
this.showIndex = this.viewpoints.length;
this.wpCache.require(this.workPackage.id!, true);
}
});
galleryPreviewOpen():void {
jQuery('#top-menu').addClass('-no-z-index');
}
/* const viewpoint = await this.viewerBridge!.getViewpoint();
galleryPreviewClose():void {
jQuery('#top-menu').removeClass('-no-z-index');
}
await this.persistViewpoint(viewpoint);
selectViewpointInGallery() {
setTimeout(() => this.gallery?.show(this.showIndex), 250);
// Update the work package to reload the viewpoint
this.notifications.addSuccess(this.text.notice_successful_create);
this.showIndex = this.viewpoints.length;
this.wpCache.require(this.workPackage.id!, true); */
}
onGalleryChanged(event:{ index:number }) {
this.showIndex = event.index;
protected loadViewpointFromRoute(workPackage:WorkPackageResource) {
if (typeof (this.state.params.viewpoint) === 'number') {
const index = this.state.params.viewpoint;
this.showViewpoint(workPackage, index);
this.showIndex = index;
this.selectViewpointInGallery();
this.state.go('.', { ...this.state.params, viewpoint: undefined }, { reload: false });
}
protected observeChanges() {
this.wpCache
.observe(this.workPackage.id!)
.pipe(
this.untilDestroyed()
)
.subscribe(async wp => {
this.workPackage = wp;
this.setTopicUUIDFromWorkPackage();
const projectId = this.workPackage.project.idFromLink;
this.viewAllowed = await this.bcfAuthorization.isAllowedTo(projectId, 'project_actions', 'viewTopic');
this.createAllowed = await this.bcfAuthorization.isAllowedTo(projectId, 'topic_actions', 'createViewpoint');
if (wp.bcfViewpoints) {
this.setViewpoints(wp.bcfViewpoints.map((el:HalLink) => {
return { href: el.href, snapshotURL: `${el.href}/snapshot` };
}));
this.loadViewpointFromRoute();
}
this.cdRef.detectChanges();
});
public shouldShowGroup() {
return this.viewAllowed &&
(this.viewpoints.length > 0 ||
(this.createAllowed && this.viewerVisible));
}
protected async persistViewpoint(viewpoint:BcfViewpointInterface) {
/* protected async persistViewpoint(viewpoint:BcfViewpointInterface) {
this.topicUUID = this.topicUUID || await this.createBcfTopic();
return this.bcfApi
@ -235,95 +265,95 @@ export class BcfWpAttributeGroupComponent extends UntilDestroyedMixin implements
.viewpoints
.post(viewpoint)
.toPromise();
}
} */
protected set showIndex(value:number) {
const options = [...this.galleryOptions];
options[0].startIndex = value;
this.galleryOptions = options;
}
protected get showIndex():number {
return this.galleryOptions[0].startIndex!;
}
protected setTopicUUIDFromWorkPackage() {
/* protected setTopicUUIDFromWorkPackage() {
const topicHref:string|undefined = this.workPackage.bcfTopic?.href;
if (topicHref) {
this.topicUUID = this.bcfApi.parse<BcfViewpointPaths>(topicHref)!.id as string;
}
}
} */
protected async createBcfTopic():Promise<string> {
/* protected async createBcfTopic():Promise<string> {
return this.bcfApi
.projects.id(this.wpProjectId)
.topics
.post(this.workPackage.convertBCF.payload)
.toPromise()
.then(resource => resource.guid);
}
protected setViewpoints(viewpoints:ViewpointItem[]) {
const length = viewpoints.length;
this.setThumbnailProperties(length);
if (this.showIndex < 0 || length < 1) {
this.showIndex = 0;
} else if (this.showIndex >= length) {
this.showIndex = length - 1;
}
this.viewpoints = viewpoints;
this.galleryImages = viewpoints.map(viewpoint => {
return {
small: viewpoint.snapshotURL,
medium: viewpoint.snapshotURL,
big: viewpoint.snapshotURL
};
});
this.cdRef.detectChanges();
}
} */
protected viewpointFromIndex(index:number):BcfViewpointPaths {
/* protected viewpointFromIndex(index:number):BcfViewpointPaths {
let viewpointHref = this.workPackage.bcfViewpoints[index].href;
return this.bcfApi.parse<BcfViewpointPaths>(viewpointHref);
}
} */
protected loadViewpointFromRoute() {
if (typeof (this.state.params.viewpoint) === 'number') {
const index = this.state.params.viewpoint;
this.showViewpoint(index);
this.showIndex = index;
this.selectViewpointInGallery();
this.state.go('.', { ...this.state.params, viewpoint: undefined }, { reload: false });
}
}
/* protected get wpProjectId() {
return this.workPackage.project.idFromLink;
} */
// Gallery functionality
protected actions() {
return [
{
icon: 'icon-view-model',
onClick: (evt:any, index:number) => this.showViewpoint(index),
onClick: (evt: any, index: number) => this.showViewpoint(this.workPackage, index),
titleText: this.text.show_viewpoint
},
{
icon: 'icon-delete',
onClick: (evt:any, index:number) => this.deleteViewpoint(index),
onClick: (evt:any, index:number) => this.deleteViewpoint(this.workPackage, index),
titleText: this.text.delete_viewpoint
}
];
}
protected get wpProjectId() {
return this.workPackage.project.idFromLink;
public galleryPreviewOpen():void {
jQuery('#top-menu').addClass('-no-z-index');
}
shouldShowGroup() {
return this.viewAllowed &&
(this.viewpoints.length > 0 ||
(this.createAllowed && this.viewerVisible));
public galleryPreviewClose():void {
jQuery('#top-menu').removeClass('-no-z-index');
}
public selectViewpointInGallery() {
setTimeout(() => this.gallery?.show(this.showIndex), 250);
}
public onGalleryChanged(event:{ index:number }) {
this.showIndex = event.index;
}
protected set showIndex(value:number) {
const options = [...this.galleryOptions];
options[0].startIndex = value;
this.galleryOptions = options;
}
protected get showIndex():number {
return this.galleryOptions[0].startIndex!;
}
protected setViewpointsOnGallery(viewpoints:ViewpointItem[]) {
const length = viewpoints.length;
this.setThumbnailProperties(length);
if (this.showIndex < 0 || length < 1) {
this.showIndex = 0;
} else if (this.showIndex >= length) {
this.showIndex = length - 1;
}
this.galleryImages = viewpoints.map(viewpoint => {
return {
small: viewpoint.snapshotURL,
medium: viewpoint.snapshotURL,
big: viewpoint.snapshotURL
};
});
this.cdRef.detectChanges();
}
protected setThumbnailProperties(viewpointCount:number) {

@ -0,0 +1,86 @@
import {Injectable, Injector} from '@angular/core';
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {BcfApiService} from "core-app/modules/bim/bcf/api/bcf-api.service";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {BcfViewpointPaths} from "core-app/modules/bim/bcf/api/viewpoints/bcf-viewpoint.paths";
import {ViewerBridgeService} from "core-app/modules/bim/bcf/bcf-viewer-bridge/viewer-bridge.service";
import {switchMap, map} from 'rxjs/operators';
import {of, forkJoin, Observable} from 'rxjs';
import {BcfViewpointInterface} from "core-app/modules/bim/bcf/api/viewpoints/bcf-viewpoint.interface";
@Injectable()
export class ViewpointsService {
@InjectField() bcfApi:BcfApiService;
@InjectField() viewerBridge:ViewerBridgeService;
constructor(readonly injector:Injector) {
console.log('this.bcfApi: ', this.bcfApi);
}
public getViewPointResource(workPackage:WorkPackageResource, index:number):BcfViewpointPaths {
const viewpointHref = workPackage.bcfViewpoints[index].href;
return this.bcfApi.parse<BcfViewpointPaths>(viewpointHref);
}
public getViewPoint$(workPackage:WorkPackageResource, index:number):Observable<BcfViewpointInterface> {
const viewpointResource = this.getViewPointResource(workPackage, index);
return viewpointResource.get();
}
public deleteViewPoint$(workPackage:WorkPackageResource, index:number):Observable<BcfViewpointInterface> {
const viewpointResource = this.getViewPointResource(workPackage, index);
return viewpointResource.delete();
}
public saveCurrentAsViewpoint$(workPackage:WorkPackageResource): Observable<BcfViewpointInterface> {
const wpProjectId = workPackage.project.idFromLink;
const topicHref = workPackage.bcfTopic?.href;
const topicUUID$ = topicHref ?
of(this.bcfApi.parse<BcfViewpointPaths>(topicHref)!.id) :
this.createBcfTopic$(workPackage);
return forkJoin({
topicUUID: topicUUID$,
viewpoint: this.viewerBridge!.getViewpoint$(),
})
.pipe(
switchMap(results => this.bcfApi
.projects.id(wpProjectId)
.topics.id(results.topicUUID as (string | number))
.viewpoints
.post(results.viewpoint))
);
/* const viewpoint = await this.viewerBridge!.getViewpoint();
const topicUUID = workPackage.bcfTopic?.href ?
this.bcfApi.parse<BcfViewpointPaths>(topicHref)!.id :
this.createBcfTopic();
const wpProjectId = workPackage.project.idFromLink;
return this.bcfApi
.projects.id(wpProjectId)
.topics.id(topicUUID)
.viewpoints
.post(viewpoint); */
}
protected createBcfTopic$(workPackage:WorkPackageResource):Observable<string> {
const wpProjectId = workPackage.project.idFromLink;
const wpPayload = workPackage.convertBCF.payload
return this.bcfApi
.projects.id(wpProjectId)
.topics
.post(wpPayload)
.pipe(
map((resource: BcfViewpointInterface) => {
console.log('Type this: ', resource);
return resource.guid;
})
);
}
}

@ -35,6 +35,7 @@ import {HTTP_INTERCEPTORS} from "@angular/common/http";
import {OpenProjectHeaderInterceptor} from "core-app/modules/hal/http/openproject-header-interceptor";
import {BcfDetectorService} from "core-app/modules/bim/bcf/helper/bcf-detector.service";
import {BcfPathHelperService} from "core-app/modules/bim/bcf/helper/bcf-path-helper.service";
import {ViewpointsService} from "core-app/modules/bim/bcf/helper/viewpoints.service";
import {BcfImportButtonComponent} from "core-app/modules/bim/ifc_models/toolbar/import-export-bcf/bcf-import-button.component";
import {BcfExportButtonComponent} from "core-app/modules/bim/ifc_models/toolbar/import-export-bcf/bcf-export-button.component";
import {RevitBridgeService} from "core-app/modules/bim/bcf/bcf-viewer-bridge/revit-bridge.service";
@ -53,9 +54,9 @@ import {BcfNewWpAttributeGroupComponent} from "core-app/modules/bim/bcf/bcf-wp-a
*/
export const viewerBridgeServiceFactory = (injector:Injector) => {
if (window.navigator.userAgent.search('Revit') > -1) {
return new RevitBridgeService();
return new RevitBridgeService(injector);
} else {
return injector.get(IFCViewerService, new IFCViewerService());
return injector.get(IFCViewerService, new IFCViewerService(injector));
}
};
@ -72,7 +73,8 @@ export const viewerBridgeServiceFactory = (injector:Injector) => {
deps: [Injector]
},
BcfDetectorService,
BcfPathHelperService
BcfPathHelperService,
ViewpointsService,
],
declarations: [
BcfWpAttributeGroupComponent,

@ -16,13 +16,7 @@ export class BcfClickHandler extends CardClickHandler {
// Open the viewpoint if any
if (this.viewer.viewerVisible() && wp.bcfViewpoints) {
const first = wp.bcfViewpoints[0].href;
const resource = this.bcfApi.parse(first) as BcfViewpointPaths;
resource
.get()
.subscribe((viewpoint) => {
this.viewer.showViewpoint(viewpoint);
});
this.viewer.showViewpoint(wp, 0);
}
}
}

@ -1,8 +1,15 @@
import {Injectable} from '@angular/core';
import {Injectable, Inject, Injector} from '@angular/core';
import {XeokitServer} from "core-app/modules/bim/ifc_models/xeokit/xeokit-server";
import {BcfViewpointInterface} from "core-app/modules/bim/bcf/api/viewpoints/bcf-viewpoint.interface";
import {ViewerBridgeService} from "core-app/modules/bim/bcf/bcf-viewer-bridge/viewer-bridge.service";
import {Observable, Subject} from "rxjs";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {BcfApiService} from "core-app/modules/bim/bcf/api/bcf-api.service";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {ViewpointsService} from "core-app/modules/bim/bcf/helper/viewpoints.service";
import {of} from 'rxjs';
export interface XeokitElements {
canvasElement:HTMLElement;
@ -26,10 +33,22 @@ export interface BCFLoadOptions {
@Injectable()
export class IFCViewerService extends ViewerBridgeService {
@InjectField() pathHelper:PathHelperService;
@InjectField() bcfApi:BcfApiService;
@InjectField() viewpointsService:ViewpointsService;
private _viewer:any;
// private _loaded:BehaviorSubject<boolean> = new BehaviorSubject(false);
// readonly loaded$:Observable<boolean> = this._loaded.asObservable().pipe(shareReplay({refCount: true, bufferSize: 1}));
private $loaded = new Subject<void>();
constructor(readonly injector:Injector){
super(injector);
console.log('IFCViewerService constructor called', this.pathHelper, this.bcfApi);
}
public newViewer(elements:XeokitElements, projects:any[]) {
import('@xeokit/xeokit-bim-viewer/dist/main').then((XeokitViewerModule:any) => {
let server = new XeokitServer();
@ -70,23 +89,41 @@ export class IFCViewerService extends ViewerBridgeService {
this.viewer.setKeyboardEnabled(val);
}
public getViewpoint():Promise<BcfViewpointInterface> {
public getViewpoint$():Observable<BcfViewpointInterface> {
const viewpoint = this.viewer.saveBCFViewpoint({ spacesVisible: true });
// The backend rejects viewpoints with bitmaps
delete viewpoint.bitmaps;
return Promise.resolve(viewpoint);
return of(viewpoint);
}
public showViewpoint(viewpoint:BcfViewpointInterface) {
this.viewer.loadBCFViewpoint(viewpoint, {});
public showViewpoint(workPackage:WorkPackageResource, index:number) {
console.log('showViewpoint 1:', workPackage, workPackage.project.identifier, index, this.viewer, this.pathHelper, this.bcfApi, this.pathHelper.bimDetailsPath(
workPackage.project.idFromLink,
workPackage.id!,
index
));
if (this.viewer) {
this.viewpointsService
.getViewPoint$(workPackage, index)
.subscribe(viewpoint => this.viewer.loadBCFViewpoint(viewpoint, {}));
} else {
// Reload the whole app to get the correct menus and GON data
window.location.href = this.pathHelper.bimDetailsPath(
workPackage.project.idFromLink,
workPackage.id!,
index
);
}
}
public viewerVisible():boolean {
return !!this.viewer;
}
// TODO: Remove this?
public onLoad$():Observable<void> {
return this.$loaded;
}

Loading…
Cancel
Save