diff --git a/client/package.json b/client/package.json index dbef1d20f..672baec50 100644 --- a/client/package.json +++ b/client/package.json @@ -41,6 +41,7 @@ "@tinymce/tinymce-angular": "^2.3.1", "angular-tree-component": "^8.0.1", "core-js": "^2.5.4", + "css-element-queries": "^1.1.1", "file-saver": "^2.0.0", "material-design-icons": "^3.0.1", "ngx-file-drop": "^5.0.0", diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts index 2a307ca82..4f451f74b 100644 --- a/client/src/app/app-routing.module.ts +++ b/client/src/app/app-routing.module.ts @@ -7,6 +7,7 @@ import { LoginLegalNoticeComponent } from './site/login/components/login-legal-n import { LoginPrivacyPolicyComponent } from './site/login/components/login-privacy-policy/login-privacy-policy.component'; import { ResetPasswordComponent } from './site/login/components/reset-password/reset-password.component'; import { ResetPasswordConfirmComponent } from './site/login/components/reset-password-confirm/reset-password-confirm.component'; +import { FullscreenProjectorComponent } from './site/projector/components/fullscreen-projector/fullscreen-projector.component'; /** * Global app routing @@ -23,8 +24,8 @@ const routes: Routes = [ { path: 'privacypolicy', component: LoginPrivacyPolicyComponent } ] }, - - { path: 'projector', loadChildren: './projector-container/projector-container.module#ProjectorContainerModule' }, + { path: 'projector', component: FullscreenProjectorComponent }, + { path: 'projector/:id', component: FullscreenProjectorComponent }, { path: '', loadChildren: './site/site.module#SiteModule' }, { path: '**', redirectTo: '' } ]; diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index 631b827e3..8c0a9f0f5 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -15,6 +15,8 @@ import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { PruningTranslationLoader } from './core/pruning-loader'; import { LoginModule } from './site/login/login.module'; import { AppLoadService } from './core/services/app-load.service'; +import { ProjectorModule } from './site/projector/projector.module'; +import { SlidesModule } from './slides/slides.module'; // PWA import { ServiceWorkerModule } from '@angular/service-worker'; @@ -60,7 +62,9 @@ export function AppLoaderFactory(appLoadService: AppLoadService): () => Promise< CoreModule, LoginModule, PapaParseModule, - ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }) + ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }), + ProjectorModule, + SlidesModule.forRoot() ], providers: [{ provide: APP_INITIALIZER, useFactory: AppLoaderFactory, deps: [AppLoadService], multi: true }], bootstrap: [AppComponent] diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts index 7c7745185..779933867 100644 --- a/client/src/app/core/core.module.ts +++ b/client/src/app/core/core.module.ts @@ -14,6 +14,7 @@ import { ViewportService } from './services/viewport.service'; import { PromptDialogComponent } from '../shared/components/prompt-dialog/prompt-dialog.component'; import { HttpService } from './services/http.service'; import { ChoiceDialogComponent } from '../shared/components/choice-dialog/choice-dialog.component'; +import { ProjectionDialogComponent } from 'app/shared/components/projection-dialog/projection-dialog.component'; /** Global Core Module. Contains all global (singleton) services * @@ -32,7 +33,7 @@ import { ChoiceDialogComponent } from '../shared/components/choice-dialog/choice ViewportService, WebsocketService ], - entryComponents: [PromptDialogComponent, ChoiceDialogComponent] + entryComponents: [PromptDialogComponent, ChoiceDialogComponent, ProjectionDialogComponent] }) export class CoreModule { /** make sure CoreModule is imported only by one NgModule, the AppModule */ diff --git a/client/src/app/core/services/app-load.service.ts b/client/src/app/core/services/app-load.service.ts index af4a764a3..7af9fc7c5 100644 --- a/client/src/app/core/services/app-load.service.ts +++ b/client/src/app/core/services/app-load.service.ts @@ -14,6 +14,7 @@ import { MainMenuService } from './main-menu.service'; import { HistoryAppConfig } from 'app/site/history/history.config'; import { SearchService } from './search.service'; import { isSearchable } from '../../shared/models/base/searchable'; +import { ProjectorAppConfig } from 'app/site/projector/projector.config'; /** * A list of all app configurations of all delivered apps. @@ -27,7 +28,8 @@ const appConfigs: AppConfig[] = [ MediafileAppConfig, TagAppConfig, UsersAppConfig, - HistoryAppConfig + HistoryAppConfig, + ProjectorAppConfig ]; /** diff --git a/client/src/app/core/services/projection-dialog.service.ts b/client/src/app/core/services/projection-dialog.service.ts new file mode 100644 index 000000000..7a2453a60 --- /dev/null +++ b/client/src/app/core/services/projection-dialog.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@angular/core'; + +import { OpenSlidesComponent } from 'app/openslides.component'; +import { Projectable } from 'app/site/base/projectable'; +import { MatDialog } from '@angular/material'; +import { + ProjectionDialogComponent, + ProjectionDialogReturnType +} from 'app/shared/components/projection-dialog/projection-dialog.component'; +import { ProjectorService } from './projector.service'; + +/** + * Manages the projection dialog. Projects the result of the user's choice. + */ +@Injectable({ + providedIn: 'root' +}) +export class ProjectionDialogService extends OpenSlidesComponent { + /** + * Constructor. + * + * @param dialog + * @param projectorService + */ + public constructor(private dialog: MatDialog, private projectorService: ProjectorService) { + super(); + } + + /** + * Opens the projection dialog for the given projectable. After the user's choice, + * the projectors will be updated. + * + * @param obj The projectable. + */ + public async openProjectDialogFor(obj: Projectable): Promise { + const dialogRef = this.dialog.open( + ProjectionDialogComponent, + { + minWidth: '500px', + maxHeight: '90vh', + data: obj + } + ); + const response = await dialogRef.afterClosed().toPromise(); + if (response) { + const [projectors, projectorElement]: ProjectionDialogReturnType = response; + this.projectorService.projectOn(projectors, projectorElement); + } + } +} diff --git a/client/src/app/core/services/projection-dilog.service.spec.ts b/client/src/app/core/services/projection-dilog.service.spec.ts new file mode 100644 index 000000000..d325ab3ff --- /dev/null +++ b/client/src/app/core/services/projection-dilog.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { ProjectionDialogService } from './projection-dialog.service'; + +describe('ProjectionDialogService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ProjectionDialogService] + }); + }); + + it('should be created', inject([ProjectionDialogService], (service: ProjectionDialogService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/core/services/projector.service.spec.ts b/client/src/app/core/services/projector.service.spec.ts new file mode 100644 index 000000000..b9c04e830 --- /dev/null +++ b/client/src/app/core/services/projector.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { ProjectorService } from './projector.service'; + +describe('ProjectorService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ProjectorService] + }); + }); + + it('should be created', inject([ProjectorService], (service: ProjectorService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/core/services/projector.service.ts b/client/src/app/core/services/projector.service.ts new file mode 100644 index 000000000..ac2be0f6c --- /dev/null +++ b/client/src/app/core/services/projector.service.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@angular/core'; + +import { OpenSlidesComponent } from 'app/openslides.component'; +import { Projectable } from 'app/site/base/projectable'; +import { DataStoreService } from './data-store.service'; +import { Projector, ProjectorElement } from 'app/shared/models/core/projector'; +import { DataSendService } from './data-send.service'; + +/** + * This service cares about Projectables being projected and manage all projection-related + * actions. + * + * We cannot access the ProjectorRepository here, so we will deal with plain projector objects. + */ +@Injectable({ + providedIn: 'root' +}) +export class ProjectorService extends OpenSlidesComponent { + /** + * Constructor. + * + * @param DS + * @param dataSend + */ + public constructor(private DS: DataStoreService, private dataSend: DataSendService) { + super(); + } + + /** + * Checks, if a given object is projected. + * + * @param obj The object in question + * @returns true, if the object is projected on one projector. + */ + public isProjected(obj: Projectable): boolean { + return this.DS.getAll('core/projector').some(projector => { + return projector.isElementShown(obj.getNameForSlide(), obj.getIdForSlide()); + }); + } + + /** + * Get all projectors where the object is prejected on. + * + * @param obj The object in question + * @return All projectors, where this Object is projected on + */ + public getProjectorsWhichAreProjecting(obj: Projectable): Projector[] { + return this.DS.getAll('core/projector').filter(projector => { + return projector.isElementShown(obj.getNameForSlide(), obj.getIdForSlide()); + }); + } + + /** + * Checks, if the object is projected on the given projector. + * + * @param obj The object + * @param projector The projector to test + * @returns true, if the object is projected on the projector. + */ + public isProjectedOn(obj: Projectable, projector: Projector): boolean { + return projector.isElementShown(obj.getNameForSlide(), obj.getIdForSlide()); + } + + /** + * Projects the given ProjectorElement on the given projectors. + * + * TODO: this does not care about the element being stable. Some more logic must be added later. + * + * On the given projectors: Delete all non-stable elements and add the given element. + * On all other projectors: If the element (compared with name and id) is there, it will be deleted. + * + * @param projectors All projectors where to add the element. + * @param element The element in question. + */ + public projectOn(projectors: Projector[], element: T): void { + const changedProjectors: Projector[] = []; + this.DS.getAll('core/projector').forEach(projector => { + if (projectors.includes(projector)) { + projector.removeAllNonStableElements(); + projector.addElement(element); + changedProjectors.push(projector); + } else if (projector.isElementShown(element.name, element.id)) { + projector.removeElementByNameAndId(element.name, element.id); + changedProjectors.push(projector); + } + }); + + // TODO: Use new 'project' route. + changedProjectors.forEach(projector => { + this.dataSend.updateModel(projector); + }); + } + + /** + * Given a projectiondefault, we want to retrieve the projector, that is assigned + * to this default. + * + * @param projectiondefault The projection default + * @return the projector associated to the given projectiondefault. + */ + public getProjectorForDefault(projectiondefault: string): Projector { + return this.DS.getAll('core/projector').find(projector => { + return projector.projectiondefaults.map(pd => pd.name).includes(projectiondefault); + }); + } +} diff --git a/client/src/app/core/services/viewport.service.ts b/client/src/app/core/services/viewport.service.ts index a00f7f719..d15c01bae 100644 --- a/client/src/app/core/services/viewport.service.ts +++ b/client/src/app/core/services/viewport.service.ts @@ -28,12 +28,18 @@ export class ViewportService { * Simple boolean to determine whether the client is in mobile view or not * Use in HTML with automatic change detection */ - public isMobile: boolean; + public get isMobile(): boolean { + return this._isMobileSubject.getValue(); + } + + private _isMobileSubject = new BehaviorSubject(false); /** * Returns a subject that contains whether the viewport os mobile or not */ - public isMobileSubject = new BehaviorSubject(false); + public get isMobileSubject(): BehaviorSubject { + return this._isMobileSubject; + } /** * Get the BreakpointObserver @@ -49,14 +55,6 @@ export class ViewportService { public checkForChange(): void { this.breakpointObserver .observe([Breakpoints.Handset, '(min-width: 600px) and (max-width: 899.99px)']) - .subscribe((state: BreakpointState) => { - if (state.matches) { - this.isMobile = true; - this.isMobileSubject.next(true); - } else { - this.isMobile = false; - this.isMobileSubject.next(false); - } - }); + .subscribe((state: BreakpointState) => this._isMobileSubject.next(state.matches)); } } diff --git a/client/src/app/core/services/websocket.service.ts b/client/src/app/core/services/websocket.service.ts index 55a4b29f7..4c2fcb818 100644 --- a/client/src/app/core/services/websocket.service.ts +++ b/client/src/app/core/services/websocket.service.ts @@ -43,6 +43,10 @@ export class WebsocketService { */ private _connectEvent: EventEmitter = new EventEmitter(); + private connectionOpen = false; + + private sendQueueWhileNotConnected: string[] = []; + /** * Getter for the connect event. */ @@ -122,6 +126,11 @@ export class WebsocketService { this._reconnectEvent.emit(); } this._connectEvent.emit(); + this.connectionOpen = true; + this.sendQueueWhileNotConnected.forEach(entry => { + this.websocket.send(entry); + }); + this.sendQueueWhileNotConnected = []; }); }; @@ -143,6 +152,7 @@ export class WebsocketService { this.websocket.onclose = (event: CloseEvent) => { this.zone.run(() => { this.websocket = null; + this.connectionOpen = false; if (event.code !== 1000) { // 1000 is a normal close, like the close on logout if (!this.connectionErrorNotice) { @@ -211,6 +221,13 @@ export class WebsocketService { message.id += possible.charAt(Math.floor(Math.random() * possible.length)); } } - this.websocket.send(JSON.stringify(message)); + + // Either send directly or add to queue, if not connected. + const jsonMessage = JSON.stringify(message); + if (this.connectionOpen) { + this.websocket.send(jsonMessage); + } else { + this.sendQueueWhileNotConnected.push(jsonMessage); + } } } diff --git a/client/src/app/projector-container/projector-container.component.html b/client/src/app/projector-container/projector-container.component.html deleted file mode 100644 index 89674c3e5..000000000 --- a/client/src/app/projector-container/projector-container.component.html +++ /dev/null @@ -1,4 +0,0 @@ -

