"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",
<div i18n class="no-results" *ngIf="pagination.totalItems === 0">No videos in this playlist.</div>
-<div class="videos" myInfiniteScroller (nearOfBottom)="onNearOfBottom()">
- <div *ngFor="let video of videos" class="video">
+<div
+ class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()"
+ cdkDropList (cdkDropListDropped)="drop($event)"
+>
+ <div class="video" *ngFor="let video of videos" cdkDrag (cdkDragMoved)="onDragMove($event)">
<div class="position">{{ video.playlistElement.position }}</div>
<my-video-thumbnail [video]="video" [nsfw]="isVideoBlur(video)"></my-video-thumbnail>
@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);
}
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'
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',
private videoPlaylistId: string | number
private paramsSub: Subscription
+ private dragMoveSubject = new Subject<number>()
constructor (
private authService: AuthService,
this.loadPlaylistInfo()
})
+
+ this.dragMoveSubject.asObservable()
+ .pipe(throttleTime(200))
+ .subscribe(y => this.checkScroll(y))
}
ngOnDestroy () {
if (this.paramsSub) this.paramsSub.unsubscribe()
}
+ drop (event: CdkDragDrop<any>) {
+ 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<any>) {
+ 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())
}
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)
this.playlist = playlist
})
}
+
+ private reorderClientPositions () {
+ let i = 1
+
+ for (const video of this.videos) {
+ video.playlistElement.position = i
+ i++
+ }
+ }
}
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: [
AutoCompleteModule,
SharedModule,
TableModule,
- InputSwitchModule
+ InputSwitchModule,
+ DragDropModule
],
declarations: [
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'
PrimeSharedModule,
InputMaskModule,
- KeyFilterModule,
NgPipesModule
],
PrimeSharedModule,
InputMaskModule,
- KeyFilterModule,
BytesPipe,
KeysPipe,
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 {
)
}
+ 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)
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"
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"
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 })
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()
if (options.videoPlaylistId) {
query.include.push({
model: VideoPlaylistElementModel.unscoped(),
- required: true
+ required: true,
+ where: {
+ videoPlaylistId: options.videoPlaylistId
+ }
})
}
videoPlaylistId: options.videoPlaylistId
}
})
+
+ query.subQuery = false
}
if (options.filter || options.accountId || options.videoChannelId) {
--- /dev/null
+export interface VideoPlaylistReorder {
+ startPosition: number
+ insertAfterPosition: number
+ reorderLength?: number
+}