diff options
Diffstat (limited to 'client/src/app/shared')
-rw-r--r-- | client/src/app/shared/misc/utils.ts | 7 | ||||
-rw-r--r-- | client/src/app/shared/shared.module.ts | 8 | ||||
-rw-r--r-- | client/src/app/shared/video/abstract-video-list.html | 22 | ||||
-rw-r--r-- | client/src/app/shared/video/abstract-video-list.ts | 96 | ||||
-rw-r--r-- | client/src/app/shared/video/infinite-scroller.directive.ts | 77 |
5 files changed, 162 insertions, 48 deletions
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts index 6620ac973..e6a697098 100644 --- a/client/src/app/shared/misc/utils.ts +++ b/client/src/app/shared/misc/utils.ts | |||
@@ -55,6 +55,10 @@ function dateToHuman (date: string) { | |||
55 | return datePipe.transform(date, 'medium') | 55 | return datePipe.transform(date, 'medium') |
56 | } | 56 | } |
57 | 57 | ||
58 | function immutableAssign <A, B> (target: A, source: B) { | ||
59 | return Object.assign({}, target, source) | ||
60 | } | ||
61 | |||
58 | function isInSmallView () { | 62 | function isInSmallView () { |
59 | return window.innerWidth < 600 | 63 | return window.innerWidth < 600 |
60 | } | 64 | } |
@@ -70,5 +74,6 @@ export { | |||
70 | getAbsoluteAPIUrl, | 74 | getAbsoluteAPIUrl, |
71 | dateToHuman, | 75 | dateToHuman, |
72 | isInSmallView, | 76 | isInSmallView, |
73 | isInMobileView | 77 | isInMobileView, |
78 | immutableAssign | ||
74 | } | 79 | } |
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index d8f98bdf6..330a0ba84 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts | |||
@@ -4,13 +4,13 @@ import { NgModule } from '@angular/core' | |||
4 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' | 4 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' |
5 | import { RouterModule } from '@angular/router' | 5 | import { RouterModule } from '@angular/router' |
6 | import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' | 6 | import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' |
7 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' | ||
7 | import { MarkdownService } from '@app/videos/shared' | 8 | import { MarkdownService } from '@app/videos/shared' |
8 | import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client' | 9 | import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client' |
9 | 10 | ||
10 | import { BsDropdownModule } from 'ngx-bootstrap/dropdown' | 11 | import { BsDropdownModule } from 'ngx-bootstrap/dropdown' |
11 | import { ModalModule } from 'ngx-bootstrap/modal' | 12 | import { ModalModule } from 'ngx-bootstrap/modal' |
12 | import { TabsModule } from 'ngx-bootstrap/tabs' | 13 | import { TabsModule } from 'ngx-bootstrap/tabs' |
13 | import { InfiniteScrollModule } from 'ngx-infinite-scroll' | ||
14 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' | 14 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' |
15 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' | 15 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' |
16 | 16 | ||
@@ -42,7 +42,6 @@ import { VideoService } from './video/video.service' | |||
42 | ModalModule.forRoot(), | 42 | ModalModule.forRoot(), |
43 | 43 | ||
44 | PrimeSharedModule, | 44 | PrimeSharedModule, |
45 | InfiniteScrollModule, | ||
46 | NgPipesModule, | 45 | NgPipesModule, |
47 | TabsModule.forRoot() | 46 | TabsModule.forRoot() |
48 | ], | 47 | ], |
@@ -55,7 +54,8 @@ import { VideoService } from './video/video.service' | |||
55 | EditButtonComponent, | 54 | EditButtonComponent, |
56 | NumberFormatterPipe, | 55 | NumberFormatterPipe, |
57 | FromNowPipe, | 56 | FromNowPipe, |
58 | MarkdownTextareaComponent | 57 | MarkdownTextareaComponent, |
58 | InfiniteScrollerDirective | ||
59 | ], | 59 | ], |
60 | 60 | ||
61 | exports: [ | 61 | exports: [ |
@@ -70,7 +70,6 @@ import { VideoService } from './video/video.service' | |||
70 | BsDropdownModule, | 70 | BsDropdownModule, |
71 | ModalModule, | 71 | ModalModule, |
72 | PrimeSharedModule, | 72 | PrimeSharedModule, |
73 | InfiniteScrollModule, | ||
74 | BytesPipe, | 73 | BytesPipe, |
75 | KeysPipe, | 74 | KeysPipe, |
76 | 75 | ||
@@ -80,6 +79,7 @@ import { VideoService } from './video/video.service' | |||
80 | DeleteButtonComponent, | 79 | DeleteButtonComponent, |
81 | EditButtonComponent, | 80 | EditButtonComponent, |
82 | MarkdownTextareaComponent, | 81 | MarkdownTextareaComponent, |
82 | InfiniteScrollerDirective, | ||
83 | 83 | ||
84 | NumberFormatterPipe, | 84 | NumberFormatterPipe, |
85 | FromNowPipe | 85 | FromNowPipe |
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html index 60a2563b3..fb7f86852 100644 --- a/client/src/app/shared/video/abstract-video-list.html +++ b/client/src/app/shared/video/abstract-video-list.html | |||
@@ -6,17 +6,17 @@ | |||
6 | <div *ngIf="pagination.totalItems === 0">No results.</div> | 6 | <div *ngIf="pagination.totalItems === 0">No results.</div> |
7 | 7 | ||
8 | <div | 8 | <div |
9 | class="videos" | 9 | myInfiniteScroller |
10 | infiniteScroll | 10 | [pageHeight]="pageHeight" |
11 | [infiniteScrollUpDistance]="1.5" | 11 | (nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)" |
12 | [infiniteScrollDistance]="0.5" | 12 | class="videos" #videoElement |
13 | (scrolled)="onNearOfBottom()" | ||
14 | (scrolledUp)="onNearOfTop()" | ||
15 | > | 13 | > |
16 | <my-video-miniature | 14 | <div *ngFor="let videos of videoPages" class="videos-page"> |
17 | class="ng-animate" | 15 | <my-video-miniature |
18 | *ngFor="let video of videos" [video]="video" [user]="user" | 16 | class="ng-animate" |
19 | > | 17 | *ngFor="let video of videos" [video]="video" [user]="user" |
20 | </my-video-miniature> | 18 | > |
19 | </my-video-miniature> | ||
20 | </div> | ||
21 | </div> | 21 | </div> |
22 | </div> | 22 | </div> |
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts index a25fc532c..034d0d879 100644 --- a/client/src/app/shared/video/abstract-video-list.ts +++ b/client/src/app/shared/video/abstract-video-list.ts | |||
@@ -1,6 +1,7 @@ | |||
1 | import { OnInit } from '@angular/core' | 1 | import { ElementRef, OnInit, ViewChild, ViewChildren } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { isInMobileView, isInSmallView } from '@app/shared/misc/utils' | 3 | import { isInMobileView } from '@app/shared/misc/utils' |
4 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' | ||
4 | import { NotificationsService } from 'angular2-notifications' | 5 | import { NotificationsService } from 'angular2-notifications' |
5 | import { Observable } from 'rxjs/Observable' | 6 | import { Observable } from 'rxjs/Observable' |
6 | import { AuthService } from '../../core/auth' | 7 | import { AuthService } from '../../core/auth' |
@@ -9,30 +10,35 @@ import { SortField } from './sort-field.type' | |||
9 | import { Video } from './video.model' | 10 | import { Video } from './video.model' |
10 | 11 | ||
11 | export abstract class AbstractVideoList implements OnInit { | 12 | export abstract class AbstractVideoList implements OnInit { |
13 | private static LINES_PER_PAGE = 3 | ||
14 | |||
15 | @ViewChild('videoElement') videosElement: ElementRef | ||
16 | @ViewChild(InfiniteScrollerDirective) infiniteScroller: InfiniteScrollerDirective | ||
17 | |||
12 | pagination: ComponentPagination = { | 18 | pagination: ComponentPagination = { |
13 | currentPage: 1, | 19 | currentPage: 1, |
14 | itemsPerPage: 25, | 20 | itemsPerPage: 10, |
15 | totalItems: null | 21 | totalItems: null |
16 | } | 22 | } |
17 | sort: SortField = '-createdAt' | 23 | sort: SortField = '-createdAt' |
18 | defaultSort: SortField = '-createdAt' | 24 | defaultSort: SortField = '-createdAt' |
19 | videos: Video[] = [] | ||
20 | loadOnInit = true | 25 | loadOnInit = true |
26 | pageHeight: number | ||
27 | videoWidth = 215 | ||
28 | videoHeight = 230 | ||
29 | videoPages: Video[][] | ||
21 | 30 | ||
22 | protected abstract notificationsService: NotificationsService | 31 | protected abstract notificationsService: NotificationsService |
23 | protected abstract authService: AuthService | 32 | protected abstract authService: AuthService |
24 | protected abstract router: Router | 33 | protected abstract router: Router |
25 | protected abstract route: ActivatedRoute | 34 | protected abstract route: ActivatedRoute |
26 | |||
27 | protected abstract currentRoute: string | 35 | protected abstract currentRoute: string |
28 | |||
29 | abstract titlePage: string | 36 | abstract titlePage: string |
30 | 37 | ||
31 | protected otherParams = {} | 38 | protected loadedPages: { [ id: number ]: Video[] } = {} |
32 | 39 | protected otherRouteParams = {} | |
33 | private loadedPages: { [ id: number ]: boolean } = {} | ||
34 | 40 | ||
35 | abstract getVideosObservable (): Observable<{ videos: Video[], totalVideos: number}> | 41 | abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}> |
36 | 42 | ||
37 | get user () { | 43 | get user () { |
38 | return this.authService.getUser() | 44 | return this.authService.getUser() |
@@ -45,15 +51,26 @@ export abstract class AbstractVideoList implements OnInit { | |||
45 | 51 | ||
46 | if (isInMobileView()) { | 52 | if (isInMobileView()) { |
47 | this.pagination.itemsPerPage = 5 | 53 | this.pagination.itemsPerPage = 5 |
54 | this.videoWidth = -1 | ||
55 | } | ||
56 | |||
57 | if (this.videoWidth !== -1) { | ||
58 | const videosWidth = this.videosElement.nativeElement.offsetWidth | ||
59 | this.pagination.itemsPerPage = Math.floor(videosWidth / this.videoWidth) * AbstractVideoList.LINES_PER_PAGE | ||
60 | } | ||
61 | |||
62 | // Video takes all the width | ||
63 | if (this.videoWidth === -1) { | ||
64 | this.pageHeight = this.pagination.itemsPerPage * this.videoHeight | ||
65 | } else { | ||
66 | this.pageHeight = this.videoHeight * AbstractVideoList.LINES_PER_PAGE | ||
48 | } | 67 | } |
49 | 68 | ||
50 | if (this.loadOnInit === true) this.loadMoreVideos('after') | 69 | if (this.loadOnInit === true) this.loadMoreVideos(this.pagination.currentPage) |
51 | } | 70 | } |
52 | 71 | ||
53 | onNearOfTop () { | 72 | onNearOfTop () { |
54 | if (this.pagination.currentPage > 1) { | 73 | this.previousPage() |
55 | this.previousPage() | ||
56 | } | ||
57 | } | 74 | } |
58 | 75 | ||
59 | onNearOfBottom () { | 76 | onNearOfBottom () { |
@@ -62,16 +79,20 @@ export abstract class AbstractVideoList implements OnInit { | |||
62 | } | 79 | } |
63 | } | 80 | } |
64 | 81 | ||
82 | onPageChanged (page: number) { | ||
83 | this.pagination.currentPage = page | ||
84 | this.setNewRouteParams() | ||
85 | } | ||
86 | |||
65 | reloadVideos () { | 87 | reloadVideos () { |
66 | this.videos = [] | ||
67 | this.loadedPages = {} | 88 | this.loadedPages = {} |
68 | this.loadMoreVideos('before') | 89 | this.loadMoreVideos(this.pagination.currentPage) |
69 | } | 90 | } |
70 | 91 | ||
71 | loadMoreVideos (where: 'before' | 'after') { | 92 | loadMoreVideos (page: number) { |
72 | if (this.loadedPages[this.pagination.currentPage] === true) return | 93 | if (this.loadedPages[page] !== undefined) return |
73 | 94 | ||
74 | const observable = this.getVideosObservable() | 95 | const observable = this.getVideosObservable(page) |
75 | 96 | ||
76 | observable.subscribe( | 97 | observable.subscribe( |
77 | ({ videos, totalVideos }) => { | 98 | ({ videos, totalVideos }) => { |
@@ -82,13 +103,14 @@ export abstract class AbstractVideoList implements OnInit { | |||
82 | return this.reloadVideos() | 103 | return this.reloadVideos() |
83 | } | 104 | } |
84 | 105 | ||
85 | this.loadedPages[this.pagination.currentPage] = true | 106 | this.loadedPages[page] = videos |
107 | this.buildVideoPages() | ||
86 | this.pagination.totalItems = totalVideos | 108 | this.pagination.totalItems = totalVideos |
87 | 109 | ||
88 | if (where === 'before') { | 110 | // Initialize infinite scroller now we loaded the first page |
89 | this.videos = videos.concat(this.videos) | 111 | if (Object.keys(this.loadedPages).length === 1) { |
90 | } else { | 112 | // Wait elements creation |
91 | this.videos = this.videos.concat(videos) | 113 | setTimeout(() => this.infiniteScroller.initialize(), 500) |
92 | } | 114 | } |
93 | }, | 115 | }, |
94 | error => this.notificationsService.error('Error', error.message) | 116 | error => this.notificationsService.error('Error', error.message) |
@@ -107,17 +129,15 @@ export abstract class AbstractVideoList implements OnInit { | |||
107 | } | 129 | } |
108 | 130 | ||
109 | protected previousPage () { | 131 | protected previousPage () { |
110 | this.pagination.currentPage-- | 132 | const min = this.minPageLoaded() |
111 | 133 | ||
112 | this.setNewRouteParams() | 134 | if (min > 1) { |
113 | this.loadMoreVideos('before') | 135 | this.loadMoreVideos(min - 1) |
136 | } | ||
114 | } | 137 | } |
115 | 138 | ||
116 | protected nextPage () { | 139 | protected nextPage () { |
117 | this.pagination.currentPage++ | 140 | this.loadMoreVideos(this.maxPageLoaded() + 1) |
118 | |||
119 | this.setNewRouteParams() | ||
120 | this.loadMoreVideos('after') | ||
121 | } | 141 | } |
122 | 142 | ||
123 | protected buildRouteParams () { | 143 | protected buildRouteParams () { |
@@ -127,7 +147,7 @@ export abstract class AbstractVideoList implements OnInit { | |||
127 | page: this.pagination.currentPage | 147 | page: this.pagination.currentPage |
128 | } | 148 | } |
129 | 149 | ||
130 | return Object.assign(params, this.otherParams) | 150 | return Object.assign(params, this.otherRouteParams) |
131 | } | 151 | } |
132 | 152 | ||
133 | protected loadRouteParams (routeParams: { [ key: string ]: any }) { | 153 | protected loadRouteParams (routeParams: { [ key: string ]: any }) { |
@@ -144,4 +164,16 @@ export abstract class AbstractVideoList implements OnInit { | |||
144 | const routeParams = this.buildRouteParams() | 164 | const routeParams = this.buildRouteParams() |
145 | this.router.navigate([ this.currentRoute, routeParams ]) | 165 | this.router.navigate([ this.currentRoute, routeParams ]) |
146 | } | 166 | } |
167 | |||
168 | protected buildVideoPages () { | ||
169 | this.videoPages = Object.values(this.loadedPages) | ||
170 | } | ||
171 | |||
172 | private minPageLoaded () { | ||
173 | return Math.min(...Object.keys(this.loadedPages).map(e => parseInt(e, 10))) | ||
174 | } | ||
175 | |||
176 | private maxPageLoaded () { | ||
177 | return Math.max(...Object.keys(this.loadedPages).map(e => parseInt(e, 10))) | ||
178 | } | ||
147 | } | 179 | } |
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts new file mode 100644 index 000000000..43e014cbd --- /dev/null +++ b/client/src/app/shared/video/infinite-scroller.directive.ts | |||
@@ -0,0 +1,77 @@ | |||
1 | import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core' | ||
2 | import 'rxjs/add/operator/distinct' | ||
3 | import 'rxjs/add/operator/startWith' | ||
4 | import { fromEvent } from 'rxjs/observable/fromEvent' | ||
5 | |||
6 | @Directive({ | ||
7 | selector: '[myInfiniteScroller]' | ||
8 | }) | ||
9 | export class InfiniteScrollerDirective implements OnInit { | ||
10 | private static PAGE_VIEW_TOP_MARGIN = 500 | ||
11 | |||
12 | @Input() containerHeight: number | ||
13 | @Input() pageHeight: number | ||
14 | @Input() percentLimit = 70 | ||
15 | @Input() autoLoading = false | ||
16 | |||
17 | @Output() nearOfBottom = new EventEmitter<void>() | ||
18 | @Output() nearOfTop = new EventEmitter<void>() | ||
19 | @Output() pageChanged = new EventEmitter<number>() | ||
20 | |||
21 | private decimalLimit = 0 | ||
22 | private lastCurrentBottom = -1 | ||
23 | private lastCurrentTop = 0 | ||
24 | |||
25 | constructor () { | ||
26 | this.decimalLimit = this.percentLimit / 100 | ||
27 | } | ||
28 | |||
29 | ngOnInit () { | ||
30 | if (this.autoLoading === true) return this.initialize() | ||
31 | } | ||
32 | |||
33 | initialize () { | ||
34 | const scrollObservable = fromEvent(window, 'scroll') | ||
35 | .startWith(true) | ||
36 | .map(() => ({ current: window.scrollY, maximumScroll: document.body.clientHeight - window.innerHeight })) | ||
37 | |||
38 | // Scroll Down | ||
39 | scrollObservable | ||
40 | // Check we scroll down | ||
41 | .filter(({ current }) => { | ||
42 | const res = this.lastCurrentBottom < current | ||
43 | |||
44 | this.lastCurrentBottom = current | ||
45 | return res | ||
46 | }) | ||
47 | .filter(({ current, maximumScroll }) => maximumScroll <= 0 || (current / maximumScroll) > this.decimalLimit) | ||
48 | .debounceTime(200) | ||
49 | .distinct() | ||
50 | .subscribe(() => this.nearOfBottom.emit()) | ||
51 | |||
52 | // Scroll up | ||
53 | scrollObservable | ||
54 | // Check we scroll up | ||
55 | .filter(({ current }) => { | ||
56 | const res = this.lastCurrentTop > current | ||
57 | |||
58 | this.lastCurrentTop = current | ||
59 | return res | ||
60 | }) | ||
61 | .filter(({ current, maximumScroll }) => { | ||
62 | return current !== 0 && (1 - (current / maximumScroll)) > this.decimalLimit | ||
63 | }) | ||
64 | .debounceTime(200) | ||
65 | .distinct() | ||
66 | .subscribe(() => this.nearOfTop.emit()) | ||
67 | |||
68 | // Page change | ||
69 | scrollObservable | ||
70 | .debounceTime(500) | ||
71 | .distinct() | ||
72 | .map(({ current }) => Math.max(1, Math.round((current + InfiniteScrollerDirective.PAGE_VIEW_TOP_MARGIN) / this.pageHeight))) | ||
73 | .distinctUntilChanged() | ||
74 | .subscribe(res => this.pageChanged.emit(res)) | ||
75 | } | ||
76 | |||
77 | } | ||