- projector-container works! - Here an iframe with the real-projector is needed -

diff --git a/client/src/app/projector-container/projector-container.component.spec.ts b/client/src/app/projector-container/projector-container.component.spec.ts deleted file mode 100644 index ce8a1256a..000000000 --- a/client/src/app/projector-container/projector-container.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ProjectorContainerComponent } from './projector-container.component'; - -describe('ProjectorContainerComponent', () => { - let component: ProjectorContainerComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ProjectorContainerComponent] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(ProjectorContainerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/client/src/app/projector-container/projector-container.component.ts b/client/src/app/projector-container/projector-container.component.ts deleted file mode 100644 index 4e83019b3..000000000 --- a/client/src/app/projector-container/projector-container.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -@Component({ - selector: 'os-projector-container', - templateUrl: './projector-container.component.html', - styleUrls: ['./projector-container.component.css'] -}) -export class ProjectorContainerComponent implements OnInit { - public constructor() {} - - public ngOnInit(): void {} -} diff --git a/client/src/app/projector-container/projector-container.module.spec.ts b/client/src/app/projector-container/projector-container.module.spec.ts deleted file mode 100644 index 8c33ae21c..000000000 --- a/client/src/app/projector-container/projector-container.module.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ProjectorContainerModule } from './projector-container.module'; - -describe('ProjectorContainerModule', () => { - let projectorContainerModule: ProjectorContainerModule; - - beforeEach(() => { - projectorContainerModule = new ProjectorContainerModule(); - }); - - it('should create an instance', () => { - expect(projectorContainerModule).toBeTruthy(); - }); -}); diff --git a/client/src/app/projector-container/projector-container.module.ts b/client/src/app/projector-container/projector-container.module.ts deleted file mode 100644 index 444277914..000000000 --- a/client/src/app/projector-container/projector-container.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -import { ProjectorContainerComponent } from './projector-container.component'; -import { SharedModule } from 'app/shared/shared.module'; -import { ProjectorComponent } from './projector/projector.component'; -import { ProjectorContainerRoutingModule } from './projector/projector-container.routing.module'; - -@NgModule({ - imports: [CommonModule, ProjectorContainerRoutingModule, SharedModule], - declarations: [ProjectorContainerComponent, ProjectorComponent] -}) -export class ProjectorContainerModule {} diff --git a/client/src/app/projector-container/projector/projector-container.routing.module.ts b/client/src/app/projector-container/projector/projector-container.routing.module.ts deleted file mode 100644 index 7d032420f..000000000 --- a/client/src/app/projector-container/projector/projector-container.routing.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; -import { ProjectorContainerComponent } from '../projector-container.component'; -import { ProjectorComponent } from './projector.component'; - -const routes: Routes = [ - { path: '', component: ProjectorContainerComponent }, - { path: 'real', component: ProjectorComponent } -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule] -}) -export class ProjectorContainerRoutingModule {} diff --git a/client/src/app/projector-container/projector/projector.component.css b/client/src/app/projector-container/projector/projector.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/src/app/projector-container/projector/projector.component.html b/client/src/app/projector-container/projector/projector.component.html deleted file mode 100644 index 71ac28cd0..000000000 --- a/client/src/app/projector-container/projector/projector.component.html +++ /dev/null @@ -1,3 +0,0 @@ -

- projector works! -

\ No newline at end of file diff --git a/client/src/app/projector-container/projector/projector.component.ts b/client/src/app/projector-container/projector/projector.component.ts deleted file mode 100644 index 4555de083..000000000 --- a/client/src/app/projector-container/projector/projector.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { BaseComponent } from 'app/base.component'; -import { Title } from '@angular/platform-browser'; -import { TranslateService } from '@ngx-translate/core'; - -@Component({ - selector: 'os-projector', - templateUrl: './projector.component.html', - styleUrls: ['./projector.component.css'] -}) -export class ProjectorComponent extends BaseComponent implements OnInit { - public constructor(titleService: Title, translate: TranslateService) { - super(titleService, translate); - } - - public ngOnInit(): void { - super.setTitle('Projector'); - } -} diff --git a/client/src/app/shared/components/projection-dialog/projection-dialog.component.html b/client/src/app/shared/components/projection-dialog/projection-dialog.component.html new file mode 100644 index 000000000..72af53280 --- /dev/null +++ b/client/src/app/shared/components/projection-dialog/projection-dialog.component.html @@ -0,0 +1,42 @@ +

{{ projectable.getTitle() }}

+ + + Projectors + +
+ + {{ projector.name | translate }} + + + videocam + +
+
+
+ + + Slide options + + +
+
+ + {{ option.displayName | translate }} + +
+
+

{{ option.displayName | translate }}

