diff options
author | Chocobozzz <me@florianbigard.com> | 2021-08-19 09:24:29 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2021-08-25 11:24:11 +0200 |
commit | dd24f1bb0a4b252e5342b251ba36853364da7b8e (patch) | |
tree | 41a9506d07413f056fb90425705e258f96fdc77d /client/src/app/shared/shared-video-miniature | |
parent | 2e80d256cc75b4b02c8efc3d3e4cdf57ddf401a8 (diff) | |
download | PeerTube-dd24f1bb0a4b252e5342b251ba36853364da7b8e.tar.gz PeerTube-dd24f1bb0a4b252e5342b251ba36853364da7b8e.tar.zst PeerTube-dd24f1bb0a4b252e5342b251ba36853364da7b8e.zip |
Add video filters to common video pages
Diffstat (limited to 'client/src/app/shared/shared-video-miniature')
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 @@ | |||
1 | import { fromEvent, Observable, ReplaySubject, Subject, Subscription } from 'rxjs' | ||
2 | import { debounceTime, switchMap, tap } from 'rxjs/operators' | ||
3 | import { | ||
4 | AfterContentInit, | ||
5 | ComponentFactoryResolver, | ||
6 | Directive, | ||
7 | Injector, | ||
8 | OnDestroy, | ||
9 | OnInit, | ||
10 | Type, | ||
11 | ViewChild, | ||
12 | ViewContainerRef | ||
13 | } from '@angular/core' | ||
14 | import { ActivatedRoute, Params, Router } from '@angular/router' | ||
15 | import { | ||
16 | AuthService, | ||
17 | ComponentPaginationLight, | ||
18 | LocalStorageService, | ||
19 | Notifier, | ||
20 | ScreenService, | ||
21 | ServerService, | ||
22 | User, | ||
23 | UserService | ||
24 | } from '@app/core' | ||
25 | import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | ||
26 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
27 | import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils' | ||
28 | import { HTMLServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/models' | ||
29 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | ||
30 | import { Syndication, Video } from '../shared-main' | ||
31 | import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component' | ||
32 | import { MiniatureDisplayOptions } from './video-miniature.component' | ||
33 | |||
34 | enum 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 | ||
46 | export 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 @@ | |||
1 | export * from './abstract-video-list' | ||
2 | export * from './video-actions-dropdown.component' | 1 | export * from './video-actions-dropdown.component' |
3 | export * from './video-download.component' | 2 | export * from './video-download.component' |
3 | export * from './video-filters-header.component' | ||
4 | export * from './video-filters.model' | ||
4 | export * from './video-miniature.component' | 5 | export * from './video-miniature.component' |
6 | export * from './videos-list.component' | ||
5 | export * from './videos-selection.component' | 7 | export * from './videos-selection.component' |
6 | export * from './video-list-header.component' | ||
7 | export * from './shared-video-miniature.module' | 8 | export * 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 | ||
2 | import { NgModule } from '@angular/core' | 2 | import { NgModule } from '@angular/core' |
3 | import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' | ||
3 | import { SharedFormModule } from '../shared-forms' | 4 | import { SharedFormModule } from '../shared-forms' |
4 | import { SharedGlobalIconModule } from '../shared-icons' | 5 | import { SharedGlobalIconModule } from '../shared-icons' |
5 | import { SharedMainModule } from '../shared-main/shared-main.module' | 6 | import { SharedMainModule } from '../shared-main/shared-main.module' |
6 | import { SharedModerationModule } from '../shared-moderation' | 7 | import { SharedModerationModule } from '../shared-moderation' |
7 | import { SharedVideoModule } from '../shared-video' | ||
8 | import { SharedThumbnailModule } from '../shared-thumbnail' | 8 | import { SharedThumbnailModule } from '../shared-thumbnail' |
9 | import { SharedVideoModule } from '../shared-video' | ||
9 | import { SharedVideoLiveModule } from '../shared-video-live' | 10 | import { SharedVideoLiveModule } from '../shared-video-live' |
10 | import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' | 11 | import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' |
11 | import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' | 12 | import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' |
12 | import { VideoDownloadComponent } from './video-download.component' | 13 | import { VideoDownloadComponent } from './video-download.component' |
14 | import { VideoFiltersHeaderComponent } from './video-filters-header.component' | ||
13 | import { VideoMiniatureComponent } from './video-miniature.component' | 15 | import { VideoMiniatureComponent } from './video-miniature.component' |
16 | import { VideosListComponent } from './videos-list.component' | ||
14 | import { VideosSelectionComponent } from './videos-selection.component' | 17 | import { VideosSelectionComponent } from './videos-selection.component' |
15 | import { VideoListHeaderComponent } from './video-list-header.component' | ||
16 | import { 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 | |||
119 | my-select-languages, | ||
120 | my-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 @@ | |||
1 | import * as debug from 'debug' | ||
2 | import { Subscription } from 'rxjs' | ||
3 | import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | ||
4 | import { FormBuilder, FormGroup } from '@angular/forms' | ||
5 | import { AuthService } from '@app/core' | ||
6 | import { ServerService } from '@app/core/server/server.service' | ||
7 | import { UserRight } from '@shared/models' | ||
8 | import { NSFWPolicyType } from '@shared/models/videos' | ||
9 | import { PeertubeModalService } from '../shared-main' | ||
10 | import { VideoFilters } from './video-filters.model' | ||
11 | |||
12 | const 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 | }) | ||
19 | export 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 @@ | |||
1 | import { intoArray, toBoolean } from '@app/helpers' | ||
2 | import { AttributesOnly } from '@shared/core-utils' | ||
3 | import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models' | ||
4 | |||
5 | type VideoFiltersKeys = { | ||
6 | [ id in keyof AttributesOnly<VideoFilters> ]: any | ||
7 | } | ||
8 | |||
9 | export type VideoFilterScope = 'local' | 'federated' | ||
10 | |||
11 | export 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 @@ | |||
1 | import { Component, Inject, ViewEncapsulation } from '@angular/core' | ||
2 | |||
3 | export interface GenericHeaderData { | ||
4 | titlePage: string | ||
5 | titleTooltip?: string | ||
6 | } | ||
7 | |||
8 | export 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 | }) | ||
18 | export 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 @@ | |||
1 | import * as debug from 'debug' | ||
2 | import { fromEvent, Observable, Subject, Subscription } from 'rxjs' | ||
3 | import { debounceTime, switchMap } from 'rxjs/operators' | ||
4 | import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core' | ||
5 | import { ActivatedRoute } from '@angular/router' | ||
6 | import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core' | ||
7 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
8 | import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils' | ||
9 | import { ResultList, UserRight, VideoSortField } from '@shared/models' | ||
10 | import { Syndication, Video } from '../shared-main' | ||
11 | import { VideoFilters, VideoFilterScope } from './video-filters.model' | ||
12 | import { MiniatureDisplayOptions } from './video-miniature.component' | ||
13 | |||
14 | const logger = debug('peertube:videos:VideosListComponent') | ||
15 | |||
16 | export type HeaderAction = { | ||
17 | iconName: GlobalIconName | ||
18 | label: string | ||
19 | justIcon?: boolean | ||
20 | routerLink?: string | ||
21 | href?: string | ||
22 | click?: (e: Event) => void | ||
23 | } | ||
24 | |||
25 | enum 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 | }) | ||
40 | export 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 @@ | |||
1 | import { Observable } from 'rxjs' | 1 | import { Observable, Subject } from 'rxjs' |
2 | import { | 2 | import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core' |
3 | AfterContentInit, | 3 | import { 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' | ||
15 | import { ActivatedRoute, Router } from '@angular/router' | ||
16 | import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core' | ||
17 | import { ResultList, VideoSortField } from '@shared/models' | 4 | import { ResultList, VideoSortField } from '@shared/models' |
18 | import { PeerTubeTemplateDirective, Video } from '../shared-main' | 5 | import { PeerTubeTemplateDirective, Video } from '../shared-main' |
19 | import { AbstractVideoList } from './abstract-video-list' | ||
20 | import { MiniatureDisplayOptions } from './video-miniature.component' | 6 | import { MiniatureDisplayOptions } from './video-miniature.component' |
21 | 7 | ||
22 | export type SelectionType = { [ id: number ]: boolean } | 8 | export 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 | }) |
29 | export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { | 15 | export 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 | } |