aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared/shared-video-miniature
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared/shared-video-miniature')
-rw-r--r--client/src/app/shared/shared-video-miniature/abstract-video-list.ts404
-rw-r--r--client/src/app/shared/shared-video-miniature/index.ts5
-rw-r--r--client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts14
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.scss1
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.html131
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.scss139
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.ts119
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters.model.ts240
-rw-r--r--client/src/app/shared/shared-video-miniature/video-list-header.component.html5
-rw-r--r--client/src/app/shared/shared-video-miniature/video-list-header.component.ts22
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.html (renamed from client/src/app/shared/shared-video-miniature/abstract-video-list.html)41
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.scss (renamed from client/src/app/shared/shared-video-miniature/abstract-video-list.scss)71
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.ts396
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-selection.component.html5
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-selection.component.ts106
15 files changed, 1172 insertions, 527 deletions
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts
deleted file mode 100644
index f12ae2ee5..000000000
--- a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts
+++ /dev/null
@@ -1,404 +0,0 @@
1import { fromEvent, Observable, ReplaySubject, Subject, Subscription } from 'rxjs'
2import { debounceTime, switchMap, tap } from 'rxjs/operators'
3import {
4 AfterContentInit,
5 ComponentFactoryResolver,
6 Directive,
7 Injector,
8 OnDestroy,
9 OnInit,
10 Type,
11 ViewChild,
12 ViewContainerRef
13} from '@angular/core'
14import { ActivatedRoute, Params, Router } from '@angular/router'
15import {
16 AuthService,
17 ComponentPaginationLight,
18 LocalStorageService,
19 Notifier,
20 ScreenService,
21 ServerService,
22 User,
23 UserService
24} from '@app/core'
25import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
26import { GlobalIconName } from '@app/shared/shared-icons'
27import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
28import { HTMLServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/models'
29import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
30import { Syndication, Video } from '../shared-main'
31import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component'
32import { MiniatureDisplayOptions } from './video-miniature.component'
33
34enum GroupDate {
35 UNKNOWN = 0,
36 TODAY = 1,
37 YESTERDAY = 2,
38 THIS_WEEK = 3,
39 THIS_MONTH = 4,
40 LAST_MONTH = 5,
41 OLDER = 6
42}
43
44@Directive()
45// eslint-disable-next-line @angular-eslint/directive-class-suffix
46export abstract class AbstractVideoList implements OnInit, OnDestroy, AfterContentInit, DisableForReuseHook {
47 @ViewChild('videoListHeader', { static: true, read: ViewContainerRef }) videoListHeader: ViewContainerRef
48
49 HeaderComponent: Type<GenericHeaderComponent> = VideoListHeaderComponent
50 headerComponentInjector: Injector
51
52 pagination: ComponentPaginationLight = {
53 currentPage: 1,
54 itemsPerPage: 25
55 }
56 sort: VideoSortField = '-publishedAt'
57
58 categoryOneOf?: number[]
59 languageOneOf?: string[]
60 nsfwPolicy?: NSFWPolicyType
61 defaultSort: VideoSortField = '-publishedAt'
62
63 syndicationItems: Syndication[] = []
64
65 loadOnInit = true
66 loadUserVideoPreferences = false
67
68 displayModerationBlock = false
69 titleTooltip: string
70 displayVideoActions = true
71 groupByDate = false
72
73 videos: Video[] = []
74 hasDoneFirstQuery = false
75 disabled = false
76
77 displayOptions: MiniatureDisplayOptions = {
78 date: true,
79 views: true,
80 by: true,
81 avatar: false,
82 privacyLabel: true,
83 privacyText: false,
84 state: false,
85 blacklistInfo: false
86 }
87
88 actions: {
89 iconName: GlobalIconName
90 label: string
91 justIcon?: boolean
92 routerLink?: string
93 href?: string
94 click?: (e: Event) => void
95 }[] = []
96
97 onDataSubject = new Subject<any[]>()
98
99 userMiniature: User
100
101 protected onUserLoadedSubject = new ReplaySubject<void>(1)
102
103 protected serverConfig: HTMLServerConfig
104
105 protected abstract notifier: Notifier
106 protected abstract authService: AuthService
107 protected abstract userService: UserService
108 protected abstract route: ActivatedRoute
109 protected abstract serverService: ServerService
110 protected abstract screenService: ScreenService
111 protected abstract storageService: LocalStorageService
112 protected abstract router: Router
113 protected abstract cfr: ComponentFactoryResolver
114 abstract titlePage: string
115
116 private resizeSubscription: Subscription
117 private angularState: number
118
119 private groupedDateLabels: { [id in GroupDate]: string }
120 private groupedDates: { [id: number]: GroupDate } = {}
121
122 private lastQueryLength: number
123
124 abstract getVideosObservable (page: number): Observable<{ data: Video[] }>
125
126 abstract generateSyndicationList (): void
127
128 ngOnInit () {
129 this.serverConfig = this.serverService.getHTMLConfig()
130
131 this.groupedDateLabels = {
132 [GroupDate.UNKNOWN]: null,
133 [GroupDate.TODAY]: $localize`Today`,
134 [GroupDate.YESTERDAY]: $localize`Yesterday`,
135 [GroupDate.THIS_WEEK]: $localize`This week`,
136 [GroupDate.THIS_MONTH]: $localize`This month`,
137 [GroupDate.LAST_MONTH]: $localize`Last month`,
138 [GroupDate.OLDER]: $localize`Older`
139 }
140
141 // Subscribe to route changes
142 const routeParams = this.route.snapshot.queryParams
143 this.loadRouteParams(routeParams)
144
145 this.resizeSubscription = fromEvent(window, 'resize')
146 .pipe(debounceTime(500))
147 .subscribe(() => this.calcPageSizes())
148
149 this.calcPageSizes()
150
151 const loadUserObservable = this.loadUserAndSettings()
152 loadUserObservable.subscribe(() => {
153 this.onUserLoadedSubject.next()
154
155 if (this.loadOnInit === true) this.loadMoreVideos()
156 })
157
158 this.userService.listenAnonymousUpdate()
159 .pipe(switchMap(() => this.loadUserAndSettings()))
160 .subscribe(() => {
161 if (this.hasDoneFirstQuery) this.reloadVideos()
162 })
163
164 // Display avatar in mobile view
165 if (this.screenService.isInMobileView()) {
166 this.displayOptions.avatar = true
167 }
168 }
169
170 ngOnDestroy () {
171 if (this.resizeSubscription) this.resizeSubscription.unsubscribe()
172 }
173
174 ngAfterContentInit () {
175 if (this.videoListHeader) {
176 // some components don't use the header: they use their own template, like my-history.component.html
177 this.setHeader(this.HeaderComponent, this.headerComponentInjector)
178 }
179 }
180
181 disableForReuse () {
182 this.disabled = true
183 }
184
185 enabledForReuse () {
186 this.disabled = false
187 }
188
189 videoById (index: number, video: Video) {
190 return video.id
191 }
192
193 onNearOfBottom () {
194 if (this.disabled) return
195
196 // No more results
197 if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return
198
199 this.pagination.currentPage += 1
200
201 this.setScrollRouteParams()
202
203 this.loadMoreVideos()
204 }
205
206 loadMoreVideos (reset = false) {
207 this.getVideosObservable(this.pagination.currentPage)
208 .subscribe({
209 next: ({ data }) => {
210 this.hasDoneFirstQuery = true
211 this.lastQueryLength = data.length
212
213 if (reset) this.videos = []
214 this.videos = this.videos.concat(data)
215
216 if (this.groupByDate) this.buildGroupedDateLabels()
217
218 this.onMoreVideos()
219
220 this.onDataSubject.next(data)
221 },
222
223 error: err => {
224 const message = $localize`Cannot load more videos. Try again later.`
225
226 console.error(message, { err })
227 this.notifier.error(message)
228 }
229 })
230 }
231
232 reloadVideos () {
233 this.pagination.currentPage = 1
234 this.loadMoreVideos(true)
235 }
236
237 removeVideoFromArray (video: Video) {
238 this.videos = this.videos.filter(v => v.id !== video.id)
239 }
240
241 buildGroupedDateLabels () {
242 let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
243
244 const periods = [
245 {
246 value: GroupDate.TODAY,
247 validator: (d: Date) => isToday(d)
248 },
249 {
250 value: GroupDate.YESTERDAY,
251 validator: (d: Date) => isYesterday(d)
252 },
253 {
254 value: GroupDate.THIS_WEEK,
255 validator: (d: Date) => isLastWeek(d)
256 },
257 {
258 value: GroupDate.THIS_MONTH,
259 validator: (d: Date) => isThisMonth(d)
260 },
261 {
262 value: GroupDate.LAST_MONTH,
263 validator: (d: Date) => isLastMonth(d)
264 },
265 {
266 value: GroupDate.OLDER,
267 validator: () => true
268 }
269 ]
270
271 for (const video of this.videos) {
272 const publishedDate = video.publishedAt
273
274 for (let i = 0; i < periods.length; i++) {
275 const period = periods[i]
276
277 if (currentGroupedDate <= period.value && period.validator(publishedDate)) {
278
279 if (currentGroupedDate !== period.value) {
280 currentGroupedDate = period.value
281 this.groupedDates[video.id] = currentGroupedDate
282 }
283
284 break
285 }
286 }
287 }
288 }
289
290 getCurrentGroupedDateLabel (video: Video) {
291 if (this.groupByDate === false) return undefined
292
293 return this.groupedDateLabels[this.groupedDates[video.id]]
294 }
295
296 toggleModerationDisplay () {
297 throw new Error('toggleModerationDisplay ' + $localize`function is not implemented`)
298 }
299
300 setHeader (
301 t: Type<any> = this.HeaderComponent,
302 i: Injector = this.headerComponentInjector
303 ) {
304 const injector = i || Injector.create({
305 providers: [ {
306 provide: 'data',
307 useValue: {
308 titlePage: this.titlePage,
309 titleTooltip: this.titleTooltip
310 }
311 } ]
312 })
313 const viewContainerRef = this.videoListHeader
314 viewContainerRef.clear()
315
316 const componentFactory = this.cfr.resolveComponentFactory(t)
317 viewContainerRef.createComponent(componentFactory, 0, injector)
318 }
319
320 // Can be redefined by child
321 displayAsRow () {
322 return false
323 }
324
325 // On videos hook for children that want to do something
326 protected onMoreVideos () { /* empty */ }
327
328 protected load () { /* empty */ }
329
330 // Hook if the page has custom route params
331 protected loadPageRouteParams (_queryParams: Params) { /* empty */ }
332
333 protected loadRouteParams (queryParams: Params) {
334 this.sort = queryParams['sort'] as VideoSortField || this.defaultSort
335 this.categoryOneOf = queryParams['categoryOneOf']
336 this.angularState = queryParams['a-state']
337
338 this.loadPageRouteParams(queryParams)
339 }
340
341 protected buildLocalFilter (existing: VideoFilter, base: VideoFilter) {
342 if (base === 'local') {
343 return existing === 'local'
344 ? 'all-local' as 'all-local'
345 : 'local' as 'local'
346 }
347
348 return existing === 'all'
349 ? null
350 : 'all'
351 }
352
353 protected enableAllFilterIfPossible () {
354 if (!this.authService.isLoggedIn()) return
355
356 this.authService.userInformationLoaded
357 .subscribe(() => {
358 const user = this.authService.getUser()
359 this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS)
360 })
361 }
362
363 private calcPageSizes () {
364 if (this.screenService.isInMobileView()) {
365 this.pagination.itemsPerPage = 5
366 }
367 }
368
369 private setScrollRouteParams () {
370 // Already set
371 if (this.angularState) return
372
373 this.angularState = 42
374
375 const queryParams = {
376 'a-state': this.angularState,
377 categoryOneOf: this.categoryOneOf
378 }
379
380 let path = this.getUrlWithoutParams()
381 if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute
382
383 this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
384 }
385
386 private loadUserAndSettings () {
387 return this.userService.getAnonymousOrLoggedUser()
388 .pipe(tap(user => {
389 this.userMiniature = user
390
391 if (!this.loadUserVideoPreferences) return
392
393 this.languageOneOf = user.videoLanguages
394 this.nsfwPolicy = user.nsfwPolicy
395 }))
396 }
397
398 private getUrlWithoutParams () {
399 const urlTree = this.router.parseUrl(this.router.url)
400 urlTree.queryParams = {}
401
402 return urlTree.toString()
403 }
404}
diff --git a/client/src/app/shared/shared-video-miniature/index.ts b/client/src/app/shared/shared-video-miniature/index.ts
index a8fd82bb9..0086d8e6a 100644
--- a/client/src/app/shared/shared-video-miniature/index.ts
+++ b/client/src/app/shared/shared-video-miniature/index.ts
@@ -1,7 +1,8 @@
1export * from './abstract-video-list'
2export * from './video-actions-dropdown.component' 1export * from './video-actions-dropdown.component'
3export * from './video-download.component' 2export * from './video-download.component'
3export * from './video-filters-header.component'
4export * from './video-filters.model'
4export * from './video-miniature.component' 5export * from './video-miniature.component'
6export * from './videos-list.component'
5export * from './videos-selection.component' 7export * from './videos-selection.component'
6export * from './video-list-header.component'
7export * from './shared-video-miniature.module' 8export * from './shared-video-miniature.module'
diff --git a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts
index 03be6d2ff..632213922 100644
--- a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts
+++ b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts
@@ -1,19 +1,20 @@
1 1
2import { NgModule } from '@angular/core' 2import { NgModule } from '@angular/core'
3import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
3import { SharedFormModule } from '../shared-forms' 4import { SharedFormModule } from '../shared-forms'
4import { SharedGlobalIconModule } from '../shared-icons' 5import { SharedGlobalIconModule } from '../shared-icons'
5import { SharedMainModule } from '../shared-main/shared-main.module' 6import { SharedMainModule } from '../shared-main/shared-main.module'
6import { SharedModerationModule } from '../shared-moderation' 7import { SharedModerationModule } from '../shared-moderation'
7import { SharedVideoModule } from '../shared-video'
8import { SharedThumbnailModule } from '../shared-thumbnail' 8import { SharedThumbnailModule } from '../shared-thumbnail'
9import { SharedVideoModule } from '../shared-video'
9import { SharedVideoLiveModule } from '../shared-video-live' 10import { SharedVideoLiveModule } from '../shared-video-live'
10import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' 11import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module'
11import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' 12import { VideoActionsDropdownComponent } from './video-actions-dropdown.component'
12import { VideoDownloadComponent } from './video-download.component' 13import { VideoDownloadComponent } from './video-download.component'
14import { VideoFiltersHeaderComponent } from './video-filters-header.component'
13import { VideoMiniatureComponent } from './video-miniature.component' 15import { VideoMiniatureComponent } from './video-miniature.component'
16import { VideosListComponent } from './videos-list.component'
14import { VideosSelectionComponent } from './videos-selection.component' 17import { VideosSelectionComponent } from './videos-selection.component'
15import { VideoListHeaderComponent } from './video-list-header.component'
16import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
17 18
18@NgModule({ 19@NgModule({
19 imports: [ 20 imports: [
@@ -33,14 +34,17 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image
33 VideoDownloadComponent, 34 VideoDownloadComponent,
34 VideoMiniatureComponent, 35 VideoMiniatureComponent,
35 VideosSelectionComponent, 36 VideosSelectionComponent,
36 VideoListHeaderComponent 37 VideoFiltersHeaderComponent,
38 VideosListComponent
37 ], 39 ],
38 40
39 exports: [ 41 exports: [
40 VideoActionsDropdownComponent, 42 VideoActionsDropdownComponent,
41 VideoDownloadComponent, 43 VideoDownloadComponent,
42 VideoMiniatureComponent, 44 VideoMiniatureComponent,
43 VideosSelectionComponent 45 VideosSelectionComponent,
46 VideoFiltersHeaderComponent,
47 VideosListComponent
44 ], 48 ],
45 49
46 providers: [ ] 50 providers: [ ]
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.scss b/client/src/app/shared/shared-video-miniature/video-download.component.scss
index c986228d9..bd42f4813 100644
--- a/client/src/app/shared/shared-video-miniature/video-download.component.scss
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.scss
@@ -39,7 +39,6 @@
39 margin-top: 20px; 39 margin-top: 20px;
40 40
41 .peertube-radio-container { 41 .peertube-radio-container {
42 @include peertube-radio-container;
43 @include margin-right(30px); 42 @include margin-right(30px);
44 43
45 display: inline-block; 44 display: inline-block;
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
new file mode 100644
index 000000000..44c21c089
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
@@ -0,0 +1,131 @@
1<ng-template #updateSettings let-fragment>
2 <div class="label-description text-muted" i18n>
3 Update
4 <a routerLink="/my-account/settings" [fragment]="fragment">
5 <span (click)="onAccountSettingsClick($event)">your settings</span>
6 </a
7 ></div>
8</ng-template>
9
10
11<div class="root" [formGroup]="form">
12
13 <div class="first-row">
14 <div class="active-filters">
15 <div
16 class="pastille filters-toggle" (click)="areFiltersCollapsed = !areFiltersCollapsed" role="button"
17 [attr.aria-expanded]="!areFiltersCollapsed" aria-controls="collapseBasic"
18 [ngClass]="{ active: !areFiltersCollapsed }"
19 >
20 <ng-container i18n *ngIf="areFiltersCollapsed">More filters</ng-container>
21 <ng-container i18n *ngIf="!areFiltersCollapsed">Less filters</ng-container>
22
23 <my-global-icon iconName="chevrons-up"></my-global-icon>
24 </div>
25
26 <div
27 *ngFor="let activeFilter of filters.getActiveFilters()" (click)="resetFilter(activeFilter.key, activeFilter.canRemove)"
28 class="active-filter pastille" [ngClass]="{ 'can-remove': activeFilter.canRemove }" [title]="getFilterTitle(activeFilter.canRemove)"
29 >
30 <span>
31 {{ activeFilter.label }}
32
33 <ng-container *ngIf="activeFilter.value">: {{ activeFilter.value }}</ng-container>
34 </span>
35
36 <my-global-icon *ngIf="activeFilter.canRemove" iconName="cross"></my-global-icon>
37 </div>
38 </div>
39
40 <ng-select
41 class="sort"
42 formControlName="sort"
43 [clearable]="false"
44 [searchable]="false"
45 >
46 <ng-option i18n value="-publishedAt">Sort by <strong>"Recently Added"</strong></ng-option>
47
48 <ng-option i18n *ngIf="isTrendingSortEnabled('most-viewed')" value="-trending">Sort by <strong>"Views"</strong></ng-option>
49 <ng-option i18n *ngIf="isTrendingSortEnabled('hot')" value="-hot">Sort by <strong>"Hot"</strong></ng-option>
50 <ng-option i18n *ngIf="isTrendingSortEnabled('best')" value="-best">Sort by <strong>"Best"</strong></ng-option>
51 <ng-option i18n *ngIf="isTrendingSortEnabled('most-liked')" value="-likes">Sort by <strong>"Likes"</strong></ng-option>
52 </ng-select>
53
54 </div>
55
56 <div class="collapse-transition" [ngbCollapse]="areFiltersCollapsed">
57 <div class="filters">
58 <div class="form-group">
59 <label class="with-description" for="languageOneOf" i18n>Languages:</label>
60 <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-languages-subtitles' }"></ng-template>
61
62 <my-select-languages [maxLanguages]="20" formControlName="languageOneOf"></my-select-languages>
63 </div>
64
65 <div class="form-group">
66 <label class="with-description" for="nsfw" i18n>Sensitive content:</label>
67 <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-sensitive-content-policy' }"></ng-template>
68
69 <div class="peertube-radio-container">
70 <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwBoth" i18n-value value="both" />
71 <label for="nsfwBoth">{{ filters.getNSFWDisplayLabel() }}</label>
72 </div>
73
74 <div class="peertube-radio-container">
75 <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwFalse" i18n-value value="false" />
76 <label for="nsfwFalse" i18n>Hide</label>
77 </div>
78 </div>
79
80 <div class="form-group">
81 <label for="scope" i18n>Scope:</label>
82
83 <div class="peertube-radio-container">
84 <input formControlName="scope" type="radio" name="scope" id="scopeLocal" i18n-value value="local" />
85 <label for="scopeLocal" i18n>Local videos (this instance)</label>
86 </div>
87
88 <div class="peertube-radio-container">
89 <input formControlName="scope" type="radio" name="scope" id="scopeFederated" i18n-value value="federated" />
90 <label for="scopeFederated" i18n>Federated videos (this instance + followed instances)</label>
91 </div>
92 </div>
93
94 <div class="form-group">
95 <label for="type" i18n>Type:</label>
96
97 <div class="peertube-radio-container">
98 <input formControlName="live" type="radio" name="live" id="liveBoth" i18n-value value="both" />
99 <label for="liveBoth" i18n>VOD & Live videos</label>
100 </div>
101
102 <div class="peertube-radio-container">
103 <input formControlName="live" type="radio" name="live" id="liveTrue" i18n-value value="true" />
104 <label for="liveTrue" i18n>Live videos</label>
105 </div>
106
107 <div class="peertube-radio-container">
108 <input formControlName="live" type="radio" name="live" id="liveFalse" i18n-value value="false" />
109 <label for="liveFalse" i18n>VOD videos</label>
110 </div>
111 </div>
112
113 <div class="form-group">
114 <label for="categoryOneOf" i18n>Categories:</label>
115
116 <my-select-categories formControlName="categoryOneOf"></my-select-categories>
117 </div>
118
119 <div class="form-group" *ngIf="canSeeAllVideos()">
120 <label for="allVideos" i18n>Moderation:</label>
121
122 <my-peertube-checkbox
123 formControlName="allVideos"
124 inputName="allVideos"
125 i18n-labelText labelText="Display all videos (private, unlisted or not yet published)"
126 ></my-peertube-checkbox>
127 </div>
128 </div>
129 </div>
130
131</div>
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
new file mode 100644
index 000000000..8cb1ff5b8
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
@@ -0,0 +1,139 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.root {
5 margin-bottom: 45px;
6 font-size: 15px;
7}
8
9.first-row {
10 display: flex;
11 justify-content: space-between;
12}
13
14.active-filters {
15 display: flex;
16 flex-wrap: wrap;
17}
18
19.filters {
20 display: flex;
21 flex-wrap: wrap;
22 margin-top: 25px;
23
24 border-bottom: 1px solid $separator-border-color;
25
26 input[type=radio] + label {
27 font-weight: $font-regular;
28 }
29
30 .form-group > label:first-child {
31 display: block;
32
33 &.with-description {
34 margin-bottom: 0;
35 }
36
37 &:not(.with-description) {
38 margin-bottom: 10px;
39 }
40 }
41
42 .form-group {
43 @include margin-right(30px);
44 }
45}
46
47.pastille {
48 @include margin-right(15px);
49
50 border-radius: 24px;
51 padding: 4px 15px;
52 font-size: 16px;
53 margin-bottom: 15px;
54 cursor: pointer;
55}
56
57.filters-toggle {
58 border: 2px solid pvar(--mainForegroundColor);
59
60 my-global-icon {
61 @include margin-left(5px);
62 }
63
64 &.active my-global-icon {
65 position: relative;
66 top: -1px;
67 }
68
69 &:not(.active) {
70 my-global-icon ::ng-deep svg {
71 transform: rotate(180deg);
72 }
73 }
74}
75
76// Than have an icon
77.filters-toggle,
78.active-filter.can-remove {
79 padding: 4px 11px 4px 15px;
80}
81
82.active-filter {
83 background-color: pvar(--channelBackgroundColor);
84 display: flex;
85 align-items: center;
86
87 &:not(.can-remove) {
88 cursor: default;
89 }
90
91 &.can-remove:hover {
92 opacity: 0.9;
93 }
94
95 my-global-icon {
96 @include margin-left(10px);
97
98 width: 16px;
99 color: pvar(--greyForegroundColor);
100 }
101}
102
103.sort {
104 min-width: 200px;
105 max-width: 300px;
106 height: min-content;
107
108 ::ng-deep {
109 .ng-select-container {
110 height: 33px !important;
111 }
112
113 .ng-value strong {
114 @include margin-left(5px);
115 }
116 }
117}
118
119my-select-languages,
120my-select-categories {
121 width: 300px;
122 display: inline-block;
123}
124
125.label-description {
126 font-size: 12px;
127 font-style: italic;
128 margin-bottom: 10px;
129
130 a {
131 color: pvar(--mainColor);
132 }
133}
134
135@media screen and (max-width: $small-view) {
136 .first-row {
137 flex-direction: column;
138 }
139}
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts
new file mode 100644
index 000000000..99f133e54
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts
@@ -0,0 +1,119 @@
1import * as debug from 'debug'
2import { Subscription } from 'rxjs'
3import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
4import { FormBuilder, FormGroup } from '@angular/forms'
5import { AuthService } from '@app/core'
6import { ServerService } from '@app/core/server/server.service'
7import { UserRight } from '@shared/models'
8import { NSFWPolicyType } from '@shared/models/videos'
9import { PeertubeModalService } from '../shared-main'
10import { VideoFilters } from './video-filters.model'
11
12const logger = debug('peertube:videos:VideoFiltersHeaderComponent')
13
14@Component({
15 selector: 'my-video-filters-header',
16 styleUrls: [ './video-filters-header.component.scss' ],
17 templateUrl: './video-filters-header.component.html'
18})
19export class VideoFiltersHeaderComponent implements OnInit, OnDestroy {
20 @Input() filters: VideoFilters
21
22 @Input() displayModerationBlock = false
23
24 @Input() defaultSort = '-publishedAt'
25 @Input() nsfwPolicy: NSFWPolicyType
26
27 @Output() filtersChanged = new EventEmitter()
28
29 areFiltersCollapsed = true
30
31 form: FormGroup
32
33 private routeSub: Subscription
34
35 constructor (
36 private auth: AuthService,
37 private serverService: ServerService,
38 private fb: FormBuilder,
39 private modalService: PeertubeModalService
40 ) {
41 }
42
43 ngOnInit () {
44 this.form = this.fb.group({
45 sort: [ '' ],
46 nsfw: [ '' ],
47 languageOneOf: [ '' ],
48 categoryOneOf: [ '' ],
49 scope: [ '' ],
50 allVideos: [ '' ],
51 live: [ '' ]
52 })
53
54 this.patchForm(false)
55
56 this.filters.onChange(() => {
57 this.patchForm(false)
58 })
59
60 this.form.valueChanges.subscribe(values => {
61 logger('Loading values from form: %O', values)
62
63 this.filters.load(values)
64 this.filtersChanged.emit()
65 })
66 }
67
68 ngOnDestroy () {
69 if (this.routeSub) this.routeSub.unsubscribe()
70 }
71
72 canSeeAllVideos () {
73 if (!this.auth.isLoggedIn()) return false
74 if (!this.displayModerationBlock) return false
75
76 return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS)
77 }
78
79 isTrendingSortEnabled (sort: 'most-viewed' | 'hot' | 'best' | 'most-liked') {
80 const serverConfig = this.serverService.getHTMLConfig()
81
82 const enabled = serverConfig.trending.videos.algorithms.enabled.includes(sort)
83
84 // Best is adapted from the user
85 if (sort === 'best') return enabled && this.auth.isLoggedIn()
86
87 return enabled
88 }
89
90 resetFilter (key: string, canRemove: boolean) {
91 if (!canRemove) return
92
93 this.filters.reset(key)
94 this.patchForm(false)
95 this.filtersChanged.emit()
96 }
97
98 getFilterTitle (canRemove: boolean) {
99 if (canRemove) return $localize`Remove this filter`
100
101 return ''
102 }
103
104 onAccountSettingsClick (event: Event) {
105 if (this.auth.isLoggedIn()) return
106
107 event.preventDefault()
108 event.stopPropagation()
109
110 this.modalService.openQuickSettingsSubject.next()
111 }
112
113 private patchForm (emitEvent: boolean) {
114 const defaultValues = this.filters.toFormObject()
115 this.form.patchValue(defaultValues, { emitEvent })
116
117 logger('Patched form: %O', defaultValues)
118 }
119}
diff --git a/client/src/app/shared/shared-video-miniature/video-filters.model.ts b/client/src/app/shared/shared-video-miniature/video-filters.model.ts
new file mode 100644
index 000000000..a3b8129f0
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/video-filters.model.ts
@@ -0,0 +1,240 @@
1import { intoArray, toBoolean } from '@app/helpers'
2import { AttributesOnly } from '@shared/core-utils'
3import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models'
4
5type VideoFiltersKeys = {
6 [ id in keyof AttributesOnly<VideoFilters> ]: any
7}
8
9export type VideoFilterScope = 'local' | 'federated'
10
11export class VideoFilters {
12 sort: VideoSortField
13 nsfw: BooleanBothQuery
14
15 languageOneOf: string[]
16 categoryOneOf: number[]
17
18 scope: VideoFilterScope
19 allVideos: boolean
20
21 live: BooleanBothQuery
22
23 search: string
24
25 private defaultValues = new Map<keyof VideoFilters, any>([
26 [ 'sort', '-publishedAt' ],
27 [ 'nsfw', 'false' ],
28 [ 'languageOneOf', undefined ],
29 [ 'categoryOneOf', undefined ],
30 [ 'scope', 'federated' ],
31 [ 'allVideos', false ],
32 [ 'live', 'both' ]
33 ])
34
35 private activeFilters: { key: string, canRemove: boolean, label: string, value?: string }[] = []
36 private defaultNSFWPolicy: NSFWPolicyType
37
38 private onChangeCallbacks: Array<() => void> = []
39 private oldFormObjectString: string
40
41 constructor (defaultSort: string, defaultScope: VideoFilterScope) {
42 this.setDefaultSort(defaultSort)
43 this.setDefaultScope(defaultScope)
44
45 this.reset()
46 }
47
48 onChange (cb: () => void) {
49 this.onChangeCallbacks.push(cb)
50 }
51
52 triggerChange () {
53 // Don't run on change if the values did not change
54 const currentFormObjectString = JSON.stringify(this.toFormObject())
55 if (this.oldFormObjectString && currentFormObjectString === this.oldFormObjectString) return
56
57 this.oldFormObjectString = currentFormObjectString
58
59 for (const cb of this.onChangeCallbacks) {
60 cb()
61 }
62 }
63
64 setDefaultScope (scope: VideoFilterScope) {
65 this.defaultValues.set('scope', scope)
66 }
67
68 setDefaultSort (sort: string) {
69 this.defaultValues.set('sort', sort)
70 }
71
72 setNSFWPolicy (nsfwPolicy: NSFWPolicyType) {
73 this.updateDefaultNSFW(nsfwPolicy)
74 }
75
76 reset (specificKey?: string) {
77 for (const [ key, value ] of this.defaultValues) {
78 if (specificKey && specificKey !== key) continue
79
80 // FIXME: typings
81 this[key as any] = value
82 }
83
84 this.buildActiveFilters()
85 }
86
87 load (obj: Partial<AttributesOnly<VideoFilters>>) {
88 if (obj.sort !== undefined) this.sort = obj.sort
89
90 if (obj.nsfw !== undefined) this.nsfw = obj.nsfw
91
92 if (obj.languageOneOf !== undefined) this.languageOneOf = intoArray(obj.languageOneOf)
93 if (obj.categoryOneOf !== undefined) this.categoryOneOf = intoArray(obj.categoryOneOf)
94
95 if (obj.scope !== undefined) this.scope = obj.scope
96 if (obj.allVideos !== undefined) this.allVideos = toBoolean(obj.allVideos)
97
98 if (obj.live !== undefined) this.live = obj.live
99
100 if (obj.search !== undefined) this.search = obj.search
101
102 this.buildActiveFilters()
103 }
104
105 buildActiveFilters () {
106 this.activeFilters = []
107
108 this.activeFilters.push({
109 key: 'nsfw',
110 canRemove: false,
111 label: $localize`Sensitive content`,
112 value: this.getNSFWValue()
113 })
114
115 this.activeFilters.push({
116 key: 'scope',
117 canRemove: false,
118 label: $localize`Scope`,
119 value: this.scope === 'federated'
120 ? $localize`Federated`
121 : $localize`Local`
122 })
123
124 if (this.languageOneOf && this.languageOneOf.length !== 0) {
125 this.activeFilters.push({
126 key: 'languageOneOf',
127 canRemove: true,
128 label: $localize`Languages`,
129 value: this.languageOneOf.map(l => l.toUpperCase()).join(', ')
130 })
131 }
132
133 if (this.categoryOneOf && this.categoryOneOf.length !== 0) {
134 this.activeFilters.push({
135 key: 'categoryOneOf',
136 canRemove: true,
137 label: $localize`Categories`,
138 value: this.categoryOneOf.join(', ')
139 })
140 }
141
142 if (this.allVideos) {
143 this.activeFilters.push({
144 key: 'allVideos',
145 canRemove: true,
146 label: $localize`All videos`
147 })
148 }
149
150 if (this.live === 'true') {
151 this.activeFilters.push({
152 key: 'live',
153 canRemove: true,
154 label: $localize`Live videos`
155 })
156 } else if (this.live === 'false') {
157 this.activeFilters.push({
158 key: 'live',
159 canRemove: true,
160 label: $localize`VOD videos`
161 })
162 }
163 }
164
165 getActiveFilters () {
166 return this.activeFilters
167 }
168
169 toFormObject (): VideoFiltersKeys {
170 const result: Partial<VideoFiltersKeys> = {}
171
172 for (const [ key ] of this.defaultValues) {
173 result[key] = this[key]
174 }
175
176 return result as VideoFiltersKeys
177 }
178
179 toUrlObject () {
180 const result: { [ id: string ]: any } = {}
181
182 for (const [ key, defaultValue ] of this.defaultValues) {
183 if (this[key] !== defaultValue) {
184 result[key] = this[key]
185 }
186 }
187
188 return result
189 }
190
191 toVideosAPIObject () {
192 let filter: VideoFilter
193
194 if (this.scope === 'local' && this.allVideos) {
195 filter = 'all-local'
196 } else if (this.scope === 'federated' && this.allVideos) {
197 filter = 'all'
198 } else if (this.scope === 'local') {
199 filter = 'local'
200 }
201
202 let isLive: boolean
203 if (this.live === 'true') isLive = true
204 else if (this.live === 'false') isLive = false
205
206 return {
207 sort: this.sort,
208 nsfw: this.nsfw,
209 languageOneOf: this.languageOneOf,
210 categoryOneOf: this.categoryOneOf,
211 search: this.search,
212 filter,
213 isLive
214 }
215 }
216
217 getNSFWDisplayLabel () {
218 if (this.defaultNSFWPolicy === 'blur') return $localize`Blurred`
219
220 return $localize`Displayed`
221 }
222
223 private getNSFWValue () {
224 if (this.nsfw === 'false') return $localize`hidden`
225 if (this.defaultNSFWPolicy === 'blur') return $localize`blurred`
226
227 return $localize`displayed`
228 }
229
230 private updateDefaultNSFW (nsfwPolicy: NSFWPolicyType) {
231 const nsfw = nsfwPolicy === 'do_not_list'
232 ? 'false'
233 : 'both'
234
235 this.defaultValues.set('nsfw', nsfw)
236 this.defaultNSFWPolicy = nsfwPolicy
237
238 this.reset('nsfw')
239 }
240}
diff --git a/client/src/app/shared/shared-video-miniature/video-list-header.component.html b/client/src/app/shared/shared-video-miniature/video-list-header.component.html
deleted file mode 100644
index 58db437b8..000000000
--- a/client/src/app/shared/shared-video-miniature/video-list-header.component.html
+++ /dev/null
@@ -1,5 +0,0 @@
1<h1 class="title-page title-page-single">
2 <div placement="bottom" [ngbTooltip]="data.titleTooltip" container="body">
3 {{ data.titlePage }}
4 </div>
5</h1> \ No newline at end of file
diff --git a/client/src/app/shared/shared-video-miniature/video-list-header.component.ts b/client/src/app/shared/shared-video-miniature/video-list-header.component.ts
deleted file mode 100644
index fed696672..000000000
--- a/client/src/app/shared/shared-video-miniature/video-list-header.component.ts
+++ /dev/null
@@ -1,22 +0,0 @@
1import { Component, Inject, ViewEncapsulation } from '@angular/core'
2
3export interface GenericHeaderData {
4 titlePage: string
5 titleTooltip?: string
6}
7
8export abstract class GenericHeaderComponent {
9 constructor (@Inject('data') public data: GenericHeaderData) {}
10}
11
12@Component({
13 selector: 'my-video-list-header',
14 // eslint-disable-next-line @angular-eslint/use-component-view-encapsulation
15 encapsulation: ViewEncapsulation.None,
16 templateUrl: './video-list-header.component.html'
17})
18export class VideoListHeaderComponent extends GenericHeaderComponent {
19 constructor (@Inject('data') public data: GenericHeaderData) {
20 super(data)
21 }
22}
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.html b/client/src/app/shared/shared-video-miniature/videos-list.component.html
index 9ffeac5e8..4ccb4092c 100644
--- a/client/src/app/shared/shared-video-miniature/abstract-video-list.html
+++ b/client/src/app/shared/shared-video-miniature/videos-list.component.html
@@ -1,11 +1,17 @@
1<div class="margin-content"> 1<div class="margin-content">
2 <div class="videos-header"> 2 <div class="videos-header">
3 <ng-template #videoListHeader></ng-template> 3 <h1 *ngIf="displayTitle" class="title" placement="bottom" [ngbTooltip]="titleTooltip" container="body">
4 {{ title }}
5 </h1>
4 6
5 <div class="action-block"> 7 <div *ngIf="syndicationItems" [ngClass]="{ 'no-title': !displayTitle }" class="title-subscription">
6 <my-feed *ngIf="syndicationItems" [syndicationItems]="syndicationItems"></my-feed> 8 <ng-container i18n>Subscribe to RSS feed "{{ title }}"</ng-container>
9
10 <my-feed [syndicationItems]="syndicationItems"></my-feed>
11 </div>
7 12
8 <ng-container *ngFor="let action of actions"> 13 <div class="action-block">
14 <ng-container *ngFor="let action of headerActions">
9 <a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active"> 15 <a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active">
10 <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container> 16 <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
11 </a> 17 </a>
@@ -24,27 +30,18 @@
24 </ng-template> 30 </ng-template>
25 </ng-container> 31 </ng-container>
26 </div> 32 </div>
27
28 <div class="moderation-block" *ngIf="displayModerationBlock">
29 <div class="c-hand" ngbDropdown placement="bottom-right auto">
30 <my-global-icon iconName="cog" ngbDropdownToggle></my-global-icon>
31
32 <div role="menu" class="dropdown-menu" ngbDropdownMenu>
33 <div class="dropdown-item">
34 <my-peertube-checkbox
35 (change)="toggleModerationDisplay()"
36 inputName="display-unlisted-private" i18n-labelText labelText="Display all videos (private, unlisted or not yet published)"
37 ></my-peertube-checkbox>
38 </div>
39 </div>
40 </div>
41 </div>
42 </div> 33 </div>
43 34
35 <my-video-filters-header
36 *ngIf="displayFilters" [displayModerationBlock]="displayModerationBlock"
37 [defaultSort]="defaultSort" [filters]="filters"
38 (filtersChanged)="onFiltersChanged(true)"
39 ></my-video-filters-header>
40
44 <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div> 41 <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div>
45 <div 42 <div
46 myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" 43 myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true"
47 class="videos" [ngClass]="{ 'display-as-row': displayAsRow() }" 44 class="videos" [ngClass]="{ 'display-as-row': displayAsRow }"
48 > 45 >
49 <ng-container *ngFor="let video of videos; trackBy: videoById;"> 46 <ng-container *ngFor="let video of videos; trackBy: videoById;">
50 <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)"> 47 <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
@@ -53,7 +50,7 @@
53 50
54 <div class="video-wrapper"> 51 <div class="video-wrapper">
55 <my-video-miniature 52 <my-video-miniature
56 [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow()" 53 [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow"
57 [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions" 54 [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
58 (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)" 55 (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
59 > 56 >
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss b/client/src/app/shared/shared-video-miniature/videos-list.component.scss
index 79e3c1bdf..e82ef05ba 100644
--- a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss
+++ b/client/src/app/shared/shared-video-miniature/videos-list.component.scss
@@ -3,44 +3,57 @@
3@use '_mixins' as *; 3@use '_mixins' as *;
4@use '_miniature' as *; 4@use '_miniature' as *;
5 5
6$icon-size: 16px;
7
8::ng-deep my-video-list-header {
9 display: flex;
10 flex-grow: 1;
11}
12
13.videos-header { 6.videos-header {
14 display: flex; 7 display: grid;
15 justify-content: space-between; 8 grid-template-columns: auto 1fr auto;
16 align-items: center; 9 margin-bottom: 30px;
17 10
18 my-feed { 11 .title,
19 display: inline-block; 12 .title-subscription {
20 width: calc(#{$icon-size} - 2px); 13 grid-column: 1;
21 } 14 }
22 15
23 .moderation-block { 16 .title {
24 @include margin-left(.4rem); 17 font-size: 18px;
18 color: pvar(--mainForegroundColor);
19 display: inline-block;
20 font-weight: $font-semibold;
25 21
26 display: flex; 22 margin-top: 30px;
27 justify-content: flex-end; 23 margin-bottom: 0;
28 align-items: center; 24 }
25
26 .title-subscription {
27 grid-row: 2;
28 font-size: 14px;
29 color: pvar(--greyForegroundColor);
29 30
30 my-global-icon { 31 &.no-title {
31 position: relative; 32 margin-top: 10px;
32 width: $icon-size;
33 } 33 }
34 } 34 }
35
36 .action-block {
37 grid-column: 3;
38 }
39
40 my-feed {
41 @include margin-left(5px);
42
43 display: inline-block;
44 width: 16px;
45 color: pvar(--mainColor);
46 position: relative;
47 top: -2px;
48 }
35} 49}
36 50
37.date-title { 51.date-title {
38 font-size: 16px; 52 font-size: 16px;
39 font-weight: $font-semibold; 53 font-weight: $font-semibold;
40 margin-bottom: 20px; 54 margin-bottom: 20px;
41 margin-top: -10px;
42 55
43 // make the element span a full grid row within .videos grid 56 // Make the element span a full grid row within .videos grid
44 grid-column: 1 / -1; 57 grid-column: 1 / -1;
45 58
46 &:not(:first-child) { 59 &:not(:first-child) {
@@ -64,6 +77,18 @@ $icon-size: 16px;
64} 77}
65 78
66@media screen and (max-width: $mobile-view) { 79@media screen and (max-width: $mobile-view) {
80 .videos-header,
81 my-video-filters-header {
82 @include margin-left(15px);
83 @include margin-right(15px);
84
85 display: inline-block;
86 }
87
88 .date-title {
89 text-align: center;
90 }
91
67 .videos-header { 92 .videos-header {
68 flex-direction: column; 93 flex-direction: column;
69 align-items: center; 94 align-items: center;
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
new file mode 100644
index 000000000..10de97298
--- /dev/null
+++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
@@ -0,0 +1,396 @@
1import * as debug from 'debug'
2import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
3import { debounceTime, switchMap } from 'rxjs/operators'
4import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
5import { ActivatedRoute } from '@angular/router'
6import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core'
7import { GlobalIconName } from '@app/shared/shared-icons'
8import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
9import { ResultList, UserRight, VideoSortField } from '@shared/models'
10import { Syndication, Video } from '../shared-main'
11import { VideoFilters, VideoFilterScope } from './video-filters.model'
12import { MiniatureDisplayOptions } from './video-miniature.component'
13
14const logger = debug('peertube:videos:VideosListComponent')
15
16export type HeaderAction = {
17 iconName: GlobalIconName
18 label: string
19 justIcon?: boolean
20 routerLink?: string
21 href?: string
22 click?: (e: Event) => void
23}
24
25enum GroupDate {
26 UNKNOWN = 0,
27 TODAY = 1,
28 YESTERDAY = 2,
29 THIS_WEEK = 3,
30 THIS_MONTH = 4,
31 LAST_MONTH = 5,
32 OLDER = 6
33}
34
35@Component({
36 selector: 'my-videos-list',
37 templateUrl: './videos-list.component.html',
38 styleUrls: [ './videos-list.component.scss' ]
39})
40export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
41 @Input() getVideosObservableFunction: (pagination: ComponentPaginationLight, filters: VideoFilters) => Observable<ResultList<Video>>
42 @Input() getSyndicationItemsFunction: (filters: VideoFilters) => Promise<Syndication[]> | Syndication[]
43 @Input() baseRouteBuilderFunction: (filters: VideoFilters) => string[]
44
45 @Input() title: string
46 @Input() titleTooltip: string
47 @Input() displayTitle = true
48
49 @Input() defaultSort: VideoSortField
50 @Input() defaultScope: VideoFilterScope = 'federated'
51 @Input() displayFilters = false
52 @Input() displayModerationBlock = false
53
54 @Input() loadUserVideoPreferences = false
55
56 @Input() displayAsRow = false
57 @Input() displayVideoActions = true
58 @Input() groupByDate = false
59
60 @Input() headerActions: HeaderAction[] = []
61
62 @Input() displayOptions: MiniatureDisplayOptions = {
63 date: true,
64 views: true,
65 by: true,
66 avatar: false,
67 privacyLabel: true,
68 privacyText: false,
69 state: false,
70 blacklistInfo: false
71 }
72
73 @Input() disabled = false
74
75 @Output() filtersChanged = new EventEmitter<VideoFilters>()
76
77 videos: Video[] = []
78 filters: VideoFilters
79 syndicationItems: Syndication[]
80
81 onDataSubject = new Subject<any[]>()
82 hasDoneFirstQuery = false
83
84 userMiniature: User
85
86 private routeSub: Subscription
87 private userSub: Subscription
88 private resizeSub: Subscription
89
90 private pagination: ComponentPaginationLight = {
91 currentPage: 1,
92 itemsPerPage: 25
93 }
94
95 private groupedDateLabels: { [id in GroupDate]: string }
96 private groupedDates: { [id: number]: GroupDate } = {}
97
98 private lastQueryLength: number
99
100 constructor (
101 private notifier: Notifier,
102 private authService: AuthService,
103 private userService: UserService,
104 private route: ActivatedRoute,
105 private screenService: ScreenService,
106 private peertubeRouter: PeerTubeRouterService
107 ) {
108
109 }
110
111 ngOnInit () {
112 this.filters = new VideoFilters(this.defaultSort, this.defaultScope)
113 this.filters.load({ ...this.route.snapshot.queryParams, scope: this.defaultScope })
114
115 this.groupedDateLabels = {
116 [GroupDate.UNKNOWN]: null,
117 [GroupDate.TODAY]: $localize`Today`,
118 [GroupDate.YESTERDAY]: $localize`Yesterday`,
119 [GroupDate.THIS_WEEK]: $localize`This week`,
120 [GroupDate.THIS_MONTH]: $localize`This month`,
121 [GroupDate.LAST_MONTH]: $localize`Last month`,
122 [GroupDate.OLDER]: $localize`Older`
123 }
124
125 this.resizeSub = fromEvent(window, 'resize')
126 .pipe(debounceTime(500))
127 .subscribe(() => this.calcPageSizes())
128
129 this.calcPageSizes()
130
131 this.userService.getAnonymousOrLoggedUser()
132 .subscribe(user => {
133 this.userMiniature = user
134
135 if (this.loadUserVideoPreferences) {
136 this.loadUserSettings(user)
137 }
138
139 this.scheduleOnFiltersChanged(false)
140
141 this.subscribeToAnonymousUpdate()
142 this.subscribeToSearchChange()
143 })
144
145 // Display avatar in mobile view
146 if (this.screenService.isInMobileView()) {
147 this.displayOptions.avatar = true
148 }
149 }
150
151 ngOnDestroy () {
152 if (this.resizeSub) this.resizeSub.unsubscribe()
153 if (this.routeSub) this.routeSub.unsubscribe()
154 if (this.userSub) this.userSub.unsubscribe()
155 }
156
157 ngOnChanges (changes: SimpleChanges) {
158 if (!this.filters) return
159
160 let updated = false
161
162 if (changes['defaultScope']) {
163 updated = true
164 this.filters.setDefaultScope(this.defaultScope)
165 }
166
167 if (changes['defaultSort']) {
168 updated = true
169 this.filters.setDefaultSort(this.defaultSort)
170 }
171
172 if (!updated) return
173
174 const customizedByUser = this.hasBeenCustomizedByUser()
175
176 if (!customizedByUser) {
177 if (this.loadUserVideoPreferences) {
178 this.loadUserSettings(this.userMiniature)
179 }
180
181 this.filters.reset('scope')
182 this.filters.reset('sort')
183 }
184
185 this.scheduleOnFiltersChanged(customizedByUser)
186 }
187
188 videoById (_index: number, video: Video) {
189 return video.id
190 }
191
192 onNearOfBottom () {
193 if (this.disabled) return
194
195 // No more results
196 if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return
197
198 this.pagination.currentPage += 1
199
200 this.loadMoreVideos()
201 }
202
203 loadMoreVideos (reset = false) {
204 this.getVideosObservableFunction(this.pagination, this.filters)
205 .subscribe({
206 next: ({ data }) => {
207 this.hasDoneFirstQuery = true
208 this.lastQueryLength = data.length
209
210 if (reset) this.videos = []
211 this.videos = this.videos.concat(data)
212
213 if (this.groupByDate) this.buildGroupedDateLabels()
214
215 this.onDataSubject.next(data)
216 },
217
218 error: err => {
219 const message = $localize`Cannot load more videos. Try again later.`
220
221 console.error(message, { err })
222 this.notifier.error(message)
223 }
224 })
225 }
226
227 reloadVideos () {
228 this.pagination.currentPage = 1
229 this.loadMoreVideos(true)
230 }
231
232 removeVideoFromArray (video: Video) {
233 this.videos = this.videos.filter(v => v.id !== video.id)
234 }
235
236 buildGroupedDateLabels () {
237 let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
238
239 const periods = [
240 {
241 value: GroupDate.TODAY,
242 validator: (d: Date) => isToday(d)
243 },
244 {
245 value: GroupDate.YESTERDAY,
246 validator: (d: Date) => isYesterday(d)
247 },
248 {
249 value: GroupDate.THIS_WEEK,
250 validator: (d: Date) => isLastWeek(d)
251 },
252 {
253 value: GroupDate.THIS_MONTH,
254 validator: (d: Date) => isThisMonth(d)
255 },
256 {
257 value: GroupDate.LAST_MONTH,
258 validator: (d: Date) => isLastMonth(d)
259 },
260 {
261 value: GroupDate.OLDER,
262 validator: () => true
263 }
264 ]
265
266 for (const video of this.videos) {
267 const publishedDate = video.publishedAt
268
269 for (let i = 0; i < periods.length; i++) {
270 const period = periods[i]
271
272 if (currentGroupedDate <= period.value && period.validator(publishedDate)) {
273
274 if (currentGroupedDate !== period.value) {
275 currentGroupedDate = period.value
276 this.groupedDates[video.id] = currentGroupedDate
277 }
278
279 break
280 }
281 }
282 }
283 }
284
285 getCurrentGroupedDateLabel (video: Video) {
286 if (this.groupByDate === false) return undefined
287
288 return this.groupedDateLabels[this.groupedDates[video.id]]
289 }
290
291 scheduleOnFiltersChanged (customizedByUser: boolean) {
292 // We'll reload videos, but avoid weird UI effect
293 this.videos = []
294
295 setTimeout(() => this.onFiltersChanged(customizedByUser))
296 }
297
298 onFiltersChanged (customizedByUser: boolean) {
299 logger('Running on filters changed')
300
301 this.updateUrl(customizedByUser)
302
303 this.filters.triggerChange()
304
305 this.reloadSyndicationItems()
306 this.reloadVideos()
307 }
308
309 protected enableAllFilterIfPossible () {
310 if (!this.authService.isLoggedIn()) return
311
312 this.authService.userInformationLoaded
313 .subscribe(() => {
314 const user = this.authService.getUser()
315 this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS)
316 })
317 }
318
319 private calcPageSizes () {
320 if (this.screenService.isInMobileView()) {
321 this.pagination.itemsPerPage = 5
322 }
323 }
324
325 private loadUserSettings (user: User) {
326 this.filters.setNSFWPolicy(user.nsfwPolicy)
327
328 // Don't reset language filter if we don't want to refresh the component
329 if (!this.hasBeenCustomizedByUser()) {
330 this.filters.load({ languageOneOf: user.videoLanguages })
331 }
332 }
333
334 private reloadSyndicationItems () {
335 Promise.resolve(this.getSyndicationItemsFunction(this.filters))
336 .then(items => {
337 if (!items || items.length === 0) this.syndicationItems = undefined
338 else this.syndicationItems = items
339 })
340 .catch(err => console.error('Cannot get syndication items.', err))
341 }
342
343 private updateUrl (customizedByUser: boolean) {
344 const baseQuery = this.filters.toUrlObject()
345
346 // Set or reset customized by user query param
347 const queryParams = customizedByUser || this.hasBeenCustomizedByUser()
348 ? { ...baseQuery, c: customizedByUser }
349 : baseQuery
350
351 logger('Will inject %O in URL query', queryParams)
352
353 const baseRoute = this.baseRouteBuilderFunction
354 ? this.baseRouteBuilderFunction(this.filters)
355 : []
356
357 const pathname = window.location.pathname
358
359 const baseRouteChanged = baseRoute.length !== 0 &&
360 pathname !== '/' && // Exclude special '/' case, we'll be redirected without component change
361 baseRoute.length !== 0 && pathname !== baseRoute.join('/')
362
363 if (baseRouteChanged || Object.keys(baseQuery).length !== 0 || customizedByUser) {
364 this.peertubeRouter.silentNavigate(baseRoute, queryParams)
365 }
366
367 this.filtersChanged.emit(this.filters)
368 }
369
370 private hasBeenCustomizedByUser () {
371 return this.route.snapshot.queryParams['c'] === 'true'
372 }
373
374 private subscribeToAnonymousUpdate () {
375 this.userSub = this.userService.listenAnonymousUpdate()
376 .pipe(switchMap(() => this.userService.getAnonymousOrLoggedUser()))
377 .subscribe(user => {
378 if (this.loadUserVideoPreferences) {
379 this.loadUserSettings(user)
380 }
381
382 if (this.hasDoneFirstQuery) {
383 this.reloadVideos()
384 }
385 })
386 }
387
388 private subscribeToSearchChange () {
389 this.routeSub = this.route.queryParams.subscribe(param => {
390 if (!param['search']) return
391
392 this.filters.load({ search: param['search'] })
393 this.onFiltersChanged(true)
394 })
395 }
396}
diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.html b/client/src/app/shared/shared-video-miniature/videos-selection.component.html
index 4ee90ce7f..f2af874dd 100644
--- a/client/src/app/shared/shared-video-miniature/videos-selection.component.html
+++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.html
@@ -1,6 +1,9 @@
1<div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">{{ noResultMessage }}</div> 1<div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">{{ noResultMessage }}</div>
2 2
3<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos"> 3<div
4 class="videos"
5 myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true"
6>
4 <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById"> 7 <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById">
5 8
6 <div class="checkbox-container" *ngIf="enableSelection"> 9 <div class="checkbox-container" *ngIf="enableSelection">
diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.ts b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts
index 456b36926..cafaf6e85 100644
--- a/client/src/app/shared/shared-video-miniature/videos-selection.component.ts
+++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts
@@ -1,22 +1,8 @@
1import { Observable } from 'rxjs' 1import { Observable, Subject } from 'rxjs'
2import { 2import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core'
3 AfterContentInit, 3import { ComponentPagination, Notifier, User } from '@app/core'
4 Component,
5 ComponentFactoryResolver,
6 ContentChildren,
7 EventEmitter,
8 Input,
9 OnDestroy,
10 OnInit,
11 Output,
12 QueryList,
13 TemplateRef
14} from '@angular/core'
15import { ActivatedRoute, Router } from '@angular/router'
16import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
17import { ResultList, VideoSortField } from '@shared/models' 4import { ResultList, VideoSortField } from '@shared/models'
18import { PeerTubeTemplateDirective, Video } from '../shared-main' 5import { PeerTubeTemplateDirective, Video } from '../shared-main'
19import { AbstractVideoList } from './abstract-video-list'
20import { MiniatureDisplayOptions } from './video-miniature.component' 6import { MiniatureDisplayOptions } from './video-miniature.component'
21 7
22export type SelectionType = { [ id: number ]: boolean } 8export type SelectionType = { [ id: number ]: boolean }
@@ -26,14 +12,18 @@ export type SelectionType = { [ id: number ]: boolean }
26 templateUrl: './videos-selection.component.html', 12 templateUrl: './videos-selection.component.html',
27 styleUrls: [ './videos-selection.component.scss' ] 13 styleUrls: [ './videos-selection.component.scss' ]
28}) 14})
29export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { 15export class VideosSelectionComponent implements AfterContentInit {
30 @Input() user: User 16 @Input() user: User
31 @Input() pagination: ComponentPagination 17 @Input() pagination: ComponentPagination
18
32 @Input() titlePage: string 19 @Input() titlePage: string
20
33 @Input() miniatureDisplayOptions: MiniatureDisplayOptions 21 @Input() miniatureDisplayOptions: MiniatureDisplayOptions
22
34 @Input() noResultMessage = $localize`No results.` 23 @Input() noResultMessage = $localize`No results.`
35 @Input() enableSelection = true 24 @Input() enableSelection = true
36 @Input() loadOnInit = true 25
26 @Input() disabled = false
37 27
38 @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>> 28 @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
39 29
@@ -47,19 +37,18 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
47 rowButtonsTemplate: TemplateRef<any> 37 rowButtonsTemplate: TemplateRef<any>
48 globalButtonsTemplate: TemplateRef<any> 38 globalButtonsTemplate: TemplateRef<any>
49 39
40 videos: Video[] = []
41 sort: VideoSortField = '-publishedAt'
42
43 onDataSubject = new Subject<any[]>()
44
45 hasDoneFirstQuery = false
46
47 private lastQueryLength: number
48
50 constructor ( 49 constructor (
51 protected router: Router, 50 private notifier: Notifier
52 protected route: ActivatedRoute, 51 ) { }
53 protected notifier: Notifier,
54 protected authService: AuthService,
55 protected userService: UserService,
56 protected screenService: ScreenService,
57 protected storageService: LocalStorageService,
58 protected serverService: ServerService,
59 protected cfr: ComponentFactoryResolver
60 ) {
61 super()
62 }
63 52
64 @Input() get selection () { 53 @Input() get selection () {
65 return this._selection 54 return this._selection
@@ -79,10 +68,6 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
79 this.videosModelChange.emit(this.videos) 68 this.videosModelChange.emit(this.videos)
80 } 69 }
81 70
82 ngOnInit () {
83 super.ngOnInit()
84 }
85
86 ngAfterContentInit () { 71 ngAfterContentInit () {
87 { 72 {
88 const t = this.templates.find(t => t.name === 'rowButtons') 73 const t = this.templates.find(t => t.name === 'rowButtons')
@@ -93,10 +78,8 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
93 const t = this.templates.find(t => t.name === 'globalButtons') 78 const t = this.templates.find(t => t.name === 'globalButtons')
94 if (t) this.globalButtonsTemplate = t.template 79 if (t) this.globalButtonsTemplate = t.template
95 } 80 }
96 }
97 81
98 ngOnDestroy () { 82 this.loadMoreVideos()
99 super.ngOnDestroy()
100 } 83 }
101 84
102 getVideosObservable (page: number) { 85 getVideosObservable (page: number) {
@@ -111,11 +94,50 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
111 return Object.keys(this._selection).some(k => this._selection[k] === true) 94 return Object.keys(this._selection).some(k => this._selection[k] === true)
112 } 95 }
113 96
114 generateSyndicationList () { 97 videoById (index: number, video: Video) {
115 throw new Error('Method not implemented.') 98 return video.id
99 }
100
101 onNearOfBottom () {
102 if (this.disabled) return
103
104 // No more results
105 if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return
106
107 this.pagination.currentPage += 1
108
109 this.loadMoreVideos()
110 }
111
112 loadMoreVideos (reset = false) {
113 this.getVideosObservable(this.pagination.currentPage)
114 .subscribe({
115 next: ({ data }) => {
116 this.hasDoneFirstQuery = true
117 this.lastQueryLength = data.length
118
119 if (reset) this.videos = []
120 this.videos = this.videos.concat(data)
121 this.videosModel = this.videos
122
123 this.onDataSubject.next(data)
124 },
125
126 error: err => {
127 const message = $localize`Cannot load more videos. Try again later.`
128
129 console.error(message, { err })
130 this.notifier.error(message)
131 }
132 })
133 }
134
135 reloadVideos () {
136 this.pagination.currentPage = 1
137 this.loadMoreVideos(true)
116 } 138 }
117 139
118 protected onMoreVideos () { 140 removeVideoFromArray (video: Video) {
119 this.videosModel = this.videos 141 this.videos = this.videos.filter(v => v.id !== video.id)
120 } 142 }
121} 143}