+ + + {{ choice.displayName | translate }} + + +
+
+
+
+
+ + + + diff --git a/client/src/app/shared/components/projection-dialog/projection-dialog.component.scss b/client/src/app/shared/components/projection-dialog/projection-dialog.component.scss new file mode 100644 index 000000000..e4a958438 --- /dev/null +++ b/client/src/app/shared/components/projection-dialog/projection-dialog.component.scss @@ -0,0 +1,19 @@ +mat-dialog-content { + overflow: inherit; + + div.projectors { + padding: 15px; + + &.projected { + background-color: lightblue; + } + + .right { + float: right; + } + } + + mat-card { + margin-bottom: 10px; + } +} diff --git a/client/src/app/shared/components/projection-dialog/projection-dialog.component.spec.ts b/client/src/app/shared/components/projection-dialog/projection-dialog.component.spec.ts new file mode 100644 index 000000000..84163c7e6 --- /dev/null +++ b/client/src/app/shared/components/projection-dialog/projection-dialog.component.spec.ts @@ -0,0 +1,26 @@ +import { async, TestBed } from '@angular/core/testing'; + +// import { ProjectionDialogComponent } from './prjection-dialog.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('ProjectionDialogComponent', () => { + // let component: ProjectionDialogComponent; + // let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + // TODO: You cannot create this component in the standard way. Needs different testing. + beforeEach(() => { + /*fixture = TestBed.createComponent(ProjectionDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges();*/ + }); + + /*it('should create', () => { + expect(component).toBeTruthy(); + });*/ +}); diff --git a/client/src/app/shared/components/projection-dialog/projection-dialog.component.ts b/client/src/app/shared/components/projection-dialog/projection-dialog.component.ts new file mode 100644 index 000000000..16812603b --- /dev/null +++ b/client/src/app/shared/components/projection-dialog/projection-dialog.component.ts @@ -0,0 +1,96 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { Projectable } from 'app/site/base/projectable'; +import { DataStoreService } from 'app/core/services/data-store.service'; +import { Projector, ProjectorElement } from 'app/shared/models/core/projector'; +import { ProjectorService } from 'app/core/services/projector.service'; +import { + ProjectorOption, + isProjectorDecisionOption, + isProjectorChoiceOption, + ProjectorDecisionOption, + ProjectorChoiceOption, + ProjectorOptions +} from 'app/site/base/projector-options'; + +export type ProjectionDialogReturnType = [Projector[], ProjectorElement]; + +/** + */ +@Component({ + selector: 'os-projection-dialog', + templateUrl: './projection-dialog.component.html', + styleUrls: ['./projection-dialog.component.scss'] +}) +export class ProjectionDialogComponent { + public projectors: Projector[]; + private selectedProjectors: Projector[] = []; + public projectorElement: ProjectorElement; + public options: ProjectorOptions; + + public constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public projectable: Projectable, + private DS: DataStoreService, + private projectorService: ProjectorService + ) { + this.projectors = this.DS.getAll('core/projector'); + // TODO: Maybe watch. But this may not be necessary for the short living time of this dialog. + + this.selectedProjectors = this.projectorService.getProjectorsWhichAreProjecting(this.projectable); + + // Add default projector, if the projectable is not projected on it. + const defaultProjector: Projector = this.projectorService.getProjectorForDefault( + this.projectable.getProjectionDefaultName() + ); + if (!this.selectedProjectors.includes(defaultProjector)) { + this.selectedProjectors.push(defaultProjector); + } + + this.projectorElement = { + id: this.projectable.getIdForSlide(), + name: this.projectable.getNameForSlide(), + stable: this.projectable.isStableSlide() + }; + + // Set option defaults + this.projectable.getProjectorOptions().forEach(option => { + this.projectorElement[option.key] = option.default; + }); + + this.options = this.projectable.getProjectorOptions(); + } + + public toggleProjector(projector: Projector): void { + const index = this.selectedProjectors.indexOf(projector); + if (index < 0) { + this.selectedProjectors.push(projector); + } else { + this.selectedProjectors.splice(index, 1); + } + } + + public isProjectorSelected(projector: Projector): boolean { + return this.selectedProjectors.includes(projector); + } + + public isProjectedOn(projector: Projector): boolean { + return this.projectorService.isProjectedOn(this.projectable, projector); + } + + public isDecisionOption(option: ProjectorOption): option is ProjectorDecisionOption { + return isProjectorDecisionOption(option); + } + + public isChoiceOption(option: ProjectorOption): option is ProjectorChoiceOption { + return isProjectorChoiceOption(option); + } + + public onOk(): void { + this.dialogRef.close([this.selectedProjectors, this.projectorElement]); + } + + public onCancel(): void { + this.dialogRef.close(); + } +} diff --git a/client/src/app/shared/components/projector-button/projector-button.component.html b/client/src/app/shared/components/projector-button/projector-button.component.html new file mode 100644 index 000000000..ac70297d1 --- /dev/null +++ b/client/src/app/shared/components/projector-button/projector-button.component.html @@ -0,0 +1,3 @@ + diff --git a/client/src/app/projector-container/projector-container.component.css b/client/src/app/shared/components/projector-button/projector-button.component.scss similarity index 100% rename from client/src/app/projector-container/projector-container.component.css rename to client/src/app/shared/components/projector-button/projector-button.component.scss diff --git a/client/src/app/shared/components/projector-button/projector-button.component.spec.ts b/client/src/app/shared/components/projector-button/projector-button.component.spec.ts new file mode 100644 index 000000000..08c24275e --- /dev/null +++ b/client/src/app/shared/components/projector-button/projector-button.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { ProjectorButtonComponent } from './projector-button.component'; + +describe('ProjectorButtonComponent', () => { + let component: ProjectorButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectorButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/shared/components/projector-button/projector-button.component.ts b/client/src/app/shared/components/projector-button/projector-button.component.ts new file mode 100644 index 000000000..bdb1804e7 --- /dev/null +++ b/client/src/app/shared/components/projector-button/projector-button.component.ts @@ -0,0 +1,30 @@ +import { Component, OnInit, Input } from '@angular/core'; +import { Projectable } from 'app/site/base/projectable'; +import { ProjectionDialogService } from 'app/core/services/projection-dialog.service'; + +/** + */ +@Component({ + selector: 'os-projector-button', + templateUrl: './projector-button.component.html', + styleUrls: ['./projector-button.component.scss'] +}) +export class ProjectorButtonComponent implements OnInit { + @Input() + public object: Projectable; + + /** + * The consotructor + */ + public constructor(private projectionDialogService: ProjectionDialogService) {} + + /** + * Initialization function + */ + public ngOnInit(): void {} + + public onClick(event: Event): void { + event.stopPropagation(); + this.projectionDialogService.openProjectDialogFor(this.object); + } +} diff --git a/client/src/app/shared/directives/resized.directive.ts b/client/src/app/shared/directives/resized.directive.ts new file mode 100644 index 000000000..fde85f36a --- /dev/null +++ b/client/src/app/shared/directives/resized.directive.ts @@ -0,0 +1,59 @@ +import { OnInit, ElementRef, Directive, Input } from '@angular/core'; +import { ResizeSensor } from 'css-element-queries'; +import { Subject } from 'rxjs'; + +/** + * This directive takes a Subject as input and everytime the surrounding element + * was resized, the subject is fired. + * + * Usage: + * `
...content...
` + */ +@Directive({ + selector: '[osResized]' +}) +export class ResizedDirective implements OnInit { + @Input() + public osResized: Subject; + + /** + * Old width, to check, if the width has actually changed. + */ + private oldWidth: number; + + /** + * Old height, to check, if the height has actually changed. + */ + private oldHeight: number; + + public constructor(private element: ElementRef) {} + + /** + * Inits the ResizeSensor. triggers initial size change. + */ + public ngOnInit(): void { + // tslint:disable-next-line:no-unused-expression + new ResizeSensor(this.element.nativeElement, x => this.onSizeChanged()); + this.onSizeChanged(); + } + + /** + * The size has changed. Check, if the size actually hs changed. If so, + * trigger the given subject. + */ + private onSizeChanged(): void { + const newWidth = this.element.nativeElement.clientWidth; + const newHeight = this.element.nativeElement.clientHeight; + + if (newWidth === this.oldWidth && newHeight === this.oldHeight) { + return; + } + + this.oldWidth = newWidth; + this.oldHeight = newHeight; + + if (this.osResized) { + this.osResized.next(); + } + } +} diff --git a/client/src/app/shared/models/agenda/item.ts b/client/src/app/shared/models/agenda/item.ts index 1b64cf690..1bdadb796 100644 --- a/client/src/app/shared/models/agenda/item.ts +++ b/client/src/app/shared/models/agenda/item.ts @@ -1,5 +1,5 @@ -import { ProjectableBaseModel } from '../base/projectable-base-model'; import { Speaker, SpeakerState } from './speaker'; +import { BaseModel } from '../base/base-model'; /** * The representation of the content object for agenda items. The unique combination @@ -24,7 +24,7 @@ export const itemVisibilityChoices = [ * Representations of agenda Item * @ignore */ -export class Item extends ProjectableBaseModel { +export class Item extends BaseModel { public id: number; public item_number: string; public title: string; diff --git a/client/src/app/shared/models/base/agenda-base-model.ts b/client/src/app/shared/models/base/agenda-base-model.ts index d3b122c91..e1e7c9d96 100644 --- a/client/src/app/shared/models/base/agenda-base-model.ts +++ b/client/src/app/shared/models/base/agenda-base-model.ts @@ -1,13 +1,13 @@ import { AgendaInformation } from './agenda-information'; -import { ProjectableBaseModel } from './projectable-base-model'; import { Searchable } from './searchable'; import { SearchRepresentation } from '../../../core/services/search.service'; +import { BaseModel } from './base-model'; /** * A base model for models, that can be content objects in the agenda. Provides title and navigation * information for the agenda. */ -export abstract class AgendaBaseModel extends ProjectableBaseModel implements AgendaInformation, Searchable { +export abstract class AgendaBaseModel extends BaseModel implements AgendaInformation, Searchable { /** * A model that can be a content object of an agenda item. * @param collectionString diff --git a/client/src/app/shared/models/base/projectable-base-model.ts b/client/src/app/shared/models/base/projectable-base-model.ts deleted file mode 100644 index 7ee0bb370..000000000 --- a/client/src/app/shared/models/base/projectable-base-model.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { BaseModel } from './base-model'; -import { Projectable } from './projectable'; - -export abstract class ProjectableBaseModel extends BaseModel implements Projectable { - /** - * A model which can be projected. This class give basic implementation for the projector. - * - * @param collectionString - * @param verboseName - * @param input - */ - protected constructor(collectionString: string, verboseName: string, input?: any) { - super(collectionString, verboseName, input); - } - - /** - * This is a Dummy, which should be changed if the projector gets implemented. - */ - public project(): void {} - - /** - * @returns the projector title. - */ - public getProjectorTitle(): string { - return this.getTitle(); - } -} diff --git a/client/src/app/shared/models/base/projectable.ts b/client/src/app/shared/models/base/projectable.ts deleted file mode 100644 index d2f002f76..000000000 --- a/client/src/app/shared/models/base/projectable.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Interface for every model, that should be projectable. - */ -export interface Projectable { - /** - * Should return the title for the projector. - */ - getProjectorTitle(): string; - - /** - * Dummy. I don't know how the projctor system will be, so this function may change - */ - project(): void; -} diff --git a/client/src/app/shared/models/core/countdown.ts b/client/src/app/shared/models/core/countdown.ts index 66da83a9e..6a2541f7b 100644 --- a/client/src/app/shared/models/core/countdown.ts +++ b/client/src/app/shared/models/core/countdown.ts @@ -1,10 +1,10 @@ -import { ProjectableBaseModel } from '../base/projectable-base-model'; +import { BaseModel } from '../base/base-model'; /** * Representation of a countdown * @ignore */ -export class Countdown extends ProjectableBaseModel { +export class Countdown extends BaseModel { public id: number; public description: string; public default_time: number; diff --git a/client/src/app/shared/models/core/projector-message.ts b/client/src/app/shared/models/core/projector-message.ts index cdb086d28..2e7690c3f 100644 --- a/client/src/app/shared/models/core/projector-message.ts +++ b/client/src/app/shared/models/core/projector-message.ts @@ -1,10 +1,10 @@ -import { ProjectableBaseModel } from '../base/projectable-base-model'; +import { BaseModel } from '../base/base-model'; /** * Representation of a projector message. * @ignore */ -export class ProjectorMessage extends ProjectableBaseModel { +export class ProjectorMessage extends BaseModel { public id: number; public message: string; diff --git a/client/src/app/shared/models/core/projector.ts b/client/src/app/shared/models/core/projector.ts index 8164b35af..e0977627d 100644 --- a/client/src/app/shared/models/core/projector.ts +++ b/client/src/app/shared/models/core/projector.ts @@ -1,24 +1,104 @@ import { BaseModel } from '../base/base-model'; +/** + * A projectorelement must have a name and optional attributes. + * error is listed here, because this might be set by the server, if + * something is wrong and I want you to be sensible about this. + */ +export interface ProjectorElement { + /** + * The name of the element. + */ + name: string; + + /** + * An optional error. If this is set, this element is invalid, so + * DO NOT read additional data (name is save). + */ + error?: string; + + /** + * Additional data. + */ + [key: string]: any; +} + +/** + * Multiple elements. + */ +export type ProjectorElements = ProjectorElement[]; + +/** + * A projectiondefault + */ +export interface ProjectionDefault { + id: number; + name: string; + display_name: string; + projector_id: number; +} + /** * Representation of a projector. Has the nested property "projectiondefaults" * @ignore */ export class Projector extends BaseModel { public id: number; - public elements: Object; + public elements: ProjectorElements; public scale: number; public scroll: number; public name: string; - public blank: boolean; public width: number; public height: number; - public projectiondefaults: Object[]; + public projectiondefaults: ProjectionDefault[]; public constructor(input?: any) { super('core/projector', 'Projector', input); } + /** + * Returns true, if there is an element with the given name (and optionally + * an id). If the id is given, the element to search MUST have this id. + * + * @param name The name of the element + * @param id The optional id to check. + * @returns true, if there is at least one element with the given name (and id). + */ + public isElementShown(name: string, id?: number): boolean { + return this.elements.some(element => element.name === name && (!id || element.id === id)); + } + + /** + * Removes all elements, that do not have `stable=true`. + */ + public removeAllNonStableElements(): void { + this.elements = this.elements.filter(element => element.stable); + } + + /** + * Adds the given element to the projectorelements + * + * @param element The element to add. + */ + public addElement(element: T): void { + this.elements.push(element); + } + + /** + * Removes elements given by the name and optional id. If no id is given + * all elements with a matching name are removed. + * + * If an id is given, ut the element dies not specify an id, it will be removed. + * + * @param name The name to search + * @param id The optional id to search. + */ + public removeElementByNameAndId(name: string, id?: number): void { + this.elements = this.elements.filter( + element => element.name !== name || (!id && !element.id && element.id !== id) + ); + } + public getTitle(): string { return this.name; } diff --git a/client/src/app/shared/models/mediafiles/mediafile.ts b/client/src/app/shared/models/mediafiles/mediafile.ts index 97c90916c..f26684009 100644 --- a/client/src/app/shared/models/mediafiles/mediafile.ts +++ b/client/src/app/shared/models/mediafiles/mediafile.ts @@ -1,12 +1,12 @@ import { File } from './file'; -import { ProjectableBaseModel } from '../base/projectable-base-model'; import { Searchable } from '../base/searchable'; +import { BaseModel } from '../base/base-model'; /** * Representation of MediaFile. Has the nested property "File" * @ignore */ -export class Mediafile extends ProjectableBaseModel implements Searchable { +export class Mediafile extends BaseModel implements Searchable { public id: number; public title: string; public mediafile: File; diff --git a/client/src/app/shared/models/motions/motion.ts b/client/src/app/shared/models/motions/motion.ts index 48e96c726..b48250223 100644 --- a/client/src/app/shared/models/motions/motion.ts +++ b/client/src/app/shared/models/motions/motion.ts @@ -13,6 +13,8 @@ import { MotionPoll } from './motion-poll'; * @ignore */ export class Motion extends AgendaBaseModel { + public static COLLECTIONSTRING = 'motions/motion'; + public id: number; public identifier: string; public title: string; @@ -45,7 +47,7 @@ export class Motion extends AgendaBaseModel { public last_modified: string; public constructor(input?: any) { - super('motions/motion', 'Motion', input); + super(Motion.COLLECTIONSTRING, 'Motion', input); } /** diff --git a/client/src/app/shared/models/users/user.ts b/client/src/app/shared/models/users/user.ts index 9331dda0a..47f3e9b41 100644 --- a/client/src/app/shared/models/users/user.ts +++ b/client/src/app/shared/models/users/user.ts @@ -1,12 +1,14 @@ -import { ProjectableBaseModel } from '../base/projectable-base-model'; import { Searchable } from '../base/searchable'; import { SearchRepresentation } from '../../../core/services/search.service'; +import { BaseModel } from '../base/base-model'; /** * Representation of a user in contrast to the operator. * @ignore */ -export class User extends ProjectableBaseModel implements Searchable { +export class User extends BaseModel implements Searchable { + public static COLLECTIONSTRING = 'users/user'; + public id: number; public username: string; public title: string; @@ -25,7 +27,7 @@ export class User extends ProjectableBaseModel implements Searchable { public default_password: string; public constructor(input?: any) { - super('users/user', 'Participant', input); + super(User.COLLECTIONSTRING, 'Participant', input); } public get full_name(): string { diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index e678969a7..6c24e1251 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -26,7 +26,8 @@ import { MatBadgeModule, MatStepperModule, MatTabsModule, - MatBottomSheetModule + MatBottomSheetModule, + MatSliderModule } from '@angular/material'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material'; @@ -73,6 +74,9 @@ import { OsSortBottomSheetComponent } from './components/os-sort-filter-bar/os-s import { FilterMenuComponent } from './components/os-sort-filter-bar/filter-menu/filter-menu.component'; import { LogoComponent } from './components/logo/logo.component'; import { C4DialogComponent, CopyrightSignComponent } from './components/copyright-sign/copyright-sign.component'; +import { ProjectorButtonComponent } from './components/projector-button/projector-button.component'; +import { ProjectionDialogComponent } from './components/projection-dialog/projection-dialog.component'; +import { ResizedDirective } from './directives/resized.directive'; /** * Share Module for all "dumb" components and pipes. @@ -121,6 +125,7 @@ import { C4DialogComponent, CopyrightSignComponent } from './components/copyrigh MatButtonToggleModule, MatStepperModule, MatTabsModule, + MatSliderModule, DragDropModule, TranslateModule.forChild(), RouterModule, @@ -160,6 +165,7 @@ import { C4DialogComponent, CopyrightSignComponent } from './components/copyrigh MatRadioModule, MatButtonToggleModule, MatStepperModule, + MatSliderModule, DragDropModule, NgxMatSelectSearchModule, FileDropModule, @@ -179,7 +185,10 @@ import { C4DialogComponent, CopyrightSignComponent } from './components/copyrigh OsSortFilterBarComponent, LogoComponent, CopyrightSignComponent, - C4DialogComponent + C4DialogComponent, + ProjectorButtonComponent, + ProjectionDialogComponent, + ResizedDirective ], declarations: [ PermsDirective, @@ -199,7 +208,10 @@ import { C4DialogComponent, CopyrightSignComponent } from './components/copyrigh FilterMenuComponent, LogoComponent, CopyrightSignComponent, - C4DialogComponent + C4DialogComponent, + ProjectorButtonComponent, + ProjectionDialogComponent, + ResizedDirective ], providers: [ { provide: DateAdapter, useClass: OpenSlidesDateAdapter }, diff --git a/client/src/app/site/agenda/services/agenda-repository.service.ts b/client/src/app/site/agenda/services/agenda-repository.service.ts index 5f15ea3e2..6a65a4fef 100644 --- a/client/src/app/site/agenda/services/agenda-repository.service.ts +++ b/client/src/app/site/agenda/services/agenda-repository.service.ts @@ -66,7 +66,7 @@ export class AgendaRepositoryService extends BaseRepository { throw new Error( `The content object (${agendaItem.content_object.collection}, ${ agendaItem.content_object.id - }) of item ${agendaItem.id} is not a BaseProjectableModel.` + }) of item ${agendaItem.id} is not a AgendaBaseModel.` ); } } diff --git a/client/src/app/site/assignments/assignment-list/assignment-list.component.html b/client/src/app/site/assignments/assignment-list/assignment-list.component.html index d77116911..c161b0716 100644 --- a/client/src/app/site/assignments/assignment-list/assignment-list.component.html +++ b/client/src/app/site/assignments/assignment-list/assignment-list.component.html @@ -22,8 +22,8 @@ - - + + {{ isSelected(assignment) ? 'check_circle' : '' }} diff --git a/client/src/app/site/base/base-projectable-model.ts b/client/src/app/site/base/base-projectable-model.ts new file mode 100644 index 000000000..4220cd5a0 --- /dev/null +++ b/client/src/app/site/base/base-projectable-model.ts @@ -0,0 +1,47 @@ +import { Projectable } from './projectable'; +import { BaseViewModel } from './base-view-model'; +import { ProjectorOptions } from './projector-options'; + +/** + * Base view class for projectable models. + */ +export abstract class BaseProjectableModel extends BaseViewModel implements Projectable { + /** + * Per default, a slide does not have any options + * + * @override + */ + public getProjectorOptions(): ProjectorOptions { + return []; + } + + /** + * @override + */ + public abstract getProjectionDefaultName(): string; + + /** + * The id should match the model's id. + * + * @override + */ + public getIdForSlide(): number { + return this.id; + } + + /** + * A model s return the collection string + * + * @override + */ + public abstract getNameForSlide(): string; + + /** + * Per default a model is a non-stable element. + * + * @override + */ + public isStableSlide(): boolean { + return false; + } +} diff --git a/client/src/app/site/base/projectable.ts b/client/src/app/site/base/projectable.ts new file mode 100644 index 000000000..724e392e2 --- /dev/null +++ b/client/src/app/site/base/projectable.ts @@ -0,0 +1,32 @@ +import { ProjectorOptions } from './projector-options'; +import { Displayable } from 'app/shared/models/base/displayable'; + +/** + * Interface for every model, that should be projectable. + */ +export interface Projectable extends Displayable { + /** + * All options for the slide + */ + getProjectorOptions(): ProjectorOptions; + + /** + * The projection default name for the slide + */ + getProjectionDefaultName(): string; + + /** + * The (optional) id for the slide + */ + getIdForSlide(): number | null; + + /** + * The slide's name + */ + getNameForSlide(): string; + + /** + * The stable attribute for the slide. + */ + isStableSlide(): boolean; +} diff --git a/client/src/app/site/base/projector-options.ts b/client/src/app/site/base/projector-options.ts new file mode 100644 index 000000000..0a646f323 --- /dev/null +++ b/client/src/app/site/base/projector-options.ts @@ -0,0 +1,32 @@ +export interface ProjectorDecisionOption { + key: string; + displayName: string; + default: string; +} + +export interface ProjectorChoiceOption extends ProjectorDecisionOption { + choices: { value: string; displayName: string }[]; +} + +export type ProjectorOption = ProjectorDecisionOption | ProjectorChoiceOption; +export type ProjectorOptions = ProjectorOption[]; + +export function isProjectorDecisionOption(object: any): object is ProjectorDecisionOption { + const option = object; + return ( + option.key !== undefined && + option.displayName !== undefined && + option.default !== undefined && + (object).choices === undefined + ); +} + +export function isProjectorChoiceOption(object: any): object is ProjectorChoiceOption { + const option = object; + return ( + option.key !== undefined && + option.displayName !== undefined && + option.default !== undefined && + option.choices !== undefined + ); +} diff --git a/client/src/app/site/common/common.config.ts b/client/src/app/site/common/common.config.ts index 9b52d460b..0fadab14c 100644 --- a/client/src/app/site/common/common.config.ts +++ b/client/src/app/site/common/common.config.ts @@ -1,17 +1,11 @@ import { AppConfig } from '../base/app-config'; -import { Projector } from '../../shared/models/core/projector'; -import { Countdown } from '../../shared/models/core/countdown'; import { ChatMessage } from '../../shared/models/core/chat-message'; -import { ProjectorMessage } from '../../shared/models/core/projector-message'; import { Tag } from '../../shared/models/core/tag'; export const CommonAppConfig: AppConfig = { name: 'common', models: [ - { collectionString: 'core/projector', model: Projector }, { collectionString: 'core/chat-message', model: ChatMessage }, - { collectionString: 'core/countdown', model: Countdown }, - { collectionString: 'core/projector-message', model: ProjectorMessage }, { collectionString: 'core/tag', model: Tag } ], mainMenuEntries: [ diff --git a/client/src/app/site/config/config.config.ts b/client/src/app/site/config/config.config.ts index 9d613b778..ca6d7cbf9 100644 --- a/client/src/app/site/config/config.config.ts +++ b/client/src/app/site/config/config.config.ts @@ -9,7 +9,7 @@ export const ConfigAppConfig: AppConfig = { route: '/settings', displayName: 'Settings', icon: 'settings', - weight: 700, + weight: 1300, permission: 'core.can_manage_config' } ] diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html index 99eb6226a..e511ca728 100644 --- a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html @@ -55,8 +55,8 @@ - - + + {{ isSelected(item) ? 'check_circle' : '' }} diff --git a/client/src/app/site/motions/components/category-list/category-list.component.html b/client/src/app/site/motions/components/category-list/category-list.component.html index 0d9ebea9a..a980d511e 100644 --- a/client/src/app/site/motions/components/category-list/category-list.component.html +++ b/client/src/app/site/motions/components/category-list/category-list.component.html @@ -15,7 +15,7 @@ - + Required diff --git a/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.html b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.html index a0d4c12b6..1642b2d73 100644 --- a/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.html +++ b/client/src/app/site/motions/components/motion-comment-section-list/motion-comment-section-list.component.html @@ -30,7 +30,7 @@ + + + +
+
+ {{ projector?.scale }} + + + +
+ + diff --git a/client/src/app/site/projector/components/projector-detail/projector-detail.component.scss b/client/src/app/site/projector/components/projector-detail/projector-detail.component.scss new file mode 100644 index 000000000..c4b643c2e --- /dev/null +++ b/client/src/app/site/projector/components/projector-detail/projector-detail.component.scss @@ -0,0 +1,20 @@ +#projector { + width: 100%; /*1000px;*/ +} + +.column-left { + display: inline-block; + padding-top: 20px; + width: 70%; + padding-right: 25px; +} + +.column-right { + padding-top: 20px; + min-width: calc(30% - 25px); + float: right; +} + +.control-group { + text-align: center; +} diff --git a/client/src/app/site/projector/components/projector-detail/projector-detail.component.spec.ts b/client/src/app/site/projector/components/projector-detail/projector-detail.component.spec.ts new file mode 100644 index 000000000..7730dba37 --- /dev/null +++ b/client/src/app/site/projector/components/projector-detail/projector-detail.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { ProjectorDetailComponent } from './projector-detail.component'; +import { ProjectorModule } from '../../projector.module'; + +describe('ProjectorDetailComponent', () => { + let component: ProjectorDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule, ProjectorModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectorDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/projector/components/projector-detail/projector-detail.component.ts b/client/src/app/site/projector/components/projector-detail/projector-detail.component.ts new file mode 100644 index 000000000..3ebdd00b2 --- /dev/null +++ b/client/src/app/site/projector/components/projector-detail/projector-detail.component.ts @@ -0,0 +1,71 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { MatSnackBar } from '@angular/material'; +import { ActivatedRoute } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; + +import { ProjectorRepositoryService, ScrollScaleDirection } from '../../services/projector-repository.service'; +import { ViewProjector } from '../../models/view-projector'; +import { BaseViewComponent } from 'app/site/base/base-view'; + +/** + * The projector detail view. + */ +@Component({ + selector: 'os-projector-detail', + templateUrl: './projector-detail.component.html', + styleUrls: ['./projector-detail.component.scss'] +}) +export class ProjectorDetailComponent extends BaseViewComponent implements OnInit { + /** + * The projector to show. + */ + public projector: ViewProjector; + + public scrollScaleDirection = ScrollScaleDirection; + + /** + * @param titleService + * @param translate + * @param matSnackBar + * @param repo + * @param route + */ + public constructor( + titleService: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + private repo: ProjectorRepositoryService, + private route: ActivatedRoute + ) { + super(titleService, translate, matSnackBar); + } + + /** + * Gets the projector and subscribes to it. + */ + public ngOnInit(): void { + super.setTitle('Projector'); + this.route.params.subscribe(params => { + const projectorId = parseInt(params.id, 10) || 1; + this.repo.getViewModelObservable(projectorId).subscribe(projector => (this.projector = projector)); + }); + } + + /** + * Change the scroll + * @param direction The direction to send. + */ + public scroll(direction: ScrollScaleDirection): void { + this.repo.scroll(this.projector, direction).then(null, this.raiseError); + } + + /** + * Change the scale + * @param direction The direction to send. + */ + public scale(direction: ScrollScaleDirection): void { + this.repo.scale(this.projector, direction).then(null, this.raiseError); + } +} diff --git a/client/src/app/site/projector/components/projector-list/projector-list.component.html b/client/src/app/site/projector/components/projector-list/projector-list.component.html new file mode 100644 index 000000000..26ee53bfd --- /dev/null +++ b/client/src/app/site/projector/components/projector-list/projector-list.component.html @@ -0,0 +1,101 @@ + + +
+

Projectors

+
+ + + +
+ + + New Projector + +
+

+ + + + Required + + +

+
+
+ + + + +
+ +
+ + + + {{ projector.name }} + + +
+ + + + +
+
+ +

TODO: projector

+ +
+

+ + + + + Required + + +

+ + + + {{ ratio }} + + +

+ + {{ updateForm.value.width }} +

+
+
+
+
+
+ + + + + diff --git a/client/src/app/site/projector/components/projector-list/projector-list.component.scss b/client/src/app/site/projector/components/projector-list/projector-list.component.scss new file mode 100644 index 000000000..d7d6e534d --- /dev/null +++ b/client/src/app/site/projector/components/projector-list/projector-list.component.scss @@ -0,0 +1,13 @@ +#card-wrapper { + margin-top: 10px; + + .projector-card { + margin: 20px; + width: 300px; + display: inline-block; + } + + .card-actions { + float: right; + } +} diff --git a/client/src/app/site/projector/components/projector-list/projector-list.component.spec.ts b/client/src/app/site/projector/components/projector-list/projector-list.component.spec.ts new file mode 100644 index 000000000..a07e8866d --- /dev/null +++ b/client/src/app/site/projector/components/projector-list/projector-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProjectorListComponent } from './projector-list.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { ProjectorModule } from '../../projector.module'; + +describe('ProjectorListComponent', () => { + let component: ProjectorListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule, ProjectorModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProjectorListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/projector/components/projector-list/projector-list.component.ts b/client/src/app/site/projector/components/projector-list/projector-list.component.ts new file mode 100644 index 000000000..0f463b5de --- /dev/null +++ b/client/src/app/site/projector/components/projector-list/projector-list.component.ts @@ -0,0 +1,225 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { ProjectorRepositoryService } from '../../services/projector-repository.service'; +import { ViewProjector } from '../../models/view-projector'; +import { Projector } from 'app/shared/models/core/projector'; +import { BaseViewComponent } from 'app/site/base/base-view'; +import { MatSnackBar } from '@angular/material'; +import { PromptService } from 'app/core/services/prompt.service'; + +/** + * All supported aspect rations for projectors. + */ +const aspectRatios: { [ratio: string]: number } = { + '4:3': 4 / 3, + '16:9': 16 / 9, + '16:10': 16 / 10 +}; + +/** + * List for all projectors. + */ +@Component({ + selector: 'os-projector-list', + templateUrl: './projector-list.component.html', + styleUrls: ['./projector-list.component.scss'] +}) +export class ProjectorListComponent extends BaseViewComponent implements OnInit { + /** + * This member is set, if the user is creating a new projector. + */ + public projectorToCreate: Projector | null; + + /** + * The create form. + */ + public createForm: FormGroup; + + /** + * The update form. Will be refreahed for each projector. Just one update + * form can be shown per time. + */ + public updateForm: FormGroup; + + /** + * The id of the currently edited projector. + */ + public editId: number | null = null; + + /** + * All aspect ratio keys/strings for the UI. + */ + public aspectRatiosKeys: string[]; + + /** + * All projectors. + */ + public projectors: ViewProjector[]; + + /** + * Constructor. Initializes all forms. + * + * @param titleService + * @param translate + * @param matSnackBar + * @param repo + * @param formBuilder + * @param promptService + */ + public constructor( + titleService: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + private repo: ProjectorRepositoryService, + private formBuilder: FormBuilder, + private promptService: PromptService + ) { + super(titleService, translate, matSnackBar); + + this.aspectRatiosKeys = Object.keys(aspectRatios); + + this.createForm = this.formBuilder.group({ + name: ['', Validators.required] + }); + this.updateForm = this.formBuilder.group({ + name: ['', Validators.required], + aspectRatio: ['', Validators.required], + width: [0, Validators.required] + }); + } + + /** + * Watches all projectors. + */ + public ngOnInit(): void { + super.setTitle('Projectors'); + this.repo.getViewModelListObservable().subscribe(projectors => (this.projectors = projectors)); + } + + /** + * Opens the create form. + */ + public onPlusButton(): void { + if (!this.projectorToCreate) { + this.projectorToCreate = new Projector(); + this.createForm.setValue({ name: '' }); + } + } + + /** + * Creates the comment section from the create form. + */ + public create(): void { + if (this.createForm.valid && this.projectorToCreate) { + this.projectorToCreate.patchValues(this.createForm.value as Projector); + this.repo.create(this.projectorToCreate).then(() => (this.projectorToCreate = null), this.raiseError); + } + } + + /** + * Event on Key Down in update or create form. + * + * @param event the keyboard event + * @param the current view in scope + */ + public keyDownFunction(event: KeyboardEvent, projector?: ViewProjector): void { + if (event.key === 'Enter' && event.shiftKey) { + if (projector) { + this.onSaveButton(projector); + } else { + this.create(); + } + } + if (event.key === 'Escape') { + if (projector) { + this.onCancelButton(projector); + } else { + this.projectorToCreate = null; + } + } + } + + /** + * Calculates the aspect ratio of the given projector. + * If no matching ratio is found, the first ratio is returned. + * + * @param projector The projector to check + * @returns the found ratio key. + */ + public getAspectRatioKey(projector: ViewProjector): string { + const ratio = projector.width / projector.height; + const RATIO_ENVIRONMENT = 0.05; + const foundRatioKey = Object.keys(aspectRatios).find(key => { + const value = aspectRatios[key]; + return value >= ratio - RATIO_ENVIRONMENT && value <= ratio + RATIO_ENVIRONMENT; + }); + if (!foundRatioKey) { + return Object.keys(aspectRatios)[0]; + } else { + return foundRatioKey; + } + } + + /** + * Starts editing for the given projector. + * + * @param projector The projector to edit + */ + public onEditButton(projector: ViewProjector): void { + if (this.editId !== null) { + return; + } + this.editId = projector.id; + this.updateForm.reset(); + this.updateForm.patchValue({ + name: projector.name, + aspectRatio: this.getAspectRatioKey(projector), + width: projector.width + }); + } + + /** + * Cancels the current editing. + * @param projector the projector + */ + public onCancelButton(projector: ViewProjector): void { + if (projector.id !== this.editId) { + return; + } + this.editId = null; + } + + /** + * Saves the projector + * + * @param projector The projector to save. + */ + public onSaveButton(projector: ViewProjector): void { + if (projector.id !== this.editId || !this.updateForm.valid) { + return; + } + const updateProjector: Partial = { + name: this.updateForm.value.name, + width: this.updateForm.value.width, + height: Math.round(this.updateForm.value.width / aspectRatios[this.updateForm.value.aspectRatio]) + }; + this.repo.update(updateProjector, projector); + this.editId = null; + } + + /** + * Delete the projector. + * + * @param projector The projector to delete + */ + public async onDeleteButton(projector: ViewProjector): Promise { + const content = this.translate.instant('Delete') + ` ${projector.name}?`; + if (await this.promptService.open('Are you sure?', content)) { + this.repo.delete(projector).then(null, this.raiseError); + } + } +} diff --git a/client/src/app/site/projector/components/projector/projector.component.html b/client/src/app/site/projector/components/projector/projector.component.html new file mode 100644 index 000000000..a7ed7087d --- /dev/null +++ b/client/src/app/site/projector/components/projector/projector.component.html @@ -0,0 +1,17 @@ +
+
+
+
+ Header Title +
+
+ +
+ +
+ + +
+
diff --git a/client/src/app/site/projector/components/projector/projector.component.scss b/client/src/app/site/projector/components/projector/projector.component.scss new file mode 100644 index 000000000..c5c4d1613 --- /dev/null +++ b/client/src/app/site/projector/components/projector/projector.component.scss @@ -0,0 +1,36 @@ +#container { + background-color: lightgoldenrodyellow; + position: relative; + + #projector { + position: absolute; + top: 0; + left: 0; + transform-origin: left top; + overflow: hidden; + + .header { + position: absolute; + top: 0; + left: 0; + color: white; + width: 100%; + height: 50px; + z-index: 1; + } + .content { + width: 100%; + position: absolute; + top: 0; + left: 0; + } + .footer { + position: absolute; + color: white; + width: 100%; + height: 50px; + bottom: 0; + z-index: 1; + } + } +} diff --git a/client/src/app/projector-container/projector/projector.component.spec.ts b/client/src/app/site/projector/components/projector/projector.component.spec.ts similarity index 77% rename from client/src/app/projector-container/projector/projector.component.spec.ts rename to client/src/app/site/projector/components/projector/projector.component.spec.ts index 5b878f9fd..44c707690 100644 --- a/client/src/app/projector-container/projector/projector.component.spec.ts +++ b/client/src/app/site/projector/components/projector/projector.component.spec.ts @@ -1,7 +1,8 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ProjectorComponent } from './projector.component'; -import { E2EImportsModule } from '../../../e2e-imports.module'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { ProjectorModule } from '../../projector.module'; describe('ProjectorComponent', () => { let component: ProjectorComponent; @@ -9,8 +10,7 @@ describe('ProjectorComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [E2EImportsModule], - declarations: [ProjectorComponent] + imports: [E2EImportsModule, ProjectorModule] }).compileComponents(); })); diff --git a/client/src/app/site/projector/components/projector/projector.component.ts b/client/src/app/site/projector/components/projector/projector.component.ts new file mode 100644 index 000000000..b07609381 --- /dev/null +++ b/client/src/app/site/projector/components/projector/projector.component.ts @@ -0,0 +1,236 @@ +import { Component, Input, ViewChild, ElementRef, OnDestroy } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; + +import { BaseComponent } from 'app/base.component'; +import { ViewProjector } from '../../models/view-projector'; +import { ProjectorDataService, SlideData } from '../../services/projector-data.service'; +import { Subscription, Subject } from 'rxjs'; +import { ProjectorRepositoryService } from '../../services/projector-repository.service'; +import { ConfigService } from 'app/core/services/config.service'; +import { Size } from '../../size'; + +/** + * THE projector. Cares about scaling and the right size and resolution. + * Watches the given projector and creates slide-containers for each projectorelement. + */ +@Component({ + selector: 'os-projector', + templateUrl: './projector.component.html', + styleUrls: ['./projector.component.scss'] +}) +export class ProjectorComponent extends BaseComponent implements OnDestroy { + /** + * The current projector id. + */ + private projectorId: number | null = null; + + /** + * The projector. Accessors are below. + */ + private _projector: ViewProjector; + + @Input() + public set projector(projector: ViewProjector) { + this._projector = projector; + // check, if ID changed: + const newId = projector ? projector.id : null; + if (this.projectorId !== newId) { + this.projectorIdChanged(this.projectorId, newId); + this.projectorId = newId; + } + + // Update scaling, if projector is set. + if (projector) { + const oldSize: Size = { ...this.currentProjectorSize }; + this.currentProjectorSize.height = projector.height; + this.currentProjectorSize.width = projector.width; + if ( + oldSize.height !== this.currentProjectorSize.height || + oldSize.width !== this.currentProjectorSize.width + ) { + this.updateScaling(); + } + } + } + + public get projector(): ViewProjector { + return this._projector; + } + + /** + * The current projector size. This is for checking, + * if the size actually has changed. + */ + private currentProjectorSize: Size = { width: 0, height: 0 }; + + /** + * Ths subscription to the projectordata. + */ + private dataSubscription: Subscription; + + /** + * The container element. THis is neede to get the size of the element, + * in which the projector must fit and be scaled to. + */ + @ViewChild('container') + private containerElement: ElementRef; + + /** + * Dynamic style attributes for the projector. + */ + public projectorStyle: { + transform?: string; + width: string; + height: string; + 'background-color': string; + } = { + width: '0px', + height: '0px', + 'background-color': 'white' + }; + + /** + * Dynamic style attributes for the header and footer. + */ + public headerFooterStyle: { 'background-color': string; color: string } = { + 'background-color': 'blue', + color: 'white' + }; + + /** + * Dynamic style attributes for the container. + */ + public containerStyle: { height?: string } = {}; + + /** + * All slides to show on this projector + */ + public slides: SlideData[] = []; + + /** + * The scroll for this projector. 0 is the default. + */ + public scroll = 0; + + /** + * The scale for this projector. 0 is the default. + */ + public scale = 0; + + /** + * The subscription to the projector. + */ + private projectorSubscription: Subscription; + + /** + * A subject that fires, if the container is resized. + */ + public resizeSubject = new Subject(); + + // Some settings for the view from the config. + public enableHeaderAndFooter = true; + public enableTitle = true; + public enableLogo = true; + + /** + * Listen to all related config variables. Register the resizeSubject. + * + * @param titleService + * @param translate + * @param projectorDataService + * @param projectorRepository + * @param configService + */ + public constructor( + titleService: Title, + translate: TranslateService, + private projectorDataService: ProjectorDataService, + private projectorRepository: ProjectorRepositoryService, + private configService: ConfigService + ) { + super(titleService, translate); + + // Get all important config variables. + this.configService + .get('projector_enable_header_footer') + .subscribe(val => (this.enableHeaderAndFooter = val)); + this.configService.get('projector_enable_title').subscribe(val => (this.enableTitle = val)); + this.configService + .get('projector_header_fontcolor') + .subscribe(val => (this.headerFooterStyle.color = val)); + this.configService + .get('projector_header_backgroundcolor') + .subscribe(val => (this.headerFooterStyle['background-color'] = val)); + this.configService.get('projector_enable_logo').subscribe(val => (this.enableLogo = val)); + this.configService + .get('projector_background_color') + .subscribe(val => (this.projectorStyle['background-color'] = val)); + + // Watches for resizing of the container. + this.resizeSubject.subscribe(() => { + if (this.containerElement) { + this.updateScaling(); + } + }); + } + + /** + * Scales the projector to the right format. + */ + private updateScaling(): void { + if ( + !this.containerElement || + this.currentProjectorSize.width === 0 || + this.containerElement.nativeElement.offsetWidth === 0 + ) { + return; + } + const scale = this.containerElement.nativeElement.offsetWidth / this.currentProjectorSize.width; + if (isNaN(scale)) { + return; + } + this.projectorStyle.transform = 'scale(' + scale + ')'; + this.projectorStyle.width = this.currentProjectorSize.width + 'px'; + this.projectorStyle.height = this.currentProjectorSize.height + 'px'; + this.containerStyle.height = Math.round(scale * this.currentProjectorSize.height) + 'px'; + } + + /** + * Called, if the projector id changes. + */ + private projectorIdChanged(from: number, to: number): void { + // Unsubscribe form data and projector subscriptions. + if (this.dataSubscription) { + this.dataSubscription.unsubscribe(); + } + if (this.projectorSubscription) { + this.projectorSubscription.unsubscribe(); + } + if (to > 0) { + if (from > 0) { + this.projectorDataService.projectorClosed(from); + } + + this.dataSubscription = this.projectorDataService + .getProjectorObservable(to) + .subscribe(data => (this.slides = data || [])); + this.projectorSubscription = this.projectorRepository.getViewModelObservable(to).subscribe(projector => { + this.scroll = projector.scroll; + this.scale = projector.scale; + }); + } else if (!to && from > 0) { + // no new projector + this.projectorDataService.projectorClosed(from); + } + } + + /** + * Deregister the projector from the projectordataservice. + */ + public ngOnDestroy(): void { + if (this.projectorId > 0) { + this.projectorDataService.projectorClosed(this.projectorId); + } + } +} diff --git a/client/src/app/site/projector/components/slide-container/slide-container.component.html b/client/src/app/site/projector/components/slide-container/slide-container.component.html new file mode 100644 index 000000000..eeb146cf0 --- /dev/null +++ b/client/src/app/site/projector/components/slide-container/slide-container.component.html @@ -0,0 +1 @@ +
diff --git a/client/src/app/site/projector/components/slide-container/slide-container.component.scss b/client/src/app/site/projector/components/slide-container/slide-container.component.scss new file mode 100644 index 000000000..73841039a --- /dev/null +++ b/client/src/app/site/projector/components/slide-container/slide-container.component.scss @@ -0,0 +1,3 @@ +::ng-deep #slide { + z-index: 5; +} diff --git a/client/src/app/site/projector/components/slide-container/slide-container.component.spec.ts b/client/src/app/site/projector/components/slide-container/slide-container.component.spec.ts new file mode 100644 index 000000000..b0d6deb5e --- /dev/null +++ b/client/src/app/site/projector/components/slide-container/slide-container.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SlideContainerComponent } from './slide-container.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { ProjectorModule } from '../../projector.module'; + +describe('SlideContainerComponent', () => { + let component: SlideContainerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule, ProjectorModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SlideContainerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/projector/components/slide-container/slide-container.component.ts b/client/src/app/site/projector/components/slide-container/slide-container.component.ts new file mode 100644 index 000000000..8c0549230 --- /dev/null +++ b/client/src/app/site/projector/components/slide-container/slide-container.component.ts @@ -0,0 +1,157 @@ +import { Component, Input, ViewChild, ViewContainerRef, ComponentRef } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; + +import { BaseComponent } from 'app/base.component'; +import { SlideData } from '../../services/projector-data.service'; +import { DynamicSlideLoader } from 'app/slides/services/dynamic-slide-loader.service'; +import { BaseSlideComponent } from 'app/slides/base-slide-component'; +import { SlideOptions } from 'app/slides/slide-manifest'; +import { ConfigService } from 'app/core/services/config.service'; + +/** + * Container for one slide. Cares about the position (scale, scroll) in the projector, + * and loading of slides. + */ +@Component({ + selector: 'os-slide-container', + templateUrl: './slide-container.component.html', + styleUrls: ['./slide-container.component.scss'] +}) +export class SlideContainerComponent extends BaseComponent { + private previousSlideName: string; + + @ViewChild('slide', { read: ViewContainerRef }) + private slide: ViewContainerRef; + private slideRef: ComponentRef>; + + /** + * The data for this slide. Will be accessed below. + */ + private _slideData: SlideData; + + @Input() + public set slideData(data: SlideData) { + // If there is no ata or an error, clear and exit. + if (!data || data.error) { + // clear slide container: + if (this.slide) { + this.slide.clear(); + } + + if (data.error) { + console.error(data.error); + } + return; + } + + this._slideData = data; + if (this.previousSlideName !== data.element.name) { + this.slideChanged(data.element.name); + this.previousSlideName = data.element.name; + } + this.setDataForComponent(); + } + + public get slideData(): SlideData { + return this._slideData; + } + + /** + * The current projector scroll. + */ + private _scroll: number; + + /** + * Updates the slideStyle, when the scroll changes. + */ + @Input() + public set scroll(value: number) { + this._scroll = value; + this.updateScroll(); + } + + /** + * Update the slideStyle, when the scale changes. + */ + @Input() + public set scale(value: number) { + if (this.slideOptions.scaleable) { + value *= 10; + value += 100; + this.slideStyle['font-size'] = `${value}%`; + } else { + this.slideStyle['font-size'] = '100%'; + } + } + + /** + * The current slideoptions. + */ + private slideOptions: SlideOptions = { scaleable: false, scrollable: false }; + + /** + * Styles for scaling and scrolling. + */ + public slideStyle: { 'font-size': string; 'margin-top': string } = { + 'font-size': '100%', + 'margin-top': '50px' + }; + + /** + * Variable, if the projector header is enabled. + */ + private headerEnabled = true; + + public constructor( + titleService: Title, + translate: TranslateService, + private dynamicSlideLoader: DynamicSlideLoader, + private configService: ConfigService + ) { + super(titleService, translate); + + this.configService.get('projector_enable_header_footer').subscribe(val => { + this.headerEnabled = val; + }); + } + + /** + * Updates the 'margin-top' attribute in the slide styles. + */ + private updateScroll(): void { + if (this.slideOptions.scrollable) { + let value = this._scroll; + value *= -50; + if (this.headerEnabled) { + value += 50; // Default offset for the header + } + this.slideStyle['margin-top'] = `${value}px`; + } else { + this.slideStyle['margin-top'] = '0px'; + } + } + + /** + * Loads the slides via the dynamicSlideLoader. Creates the slide components and provide the slide data to it. + * + * @param slideName The slide to load. + */ + private slideChanged(slideName: string): void { + this.slideOptions = this.dynamicSlideLoader.getSlideOptions(slideName); + this.dynamicSlideLoader.getSlideFactory(slideName).then(slideFactory => { + this.slide.clear(); + this.slideRef = this.slide.createComponent(slideFactory); + this.setDataForComponent(); + }); + } + + /** + * "injects" the slide data into the slide component. + */ + private setDataForComponent(): void { + if (this.slideRef && this.slideRef.instance) { + this.slideRef.instance.data = this.slideData; + } + } +} diff --git a/client/src/app/site/projector/models/view-projector.ts b/client/src/app/site/projector/models/view-projector.ts new file mode 100644 index 000000000..14c93483e --- /dev/null +++ b/client/src/app/site/projector/models/view-projector.ts @@ -0,0 +1,51 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Projector, ProjectorElements } from 'app/shared/models/core/projector'; + +export class ViewProjector extends BaseViewModel { + private _projector: Projector; + + public get projector(): Projector { + return this._projector ? this._projector : null; + } + + public get id(): number { + return this.projector ? this.projector.id : null; + } + + public get name(): string { + return this.projector ? this.projector.name : null; + } + + public get elements(): ProjectorElements { + return this.projector ? this.projector.elements : null; + } + + public get height(): number { + return this.projector ? this.projector.height : null; + } + + public get width(): number { + return this.projector ? this.projector.width : null; + } + + public get scale(): number { + return this.projector ? this.projector.scale : null; + } + + public get scroll(): number { + return this.projector ? this.projector.scroll : null; + } + + public constructor(projector?: Projector) { + super(); + this._projector = projector; + } + + public getTitle(): string { + return this.name; + } + + public updateValues(projector: Projector): void { + console.log('Update projector TODO with vals:', projector); + } +} diff --git a/client/src/app/site/projector/projector-routing.module.ts b/client/src/app/site/projector/projector-routing.module.ts new file mode 100644 index 000000000..133f0cd7b --- /dev/null +++ b/client/src/app/site/projector/projector-routing.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { ProjectorListComponent } from './components/projector-list/projector-list.component'; +import { ProjectorDetailComponent } from './components/projector-detail/projector-detail.component'; + +const routes: Routes = [ + { + path: 'list', + component: ProjectorListComponent + }, + { + path: 'detail/:id', + component: ProjectorDetailComponent + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class ProjectorRoutingModule {} diff --git a/client/src/app/site/projector/projector.config.ts b/client/src/app/site/projector/projector.config.ts new file mode 100644 index 000000000..fb23de7d6 --- /dev/null +++ b/client/src/app/site/projector/projector.config.ts @@ -0,0 +1,22 @@ +import { AppConfig } from '../base/app-config'; +import { Projector } from 'app/shared/models/core/projector'; +import { Countdown } from 'app/shared/models/core/countdown'; +import { ProjectorMessage } from 'app/shared/models/core/projector-message'; + +export const ProjectorAppConfig: AppConfig = { + name: 'projector', + models: [ + { collectionString: 'core/projector', model: Projector }, + { collectionString: 'core/countdown', model: Countdown }, + { collectionString: 'core/projector-message', model: ProjectorMessage } + ], + mainMenuEntries: [ + { + route: '/projector-site/list', + displayName: 'Projector', + icon: 'videocam', + weight: 700, + permission: 'core.can_see_projector' + } + ] +}; diff --git a/client/src/app/site/projector/projector.module.spec.ts b/client/src/app/site/projector/projector.module.spec.ts new file mode 100644 index 000000000..28b27239e --- /dev/null +++ b/client/src/app/site/projector/projector.module.spec.ts @@ -0,0 +1,13 @@ +import { ProjectorModule } from './projector.module'; + +describe('ProjectorModule', () => { + let projectorModule: ProjectorModule; + + beforeEach(() => { + projectorModule = new ProjectorModule(); + }); + + it('should create an instance', () => { + expect(projectorModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/projector/projector.module.ts b/client/src/app/site/projector/projector.module.ts new file mode 100644 index 000000000..1150e90ef --- /dev/null +++ b/client/src/app/site/projector/projector.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { ProjectorRoutingModule } from './projector-routing.module'; +import { SharedModule } from '../../shared/shared.module'; +import { ProjectorComponent } from './components/projector/projector.component'; +import { ProjectorListComponent } from './components/projector-list/projector-list.component'; +import { ProjectorDetailComponent } from './components/projector-detail/projector-detail.component'; +import { SlideContainerComponent } from './components/slide-container/slide-container.component'; +import { FullscreenProjectorComponent } from './components/fullscreen-projector/fullscreen-projector.component'; + +@NgModule({ + imports: [CommonModule, ProjectorRoutingModule, SharedModule], + declarations: [ + ProjectorComponent, + ProjectorListComponent, + ProjectorDetailComponent, + SlideContainerComponent, + FullscreenProjectorComponent + ] +}) +export class ProjectorModule {} diff --git a/client/src/app/site/projector/services/projector-data.service.spec.ts b/client/src/app/site/projector/services/projector-data.service.spec.ts new file mode 100644 index 000000000..80cfc4aeb --- /dev/null +++ b/client/src/app/site/projector/services/projector-data.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; +import { ProjectorDataService } from './projector-data.service'; + +describe('ProjectorDataService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ProjectorDataService] + }); + }); + it('should be created', inject([ProjectorDataService], (service: ProjectorDataService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/projector/services/projector-data.service.ts b/client/src/app/site/projector/services/projector-data.service.ts new file mode 100644 index 000000000..d0474981f --- /dev/null +++ b/client/src/app/site/projector/services/projector-data.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@angular/core'; +import { WebsocketService } from 'app/core/services/websocket.service'; +import { Observable, BehaviorSubject } from 'rxjs'; +import { ProjectorElement } from 'app/shared/models/core/projector'; + +export interface SlideData { + data: T; + element: ProjectorElement; + error?: string; +} + +export type ProjectorData = SlideData[]; + +interface AllProjectorData { + [id: number]: ProjectorData; +} + +/** + * This service handles the websocket connection for the projector data. + * Each projector instance registers itself by calling `getProjectorObservable`. + * A projector should deregister itself, when the component is destroyed. + */ +@Injectable({ + providedIn: 'root' +}) +export class ProjectorDataService { + /** + * Counts the open projector instances per projector id. + */ + private openProjectorInstances: { [id: number]: number } = {}; + + /** + * Holds the current projector data for each projector. + */ + private currentProjectorData: { [id: number]: BehaviorSubject } = {}; + + /** + * Constructor. + * + * @param websocketService + */ + public constructor(private websocketService: WebsocketService) { + // TODO: On reconnect, we do need to re-inform the server about all needed projectors. This also + // updates our projector data, which is great! + this.websocketService.getOberservable('projector').subscribe((update: AllProjectorData) => { + Object.keys(update).forEach(_id => { + const id = parseInt(_id, 10); + if (this.currentProjectorData[id]) { + this.currentProjectorData[id].next(update[id]); + } + }); + }); + } + + /** + * Gets an observable for the projector data. + * + * @param projectorId The requested projector + * @return an observable for the projector data of the given projector. + */ + public getProjectorObservable(projectorId: number): Observable { + // Count projectors. + if (!this.openProjectorInstances[projectorId]) { + this.openProjectorInstances[projectorId] = 1; + if (!this.currentProjectorData[projectorId]) { + this.currentProjectorData[projectorId] = new BehaviorSubject(null); + } + } else { + this.openProjectorInstances[projectorId]++; + } + + // Projector opened the first time. + if (this.openProjectorInstances[projectorId] === 1) { + this.updateProjectorDataSubscription(); + } + return this.currentProjectorData[projectorId].asObservable(); + } + + /** + * Unsubscribe data from the server, if the last projector was closed. + * + * @param projectorId the projector. + */ + public projectorClosed(projectorId: number): void { + if (this.openProjectorInstances[projectorId]) { + this.openProjectorInstances[projectorId]--; + } + if (this.openProjectorInstances[projectorId] === 0) { + this.updateProjectorDataSubscription(); + this.currentProjectorData[projectorId].next(null); + } + } + + /** + * Gets initial data and keeps reuesting data. + */ + private updateProjectorDataSubscription(): void { + const allActiveProjectorIds = Object.keys(this.openProjectorInstances) + .map(id => parseInt(id, 10)) + .filter(id => this.openProjectorInstances[id] > 0); + this.websocketService.send('listenToProjectors', { projector_ids: allActiveProjectorIds }); + } +} diff --git a/client/src/app/site/projector/services/projector-repository.service.spec.ts b/client/src/app/site/projector/services/projector-repository.service.spec.ts new file mode 100644 index 000000000..fc19a4e8b --- /dev/null +++ b/client/src/app/site/projector/services/projector-repository.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { ProjectorRepositoryService } from './projector-repository.service'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('GroupRepositoryService', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ProjectorRepositoryService] + }) + ); + + it('should be created', inject([ProjectorRepositoryService], (service: ProjectorRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/projector/services/projector-repository.service.ts b/client/src/app/site/projector/services/projector-repository.service.ts new file mode 100644 index 000000000..062ccb9c3 --- /dev/null +++ b/client/src/app/site/projector/services/projector-repository.service.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@angular/core'; + +import { BaseRepository } from '../../base/base-repository'; +import { CollectionStringModelMapperService } from '../../../core/services/collectionStringModelMapper.service'; +import { DataSendService } from '../../../core/services/data-send.service'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { Identifiable } from '../../../shared/models/base/identifiable'; +import { ViewProjector } from '../models/view-projector'; +import { Projector } from '../../../shared/models/core/projector'; +import { HttpService } from 'app/core/services/http.service'; + +/** + * Directions for scale and scroll requests. + */ +export enum ScrollScaleDirection { + Up = 'up', + Down = 'down', + Reset = 'reset' +} + +/** + * Manages all projector instances. + */ +@Injectable({ + providedIn: 'root' +}) +export class ProjectorRepositoryService extends BaseRepository { + /** + * Constructor calls the parent constructor + * + * @param DS The DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects + * @param http + */ + public constructor( + DS: DataStoreService, + mapperService: CollectionStringModelMapperService, + private dataSend: DataSendService, + private http: HttpService + ) { + super(DS, mapperService, Projector); + } + + /** + * Creates a new projector. Adds the clock as default, stable element + */ + public async create(projectorData: Partial): Promise { + const projector = new Projector(); + projector.patchValues(projectorData); + projector.elements = [{ name: 'core/clock', stable: true }]; + return await this.dataSend.createModel(projector); + } + + /** + * Updates a projector. + */ + public async update(projectorData: Partial, viewProjector: ViewProjector): Promise { + const projector = new Projector(); + projector.patchValues(viewProjector.projector); + projector.patchValues(projectorData); + await this.dataSend.updateModel(projector); + } + + /** + * Deletes a given projector. + * + * @param projector + */ + public async delete(projector: ViewProjector): Promise { + await this.dataSend.deleteModel(projector.projector); + } + + public createViewModel(projector: Projector): ViewProjector { + return new ViewProjector(projector); + } + + /** + * Scroll the given projector. + * + * @param projector The projector to scroll + * @param direction The direction. + */ + public async scroll(projector: ViewProjector, direction: ScrollScaleDirection): Promise { + this.controlView(projector, direction, 'scroll'); + } + + /** + * Scale the given projector. + * + * @param projector The projector to scale + * @param direction The direction. + */ + public async scale(projector: ViewProjector, direction: ScrollScaleDirection): Promise { + this.controlView(projector, direction, 'scale'); + } + + /** + * Controls the view of a projector. + * + * @param projector The projector to control. + * @param direction The direction + * @param action The action. Can be scale or scroll. + */ + private async controlView( + projector: ViewProjector, + direction: ScrollScaleDirection, + action: 'scale' | 'scroll' + ): Promise { + this.http.post(`/rest/core/projector/${projector.id}/control_view`, { + action: action, + direction: direction + }); + } +} diff --git a/client/src/app/site/projector/size.ts b/client/src/app/site/projector/size.ts new file mode 100644 index 000000000..cf7cd4c06 --- /dev/null +++ b/client/src/app/site/projector/size.ts @@ -0,0 +1,4 @@ +export interface Size { + width: number; + height: number; +} diff --git a/client/src/app/site/site-routing.module.ts b/client/src/app/site/site-routing.module.ts index 9575dd338..47a0d7aab 100644 --- a/client/src/app/site/site-routing.module.ts +++ b/client/src/app/site/site-routing.module.ts @@ -50,6 +50,10 @@ const routes: Routes = [ { path: 'history', loadChildren: './history/history.module#HistoryModule' + }, + { + path: 'projector-site', + loadChildren: './projector/projector.module#ProjectorModule' } ], canActivateChild: [AuthGuard] diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html index 3599889f3..b125059d1 100644 --- a/client/src/app/site/site.component.html +++ b/client/src/app/site/site.component.html @@ -71,17 +71,6 @@ {{ entry.displayName | translate }} - - videocam - Projector - - - + + {{ isSelected(user) ? 'check_circle' : '' }} + + + Projector + + + + + Name diff --git a/client/src/app/site/users/components/user-list/user-list.component.ts b/client/src/app/site/users/components/user-list/user-list.component.ts index 3a74fd197..52fc7140d 100644 --- a/client/src/app/site/users/components/user-list/user-list.component.ts +++ b/client/src/app/site/users/components/user-list/user-list.component.ts @@ -226,7 +226,8 @@ export class UserListComponent extends ListViewBaseComponent implement * @returns column definition */ public getColumnDefinition(): string[] { - const columns = ['name', 'group', 'presence']; + // TODO: no projector in mobile view. + const columns = ['projector', 'name', 'group', 'presence']; if (this.isMultiSelect) { return ['selector'].concat(columns); } diff --git a/client/src/app/site/users/models/view-user.ts b/client/src/app/site/users/models/view-user.ts index e1c854444..2d44b07cc 100644 --- a/client/src/app/site/users/models/view-user.ts +++ b/client/src/app/site/users/models/view-user.ts @@ -1,9 +1,9 @@ -import { BaseViewModel } from '../../base/base-view-model'; import { User } from '../../../shared/models/users/user'; import { Group } from '../../../shared/models/users/group'; import { BaseModel } from '../../../shared/models/base/base-model'; +import { BaseProjectableModel } from 'app/site/base/base-projectable-model'; -export class ViewUser extends BaseViewModel { +export class ViewUser extends BaseProjectableModel { private _user: User; private _groups: Group[]; @@ -105,6 +105,18 @@ export class ViewUser extends BaseViewModel { this._groups = groups; } + public getProjectionDefaultName(): string { + return 'users'; + } + + public getNameForSlide(): string { + return User.COLLECTIONSTRING; + } + + public isStableSlide(): boolean { + return true; + } + /** * required by BaseViewModel. Don't confuse with the users title. */ diff --git a/client/src/app/slides/all-slides.ts b/client/src/app/slides/all-slides.ts new file mode 100644 index 000000000..d24017c39 --- /dev/null +++ b/client/src/app/slides/all-slides.ts @@ -0,0 +1,21 @@ +import { SlideManifest } from './slide-manifest'; + +/** + * Here, all slides has to be registered. + */ +export const allSlides: SlideManifest[] = [ + { + slideName: 'motions/motion', + path: 'motions/motion', + loadChildren: './slides/motions/motion/motions-motion-slide.module#MotionsMotionSlideModule', + scaleable: true, + scrollable: true + }, + { + slideName: 'users/user', + path: 'users/user', + loadChildren: './slides/users/user/users-user-slide.module#UsersUserSlideModule', + scaleable: false, + scrollable: false + } +]; diff --git a/client/src/app/slides/base-slide-component.ts b/client/src/app/slides/base-slide-component.ts new file mode 100644 index 000000000..3a5e8d485 --- /dev/null +++ b/client/src/app/slides/base-slide-component.ts @@ -0,0 +1,16 @@ +import { Input } from '@angular/core'; +import { SlideData } from 'app/site/projector/services/projector-data.service'; + +/** + * Every slide has to extends this base class. It forces the slides + * to have an input for the slidedata. + */ +export abstract class BaseSlideComponent { + /** + * Each slide must take slide data. + */ + @Input() + public data: SlideData; + + public constructor() {} +} diff --git a/client/src/app/slides/base-slide-module.ts b/client/src/app/slides/base-slide-module.ts new file mode 100644 index 000000000..1060294f8 --- /dev/null +++ b/client/src/app/slides/base-slide-module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/compiler/src/core'; +import { Type } from '@angular/core'; + +import { SharedModule } from 'app/shared/shared.module'; +import { SLIDE } from './slide-token'; +import { BaseSlideComponent } from './base-slide-component'; + +/** + * Generates the configuration for a slide module. + * + * @param slideComponent The component + * @return the Module configuration fo rthe slide module. + */ +export function makeSlideModule>(slideComponent: Type): NgModule { + return { + imports: [CommonModule, SharedModule], + declarations: [slideComponent], + providers: [{ provide: SLIDE, useValue: slideComponent }], + entryComponents: [slideComponent] + }; +} diff --git a/client/src/app/slides/motions/motion/motions-motion-slide-model.ts b/client/src/app/slides/motions/motion/motions-motion-slide-model.ts new file mode 100644 index 000000000..9de28ec8a --- /dev/null +++ b/client/src/app/slides/motions/motion/motions-motion-slide-model.ts @@ -0,0 +1,3 @@ +export interface MotionsMotionSlideData { + test: string; +} diff --git a/client/src/app/slides/motions/motion/motions-motion-slide.component.html b/client/src/app/slides/motions/motion/motions-motion-slide.component.html new file mode 100644 index 000000000..dd99a6121 --- /dev/null +++ b/client/src/app/slides/motions/motion/motions-motion-slide.component.html @@ -0,0 +1,4 @@ +
+ Motion Slide +

TEST

+
diff --git a/client/src/app/slides/motions/motion/motions-motion-slide.component.scss b/client/src/app/slides/motions/motion/motions-motion-slide.component.scss new file mode 100644 index 000000000..05920c613 --- /dev/null +++ b/client/src/app/slides/motions/motion/motions-motion-slide.component.scss @@ -0,0 +1,3 @@ +div { + background-color: red; +} diff --git a/client/src/app/slides/motions/motion/motions-motion-slide.component.spec.ts b/client/src/app/slides/motions/motion/motions-motion-slide.component.spec.ts new file mode 100644 index 000000000..e1f4e7122 --- /dev/null +++ b/client/src/app/slides/motions/motion/motions-motion-slide.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MotionsMotionSlideComponent } from './motions-motion-slide.component'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('MotionsMotionSlideComponent', () => { + let component: MotionsMotionSlideComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [MotionsMotionSlideComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MotionsMotionSlideComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/slides/motions/motion/motions-motion-slide.component.ts b/client/src/app/slides/motions/motion/motions-motion-slide.component.ts new file mode 100644 index 000000000..a283b38f5 --- /dev/null +++ b/client/src/app/slides/motions/motion/motions-motion-slide.component.ts @@ -0,0 +1,18 @@ +import { Component, OnInit } from '@angular/core'; +import { BaseSlideComponent } from 'app/slides/base-slide-component'; +import { MotionsMotionSlideData } from './motions-motion-slide-model'; + +@Component({ + selector: 'os-motions-motion-slide', + templateUrl: './motions-motion-slide.component.html', + styleUrls: ['./motions-motion-slide.component.scss'] +}) +export class MotionsMotionSlideComponent extends BaseSlideComponent implements OnInit { + public constructor() { + super(); + } + + public ngOnInit(): void { + console.log('Hello from motion slide'); + } +} diff --git a/client/src/app/slides/motions/motion/motions-motion-slide.module.spec.ts b/client/src/app/slides/motions/motion/motions-motion-slide.module.spec.ts new file mode 100644 index 000000000..921a779e0 --- /dev/null +++ b/client/src/app/slides/motions/motion/motions-motion-slide.module.spec.ts @@ -0,0 +1,13 @@ +import { MotionsMotionSlideModule } from './motions-motion-slide.module'; + +describe('MotionsMotionSlideModule', () => { + let motionsMotionSlideModule: MotionsMotionSlideModule; + + beforeEach(() => { + motionsMotionSlideModule = new MotionsMotionSlideModule(); + }); + + it('should create an instance', () => { + expect(motionsMotionSlideModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/slides/motions/motion/motions-motion-slide.module.ts b/client/src/app/slides/motions/motion/motions-motion-slide.module.ts new file mode 100644 index 000000000..39f04f618 --- /dev/null +++ b/client/src/app/slides/motions/motion/motions-motion-slide.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; + +import { MotionsMotionSlideComponent } from './motions-motion-slide.component'; +import { makeSlideModule } from 'app/slides/base-slide-module'; + +@NgModule(makeSlideModule(MotionsMotionSlideComponent)) +export class MotionsMotionSlideModule {} diff --git a/client/src/app/slides/services/dynamic-slide-loader.service.ts b/client/src/app/slides/services/dynamic-slide-loader.service.ts new file mode 100644 index 000000000..141b16037 --- /dev/null +++ b/client/src/app/slides/services/dynamic-slide-loader.service.ts @@ -0,0 +1,78 @@ +import { Injectable, Inject, Injector, NgModuleFactoryLoader, ComponentFactory, Type } from '@angular/core'; + +import { SlideManifest, SlideOptions } from '../slide-manifest'; +import { SLIDE } from '../slide-token'; +import { SLIDE_MANIFESTS } from '../slide-manifest'; +import { BaseSlideComponent } from '../base-slide-component'; + +/** + * Cares about loading slides dynamically. + */ +@Injectable() +export class DynamicSlideLoader { + public constructor( + @Inject(SLIDE_MANIFESTS) private manifests: SlideManifest[], + private loader: NgModuleFactoryLoader, + private injector: Injector + ) {} + + /** + * Searches the manifest for the given slide name. + * + * TODO: Improve by loading all manifests in an object with the + * slide name as keys in the constructor. It's just a lookup here, then. + * + * @param slideName The slide to look up. + * @returns the slide's manifest. + */ + private getManifest(slideName: string): SlideManifest { + const manifest = this.manifests.find(m => m.slideName === slideName); + + if (!manifest) { + throw new Error(`Could not find slide for "${slideName}"`); + } + return manifest; + } + + /** + * Get slide options for a given slide. + * + * @param slideName The slide + * @returns SlideOptions for the requested slide. + */ + public getSlideOptions(slideName: string): SlideOptions { + return this.getManifest(slideName); + } + + /** + * Asynchronically load the slide's component factory, which is used to create + * the slide component. + * + * @param slideName The slide to search. + */ + public async getSlideFactory>( + slideName: string + ): Promise> { + const manifest = this.getManifest(slideName); + + // Load the module factory. + return this.loader.load(manifest.loadChildren).then(ngModuleFactory => { + // create the module + const moduleRef = ngModuleFactory.create(this.injector); + + // Get the slide provided by the SLIDE-injectiontoken. + let dynamicComponentType: Type; + try { + // Read from the moduleRef injector and locate the dynamic component type + dynamicComponentType = moduleRef.injector.get(SLIDE); + } catch (e) { + console.log( + 'The module for Slide "' + slideName + '" is not configured right: Make usage of makeSlideModule.' + ); + throw e; + } + // Resolve this component factory + return moduleRef.componentFactoryResolver.resolveComponentFactory(dynamicComponentType); + }); + } +} diff --git a/client/src/app/slides/slide-manifest.ts b/client/src/app/slides/slide-manifest.ts new file mode 100644 index 000000000..cf0f78fd4 --- /dev/null +++ b/client/src/app/slides/slide-manifest.ts @@ -0,0 +1,28 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Slides can have these options. + */ +export interface SlideOptions { + /** + * Should this slide be scrollable? + */ + scrollable: boolean; + + /** + * Should this slide be scaleable? + */ + scaleable: boolean; +} + +/** + * Is similar to router entries, so we can trick the router. Keep slideName and + * path in sync. + */ +export interface SlideManifest extends SlideOptions { + slideName: string; + path: string; + loadChildren: string; +} + +export const SLIDE_MANIFESTS = new InjectionToken('SLIDE_MANIFEST'); diff --git a/client/src/app/slides/slide-token.ts b/client/src/app/slides/slide-token.ts new file mode 100644 index 000000000..f4132ed5b --- /dev/null +++ b/client/src/app/slides/slide-token.ts @@ -0,0 +1,3 @@ +import { InjectionToken } from '@angular/core'; + +export const SLIDE = new InjectionToken('SLIDE'); diff --git a/client/src/app/slides/slides.module.ts b/client/src/app/slides/slides.module.ts new file mode 100644 index 000000000..7c1ef361a --- /dev/null +++ b/client/src/app/slides/slides.module.ts @@ -0,0 +1,31 @@ +import { NgModule, NgModuleFactoryLoader, SystemJsNgModuleLoader } from '@angular/core'; +import { ModuleWithProviders } from '@angular/compiler/src/core'; +import { ROUTES } from '@angular/router'; + +import { DynamicSlideLoader } from './services/dynamic-slide-loader.service'; +import { SLIDE_MANIFESTS } from './slide-manifest'; +import { allSlides } from './all-slides'; + +/** + * This module takes care about all slides and dynamic loading of them. + * + * We (ab)use the ng-router to make one chunk of each slide that can be + * dynamically be loaded. The SlideManifest reassembles a router entry, by + * given a `loadChildren`. During static analysis of the angular CLI, these modules are + * found and put in sepearte chunks. + */ +@NgModule({ + providers: [DynamicSlideLoader, { provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader }] +}) +export class SlidesModule { + public static forRoot(): ModuleWithProviders { + return { + ngModule: SlidesModule, + providers: [ + // provider for Angular CLI to analyze + { provide: ROUTES, useValue: allSlides, multi: true }, + { provide: SLIDE_MANIFESTS, useValue: allSlides } + ] + }; + } +} diff --git a/client/src/app/slides/users/user/users-user-slide-model.ts b/client/src/app/slides/users/user/users-user-slide-model.ts new file mode 100644 index 000000000..2d7b8a59c --- /dev/null +++ b/client/src/app/slides/users/user/users-user-slide-model.ts @@ -0,0 +1,3 @@ +export interface UsersUserSlideData { + test: string; +} diff --git a/client/src/app/slides/users/user/users-user-slide.component.html b/client/src/app/slides/users/user/users-user-slide.component.html new file mode 100644 index 000000000..6b555d8ce --- /dev/null +++ b/client/src/app/slides/users/user/users-user-slide.component.html @@ -0,0 +1,3 @@ +
+ User Slide +
diff --git a/client/src/app/slides/users/user/users-user-slide.component.scss b/client/src/app/slides/users/user/users-user-slide.component.scss new file mode 100644 index 000000000..3e34e6385 --- /dev/null +++ b/client/src/app/slides/users/user/users-user-slide.component.scss @@ -0,0 +1,9 @@ +#outer { + position: absolute; + right: 0; + top: 0; + background-color: green; + height: 30px; + margin: 10px; + z-index: 2; +} diff --git a/client/src/app/slides/users/user/users-user-slide.component.spec.ts b/client/src/app/slides/users/user/users-user-slide.component.spec.ts new file mode 100644 index 000000000..17373a4f9 --- /dev/null +++ b/client/src/app/slides/users/user/users-user-slide.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UsersUserSlideComponent } from './users-user-slide.component'; +import { E2EImportsModule } from '../../../../e2e-imports.module'; + +describe('UsersUserSlideComponent', () => { + let component: UsersUserSlideComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [UsersUserSlideComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UsersUserSlideComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/slides/users/user/users-user-slide.component.ts b/client/src/app/slides/users/user/users-user-slide.component.ts new file mode 100644 index 000000000..846827ee4 --- /dev/null +++ b/client/src/app/slides/users/user/users-user-slide.component.ts @@ -0,0 +1,18 @@ +import { Component, OnInit } from '@angular/core'; +import { BaseSlideComponent } from 'app/slides/base-slide-component'; +import { UsersUserSlideData } from './users-user-slide-model'; + +@Component({ + selector: 'os-users-user-slide', + templateUrl: './users-user-slide.component.html', + styleUrls: ['./users-user-slide.component.scss'] +}) +export class UsersUserSlideComponent extends BaseSlideComponent implements OnInit { + public constructor() { + super(); + } + + public ngOnInit(): void { + console.log('Hello from user slide'); + } +} diff --git a/client/src/app/slides/users/user/users-user-slide.module.spec.ts b/client/src/app/slides/users/user/users-user-slide.module.spec.ts new file mode 100644 index 000000000..6655a6a02 --- /dev/null +++ b/client/src/app/slides/users/user/users-user-slide.module.spec.ts @@ -0,0 +1,13 @@ +import { UsersUserSlideModule } from './users-user-slide.module'; + +describe('UsersUserSlideModule', () => { + let usersUserSlideModule: UsersUserSlideModule; + + beforeEach(() => { + usersUserSlideModule = new UsersUserSlideModule(); + }); + + it('should create an instance', () => { + expect(usersUserSlideModule).toBeTruthy(); + }); +}); diff --git a/client/src/app/slides/users/user/users-user-slide.module.ts b/client/src/app/slides/users/user/users-user-slide.module.ts new file mode 100644 index 000000000..26bd48bb7 --- /dev/null +++ b/client/src/app/slides/users/user/users-user-slide.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; + +import { makeSlideModule } from 'app/slides/base-slide-module'; +import { UsersUserSlideComponent } from './users-user-slide.component'; + +@NgModule(makeSlideModule(UsersUserSlideComponent)) +export class UsersUserSlideModule {} diff --git a/client/src/styles.scss b/client/src/styles.scss index 1f0d7f811..97cafae9a 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -284,9 +284,8 @@ mat-panel-title mat-icon { display: none; } -.checkbox-cell { - flex: 1; - max-width: 30px; +.icon-cell { + flex: 0 0 40px; } // ngx-file-drop requires the custom style in the global css file diff --git a/openslides/core/config_variables.py b/openslides/core/config_variables.py index d5108c62a..54887febf 100644 --- a/openslides/core/config_variables.py +++ b/openslides/core/config_variables.py @@ -243,7 +243,7 @@ def get_config_variables(): name="projector_background_color", default_value="#FFFFFF", input_type="colorpicker", - label="Color for blanked projector", + label="Backgroundolor of the projector", weight=190, group="Projector", ) diff --git a/openslides/core/migrations/0010_auto_20190118_1908.py b/openslides/core/migrations/0010_auto_20190118_1908.py index ec4bc1334..0261f9eea 100644 --- a/openslides/core/migrations/0010_auto_20190118_1908.py +++ b/openslides/core/migrations/0010_auto_20190118_1908.py @@ -16,6 +16,7 @@ class Migration(migrations.Migration): model_name="projector", name="elements", field=jsonfield.fields.JSONField( + default=list, dump_kwargs={ "cls": jsonfield.encoder.JSONEncoder, "separators": (",", ":"), @@ -28,6 +29,7 @@ class Migration(migrations.Migration): model_name="projector", name="elements_history", field=jsonfield.fields.JSONField( + default=list, dump_kwargs={ "cls": jsonfield.encoder.JSONEncoder, "separators": (",", ":"), @@ -40,6 +42,7 @@ class Migration(migrations.Migration): model_name="projector", name="elements_preview", field=jsonfield.fields.JSONField( + default=list, dump_kwargs={ "cls": jsonfield.encoder.JSONEncoder, "separators": (",", ":"), diff --git a/openslides/core/models.py b/openslides/core/models.py index a9eeee8d8..054996d5e 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -72,9 +72,9 @@ class Projector(RESTModelMixin, models.Model): objects = ProjectorManager() - elements = JSONField() - elements_preview = JSONField() - elements_history = JSONField() + elements = JSONField(default=list) + elements_preview = JSONField(default=list) + elements_history = JSONField(default=list) scale = models.IntegerField(default=0) scroll = models.IntegerField(default=0) diff --git a/openslides/core/websocket.py b/openslides/core/websocket.py index 873e24a36..2f6fd4151 100644 --- a/openslides/core/websocket.py +++ b/openslides/core/websocket.py @@ -1,7 +1,7 @@ from typing import Any from ..utils.constants import get_constants -from ..utils.projector import get_projectot_data +from ..utils.projector import get_projector_data from ..utils.websocket import ( BaseWebsocketClientMessage, ProtocollAsyncJsonWebsocketConsumer, @@ -167,7 +167,7 @@ class ListenToProjectors(BaseWebsocketClientMessage): # Send projector data if consumer.listen_projector_ids: - projector_data = await get_projectot_data(consumer.listen_projector_ids) + projector_data = await get_projector_data(consumer.listen_projector_ids) for projector_id, data in projector_data.items(): consumer.projector_hash[projector_id] = hash(str(data)) diff --git a/openslides/motions/projector.py b/openslides/motions/projector.py index 47b279201..568db85a1 100644 --- a/openslides/motions/projector.py +++ b/openslides/motions/projector.py @@ -15,7 +15,7 @@ def motion( """ Motion slide. """ - return {"error": "TODO"} + return {"error": "TODO", "some_key": "another_value"} def motion_block( @@ -28,5 +28,5 @@ def motion_block( def register_projector_elements() -> None: - register_projector_element("motion/motion", motion) - register_projector_element("motion/motion-block", motion_block) + register_projector_element("motions/motion", motion) + register_projector_element("motions/motion-block", motion_block) diff --git a/openslides/urls.py b/openslides/urls.py index f7755a56d..7043024b4 100644 --- a/openslides/urls.py +++ b/openslides/urls.py @@ -1,5 +1,6 @@ from django.conf import settings from django.conf.urls import include, url +from django.views.generic import RedirectView from openslides.mediafiles.views import protected_serve from openslides.utils.rest_api import router @@ -14,7 +15,8 @@ urlpatterns = [ protected_serve, {"document_root": settings.MEDIA_ROOT}, ), - # URLs for the rest system + # URLs for the rest system, redirect /rest to /rest/ + url(r"^rest$", RedirectView.as_view(url="/rest/", permanent=True)), url(r"^rest/", include(router.urls)), # Other urls defined by modules and plugins url(r"^apps/", include("openslides.urls_apps")), diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index f5d8dbad8..ac652efbf 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -8,7 +8,7 @@ from django.db.models import Model from mypy_extensions import TypedDict from .cache import element_cache, get_element_id -from .projector import get_projectot_data +from .projector import get_projector_data from .utils import get_model_from_collection_string @@ -214,7 +214,7 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: "autoupdate", {"type": "send_data", "change_id": change_id} ) - projector_data = await get_projectot_data() + projector_data = await get_projector_data() # Send projector channel_layer = get_channel_layer() await channel_layer.group_send( diff --git a/openslides/utils/projector.py b/openslides/utils/projector.py index f78e02269..a0b4ac246 100644 --- a/openslides/utils/projector.py +++ b/openslides/utils/projector.py @@ -26,7 +26,7 @@ def register_projector_element(name: str, element: ProjectorElementCallable) -> projector_elements[name] = element -async def get_projectot_data( +async def get_projector_data( projector_ids: List[int] = None ) -> Dict[int, List[Dict[str, Any]]]: """