From 15e9d5ca39e0b792f61453fbf3885a0fc446afa7 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 12 Mar 2019 11:40:42 +0100 Subject: Playlist reorder support --- client/package.json | 1 + ...-account-video-playlist-elements.component.html | 7 +- ...-account-video-playlist-elements.component.scss | 154 ++++++++++++--------- ...my-account-video-playlist-elements.component.ts | 69 ++++++++- client/src/app/+my-account/my-account.module.ts | 4 +- client/src/app/shared/shared.module.ts | 3 - .../video-playlist/video-playlist.service.ts | 14 ++ client/yarn.lock | 14 ++ server/controllers/api/video-playlist.ts | 8 +- server/models/video/video.ts | 7 +- .../playlist/video-playlist-reorder.model.ts | 5 + 11 files changed, 211 insertions(+), 75 deletions(-) create mode 100644 shared/models/videos/playlist/video-playlist-reorder.model.ts diff --git a/client/package.json b/client/package.json index 08d309b07..72708bd76 100644 --- a/client/package.json +++ b/client/package.json @@ -66,6 +66,7 @@ "devDependencies": { "@angular-devkit/build-angular": "~0.13.1", "@angular/animations": "~7.2.4", + "@angular/cdk": "^7.3.4", "@angular/cli": "~7.3.1", "@angular/common": "~7.2.4", "@angular/compiler": "~7.2.4", diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html index e2d09a36d..67a8b1a91 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html @@ -1,7 +1,10 @@
No videos in this playlist.
-
-
+
+
{{ video.playlistElement.position }}
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss index 3be10078e..4ac89d08f 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss @@ -2,95 +2,121 @@ @import '_mixins'; @import '_miniature'; -.videos { - .video { +.video, .cdk-drag-preview { + display: flex; + align-items: center; + background-color: var(--mainBackgroundColor); + cursor: pointer; + padding: 10px; + border-bottom: 1px solid $separator-border-color; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + + .more { + display: block; + } + } + + .position { + font-weight: $font-semibold; + margin-right: 10px; + color: $grey-foreground-color; + min-width: 20px; + } + + my-video-thumbnail { + display: flex; // Avoids an issue with line-height that adds space below the element + margin-right: 10px; + + /deep/ .video-thumbnail { + @include miniature-thumbnail(130px, 72px); + } + } + + .video-info { display: flex; - align-items: center; - padding: 10px; - border-bottom: 1px solid $separator-border-color; + flex-direction: column; - &:hover { - background-color: rgba(0, 0, 0, 0.05); + a { + @include disable-default-a-behaviour; - .more { - display: block; - } + color: var(--mainForegroundColor); } - .position { + .video-info-name { + font-size: 18px; font-weight: $font-semibold; - margin-right: 10px; - color: $grey-foreground-color; } - my-video-thumbnail { - display: flex; // Avoids an issue with line-height that adds space below the element - margin-right: 10px; - - /deep/ .video-thumbnail { - @include miniature-thumbnail(130px, 72px); - } + .video-info-account, .video-info-timestamp { + color: $grey-foreground-color; } + } - .video-info { - display: flex; - flex-direction: column; + .more { + justify-self: flex-end; + margin-left: auto; + cursor: pointer; + display: none; - a { - @include disable-default-a-behaviour; + &.show { + display: block; + } - color: var(--mainForegroundColor); - } + .icon-more { + @include apply-svg-color($grey-foreground-color); - .video-info-name { - font-size: 18px; - font-weight: $font-semibold; + &::after { + border: none; } + } - .video-info-account, .video-info-timestamp { - color: $grey-foreground-color; - } + .dropdown-item { + @include dropdown-with-icon-item; } - .more { - justify-self: flex-end; - margin-left: auto; - cursor: pointer; - display: none; + .timestamp-options { + padding-top: 0; + padding-left: 35px; + margin-bottom: 15px; - &.show { - display: block; + > div { + display: flex; + align-items: center; } - .icon-more { - @include apply-svg-color($grey-foreground-color); + input { + @include peertube-button; + @include orange-button; - &::after { - border: none; - } + margin-top: 10px; } + } + } +} - .dropdown-item { - @include dropdown-with-icon-item; - } +// Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples +.cdk-drag-preview { + box-sizing: border-box; + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} - .timestamp-options { - padding-top: 0; - padding-left: 35px; - margin-bottom: 15px; +.cdk-drag-placeholder { + opacity: 0; +} - > div { - display: flex; - align-items: center; - } +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} - input { - @include peertube-button; - @include orange-button; +.video:last-child { + border: none; +} - margin-top: 10px; - } - } - } - } +.videos.cdk-drop-list-dragging .video:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts index 76aff3d4f..4076a3721 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts @@ -4,7 +4,7 @@ import { AuthService } from '../../core/auth' import { ConfirmService } from '../../core/confirm' import { ComponentPagination } from '@app/shared/rest/component-pagination.model' import { Video } from '@app/shared/video/video.model' -import { Subscription } from 'rxjs' +import { Subject, Subscription } from 'rxjs' import { ActivatedRoute } from '@angular/router' import { VideoService } from '@app/shared/video/video.service' import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' @@ -13,6 +13,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill' import { secondsToTime } from '../../../assets/player/utils' import { VideoPlaylistElementUpdate } from '@shared/models' import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' +import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop' +import { throttleTime } from 'rxjs/operators' @Component({ selector: 'my-account-video-playlist-elements', @@ -42,6 +44,7 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro private videoPlaylistId: string | number private paramsSub: Subscription + private dragMoveSubject = new Subject() constructor ( private authService: AuthService, @@ -61,12 +64,66 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro this.loadPlaylistInfo() }) + + this.dragMoveSubject.asObservable() + .pipe(throttleTime(200)) + .subscribe(y => this.checkScroll(y)) } ngOnDestroy () { if (this.paramsSub) this.paramsSub.unsubscribe() } + drop (event: CdkDragDrop) { + const previousIndex = event.previousIndex + const newIndex = event.currentIndex + + if (previousIndex === newIndex) return + + const oldPosition = this.videos[previousIndex].playlistElement.position + const insertAfter = newIndex === 0 ? 0 : this.videos[newIndex].playlistElement.position + + this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter) + .subscribe( + () => { /* nothing to do */ }, + + err => this.notifier.error(err.message) + ) + + const video = this.videos[previousIndex] + + this.videos.splice(previousIndex, 1) + this.videos.splice(newIndex, 0, video) + + this.reorderClientPositions() + } + + onDragMove (event: CdkDragMove) { + this.dragMoveSubject.next(event.pointerPosition.y) + } + + checkScroll (pointerY: number) { + // FIXME: Uncomment when https://github.com/angular/material2/issues/14098 is fixed + // FIXME: Remove when https://github.com/angular/material2/issues/13588 is implemented + // if (pointerY < 150) { + // window.scrollBy({ + // left: 0, + // top: -20, + // behavior: 'smooth' + // }) + // + // return + // } + // + // if (window.innerHeight - pointerY <= 50) { + // window.scrollBy({ + // left: 0, + // top: 20, + // behavior: 'smooth' + // }) + // } + } + isVideoBlur (video: Video) { return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig()) } @@ -78,6 +135,7 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName })) this.videos = this.videos.filter(v => v.id !== video.id) + this.reorderClientPositions() }, err => this.notifier.error(err.message) @@ -173,4 +231,13 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro this.playlist = playlist }) } + + private reorderClientPositions () { + let i = 1 + + for (const video of this.videos) { + video.playlistElement.position = i + i++ + } + } } diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index ba8300111..4a18a9968 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts @@ -35,6 +35,7 @@ import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-vi import { MyAccountVideoPlaylistElementsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' +import { DragDropModule } from '@angular/cdk/drag-drop' @NgModule({ imports: [ @@ -43,7 +44,8 @@ import { AutoCompleteModule, SharedModule, TableModule, - InputSwitchModule + InputSwitchModule, + DragDropModule ], declarations: [ diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 1f9eee0b7..05da0d829 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -9,7 +9,6 @@ import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.d import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' -import { KeyFilterModule } from 'primeng/keyfilter' import { AUTH_INTERCEPTOR_PROVIDER } from './auth' import { ButtonComponent } from './buttons/button.component' @@ -95,7 +94,6 @@ import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.compo PrimeSharedModule, InputMaskModule, - KeyFilterModule, NgPipesModule ], @@ -155,7 +153,6 @@ import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.compo PrimeSharedModule, InputMaskModule, - KeyFilterModule, BytesPipe, KeysPipe, diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts index f7b37f83a..da7437507 100644 --- a/client/src/app/shared/video-playlist/video-playlist.service.ts +++ b/client/src/app/shared/video-playlist/video-playlist.service.ts @@ -17,6 +17,7 @@ import { AccountService } from '@app/shared/account/account.service' import { Account } from '@app/shared/account/account.model' import { RestService } from '@app/shared/rest' import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model' +import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model' @Injectable() export class VideoPlaylistService { @@ -125,6 +126,19 @@ export class VideoPlaylistService { ) } + reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) { + const body: VideoPlaylistReorder = { + startPosition: oldPosition, + insertAfterPosition: newPosition + } + + return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + doesVideoExistInPlaylist (videoId: number) { this.videoExistsInPlaylistSubject.next(videoId) diff --git a/client/yarn.lock b/client/yarn.lock index 2d3ade3dd..8f643aad4 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -107,6 +107,15 @@ dependencies: tslib "^1.9.0" +"@angular/cdk@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-7.3.4.tgz#b9d8c62cdd24fa6517dd3ae68c78632b1525d35f" + integrity sha512-cHl1o7obogCO3Nxf9n8MrXpfHa7AH1QNX2BY+bftYBTHW++YJe+qAwkwWLVqnJD9TQE2OpiR058zoJU20khM/g== + dependencies: + tslib "^1.7.1" + optionalDependencies: + parse5 "^5.0.0" + "@angular/cli@~7.3.1": version "7.3.1" resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-7.3.1.tgz#a18acdec84deb03a1fae79cae415bbc8f9c87ffa" @@ -7469,6 +7478,11 @@ parse5@4.0.0: resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA== +parse5@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" + integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ== + parseqs@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts index 49432d3aa..0a7ff92df 100644 --- a/server/controllers/api/video-playlist.ts +++ b/server/controllers/api/video-playlist.ts @@ -41,6 +41,7 @@ import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playli import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' import { copy, pathExists } from 'fs-extra' import { AccountModel } from '../../models/account/account' +import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model' const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) @@ -368,10 +369,11 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo async function reorderVideosPlaylist (req: express.Request, res: express.Response) { const videoPlaylist: VideoPlaylistModel = res.locals.videoPlaylist + const body: VideoPlaylistReorder = req.body - const start: number = req.body.startPosition - const insertAfter: number = req.body.insertAfterPosition - const reorderLength: number = req.body.reorderLength || 1 + const start: number = body.startPosition + const insertAfter: number = body.insertAfterPosition + const reorderLength: number = body.reorderLength || 1 if (start === insertAfter) { return res.status(204).end() diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 06c63e87c..7624b0649 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -240,7 +240,10 @@ type AvailableForListIDsOptions = { if (options.videoPlaylistId) { query.include.push({ model: VideoPlaylistElementModel.unscoped(), - required: true + required: true, + where: { + videoPlaylistId: options.videoPlaylistId + } }) } @@ -304,6 +307,8 @@ type AvailableForListIDsOptions = { videoPlaylistId: options.videoPlaylistId } }) + + query.subQuery = false } if (options.filter || options.accountId || options.videoChannelId) { diff --git a/shared/models/videos/playlist/video-playlist-reorder.model.ts b/shared/models/videos/playlist/video-playlist-reorder.model.ts new file mode 100644 index 000000000..63ec714c5 --- /dev/null +++ b/shared/models/videos/playlist/video-playlist-reorder.model.ts @@ -0,0 +1,5 @@ +export interface VideoPlaylistReorder { + startPosition: number + insertAfterPosition: number + reorderLength?: number +} -- cgit v1.2.3