From 830b4faff15fb9c81d88e8e69fcdf94aad32bef8 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 6 Mar 2019 15:36:44 +0100 Subject: [PATCH] Add/update/delete/list my playlists --- .../+my-account/my-account-routing.module.ts | 37 +++++ .../my-account-subscriptions.component.ts | 4 +- .../my-account-video-channels.component.html | 2 +- ...account-video-playlist-create.component.ts | 89 ++++++++++++ ...account-video-playlist-edit.component.html | 64 +++++++++ ...account-video-playlist-edit.component.scss | 27 ++++ .../my-account-video-playlist-edit.ts | 13 ++ ...account-video-playlist-update.component.ts | 132 +++++++++++++++++ .../my-account-video-playlists.component.html | 20 +++ .../my-account-video-playlists.component.scss | 50 +++++++ .../my-account-video-playlists.component.ts | 85 +++++++++++ .../app/+my-account/my-account.component.ts | 4 + .../src/app/+my-account/my-account.module.ts | 13 +- client/src/app/app.component.ts | 1 + client/src/app/core/server/server.service.ts | 30 +++- .../app/shared/buttons/button.component.ts | 2 +- .../app/shared/forms/form-validators/index.ts | 1 + .../video-playlist-validators.service.ts | 52 +++++++ .../global-icon.component.html | 0 .../global-icon.component.scss | 0 .../global-icon.component.ts | 0 .../images/image-upload.component.html} | 0 .../images/image-upload.component.scss} | 0 .../images/image-upload.component.ts} | 10 +- client/src/app/shared/misc/utils.ts | 2 +- client/src/app/shared/shared.module.ts | 20 ++- .../video-playlist-miniature.component.html | 22 +++ .../video-playlist-miniature.component.scss | 34 +++++ .../video-playlist-miniature.component.ts | 11 ++ .../video-playlist/video-playlist.model.ts | 74 ++++++++++ .../video-playlist/video-playlist.service.ts | 108 ++++++++++++++ .../app/shared/video/abstract-video-list.scss | 1 + .../video/video-miniature.component.scss | 22 +-- .../video/video-thumbnail.component.html | 6 +- .../video/video-thumbnail.component.scss | 72 +--------- client/src/app/shared/video/video.model.ts | 5 +- .../shared/video-edit.component.html | 8 +- .../+video-edit/shared/video-edit.module.ts | 2 - .../video-list/video-overview.component.scss | 3 +- client/src/sass/include/_miniature.scss | 133 ++++++++++++++++++ client/src/sass/include/_mixins.scss | 28 ---- client/tsconfig.json | 12 +- scripts/i18n/create-custom-files.ts | 4 +- server/initializers/constants.ts | 2 +- .../validators/videos/video-playlists.ts | 1 + shared/models/videos/index.ts | 9 +- .../videos/playlist/video-playlist.model.ts | 2 +- shared/utils/videos/video-playlists.ts | 12 ++ 48 files changed, 1076 insertions(+), 153 deletions(-) create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss create mode 100644 client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts create mode 100644 client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts rename client/src/app/shared/{icons => images}/global-icon.component.html (100%) rename client/src/app/shared/{icons => images}/global-icon.component.scss (100%) rename client/src/app/shared/{icons => images}/global-icon.component.ts (100%) rename client/src/app/{videos/+video-edit/shared/video-image.component.html => shared/images/image-upload.component.html} (100%) rename client/src/app/{videos/+video-edit/shared/video-image.component.scss => shared/images/image-upload.component.scss} (100%) rename client/src/app/{videos/+video-edit/shared/video-image.component.ts => shared/images/image-upload.component.ts} (84%) create mode 100644 client/src/app/shared/video-playlist/video-playlist-miniature.component.html create mode 100644 client/src/app/shared/video-playlist/video-playlist-miniature.component.scss create mode 100644 client/src/app/shared/video-playlist/video-playlist-miniature.component.ts create mode 100644 client/src/app/shared/video-playlist/video-playlist.model.ts create mode 100644 client/src/app/shared/video-playlist/video-playlist.service.ts create mode 100644 client/src/sass/include/_miniature.scss diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts index 9996218ca..0193afff7 100644 --- a/client/src/app/+my-account/my-account-routing.module.ts +++ b/client/src/app/+my-account/my-account-routing.module.ts @@ -15,6 +15,13 @@ import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blockli import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component' import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' +import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' +import { + MyAccountVideoPlaylistCreateComponent +} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component' +import { + MyAccountVideoPlaylistUpdateComponent +} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' const myAccountRoutes: Routes = [ { @@ -36,6 +43,7 @@ const myAccountRoutes: Routes = [ } } }, + { path: 'video-channels', component: MyAccountVideoChannelsComponent, @@ -63,6 +71,35 @@ const myAccountRoutes: Routes = [ } } }, + + { + path: 'video-playlists', + component: MyAccountVideoPlaylistsComponent, + data: { + meta: { + title: 'Account playlists' + } + } + }, + { + path: 'video-playlists/create', + component: MyAccountVideoPlaylistCreateComponent, + data: { + meta: { + title: 'Create new playlist' + } + } + }, + { + path: 'video-playlists/update/:videoPlaylistId', + component: MyAccountVideoPlaylistUpdateComponent, + data: { + meta: { + title: 'Update playlist' + } + } + }, + { path: 'videos', component: MyAccountVideosComponent, diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts index 9d2dccdf0..6ce22989b 100644 --- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts +++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts @@ -1,7 +1,6 @@ import { Component, OnInit } from '@angular/core' import { Notifier } from '@app/core' import { VideoChannel } from '@app/shared/video-channel/video-channel.model' -import { I18n } from '@ngx-translate/i18n-polyfill' import { UserSubscriptionService } from '@app/shared/user-subscription' import { ComponentPagination } from '@app/shared/rest/component-pagination.model' @@ -21,8 +20,7 @@ export class MyAccountSubscriptionsComponent implements OnInit { constructor ( private userSubscriptionService: UserSubscriptionService, - private notifier: Notifier, - private i18n: I18n + private notifier: Notifier ) {} ngOnInit () { diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html index 51db2e75d..11e87ba79 100644 --- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html +++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html @@ -1,7 +1,7 @@
- Create another video channel + Create a new video channel
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts new file mode 100644 index 000000000..61b61e221 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts @@ -0,0 +1,89 @@ +import { Component, OnInit } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService, Notifier, ServerService } from '@app/core' +import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' +import { VideoPlaylistValidatorsService } from '@app/shared' +import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model' +import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' +import { VideoConstant } from '@shared/models' +import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model' +import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils' + +@Component({ + selector: 'my-account-video-playlist-create', + templateUrl: './my-account-video-playlist-edit.component.html', + styleUrls: [ './my-account-video-playlist-edit.component.scss' ] +}) +export class MyAccountVideoPlaylistCreateComponent extends MyAccountVideoPlaylistEdit implements OnInit { + error: string + videoPlaylistPrivacies: VideoConstant[] = [] + + constructor ( + protected formValidatorService: FormValidatorService, + private authService: AuthService, + private videoPlaylistValidatorsService: VideoPlaylistValidatorsService, + private notifier: Notifier, + private router: Router, + private videoPlaylistService: VideoPlaylistService, + private serverService: ServerService, + private i18n: I18n + ) { + super() + } + + ngOnInit () { + this.buildForm({ + 'display-name': this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME, + privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY, + description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION, + videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID, + thumbnailfile: null + }) + + populateAsyncUserVideoChannels(this.authService, this.userVideoChannels) + + this.serverService.videoPlaylistPrivaciesLoaded.subscribe( + () => { + this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies() + + this.form.patchValue({ + privacy: VideoPlaylistPrivacy.PRIVATE + }) + } + ) + } + + formValidated () { + this.error = undefined + + const body = this.form.value + const videoPlaylistCreate: VideoPlaylistCreate = { + displayName: body['display-name'], + privacy: body.privacy, + description: body.description || null, + videoChannelId: body.videoChannelId || null, + thumbnailfile: body.thumbnailfile || null + } + + this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe( + () => { + this.notifier.success( + this.i18n('Playlist {{playlistName}} created.', { playlistName: videoPlaylistCreate.displayName }) + ) + this.router.navigate([ '/my-account', 'video-playlists' ]) + }, + + err => this.error = err.message + ) + } + + isCreation () { + return true + } + + getFormButtonTitle () { + return this.i18n('Create') + } +} diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html new file mode 100644 index 000000000..b76488c78 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html @@ -0,0 +1,64 @@ +
Create a new playlist
+ +
{{ error }}
+ +
+
+
+
+ + +
+ {{ formErrors['display-name'] }} +
+
+ +
+ + +
+ {{ formErrors.description }} +
+
+
+ +
+
+ +
+ +
+ +
+ {{ formErrors.privacy }} +
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+ +
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss new file mode 100644 index 000000000..5af846d8e --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss @@ -0,0 +1,27 @@ +@import '_variables'; +@import '_mixins'; + +.form-sub-title { + margin-bottom: 20px; +} + +input[type=text] { + @include peertube-input-text(340px); + + display: block; +} + +textarea { + @include peertube-textarea(500px, 150px); + + display: block; +} + +.peertube-select-container { + @include peertube-select-container(340px); +} + +input[type=submit] { + @include peertube-button; + @include orange-button; +} diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts new file mode 100644 index 000000000..fbfb4c8f7 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts @@ -0,0 +1,13 @@ +import { FormReactive } from '@app/shared' +import { VideoChannel } from '@app/shared/video-channel/video-channel.model' +import { ServerService } from '@app/core' +import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model' + +export abstract class MyAccountVideoPlaylistEdit extends FormReactive { + // Declare it here to avoid errors in create template + videoPlaylistToUpdate: VideoPlaylist + userVideoChannels: { id: number, label: string }[] = [] + + abstract isCreation (): boolean + abstract getFormButtonTitle (): string +} diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts new file mode 100644 index 000000000..167d7dd09 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts @@ -0,0 +1,132 @@ +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, Notifier, ServerService } from '@app/core' +import { Subscription } from 'rxjs' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' +import { MyAccountVideoPlaylistEdit } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-edit' +import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils' +import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' +import { VideoPlaylistValidatorsService } from '@app/shared' +import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model' +import { VideoConstant } from '@shared/models' +import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model' +import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' + +@Component({ + selector: 'my-account-video-playlist-update', + templateUrl: './my-account-video-playlist-edit.component.html', + styleUrls: [ './my-account-video-playlist-edit.component.scss' ] +}) +export class MyAccountVideoPlaylistUpdateComponent extends MyAccountVideoPlaylistEdit implements OnInit, OnDestroy { + error: string + videoPlaylistToUpdate: VideoPlaylist + videoPlaylistPrivacies: VideoConstant[] = [] + + private paramsSub: Subscription + + constructor ( + protected formValidatorService: FormValidatorService, + private authService: AuthService, + private videoPlaylistValidatorsService: VideoPlaylistValidatorsService, + private notifier: Notifier, + private router: Router, + private route: ActivatedRoute, + private videoPlaylistService: VideoPlaylistService, + private i18n: I18n, + private serverService: ServerService + ) { + super() + } + + ngOnInit () { + this.buildForm({ + 'display-name': this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME, + privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY, + description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION, + videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID, + thumbnailfile: null + }) + + populateAsyncUserVideoChannels(this.authService, this.userVideoChannels) + + this.paramsSub = this.route.params.subscribe(routeParams => { + const videoPlaylistId = routeParams['videoPlaylistId'] + + this.videoPlaylistService.getVideoPlaylist(videoPlaylistId).subscribe( + videoPlaylistToUpdate => { + this.videoPlaylistToUpdate = videoPlaylistToUpdate + + this.hydrateFormFromPlaylist() + + this.serverService.videoPlaylistPrivaciesLoaded.subscribe( + () => { + this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies() + .filter(p => { + // If the playlist is not private, we cannot put it in private anymore + return this.videoPlaylistToUpdate.privacy.id === VideoPlaylistPrivacy.PRIVATE || + p.id !== VideoPlaylistPrivacy.PRIVATE + }) + } + ) + }, + + err => this.error = err.message + ) + }) + } + + ngOnDestroy () { + if (this.paramsSub) this.paramsSub.unsubscribe() + } + + formValidated () { + this.error = undefined + + const body = this.form.value + const videoPlaylistUpdate: VideoPlaylistUpdate = { + displayName: body['display-name'], + privacy: body['privacy'], + description: body.description || null, + videoChannelId: body.videoChannelId || null, + thumbnailfile: body.thumbnailfile || undefined + } + + this.videoPlaylistService.updateVideoPlaylist(this.videoPlaylistToUpdate, videoPlaylistUpdate).subscribe( + () => { + this.notifier.success( + this.i18n('Playlist {{videoPlaylistName}} updated.', { videoPlaylistName: videoPlaylistUpdate.displayName }) + ) + + this.router.navigate([ '/my-account', 'video-playlists' ]) + }, + + err => this.error = err.message + ) + } + + isCreation () { + return false + } + + getFormButtonTitle () { + return this.i18n('Update') + } + + private hydrateFormFromPlaylist () { + this.form.patchValue({ + 'display-name': this.videoPlaylistToUpdate.displayName, + privacy: this.videoPlaylistToUpdate.privacy.id, + description: this.videoPlaylistToUpdate.description, + videoChannelId: this.videoPlaylistToUpdate.videoChannel ? this.videoPlaylistToUpdate.videoChannel.id : null + }) + + fetch(this.videoPlaylistToUpdate.thumbnailUrl) + .then(response => response.blob()) + .then(data => { + this.form.patchValue({ + thumbnailfile: data + }) + }) + } +} diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html new file mode 100644 index 000000000..ab5d9cc5a --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html @@ -0,0 +1,20 @@ + + +
+
+
+ +
+ +
+ + + +
+
+
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss new file mode 100644 index 000000000..88fba5b05 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss @@ -0,0 +1,50 @@ +@import '_variables'; +@import '_mixins'; + +.create-button { + @include create-button; +} + +/deep/ .action-button { + &.action-button-delete { + margin-right: 10px; + } +} + +.video-playlist { + @include row-blocks; + + .miniature-wrapper { + flex-grow: 1; + + /deep/ .miniature { + display: flex; + + .miniature-bottom { + margin-left: 10px; + } + } + } + + .video-playlist-buttons { + min-width: 190px; + } +} + +.video-playlists-header { + text-align: right; + margin: 20px 0 50px; +} + +@media screen and (max-width: 800px) { + .video-playlists-header { + text-align: center; + } + + .video-playlist { + + .video-playlist-buttons { + margin-top: 10px; + } + } +} diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts new file mode 100644 index 000000000..761ce90e8 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit } from '@angular/core' +import { Notifier } from '@app/core' +import { AuthService } from '../../core/auth' +import { ConfirmService } from '../../core/confirm' +import { User } from '@app/shared' +import { flatMap } from 'rxjs/operators' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' +import { ComponentPagination } from '@app/shared/rest/component-pagination.model' +import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' +import { VideoPlaylistType } from '@shared/models' + +@Component({ + selector: 'my-account-video-playlists', + templateUrl: './my-account-video-playlists.component.html', + styleUrls: [ './my-account-video-playlists.component.scss' ] +}) +export class MyAccountVideoPlaylistsComponent implements OnInit { + videoPlaylists: VideoPlaylist[] = [] + + pagination: ComponentPagination = { + currentPage: 1, + itemsPerPage: 10, + totalItems: null + } + + private user: User + + constructor ( + private authService: AuthService, + private notifier: Notifier, + private confirmService: ConfirmService, + private videoPlaylistService: VideoPlaylistService, + private i18n: I18n + ) {} + + ngOnInit () { + this.user = this.authService.getUser() + + this.loadVideoPlaylists() + } + + async deleteVideoPlaylist (videoPlaylist: VideoPlaylist) { + const res = await this.confirmService.confirm( + this.i18n( + 'Do you really want to delete {{playlistDisplayName}}?', + { playlistDisplayName: videoPlaylist.displayName } + ), + this.i18n('Delete') + ) + if (res === false) return + + this.videoPlaylistService.removeVideoPlaylist(videoPlaylist) + .subscribe( + () => { + this.videoPlaylists = this.videoPlaylists + .filter(p => p.id !== videoPlaylist.id) + + this.notifier.success( + this.i18n('Playlist {{playlistDisplayName}} deleted.', { playlistDisplayName: videoPlaylist.displayName }) + ) + }, + + error => this.notifier.error(error.message) + ) + } + + isRegularPlaylist (playlist: VideoPlaylist) { + return playlist.type.id === VideoPlaylistType.REGULAR + } + + private loadVideoPlaylists () { + this.authService.userInformationLoaded + .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account))) + .subscribe(res => this.videoPlaylists = res.data) + } + + private ofNearOfBottom () { + // Last page + if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return + + this.pagination.currentPage += 1 + this.loadVideoPlaylists() + } +} diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts index 8a4102d80..f624ff505 100644 --- a/client/src/app/+my-account/my-account.component.ts +++ b/client/src/app/+my-account/my-account.component.ts @@ -27,6 +27,10 @@ export class MyAccountComponent { label: this.i18n('My videos'), routerLink: '/my-account/videos' }, + { + label: this.i18n('My playlists'), + routerLink: '/my-account/video-playlists' + }, { label: this.i18n('My subscriptions'), routerLink: '/my-account/subscriptions' diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index 18f51f171..3dbce2b92 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts @@ -25,6 +25,13 @@ import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-b import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences' +import { + MyAccountVideoPlaylistCreateComponent +} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component' +import { + MyAccountVideoPlaylistUpdateComponent +} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' +import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' @NgModule({ imports: [ @@ -57,7 +64,11 @@ import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-a MyAccountServerBlocklistComponent, MyAccountHistoryComponent, MyAccountNotificationsComponent, - MyAccountNotificationPreferencesComponent + MyAccountNotificationPreferencesComponent, + + MyAccountVideoPlaylistCreateComponent, + MyAccountVideoPlaylistUpdateComponent, + MyAccountVideoPlaylistsComponent ], exports: [ diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 7583fdee8..c5c5a8f66 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -74,6 +74,7 @@ export class AppComponent implements OnInit { this.serverService.loadVideoLanguages() this.serverService.loadVideoLicences() this.serverService.loadVideoPrivacies() + this.serverService.loadVideoPlaylistPrivacies() // Do not display menu on small screens if (this.screenService.isInSmallView()) { diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index 10acf6e72..acaca8a01 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts @@ -9,17 +9,20 @@ import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' import { sortBy } from '@app/shared/misc/utils' +import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model' @Injectable() export class ServerService { private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server/' private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config/' private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' + private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' configLoaded = new ReplaySubject(1) videoPrivaciesLoaded = new ReplaySubject(1) + videoPlaylistPrivaciesLoaded = new ReplaySubject(1) videoCategoriesLoaded = new ReplaySubject(1) videoLicencesLoaded = new ReplaySubject(1) videoLanguagesLoaded = new ReplaySubject(1) @@ -101,6 +104,7 @@ export class ServerService { private videoLicences: Array> = [] private videoLanguages: Array> = [] private videoPrivacies: Array> = [] + private videoPlaylistPrivacies: Array> = [] constructor ( private http: HttpClient, @@ -121,19 +125,28 @@ export class ServerService { } loadVideoCategories () { - return this.loadVideoAttributeEnum('categories', this.videoCategories, this.videoCategoriesLoaded, true) + return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'categories', this.videoCategories, this.videoCategoriesLoaded, true) } loadVideoLicences () { - return this.loadVideoAttributeEnum('licences', this.videoLicences, this.videoLicencesLoaded) + return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'licences', this.videoLicences, this.videoLicencesLoaded) } loadVideoLanguages () { - return this.loadVideoAttributeEnum('languages', this.videoLanguages, this.videoLanguagesLoaded, true) + return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'languages', this.videoLanguages, this.videoLanguagesLoaded, true) } loadVideoPrivacies () { - return this.loadVideoAttributeEnum('privacies', this.videoPrivacies, this.videoPrivaciesLoaded) + return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'privacies', this.videoPrivacies, this.videoPrivaciesLoaded) + } + + loadVideoPlaylistPrivacies () { + return this.loadAttributeEnum( + ServerService.BASE_VIDEO_PLAYLIST_URL, + 'privacies', + this.videoPlaylistPrivacies, + this.videoPlaylistPrivaciesLoaded + ) } getConfig () { @@ -156,7 +169,12 @@ export class ServerService { return this.videoPrivacies } - private loadVideoAttributeEnum ( + getVideoPlaylistPrivacies () { + return this.videoPlaylistPrivacies + } + + private loadAttributeEnum ( + baseUrl: string, attributeName: 'categories' | 'licences' | 'languages' | 'privacies', hashToPopulate: VideoConstant[], notifier: ReplaySubject, @@ -165,7 +183,7 @@ export class ServerService { this.localeObservable .pipe( switchMap(translations => { - return this.http.get<{ [id: string]: string }>(ServerService.BASE_VIDEO_URL + attributeName) + return this.http.get<{ [id: string]: string }>(baseUrl + attributeName) .pipe(map(data => ({ data, translations }))) }) ) diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts index a91e9c7eb..c2b69d31a 100644 --- a/client/src/app/shared/buttons/button.component.ts +++ b/client/src/app/shared/buttons/button.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core' -import { GlobalIconName } from '@app/shared/icons/global-icon.component' +import { GlobalIconName } from '@app/shared/images/global-icon.component' @Component({ selector: 'my-button', diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts index fdcbedb71..e3de3ae13 100644 --- a/client/src/app/shared/forms/form-validators/index.ts +++ b/client/src/app/shared/forms/form-validators/index.ts @@ -10,6 +10,7 @@ export * from './video-blacklist-validators.service' export * from './video-channel-validators.service' export * from './video-comment-validators.service' export * from './video-validators.service' +export * from './video-playlist-validators.service' export * from './video-captions-validators.service' export * from './video-change-ownership-validators.service' export * from './video-accept-ownership-validators.service' diff --git a/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts new file mode 100644 index 000000000..726084b47 --- /dev/null +++ b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts @@ -0,0 +1,52 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from '@app/shared' + +@Injectable() +export class VideoPlaylistValidatorsService { + readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator + readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator + readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator + readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_PLAYLIST_DISPLAY_NAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(120) + ], + MESSAGES: { + 'required': this.i18n('Display name is required.'), + 'minlength': this.i18n('Display name must be at least 1 character long.'), + 'maxlength': this.i18n('Display name cannot be more than 120 characters long.') + } + } + + this.VIDEO_PLAYLIST_PRIVACY = { + VALIDATORS: [ + Validators.required + ], + MESSAGES: { + 'required': this.i18n('Privacy is required.') + } + } + + this.VIDEO_PLAYLIST_DESCRIPTION = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(1000) + ], + MESSAGES: { + 'minlength': i18n('Description must be at least 3 characters long.'), + 'maxlength': i18n('Description cannot be more than 1000 characters long.') + } + } + + this.VIDEO_PLAYLIST_CHANNEL_ID = { + VALIDATORS: [ ], + MESSAGES: { } + } + } +} diff --git a/client/src/app/shared/icons/global-icon.component.html b/client/src/app/shared/images/global-icon.component.html similarity index 100% rename from client/src/app/shared/icons/global-icon.component.html rename to client/src/app/shared/images/global-icon.component.html diff --git a/client/src/app/shared/icons/global-icon.component.scss b/client/src/app/shared/images/global-icon.component.scss similarity index 100% rename from client/src/app/shared/icons/global-icon.component.scss rename to client/src/app/shared/images/global-icon.component.scss diff --git a/client/src/app/shared/icons/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts similarity index 100% rename from client/src/app/shared/icons/global-icon.component.ts rename to client/src/app/shared/images/global-icon.component.ts diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.html b/client/src/app/shared/images/image-upload.component.html similarity index 100% rename from client/src/app/videos/+video-edit/shared/video-image.component.html rename to client/src/app/shared/images/image-upload.component.html diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.scss b/client/src/app/shared/images/image-upload.component.scss similarity index 100% rename from client/src/app/videos/+video-edit/shared/video-image.component.scss rename to client/src/app/shared/images/image-upload.component.scss diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.ts b/client/src/app/shared/images/image-upload.component.ts similarity index 84% rename from client/src/app/videos/+video-edit/shared/video-image.component.ts rename to client/src/app/shared/images/image-upload.component.ts index a604cde90..2da1592ff 100644 --- a/client/src/app/videos/+video-edit/shared/video-image.component.ts +++ b/client/src/app/shared/images/image-upload.component.ts @@ -4,18 +4,18 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' import { ServerService } from '@app/core' @Component({ - selector: 'my-video-image', - styleUrls: [ './video-image.component.scss' ], - templateUrl: './video-image.component.html', + selector: 'my-image-upload', + styleUrls: [ './image-upload.component.scss' ], + templateUrl: './image-upload.component.html', providers: [ { provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => VideoImageComponent), + useExisting: forwardRef(() => ImageUploadComponent), multi: true } ] }) -export class VideoImageComponent implements ControlValueAccessor { +export class ImageUploadComponent implements ControlValueAccessor { @Input() inputLabel: string @Input() inputName: string @Input() previewWidth: string diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts index 7cc6055c2..8a1d342c9 100644 --- a/client/src/app/shared/misc/utils.ts +++ b/client/src/app/shared/misc/utils.ts @@ -17,7 +17,7 @@ function getParameterByName (name: string, url: string) { return decodeURIComponent(results[2].replace(/\+/g, ' ')) } -function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support: string }[]) { +function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support?: string }[]) { return new Promise(res => { authService.userInformationLoaded .subscribe( diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 1c4e3df1a..60a7bd6e2 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -45,6 +45,7 @@ import { VideoChangeOwnershipValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, + VideoPlaylistValidatorsService, VideoValidatorsService } from '@app/shared/forms' import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' @@ -68,8 +69,11 @@ import { UserNotificationsComponent } from '@app/shared/users/user-notifications import { InstanceService } from '@app/shared/instance/instance.service' import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer' import { ConfirmComponent } from '@app/shared/confirm/confirm.component' -import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' +import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' +import { ImageUploadComponent } from '@app/shared/images/image-upload.component' +import { GlobalIconComponent } from '@app/shared/images/global-icon.component' +import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' @NgModule({ imports: [ @@ -92,8 +96,11 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' declarations: [ LoaderComponent, SmallLoaderComponent, + VideoThumbnailComponent, VideoMiniatureComponent, + VideoPlaylistMiniatureComponent, + FeedComponent, ButtonComponent, DeleteButtonComponent, @@ -116,7 +123,9 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' TopMenuDropdownComponent, UserNotificationsComponent, ConfirmComponent, - GlobalIconComponent + + GlobalIconComponent, + ImageUploadComponent ], exports: [ @@ -138,8 +147,11 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' LoaderComponent, SmallLoaderComponent, + VideoThumbnailComponent, VideoMiniatureComponent, + VideoPlaylistMiniatureComponent, + FeedComponent, ButtonComponent, DeleteButtonComponent, @@ -159,7 +171,9 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' TopMenuDropdownComponent, UserNotificationsComponent, ConfirmComponent, + GlobalIconComponent, + ImageUploadComponent, NumberFormatterPipe, ObjectLengthPipe, @@ -177,6 +191,7 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' VideoService, AccountService, VideoChannelService, + VideoPlaylistService, VideoCaptionService, VideoImportService, UserSubscriptionService, @@ -186,6 +201,7 @@ import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' LoginValidatorsService, ResetPasswordValidatorsService, UserValidatorsService, + VideoPlaylistValidatorsService, VideoAbuseValidatorsService, VideoChannelValidatorsService, VideoCommentValidatorsService, diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html new file mode 100644 index 000000000..1a39f5fe5 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html @@ -0,0 +1,22 @@ + diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss new file mode 100644 index 000000000..a47206577 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss @@ -0,0 +1,34 @@ +@import '_variables'; +@import '_mixins'; +@import '_miniature'; + +.miniature { + display: inline-block; + + .miniature-thumbnail { + @include miniature-thumbnail; + + .miniature-playlist-info-overlay { + @include static-thumbnail-overlay; + + position: absolute; + right: 0; + bottom: 0; + height: $video-thumbnail-height; + padding: 0 10px; + display: flex; + align-items: center; + font-size: 15px; + } + } + + .miniature-bottom { + width: 200px; + margin-top: 2px; + line-height: normal; + + .miniature-name { + @include miniature-name; + } + } +} diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts new file mode 100644 index 000000000..b3bba7c87 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core' +import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' + +@Component({ + selector: 'my-video-playlist-miniature', + styleUrls: [ './video-playlist-miniature.component.scss' ], + templateUrl: './video-playlist-miniature.component.html' +}) +export class VideoPlaylistMiniatureComponent { + @Input() playlist: VideoPlaylist +} diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts new file mode 100644 index 000000000..9d0b02789 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist.model.ts @@ -0,0 +1,74 @@ +import { + VideoChannelSummary, + VideoConstant, + VideoPlaylist as ServerVideoPlaylist, + VideoPlaylistPrivacy, + VideoPlaylistType +} from '../../../../../shared/models/videos' +import { AccountSummary, peertubeTranslate } from '@shared/models' +import { Actor } from '@app/shared/actor/actor.model' +import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' + +export class VideoPlaylist implements ServerVideoPlaylist { + id: number + uuid: string + isLocal: boolean + + displayName: string + description: string + privacy: VideoConstant + + thumbnailPath: string + + videosLength: number + + type: VideoConstant + + createdAt: Date | string + updatedAt: Date | string + + ownerAccount: AccountSummary + videoChannel?: VideoChannelSummary + + thumbnailUrl: string + + ownerBy: string + ownerAvatarUrl: string + + videoChannelBy?: string + videoChannelAvatarUrl?: string + + constructor (hash: ServerVideoPlaylist, translations: {}) { + const absoluteAPIUrl = getAbsoluteAPIUrl() + + this.id = hash.id + this.uuid = hash.uuid + this.isLocal = hash.isLocal + + this.displayName = hash.displayName + this.description = hash.description + this.privacy = hash.privacy + + this.thumbnailPath = hash.thumbnailPath + this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath + + this.videosLength = hash.videosLength + + this.type = hash.type + + this.createdAt = new Date(hash.createdAt) + this.updatedAt = new Date(hash.updatedAt) + + this.ownerAccount = hash.ownerAccount + this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) + this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount) + + if (hash.videoChannel) { + this.videoChannel = hash.videoChannel + this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host) + this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel) + } + + this.privacy.label = peertubeTranslate(this.privacy.label, translations) + } +} diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts new file mode 100644 index 000000000..8b66e122c --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist.service.ts @@ -0,0 +1,108 @@ +import { catchError, map, switchMap } from 'rxjs/operators' +import { Injectable } from '@angular/core' +import { Observable } from 'rxjs' +import { RestExtractor } from '../rest/rest-extractor.service' +import { HttpClient } from '@angular/common/http' +import { ResultList } from '../../../../../shared' +import { environment } from '../../../environments/environment' +import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' +import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' +import { VideoChannel } from '@app/shared/video-channel/video-channel.model' +import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model' +import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model' +import { objectToFormData } from '@app/shared/misc/utils' +import { ServerService } from '@app/core' +import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' +import { AccountService } from '@app/shared/account/account.service' +import { Account } from '@app/shared/account/account.model' + +@Injectable() +export class VideoPlaylistService { + static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' + + constructor ( + private authHttp: HttpClient, + private serverService: ServerService, + private restExtractor: RestExtractor + ) { } + + listChannelPlaylists (videoChannel: VideoChannel): Observable> { + const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' + + return this.authHttp.get>(url) + .pipe( + switchMap(res => this.extractPlaylists(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + listAccountPlaylists (account: Account): Observable> { + const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' + + return this.authHttp.get>(url) + .pipe( + switchMap(res => this.extractPlaylists(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + getVideoPlaylist (id: string | number) { + const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id + + return this.authHttp.get(url) + .pipe( + switchMap(res => this.extractPlaylist(res)), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + createVideoPlaylist (body: VideoPlaylistCreate) { + const data = objectToFormData(body) + + return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) { + const data = objectToFormData(body) + + return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + removeVideoPlaylist (videoPlaylist: VideoPlaylist) { + return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + + extractPlaylists (result: ResultList) { + return this.serverService.localeObservable + .pipe( + map(translations => { + const playlistsJSON = result.data + const total = result.total + const playlists: VideoPlaylist[] = [] + + for (const playlistJSON of playlistsJSON) { + playlists.push(new VideoPlaylist(playlistJSON, translations)) + } + + return { data: playlists, total } + }) + ) + } + + extractPlaylist (playlist: VideoPlaylistServerModel) { + return this.serverService.localeObservable + .pipe(map(translations => new VideoPlaylist(playlist, translations))) + } +} diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss index 292ede698..65842af35 100644 --- a/client/src/app/shared/video/abstract-video-list.scss +++ b/client/src/app/shared/video/abstract-video-list.scss @@ -1,4 +1,5 @@ @import '_mixins'; +@import '_miniature'; .videos { text-align: center; diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss index c118fc3a1..7d857a74e 100644 --- a/client/src/app/shared/video/video-miniature.component.scss +++ b/client/src/app/shared/video/video-miniature.component.scss @@ -1,5 +1,6 @@ @import '_variables'; @import '_mixins'; +@import '_miniature'; .video-miniature { display: inline-block; @@ -14,26 +15,7 @@ line-height: normal; .video-miniature-name { - @include ellipsis-multiline( - $font-size: 1rem, - $line-height: 1, - $lines-to-show: 2 - ); - transition: color 0.2s; - font-size: 16px; - font-weight: $font-semibold; - color: var(--mainForegroundColor); - margin-top: 5px; - margin-bottom: 5px; - - &:hover { - text-decoration: none; - } - - &.blur-filter { - filter: blur(3px); - padding-left: 4px; - } + @include miniature-name; } .video-miniature-created-at-views { diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html index a15df725e..a6757fc4a 100644 --- a/client/src/app/shared/video/video-thumbnail.component.html +++ b/client/src/app/shared/video/video-thumbnail.component.html @@ -4,9 +4,11 @@ > -
{{ video.durationLabel }}
+
{{ video.durationLabel }}
-
+
+
+
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss index b9fd9182f..0113427a3 100644 --- a/client/src/app/shared/video/video-thumbnail.component.scss +++ b/client/src/app/shared/video/video-thumbnail.component.scss @@ -1,66 +1,9 @@ @import '_variables'; @import '_mixins'; - -$play-overlay-transition: 0.2s ease; -$play-overlay-height: 26px; -$play-overlay-width: 18px; +@import '_miniature'; .video-thumbnail { - @include disable-outline; - - display: inline-block; - position: relative; - border-radius: 3px; - overflow: hidden; - width: $video-thumbnail-width; - height: $video-thumbnail-height; - background-color: #ececec; - transition: filter $play-overlay-transition; - - &:hover { - text-decoration: none !important; - - filter: brightness(85%); - - .play-overlay { - opacity: 1; - - transform: translate(-50%, -50%) scale(1); - } - } - - &.focus-visible { - box-shadow: 0 0 0 2px var(--mainColor); - } - - img { - width: $video-thumbnail-width; - height: $video-thumbnail-height; - - &.blur-filter { - filter: blur(5px); - transform : scale(1.03); - } - } - - .play-overlay { - width: 0; - height: 0; - - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%) scale(0.5); - - transition: all $play-overlay-transition; - - border-top: ($play-overlay-height / 2) solid transparent; - border-bottom: ($play-overlay-height / 2) solid transparent; - - border-left: $play-overlay-width solid rgba(255, 255, 255, 0.95); - - opacity: 0; - } + @include miniature-thumbnail; .progress-bar { height: 3px; @@ -75,16 +18,15 @@ $play-overlay-width: 18px; } } - .video-thumbnail-overlay { + .video-thumbnail-duration-overlay { + @include static-thumbnail-overlay; + position: absolute; right: 5px; bottom: 5px; - display: inline-block; - background-color: rgba(0, 0, 0, 0.7); - color: #fff; + padding: 0 5px; + border-radius: 3px; font-size: 12px; font-weight: $font-bold; - border-radius: 3px; - padding: 0 5px; } } diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index 460c09258..c936a8207 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts @@ -117,9 +117,8 @@ export class Video implements VideoServerModel { this.privacy.label = peertubeTranslate(this.privacy.label, translations) this.scheduledUpdate = hash.scheduledUpdate - this.originallyPublishedAt = hash.originallyPublishedAt ? - new Date(hash.originallyPublishedAt.toString()) - : null + this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null + if (this.state) this.state.label = peertubeTranslate(this.state.label, translations) this.blacklisted = hash.blacklisted diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.html b/client/src/app/videos/+video-edit/shared/video-edit.component.html index 1be1084ad..99695204d 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.html @@ -188,17 +188,17 @@
- + >
- + >
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.module.ts b/client/src/app/videos/+video-edit/shared/video-edit.module.ts index f441d3fde..39b6daa93 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.module.ts +++ b/client/src/app/videos/+video-edit/shared/video-edit.module.ts @@ -2,7 +2,6 @@ import { NgModule } from '@angular/core' import { TagInputModule } from 'ngx-chips' import { SharedModule } from '../../../shared/' import { VideoEditComponent } from './video-edit.component' -import { VideoImageComponent } from './video-image.component' import { CalendarModule } from 'primeng/components/calendar/calendar' import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' @@ -16,7 +15,6 @@ import { VideoCaptionAddModalComponent } from './video-caption-add-modal.compone declarations: [ VideoEditComponent, - VideoImageComponent, VideoCaptionAddModalComponent ], diff --git a/client/src/app/videos/video-list/video-overview.component.scss b/client/src/app/videos/video-list/video-overview.component.scss index aff45c072..42b542233 100644 --- a/client/src/app/videos/video-list/video-overview.component.scss +++ b/client/src/app/videos/video-list/video-overview.component.scss @@ -1,5 +1,6 @@ @import '_variables'; @import '_mixins'; +@import '_miniature'; .section { padding-top: 10px; @@ -50,4 +51,4 @@ .section { @include video-miniature-small-screen; } -} \ No newline at end of file +} diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss new file mode 100644 index 000000000..36d4e84d3 --- /dev/null +++ b/client/src/sass/include/_miniature.scss @@ -0,0 +1,133 @@ +@import '_variables'; +@import '_mixins'; + +@mixin miniature-name { + @include ellipsis-multiline( + $font-size: 1rem, + $line-height: 1, + $lines-to-show: 2 + ); + transition: color 0.2s; + font-size: 16px; + font-weight: $font-semibold; + color: var(--mainForegroundColor); + margin-top: 5px; + margin-bottom: 5px; + + &:hover { + text-decoration: none; + } + + &.blur-filter { + filter: blur(3px); + padding-left: 4px; + } +} + +$play-overlay-transition: 0.2s ease; +$play-overlay-height: 26px; +$play-overlay-width: 18px; + +@mixin miniature-thumbnail { + @include disable-outline; + + display: inline-block; + position: relative; + border-radius: 3px; + overflow: hidden; + width: $video-thumbnail-width; + height: $video-thumbnail-height; + background-color: #ececec; + transition: filter $play-overlay-transition; + + .play-overlay { + position: absolute; + right: 0; + bottom: 0; + + width: $video-thumbnail-width; + height: $video-thumbnail-height; + opacity: 0; + background-color: rgba(0, 0, 0, 0.7); + + &, .icon { + transition: all $play-overlay-transition; + } + + .icon { + width: 0; + height: 0; + + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%) scale(0.5); + + border-top: ($play-overlay-height / 2) solid transparent; + border-bottom: ($play-overlay-height / 2) solid transparent; + + border-left: $play-overlay-width solid rgba(255, 255, 255, 0.95); + } + } + + &:hover { + text-decoration: none !important; + + .play-overlay { + opacity: 1; + + .icon { + transform: translate(-50%, -50%) scale(1); + } + } + } + + &.focus-visible { + box-shadow: 0 0 0 2px var(--mainColor); + } + + img { + width: $video-thumbnail-width; + height: $video-thumbnail-height; + + &.blur-filter { + filter: blur(5px); + transform : scale(1.03); + } + } +} + +@mixin static-thumbnail-overlay { + display: inline-block; + background-color: rgba(0, 0, 0, 0.7); + color: #fff; +} + +@mixin video-miniature-small-screen { + text-align: center; + + /deep/ .video-miniature { + padding-right: 0; + height: auto; + width: 100%; + margin-bottom: 20px; + + .video-miniature-information { + width: 100% !important; + + span { + width: 100%; + } + } + + .video-thumbnail { + width: 100%; + height: auto; + + img { + width: 100%; + height: auto; + } + } + } +} diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index c2e200a14..59b2f42a5 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -516,31 +516,3 @@ } } -@mixin video-miniature-small-screen { - text-align: center; - - /deep/ .video-miniature { - padding-right: 0; - height: auto; - width: 100%; - margin-bottom: 20px; - - .video-miniature-information { - width: 100% !important; - - span { - width: 100%; - } - } - - .video-thumbnail { - width: 100%; - height: auto; - - img { - width: 100%; - height: auto; - } - } - } -} diff --git a/client/tsconfig.json b/client/tsconfig.json index 3f9986f8a..a0fbc27c6 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -28,6 +28,7 @@ "baseUrl": "src", "paths": { "@app/*": [ "app/*" ], + "@shared/*": [ "../../shared/*" ], "video.js": [ "../node_modules/video.js/dist/alt/video.core.js" ], "fs": [ "./shims/noop" ], "http": [ "./shims/http" ], @@ -41,11 +42,14 @@ "strictInjectionParameters": true, "fullTemplateTypeCheck": true }, + "include": [ + "../../shared" + ], "exclude": [ + "../../node_modules", "../node_modules", - "node_modules", - "dist", - "../server", - "src/**/*.spec.ts" + "../dist", + "../../server", + "../src/**/*.spec.ts" ] } diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts index 40c420825..95897afa3 100755 --- a/scripts/i18n/create-custom-files.ts +++ b/scripts/i18n/create-custom-files.ts @@ -5,7 +5,7 @@ import { buildLanguages, VIDEO_CATEGORIES, VIDEO_IMPORT_STATES, - VIDEO_LICENCES, + VIDEO_LICENCES, VIDEO_PLAYLIST_PRIVACIES, VIDEO_PLAYLIST_TYPES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../server/initializers/constants' @@ -46,6 +46,8 @@ values(VIDEO_CATEGORIES) .concat(values(VIDEO_PRIVACIES)) .concat(values(VIDEO_STATES)) .concat(values(VIDEO_IMPORT_STATES)) + .concat(values(VIDEO_PLAYLIST_PRIVACIES)) + .concat(values(VIDEO_PLAYLIST_TYPES)) .concat([ 'This video does not exist.', 'We cannot fetch the video. Please try again later.', diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 4cbb87ab5..54c390540 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -642,7 +642,7 @@ let STATIC_MAX_AGE = '2h' // Videos thumbnail size const THUMBNAILS_SIZE = { width: 223, - height: 112 + height: 122 } const PREVIEWS_SIZE = { width: 560, diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts index fa26e2336..22b8b8ff1 100644 --- a/server/middlewares/validators/videos/video-playlists.ts +++ b/server/middlewares/validators/videos/video-playlists.ts @@ -344,6 +344,7 @@ function getCommonPlaylistEditAttributes () { .custom(isVideoPlaylistPrivacyValid).withMessage('Should have correct playlist privacy'), body('videoChannelId') .optional() + .customSanitizer(toValueOrNull) .toInt() ] as (ValidationChain | express.Handler)[] } diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 056ae06da..9cf861048 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -11,6 +11,13 @@ export * from './blacklist/video-blacklist-update.model' export * from './channel/video-channel-create.model' export * from './channel/video-channel-update.model' export * from './channel/video-channel.model' +export * from './playlist/video-playlist-create.model' +export * from './playlist/video-playlist-element-create.model' +export * from './playlist/video-playlist-element-update.model' +export * from './playlist/video-playlist-privacy.model' +export * from './playlist/video-playlist-type.model' +export * from './playlist/video-playlist-update.model' +export * from './playlist/video-playlist.model' export * from './video-change-ownership.model' export * from './video-change-ownership-create.model' export * from './video-create.model' @@ -27,4 +34,4 @@ export * from './caption/video-caption-update.model' export * from './import/video-import-create.model' export * from './import/video-import-state.enum' export * from './import/video-import.model' -export { VideoConstant } from './video-constant.model' +export * from './video-constant.model' diff --git a/shared/models/videos/playlist/video-playlist.model.ts b/shared/models/videos/playlist/video-playlist.model.ts index 7fec0e42b..c0941727a 100644 --- a/shared/models/videos/playlist/video-playlist.model.ts +++ b/shared/models/videos/playlist/video-playlist.model.ts @@ -21,6 +21,6 @@ export interface VideoPlaylist { createdAt: Date | string updatedAt: Date | string - ownerAccount?: AccountSummary + ownerAccount: AccountSummary videoChannel?: VideoChannelSummary } diff --git a/shared/utils/videos/video-playlists.ts b/shared/utils/videos/video-playlists.ts index 4af52ec0f..b84b21623 100644 --- a/shared/utils/videos/video-playlists.ts +++ b/shared/utils/videos/video-playlists.ts @@ -265,9 +265,21 @@ async function checkPlaylistFilesWereRemoved ( } } +function getVideoPlaylistPrivacies (url: string) { + const path = '/api/v1/video-playlists/privacies' + + return makeGetRequest({ + url, + path, + statusCodeExpected: 200 + }) +} + // --------------------------------------------------------------------------- export { + getVideoPlaylistPrivacies, + getVideoPlaylistsList, getVideoChannelPlaylistsList, getAccountPlaylistsList, -- 2.41.0