diff options
Diffstat (limited to 'client')
37 files changed, 492 insertions, 350 deletions
diff --git a/client/.stylelintrc.json b/client/.stylelintrc.json index 25f0b1002..6a322da62 100644 --- a/client/.stylelintrc.json +++ b/client/.stylelintrc.json | |||
@@ -24,6 +24,12 @@ | |||
24 | "rule-empty-line-before": null, | 24 | "rule-empty-line-before": null, |
25 | "selector-max-id": null, | 25 | "selector-max-id": null, |
26 | "scss/at-function-pattern": null, | 26 | "scss/at-function-pattern": null, |
27 | "function-parentheses-space-inside": "never-single-line" | 27 | "function-parentheses-space-inside": "never-single-line", |
28 | "property-no-vendor-prefix": [ | ||
29 | true, | ||
30 | { | ||
31 | "ignoreProperties": [ "mask-image" ] | ||
32 | } | ||
33 | ] | ||
28 | } | 34 | } |
29 | } | 35 | } |
diff --git a/client/e2e/src/po/video-upload.po.ts b/client/e2e/src/po/video-upload.po.ts index 942025b6b..ad2acee7f 100644 --- a/client/e2e/src/po/video-upload.po.ts +++ b/client/e2e/src/po/video-upload.po.ts | |||
@@ -26,7 +26,12 @@ export class VideoUploadPage { | |||
26 | await elem.sendKeys(fileToUpload) | 26 | await elem.sendKeys(fileToUpload) |
27 | 27 | ||
28 | // Wait for the upload to finish | 28 | // Wait for the upload to finish |
29 | await browser.wait(browser.ExpectedConditions.elementToBeClickable(this.getSecondStepSubmitButton())) | 29 | await browser.wait(async () => { |
30 | const actionButton = this.getSecondStepSubmitButton().element(by.css('.action-button')) | ||
31 | |||
32 | const klass = await actionButton.getAttribute('class') | ||
33 | return !klass.includes('disabled') | ||
34 | }) | ||
30 | } | 35 | } |
31 | 36 | ||
32 | async validSecondUploadStep (videoName: string) { | 37 | async validSecondUploadStep (videoName: string) { |
diff --git a/client/package.json b/client/package.json index 140fc3095..8486ace22 100644 --- a/client/package.json +++ b/client/package.json | |||
@@ -96,6 +96,7 @@ | |||
96 | "lodash-es": "^4.17.4", | 96 | "lodash-es": "^4.17.4", |
97 | "markdown-it": "12.0.4", | 97 | "markdown-it": "12.0.4", |
98 | "mini-css-extract-plugin": "^1.3.1", | 98 | "mini-css-extract-plugin": "^1.3.1", |
99 | "ngx-uploadx": "^4.1.0", | ||
99 | "p2p-media-loader-hlsjs": "^0.6.2", | 100 | "p2p-media-loader-hlsjs": "^0.6.2", |
100 | "path-browserify": "^1.0.0", | 101 | "path-browserify": "^1.0.0", |
101 | "primeng": "^11.0.0-rc.1", | 102 | "primeng": "^11.0.0-rc.1", |
diff --git a/client/src/app/+about/about-follows/about-follows.component.html b/client/src/app/+about/about-follows/about-follows.component.html index f81465f88..6bc1d0448 100644 --- a/client/src/app/+about/about-follows/about-follows.component.html +++ b/client/src/app/+about/about-follows/about-follows.component.html | |||
@@ -9,7 +9,7 @@ | |||
9 | {{ follower}} | 9 | {{ follower}} |
10 | </a> | 10 | </a> |
11 | 11 | ||
12 | <button i18n class="showMore" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button> | 12 | <button i18n class="show-more" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button> |
13 | </div> | 13 | </div> |
14 | 14 | ||
15 | <div class="col-xl-6 col-md-12"> | 15 | <div class="col-xl-6 col-md-12"> |
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts b/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts index 0628c7a96..7e916e122 100644 --- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts +++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.ts | |||
@@ -79,7 +79,13 @@ export class AccountVideoChannelsComponent implements OnInit, OnDestroy { | |||
79 | } | 79 | } |
80 | 80 | ||
81 | loadMoreChannels () { | 81 | loadMoreChannels () { |
82 | this.videoChannelService.listAccountVideoChannels(this.account, this.channelPagination) | 82 | const options = { |
83 | account: this.account, | ||
84 | componentPagination: this.channelPagination, | ||
85 | sort: '-updatedAt' | ||
86 | } | ||
87 | |||
88 | this.videoChannelService.listAccountVideoChannels(options) | ||
83 | .pipe( | 89 | .pipe( |
84 | tap(res => this.channelPagination.totalItems = res.total), | 90 | tap(res => this.channelPagination.totalItems = res.total), |
85 | switchMap(res => from(res.data)), | 91 | switchMap(res => from(res.data)), |
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index fbd7380a9..c69b04a01 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts | |||
@@ -66,7 +66,7 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
66 | distinctUntilChanged(), | 66 | distinctUntilChanged(), |
67 | switchMap(accountId => this.accountService.getAccount(accountId)), | 67 | switchMap(accountId => this.accountService.getAccount(accountId)), |
68 | tap(account => this.onAccount(account)), | 68 | tap(account => this.onAccount(account)), |
69 | switchMap(account => this.videoChannelService.listAccountVideoChannels(account)), | 69 | switchMap(account => this.videoChannelService.listAccountVideoChannels({ account })), |
70 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, 'other', [ | 70 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, 'other', [ |
71 | HttpStatusCode.BAD_REQUEST_400, | 71 | HttpStatusCode.BAD_REQUEST_400, |
72 | HttpStatusCode.NOT_FOUND_404 | 72 | HttpStatusCode.NOT_FOUND_404 |
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html index 6900e8717..8d8f12c48 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html | |||
@@ -20,7 +20,7 @@ | |||
20 | <my-global-icon iconName="search"></my-global-icon> | 20 | <my-global-icon iconName="search"></my-global-icon> |
21 | 21 | ||
22 | <ng-container i18n> | 22 | <ng-container i18n> |
23 | {{ pagination.totalItems }} {pagination.totalItems, plural, =1 {result} other {results}} for {{ search }}" | 23 | {{ pagination.totalItems }} {pagination.totalItems, plural, =1 {result} other {results}} for "{{ search }}" |
24 | </ng-container> | 24 | </ng-container> |
25 | </ng-container> | 25 | </ng-container> |
26 | </div> | 26 | </div> |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts index c16368952..a0f2f28f8 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts | |||
@@ -2,7 +2,7 @@ import { ViewportScroller } from '@angular/common' | |||
2 | import { HttpErrorResponse } from '@angular/common/http' | 2 | import { HttpErrorResponse } from '@angular/common/http' |
3 | import { AfterViewChecked, Component, OnInit } from '@angular/core' | 3 | import { AfterViewChecked, Component, OnInit } from '@angular/core' |
4 | import { AuthService, Notifier, User, UserService } from '@app/core' | 4 | import { AuthService, Notifier, User, UserService } from '@app/core' |
5 | import { uploadErrorHandler } from '@app/helpers' | 5 | import { genericUploadErrorHandler } from '@app/helpers' |
6 | 6 | ||
7 | @Component({ | 7 | @Component({ |
8 | selector: 'my-account-settings', | 8 | selector: 'my-account-settings', |
@@ -46,7 +46,7 @@ export class MyAccountSettingsComponent implements OnInit, AfterViewChecked { | |||
46 | this.user.updateAccountAvatar(data.avatar) | 46 | this.user.updateAccountAvatar(data.avatar) |
47 | }, | 47 | }, |
48 | 48 | ||
49 | (err: HttpErrorResponse) => uploadErrorHandler({ | 49 | (err: HttpErrorResponse) => genericUploadErrorHandler({ |
50 | err, | 50 | err, |
51 | name: $localize`avatar`, | 51 | name: $localize`avatar`, |
52 | notifier: this.notifier | 52 | notifier: this.notifier |
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts index a29af176c..c9173039a 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts +++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts | |||
@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http' | |||
3 | import { Component, OnDestroy, OnInit } from '@angular/core' | 3 | import { Component, OnDestroy, OnInit } from '@angular/core' |
4 | import { ActivatedRoute, Router } from '@angular/router' | 4 | import { ActivatedRoute, Router } from '@angular/router' |
5 | import { AuthService, Notifier, ServerService } from '@app/core' | 5 | import { AuthService, Notifier, ServerService } from '@app/core' |
6 | import { uploadErrorHandler } from '@app/helpers' | 6 | import { genericUploadErrorHandler } from '@app/helpers' |
7 | import { | 7 | import { |
8 | VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, | 8 | VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, |
9 | VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, | 9 | VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, |
@@ -109,7 +109,7 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements | |||
109 | this.videoChannel.updateAvatar(data.avatar) | 109 | this.videoChannel.updateAvatar(data.avatar) |
110 | }, | 110 | }, |
111 | 111 | ||
112 | (err: HttpErrorResponse) => uploadErrorHandler({ | 112 | (err: HttpErrorResponse) => genericUploadErrorHandler({ |
113 | err, | 113 | err, |
114 | name: $localize`avatar`, | 114 | name: $localize`avatar`, |
115 | notifier: this.notifier | 115 | notifier: this.notifier |
@@ -139,7 +139,7 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements | |||
139 | this.videoChannel.updateBanner(data.banner) | 139 | this.videoChannel.updateBanner(data.banner) |
140 | }, | 140 | }, |
141 | 141 | ||
142 | (err: HttpErrorResponse) => uploadErrorHandler({ | 142 | (err: HttpErrorResponse) => genericUploadErrorHandler({ |
143 | err, | 143 | err, |
144 | name: $localize`banner`, | 144 | name: $localize`banner`, |
145 | notifier: this.notifier | 145 | notifier: this.notifier |
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts index 9e3bf35b4..67b3ee496 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts +++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts | |||
@@ -68,8 +68,14 @@ channel with the same name (${videoChannel.name})!`, | |||
68 | this.authService.userInformationLoaded | 68 | this.authService.userInformationLoaded |
69 | .pipe(mergeMap(() => { | 69 | .pipe(mergeMap(() => { |
70 | const user = this.authService.getUser() | 70 | const user = this.authService.getUser() |
71 | const options = { | ||
72 | account: user.account, | ||
73 | withStats: true, | ||
74 | search: this.search, | ||
75 | sort: '-updatedAt' | ||
76 | } | ||
71 | 77 | ||
72 | return this.videoChannelService.listAccountVideoChannels(user.account, null, true, this.search) | 78 | return this.videoChannelService.listAccountVideoChannels(options) |
73 | })).subscribe(res => { | 79 | })).subscribe(res => { |
74 | this.videoChannels = res.data | 80 | this.videoChannels = res.data |
75 | this.totalItems = res.total | 81 | this.totalItems = res.total |
diff --git a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.html b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.html index 088765b20..d0393a2a4 100644 --- a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.html +++ b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.html | |||
@@ -8,13 +8,8 @@ | |||
8 | <div class="modal-body" [formGroup]="form"> | 8 | <div class="modal-body" [formGroup]="form"> |
9 | <div class="form-group"> | 9 | <div class="form-group"> |
10 | <label i18n for="channel">Select a channel to receive the video</label> | 10 | <label i18n for="channel">Select a channel to receive the video</label> |
11 | <div class="peertube-select-container"> | 11 | <my-select-channel labelForId="channel" formControlName="channel" [items]="videoChannels"></my-select-channel> |
12 | <select formControlName="channel" id="channel" class="form-control"> | 12 | |
13 | <option i18n value="undefined" disabled>Channel that will receive the video</option> | ||
14 | <option *ngFor="let channel of videoChannels" [value]="channel.id">{{ channel.displayName }} | ||
15 | </option> | ||
16 | </select> | ||
17 | </div> | ||
18 | <div *ngIf="formErrors.channel" class="form-error">{{ formErrors.channel }}</div> | 13 | <div *ngIf="formErrors.channel" class="form-error">{{ formErrors.channel }}</div> |
19 | </div> | 14 | </div> |
20 | </div> | 15 | </div> |
diff --git a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts index 0e2395754..7889d0985 100644 --- a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts +++ b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts | |||
@@ -1,11 +1,12 @@ | |||
1 | import { switchMap } from 'rxjs/operators' | 1 | import { SelectChannelItem } from 'src/types/select-options-item.model' |
2 | import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' | 2 | import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' |
3 | import { AuthService, Notifier } from '@app/core' | 3 | import { AuthService, Notifier } from '@app/core' |
4 | import { listUserChannels } from '@app/helpers' | ||
4 | import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators' | 5 | import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators' |
5 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' | 6 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' |
6 | import { VideoChannelService, VideoOwnershipService } from '@app/shared/shared-main' | 7 | import { VideoOwnershipService } from '@app/shared/shared-main' |
7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 8 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
8 | import { VideoChangeOwnership, VideoChannel } from '@shared/models' | 9 | import { VideoChangeOwnership } from '@shared/models' |
9 | 10 | ||
10 | @Component({ | 11 | @Component({ |
11 | selector: 'my-accept-ownership', | 12 | selector: 'my-accept-ownership', |
@@ -18,8 +19,7 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit { | |||
18 | @ViewChild('modal', { static: true }) modal: ElementRef | 19 | @ViewChild('modal', { static: true }) modal: ElementRef |
19 | 20 | ||
20 | videoChangeOwnership: VideoChangeOwnership | undefined = undefined | 21 | videoChangeOwnership: VideoChangeOwnership | undefined = undefined |
21 | 22 | videoChannels: SelectChannelItem[] | |
22 | videoChannels: VideoChannel[] | ||
23 | 23 | ||
24 | error: string = null | 24 | error: string = null |
25 | 25 | ||
@@ -28,7 +28,6 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit { | |||
28 | private videoOwnershipService: VideoOwnershipService, | 28 | private videoOwnershipService: VideoOwnershipService, |
29 | private notifier: Notifier, | 29 | private notifier: Notifier, |
30 | private authService: AuthService, | 30 | private authService: AuthService, |
31 | private videoChannelService: VideoChannelService, | ||
32 | private modalService: NgbModal | 31 | private modalService: NgbModal |
33 | ) { | 32 | ) { |
34 | super() | 33 | super() |
@@ -37,9 +36,8 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit { | |||
37 | ngOnInit () { | 36 | ngOnInit () { |
38 | this.videoChannels = [] | 37 | this.videoChannels = [] |
39 | 38 | ||
40 | this.authService.userInformationLoaded | 39 | listUserChannels(this.authService) |
41 | .pipe(switchMap(() => this.videoChannelService.listAccountVideoChannels(this.authService.getUser().account))) | 40 | .subscribe(channels => this.videoChannels = channels) |
42 | .subscribe(videoChannels => this.videoChannels = videoChannels.data) | ||
43 | 41 | ||
44 | this.buildForm({ | 42 | this.buildForm({ |
45 | channel: OWNERSHIP_CHANGE_CHANNEL_VALIDATOR | 43 | channel: OWNERSHIP_CHANGE_CHANNEL_VALIDATOR |
diff --git a/client/src/app/+search/search-filters.component.html b/client/src/app/+search/search-filters.component.html index 1d1e7b868..421bc7f6f 100644 --- a/client/src/app/+search/search-filters.component.html +++ b/client/src/app/+search/search-filters.component.html | |||
@@ -18,6 +18,25 @@ | |||
18 | 18 | ||
19 | <div class="form-group"> | 19 | <div class="form-group"> |
20 | <div class="radio-label label-container"> | 20 | <div class="radio-label label-container"> |
21 | <label i18n>Display only</label> | ||
22 | <button i18n class="reset-button reset-button-small" (click)="resetField('isLive')" *ngIf="advancedSearch.isLive !== undefined"> | ||
23 | Reset | ||
24 | </button> | ||
25 | </div> | ||
26 | |||
27 | <div class="peertube-radio-container"> | ||
28 | <input type="radio" name="isLive" id="isLiveTrue" value="true" [(ngModel)]="advancedSearch.isLive"> | ||
29 | <label i18n for="isLiveTrue" class="radio">Live videos</label> | ||
30 | </div> | ||
31 | |||
32 | <div class="peertube-radio-container"> | ||
33 | <input type="radio" name="isLive" id="isLiveFalse" value="false" [(ngModel)]="advancedSearch.isLive"> | ||
34 | <label i18n for="isLiveFalse" class="radio">VOD videos</label> | ||
35 | </div> | ||
36 | </div> | ||
37 | |||
38 | <div class="form-group"> | ||
39 | <div class="radio-label label-container"> | ||
21 | <label i18n>Display sensitive content</label> | 40 | <label i18n>Display sensitive content</label> |
22 | <button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined"> | 41 | <button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined"> |
23 | Reset | 42 | Reset |
@@ -44,7 +63,7 @@ | |||
44 | </div> | 63 | </div> |
45 | 64 | ||
46 | <div class="peertube-radio-container" *ngFor="let date of publishedDateRanges"> | 65 | <div class="peertube-radio-container" *ngFor="let date of publishedDateRanges"> |
47 | <input type="radio" (change)="inputUpdated()" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange"> | 66 | <input type="radio" (change)="onInputUpdated()" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange"> |
48 | <label [for]="date.id" class="radio">{{ date.label }}</label> | 67 | <label [for]="date.id" class="radio">{{ date.label }}</label> |
49 | </div> | 68 | </div> |
50 | </div> | 69 | </div> |
@@ -60,7 +79,7 @@ | |||
60 | <div class="row"> | 79 | <div class="row"> |
61 | <div class="pl-0 col-sm-6"> | 80 | <div class="pl-0 col-sm-6"> |
62 | <input | 81 | <input |
63 | (change)="inputUpdated()" | 82 | (change)="onInputUpdated()" |
64 | (keydown.enter)="$event.preventDefault()" | 83 | (keydown.enter)="$event.preventDefault()" |
65 | type="text" id="original-publication-after" name="original-publication-after" | 84 | type="text" id="original-publication-after" name="original-publication-after" |
66 | i18n-placeholder placeholder="After..." | 85 | i18n-placeholder placeholder="After..." |
@@ -70,7 +89,7 @@ | |||
70 | </div> | 89 | </div> |
71 | <div class="pr-0 col-sm-6"> | 90 | <div class="pr-0 col-sm-6"> |
72 | <input | 91 | <input |
73 | (change)="inputUpdated()" | 92 | (change)="onInputUpdated()" |
74 | (keydown.enter)="$event.preventDefault()" | 93 | (keydown.enter)="$event.preventDefault()" |
75 | type="text" id="original-publication-before" name="original-publication-before" | 94 | type="text" id="original-publication-before" name="original-publication-before" |
76 | i18n-placeholder placeholder="Before..." | 95 | i18n-placeholder placeholder="Before..." |
@@ -93,7 +112,7 @@ | |||
93 | </div> | 112 | </div> |
94 | 113 | ||
95 | <div class="peertube-radio-container" *ngFor="let duration of durationRanges"> | 114 | <div class="peertube-radio-container" *ngFor="let duration of durationRanges"> |
96 | <input type="radio" (change)="inputUpdated()" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange"> | 115 | <input type="radio" (change)="onInputUpdated()" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange"> |
97 | <label [for]="duration.id" class="radio">{{ duration.label }}</label> | 116 | <label [for]="duration.id" class="radio">{{ duration.label }}</label> |
98 | </div> | 117 | </div> |
99 | </div> | 118 | </div> |
diff --git a/client/src/app/+search/search-filters.component.ts b/client/src/app/+search/search-filters.component.ts index a2af9a942..59aba22ff 100644 --- a/client/src/app/+search/search-filters.component.ts +++ b/client/src/app/+search/search-filters.component.ts | |||
@@ -3,6 +3,8 @@ import { ServerService } from '@app/core' | |||
3 | import { AdvancedSearch } from '@app/shared/shared-search' | 3 | import { AdvancedSearch } from '@app/shared/shared-search' |
4 | import { ServerConfig, VideoConstant } from '@shared/models' | 4 | import { ServerConfig, VideoConstant } from '@shared/models' |
5 | 5 | ||
6 | type FormOption = { id: string, label: string } | ||
7 | |||
6 | @Component({ | 8 | @Component({ |
7 | selector: 'my-search-filters', | 9 | selector: 'my-search-filters', |
8 | styleUrls: [ './search-filters.component.scss' ], | 10 | styleUrls: [ './search-filters.component.scss' ], |
@@ -17,9 +19,10 @@ export class SearchFiltersComponent implements OnInit { | |||
17 | videoLicences: VideoConstant<number>[] = [] | 19 | videoLicences: VideoConstant<number>[] = [] |
18 | videoLanguages: VideoConstant<string>[] = [] | 20 | videoLanguages: VideoConstant<string>[] = [] |
19 | 21 | ||
20 | publishedDateRanges: { id: string, label: string }[] = [] | 22 | publishedDateRanges: FormOption[] = [] |
21 | sorts: { id: string, label: string }[] = [] | 23 | sorts: FormOption[] = [] |
22 | durationRanges: { id: string, label: string }[] = [] | 24 | durationRanges: FormOption[] = [] |
25 | videoType: FormOption[] = [] | ||
23 | 26 | ||
24 | publishedDateRange: string | 27 | publishedDateRange: string |
25 | durationRange: string | 28 | durationRange: string |
@@ -34,10 +37,6 @@ export class SearchFiltersComponent implements OnInit { | |||
34 | ) { | 37 | ) { |
35 | this.publishedDateRanges = [ | 38 | this.publishedDateRanges = [ |
36 | { | 39 | { |
37 | id: 'any_published_date', | ||
38 | label: $localize`Any` | ||
39 | }, | ||
40 | { | ||
41 | id: 'today', | 40 | id: 'today', |
42 | label: $localize`Today` | 41 | label: $localize`Today` |
43 | }, | 42 | }, |
@@ -55,12 +54,19 @@ export class SearchFiltersComponent implements OnInit { | |||
55 | } | 54 | } |
56 | ] | 55 | ] |
57 | 56 | ||
58 | this.durationRanges = [ | 57 | this.videoType = [ |
59 | { | 58 | { |
60 | id: 'any_duration', | 59 | id: 'vod', |
61 | label: $localize`Any` | 60 | label: $localize`VOD videos` |
62 | }, | 61 | }, |
63 | { | 62 | { |
63 | id: 'live', | ||
64 | label: $localize`Live videos` | ||
65 | } | ||
66 | ] | ||
67 | |||
68 | this.durationRanges = [ | ||
69 | { | ||
64 | id: 'short', | 70 | id: 'short', |
65 | label: $localize`Short (< 4 min)` | 71 | label: $localize`Short (< 4 min)` |
66 | }, | 72 | }, |
@@ -104,24 +110,26 @@ export class SearchFiltersComponent implements OnInit { | |||
104 | this.loadOriginallyPublishedAtYears() | 110 | this.loadOriginallyPublishedAtYears() |
105 | } | 111 | } |
106 | 112 | ||
107 | inputUpdated () { | 113 | onInputUpdated () { |
108 | this.updateModelFromDurationRange() | 114 | this.updateModelFromDurationRange() |
109 | this.updateModelFromPublishedRange() | 115 | this.updateModelFromPublishedRange() |
110 | this.updateModelFromOriginallyPublishedAtYears() | 116 | this.updateModelFromOriginallyPublishedAtYears() |
111 | } | 117 | } |
112 | 118 | ||
113 | formUpdated () { | 119 | formUpdated () { |
114 | this.inputUpdated() | 120 | this.onInputUpdated() |
115 | this.filtered.emit(this.advancedSearch) | 121 | this.filtered.emit(this.advancedSearch) |
116 | } | 122 | } |
117 | 123 | ||
118 | reset () { | 124 | reset () { |
119 | this.advancedSearch.reset() | 125 | this.advancedSearch.reset() |
126 | |||
127 | this.resetOriginalPublicationYears() | ||
128 | |||
120 | this.durationRange = undefined | 129 | this.durationRange = undefined |
121 | this.publishedDateRange = undefined | 130 | this.publishedDateRange = undefined |
122 | this.originallyPublishedStartYear = undefined | 131 | |
123 | this.originallyPublishedEndYear = undefined | 132 | this.onInputUpdated() |
124 | this.inputUpdated() | ||
125 | } | 133 | } |
126 | 134 | ||
127 | resetField (fieldName: string, value?: any) { | 135 | resetField (fieldName: string, value?: any) { |
@@ -130,7 +138,7 @@ export class SearchFiltersComponent implements OnInit { | |||
130 | 138 | ||
131 | resetLocalField (fieldName: string, value?: any) { | 139 | resetLocalField (fieldName: string, value?: any) { |
132 | this[fieldName] = value | 140 | this[fieldName] = value |
133 | this.inputUpdated() | 141 | this.onInputUpdated() |
134 | } | 142 | } |
135 | 143 | ||
136 | resetOriginalPublicationYears () { | 144 | resetOriginalPublicationYears () { |
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 094b4d3b3..16233f9e0 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 | |||
@@ -5,7 +5,7 @@ | |||
5 | <a ngbNavLink i18n>Basic info</a> | 5 | <a ngbNavLink i18n>Basic info</a> |
6 | 6 | ||
7 | <ng-template ngbNavContent> | 7 | <ng-template ngbNavContent> |
8 | <div class="row"> | 8 | <div class="form-columns"> |
9 | <div class="col-video-edit"> | 9 | <div class="col-video-edit"> |
10 | <div class="form-group"> | 10 | <div class="form-group"> |
11 | <label i18n for="name">Title</label> | 11 | <label i18n for="name">Title</label> |
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss index bc32d7964..c1c7c686d 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.scss | |||
@@ -1,9 +1,3 @@ | |||
1 | // Bootstrap grid utilities require functions, variables and mixins | ||
2 | @import 'node_modules/bootstrap/scss/functions'; | ||
3 | @import 'node_modules/bootstrap/scss/variables'; | ||
4 | @import 'node_modules/bootstrap/scss/mixins'; | ||
5 | @import 'node_modules/bootstrap/scss/grid'; | ||
6 | |||
7 | @import 'variables'; | 1 | @import 'variables'; |
8 | @import 'mixins'; | 2 | @import 'mixins'; |
9 | 3 | ||
@@ -57,65 +51,62 @@ my-peertube-checkbox { | |||
57 | } | 51 | } |
58 | } | 52 | } |
59 | 53 | ||
60 | .captions { | 54 | .captions-header { |
61 | 55 | text-align: right; | |
62 | .captions-header { | 56 | margin-bottom: 1rem; |
63 | text-align: right; | 57 | } |
64 | margin-bottom: 1rem; | ||
65 | 58 | ||
66 | .create-caption { | 59 | .create-caption { |
67 | @include create-button; | 60 | @include create-button; |
68 | } | 61 | } |
69 | } | ||
70 | 62 | ||
71 | .caption-entry { | 63 | .caption-entry { |
72 | display: flex; | 64 | display: flex; |
73 | height: 40px; | 65 | height: 40px; |
74 | align-items: center; | 66 | align-items: center; |
75 | 67 | ||
76 | a.caption-entry-label { | 68 | a.caption-entry-label { |
77 | @include disable-default-a-behaviour; | 69 | @include disable-default-a-behaviour; |
78 | 70 | ||
79 | flex-grow: 1; | 71 | flex-grow: 1; |
80 | color: #000; | 72 | color: #000; |
81 | 73 | ||
82 | &:hover { | 74 | &:hover { |
83 | opacity: 0.8; | 75 | opacity: 0.8; |
84 | } | ||
85 | } | 76 | } |
77 | } | ||
86 | 78 | ||
87 | .caption-entry-label { | 79 | .caption-entry-label { |
88 | font-size: 15px; | 80 | font-size: 15px; |
89 | font-weight: bold; | 81 | font-weight: bold; |
90 | |||
91 | margin-right: 20px; | ||
92 | width: 150px; | ||
93 | } | ||
94 | 82 | ||
95 | .caption-entry-state { | 83 | margin-right: 20px; |
96 | width: 200px; | 84 | width: 150px; |
85 | } | ||
97 | 86 | ||
98 | &.caption-entry-state-create { | 87 | .caption-entry-state { |
99 | color: #39CC0B; | 88 | width: 200px; |
100 | } | ||
101 | 89 | ||
102 | &.caption-entry-state-delete { | 90 | &.caption-entry-state-create { |
103 | color: #FF0000; | 91 | color: #39CC0B; |
104 | } | ||
105 | } | 92 | } |
106 | 93 | ||
107 | .caption-entry-delete { | 94 | &.caption-entry-state-delete { |
108 | @include peertube-button; | 95 | color: #FF0000; |
109 | @include grey-button; | ||
110 | } | 96 | } |
111 | } | 97 | } |
112 | 98 | ||
113 | .no-caption { | 99 | .caption-entry-delete { |
114 | text-align: center; | 100 | @include peertube-button; |
115 | font-size: 15px; | 101 | @include grey-button; |
116 | } | 102 | } |
117 | } | 103 | } |
118 | 104 | ||
105 | .no-caption { | ||
106 | text-align: center; | ||
107 | font-size: 15px; | ||
108 | } | ||
109 | |||
119 | .submit-container { | 110 | .submit-container { |
120 | text-align: right; | 111 | text-align: right; |
121 | 112 | ||
@@ -143,35 +134,15 @@ p-calendar { | |||
143 | } | 134 | } |
144 | } | 135 | } |
145 | 136 | ||
146 | // columns for the video | 137 | .form-columns { |
147 | .col-video-edit { | 138 | display: grid; |
148 | @include make-col-ready(); | ||
149 | 139 | ||
150 | @include media-breakpoint-up(md) { | 140 | grid-template-columns: 66% 1fr; |
151 | @include make-col(7); | 141 | grid-gap: 30px; |
152 | |||
153 | + .col-video-edit { | ||
154 | @include make-col(5); | ||
155 | } | ||
156 | } | ||
157 | |||
158 | @include media-breakpoint-up(xl) { | ||
159 | @include make-col(8); | ||
160 | |||
161 | + .col-video-edit { | ||
162 | @include make-col(4); | ||
163 | } | ||
164 | } | ||
165 | } | 142 | } |
166 | 143 | ||
167 | :host-context(.expanded) { | 144 | @include on-small-main-col { |
168 | .col-video-edit { | 145 | .form-columns { |
169 | @include media-breakpoint-up(md) { | 146 | grid-template-columns: 1fr; |
170 | @include make-col(8); | ||
171 | |||
172 | + .col-video-edit { | ||
173 | @include make-col(4); | ||
174 | } | ||
175 | } | ||
176 | } | 147 | } |
177 | } | 148 | } |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts b/client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts new file mode 100644 index 000000000..3392a0d8a --- /dev/null +++ b/client/src/app/+videos/+video-edit/video-add-components/uploaderx-form-data.ts | |||
@@ -0,0 +1,48 @@ | |||
1 | import { objectToFormData } from '@app/helpers' | ||
2 | import { resolveUrl, UploaderX } from 'ngx-uploadx' | ||
3 | |||
4 | /** | ||
5 | * multipart/form-data uploader extending the UploaderX implementation of Google Resumable | ||
6 | * for use with multer | ||
7 | * | ||
8 | * @see https://github.com/kukhariev/ngx-uploadx/blob/637e258fe366b8095203f387a6101a230ee4f8e6/src/uploadx/lib/uploaderx.ts | ||
9 | * @example | ||
10 | * | ||
11 | * options: UploadxOptions = { | ||
12 | * uploaderClass: UploaderXFormData | ||
13 | * }; | ||
14 | */ | ||
15 | export class UploaderXFormData extends UploaderX { | ||
16 | |||
17 | async getFileUrl (): Promise<string> { | ||
18 | const headers = { | ||
19 | 'X-Upload-Content-Length': this.size.toString(), | ||
20 | 'X-Upload-Content-Type': this.file.type || 'application/octet-stream' | ||
21 | } | ||
22 | |||
23 | const previewfile = this.metadata.previewfile as any as File | ||
24 | delete this.metadata.previewfile | ||
25 | |||
26 | const data = objectToFormData(this.metadata) | ||
27 | if (previewfile !== undefined) { | ||
28 | data.append('previewfile', previewfile, previewfile.name) | ||
29 | data.append('thumbnailfile', previewfile, previewfile.name) | ||
30 | } | ||
31 | |||
32 | await this.request({ | ||
33 | method: 'POST', | ||
34 | body: data, | ||
35 | url: this.endpoint, | ||
36 | headers | ||
37 | }) | ||
38 | |||
39 | const location = this.getValueFromResponse('location') | ||
40 | if (!location) { | ||
41 | throw new Error('Invalid or missing Location header') | ||
42 | } | ||
43 | |||
44 | this.offset = this.responseStatus === 201 ? 0 : undefined | ||
45 | |||
46 | return resolveUrl(location, this.endpoint) | ||
47 | } | ||
48 | } | ||
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html index 4c0b09894..86a779f8a 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html | |||
@@ -1,12 +1,17 @@ | |||
1 | <div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="setVideoFile($event)"> | 1 | <div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="onFileDropped($event)"> |
2 | <div class="first-step-block"> | 2 | <div class="first-step-block"> |
3 | <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon> | 3 | <my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon> |
4 | 4 | ||
5 | <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'"> | 5 | <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'"> |
6 | <span i18n>Select the file to upload</span> | 6 | <span i18n>Select the file to upload</span> |
7 | <input | 7 | <input |
8 | aria-label="Select the file to upload" i18n-aria-label | 8 | aria-label="Select the file to upload" |
9 | #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" autofocus | 9 | i18n-aria-label |
10 | #videofileInput | ||
11 | [accept]="videoExtensions" | ||
12 | (change)="onFileChange($event)" | ||
13 | id="videofile" | ||
14 | type="file" | ||
10 | /> | 15 | /> |
11 | </div> | 16 | </div> |
12 | 17 | ||
@@ -41,7 +46,13 @@ | |||
41 | </div> | 46 | </div> |
42 | 47 | ||
43 | <div class="form-group upload-audio-button"> | 48 | <div class="form-group upload-audio-button"> |
44 | <my-button className="orange-button" i18n-label [label]="getAudioUploadLabel()" icon="upload" (click)="uploadFirstStep(true)"></my-button> | 49 | <my-button |
50 | className="orange-button" | ||
51 | [label]="getAudioUploadLabel()" | ||
52 | icon="upload" | ||
53 | (click)="uploadAudio()" | ||
54 | > | ||
55 | </my-button> | ||
45 | </div> | 56 | </div> |
46 | </ng-container> | 57 | </ng-container> |
47 | </div> | 58 | </div> |
@@ -64,6 +75,7 @@ | |||
64 | <span>{{ error }}</span> | 75 | <span>{{ error }}</span> |
65 | </div> | 76 | </div> |
66 | </div> | 77 | </div> |
78 | |||
67 | <div class="btn-group" role="group"> | 79 | <div class="btn-group" role="group"> |
68 | <input type="button" class="btn" i18n-value="Retry failed upload of a video" value="Retry" (click)="retryUpload()" /> | 80 | <input type="button" class="btn" i18n-value="Retry failed upload of a video" value="Retry" (click)="retryUpload()" /> |
69 | <input type="button" class="btn" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" /> | 81 | <input type="button" class="btn" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" /> |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss index 9549257f6..d9f348a70 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.scss | |||
@@ -47,8 +47,4 @@ | |||
47 | 47 | ||
48 | margin-left: 10px; | 48 | margin-left: 10px; |
49 | } | 49 | } |
50 | |||
51 | .btn-group > input:not(:first-child) { | ||
52 | margin-left: 0; | ||
53 | } | ||
54 | } | 50 | } |
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts index effb37077..2d3fc3578 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts +++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts | |||
@@ -1,15 +1,16 @@ | |||
1 | import { Subscription } from 'rxjs' | ||
2 | import { HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http' | ||
3 | import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' |
4 | import { Router } from '@angular/router' | 2 | import { Router } from '@angular/router' |
3 | import { UploadxOptions, UploadState, UploadxService } from 'ngx-uploadx' | ||
4 | import { UploaderXFormData } from './uploaderx-form-data' | ||
5 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService, UserService } from '@app/core' | 5 | import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService, UserService } from '@app/core' |
6 | import { scrollToTop, uploadErrorHandler } from '@app/helpers' | 6 | import { scrollToTop, genericUploadErrorHandler } from '@app/helpers' |
7 | import { FormValidatorService } from '@app/shared/shared-forms' | 7 | import { FormValidatorService } from '@app/shared/shared-forms' |
8 | import { BytesPipe, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 8 | import { BytesPipe, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' |
9 | import { LoadingBarService } from '@ngx-loading-bar/core' | 9 | import { LoadingBarService } from '@ngx-loading-bar/core' |
10 | import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' | 10 | import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' |
11 | import { VideoPrivacy } from '@shared/models' | 11 | import { VideoPrivacy } from '@shared/models' |
12 | import { VideoSend } from './video-send' | 12 | import { VideoSend } from './video-send' |
13 | import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http' | ||
13 | 14 | ||
14 | @Component({ | 15 | @Component({ |
15 | selector: 'my-video-upload', | 16 | selector: 'my-video-upload', |
@@ -20,23 +21,18 @@ import { VideoSend } from './video-send' | |||
20 | './video-send.scss' | 21 | './video-send.scss' |
21 | ] | 22 | ] |
22 | }) | 23 | }) |
23 | export class VideoUploadComponent extends VideoSend implements OnInit, AfterViewInit, OnDestroy, CanComponentDeactivate { | 24 | export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, AfterViewInit, CanComponentDeactivate { |
24 | @Output() firstStepDone = new EventEmitter<string>() | 25 | @Output() firstStepDone = new EventEmitter<string>() |
25 | @Output() firstStepError = new EventEmitter<void>() | 26 | @Output() firstStepError = new EventEmitter<void>() |
26 | @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement> | 27 | @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement> |
27 | 28 | ||
28 | // So that it can be accessed in the template | ||
29 | readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY | ||
30 | |||
31 | userVideoQuotaUsed = 0 | 29 | userVideoQuotaUsed = 0 |
32 | userVideoQuotaUsedDaily = 0 | 30 | userVideoQuotaUsedDaily = 0 |
33 | 31 | ||
34 | isUploadingAudioFile = false | 32 | isUploadingAudioFile = false |
35 | isUploadingVideo = false | 33 | isUploadingVideo = false |
36 | isUpdatingVideo = false | ||
37 | 34 | ||
38 | videoUploaded = false | 35 | videoUploaded = false |
39 | videoUploadObservable: Subscription = null | ||
40 | videoUploadPercents = 0 | 36 | videoUploadPercents = 0 |
41 | videoUploadedIds = { | 37 | videoUploadedIds = { |
42 | id: 0, | 38 | id: 0, |
@@ -49,7 +45,13 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView | |||
49 | error: string | 45 | error: string |
50 | enableRetryAfterError: boolean | 46 | enableRetryAfterError: boolean |
51 | 47 | ||
48 | // So that it can be accessed in the template | ||
52 | protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC | 49 | protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC |
50 | protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + 'upload-resumable' | ||
51 | |||
52 | private uploadxOptions: UploadxOptions | ||
53 | private isUpdatingVideo = false | ||
54 | private fileToUpload: File | ||
53 | 55 | ||
54 | constructor ( | 56 | constructor ( |
55 | protected formValidatorService: FormValidatorService, | 57 | protected formValidatorService: FormValidatorService, |
@@ -61,15 +63,77 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView | |||
61 | protected videoCaptionService: VideoCaptionService, | 63 | protected videoCaptionService: VideoCaptionService, |
62 | private userService: UserService, | 64 | private userService: UserService, |
63 | private router: Router, | 65 | private router: Router, |
64 | private hooks: HooksService | 66 | private hooks: HooksService, |
65 | ) { | 67 | private resumableUploadService: UploadxService |
68 | ) { | ||
66 | super() | 69 | super() |
70 | |||
71 | this.uploadxOptions = { | ||
72 | endpoint: this.BASE_VIDEO_UPLOAD_URL, | ||
73 | multiple: false, | ||
74 | token: this.authService.getAccessToken(), | ||
75 | uploaderClass: UploaderXFormData, | ||
76 | retryConfig: { | ||
77 | maxAttempts: 6, | ||
78 | shouldRetry: (code: number) => { | ||
79 | return code < 400 || code >= 501 | ||
80 | } | ||
81 | } | ||
82 | } | ||
67 | } | 83 | } |
68 | 84 | ||
69 | get videoExtensions () { | 85 | get videoExtensions () { |
70 | return this.serverConfig.video.file.extensions.join(', ') | 86 | return this.serverConfig.video.file.extensions.join(', ') |
71 | } | 87 | } |
72 | 88 | ||
89 | onUploadVideoOngoing (state: UploadState) { | ||
90 | switch (state.status) { | ||
91 | case 'error': | ||
92 | const error = state.response?.error || 'Unknow error' | ||
93 | |||
94 | this.handleUploadError({ | ||
95 | error: new Error(error), | ||
96 | name: 'HttpErrorResponse', | ||
97 | message: error, | ||
98 | ok: false, | ||
99 | headers: new HttpHeaders(state.responseHeaders), | ||
100 | status: +state.responseStatus, | ||
101 | statusText: error, | ||
102 | type: HttpEventType.Response, | ||
103 | url: state.url | ||
104 | }) | ||
105 | break | ||
106 | |||
107 | case 'cancelled': | ||
108 | this.isUploadingVideo = false | ||
109 | this.videoUploadPercents = 0 | ||
110 | |||
111 | this.firstStepError.emit() | ||
112 | this.enableRetryAfterError = false | ||
113 | this.error = '' | ||
114 | break | ||
115 | |||
116 | case 'queue': | ||
117 | this.closeFirstStep(state.name) | ||
118 | break | ||
119 | |||
120 | case 'uploading': | ||
121 | this.videoUploadPercents = state.progress | ||
122 | break | ||
123 | |||
124 | case 'paused': | ||
125 | this.notifier.info($localize`Upload cancelled`) | ||
126 | break | ||
127 | |||
128 | case 'complete': | ||
129 | this.videoUploaded = true | ||
130 | this.videoUploadPercents = 100 | ||
131 | |||
132 | this.videoUploadedIds = state?.response.video | ||
133 | break | ||
134 | } | ||
135 | } | ||
136 | |||
73 | ngOnInit () { | 137 | ngOnInit () { |
74 | super.ngOnInit() | 138 | super.ngOnInit() |
75 | 139 | ||
@@ -78,6 +142,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView | |||
78 | this.userVideoQuotaUsed = data.videoQuotaUsed | 142 | this.userVideoQuotaUsed = data.videoQuotaUsed |
79 | this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily | 143 | this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily |
80 | }) | 144 | }) |
145 | |||
146 | this.resumableUploadService.events | ||
147 | .subscribe(state => this.onUploadVideoOngoing(state)) | ||
81 | } | 148 | } |
82 | 149 | ||
83 | ngAfterViewInit () { | 150 | ngAfterViewInit () { |
@@ -85,7 +152,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView | |||
85 | } | 152 | } |
86 | 153 | ||
87 | ngOnDestroy () { | 154 | ngOnDestroy () { |
88 | if (this.videoUploadObservable) this.videoUploadObservable.unsubscribe() | 155 | this.cancelUpload() |
89 | } | 156 | } |
90 | 157 | ||
91 | canDeactivate () { | 158 | canDeactivate () { |
@@ -105,137 +172,43 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView | |||
105 | } | 172 | } |
106 | } | 173 | } |
107 | 174 | ||
108 | getVideoFile () { | 175 | onFileDropped (files: FileList) { |
109 | return this.videofileInput.nativeElement.files[0] | ||
110 | } | ||
111 | |||
112 | setVideoFile (files: FileList) { | ||
113 | this.videofileInput.nativeElement.files = files | 176 | this.videofileInput.nativeElement.files = files |
114 | this.fileChange() | ||
115 | } | ||
116 | |||
117 | getAudioUploadLabel () { | ||
118 | const videofile = this.getVideoFile() | ||
119 | if (!videofile) return $localize`Upload` | ||
120 | 177 | ||
121 | return $localize`Upload ${videofile.name}` | 178 | this.onFileChange({ target: this.videofileInput.nativeElement }) |
122 | } | 179 | } |
123 | 180 | ||
124 | fileChange () { | 181 | onFileChange (event: Event | { target: HTMLInputElement }) { |
125 | this.uploadFirstStep() | 182 | const file = (event.target as HTMLInputElement).files[0] |
126 | } | ||
127 | |||
128 | retryUpload () { | ||
129 | this.enableRetryAfterError = false | ||
130 | this.error = '' | ||
131 | this.uploadVideo() | ||
132 | } | ||
133 | |||
134 | cancelUpload () { | ||
135 | if (this.videoUploadObservable !== null) { | ||
136 | this.videoUploadObservable.unsubscribe() | ||
137 | } | ||
138 | |||
139 | this.isUploadingVideo = false | ||
140 | this.videoUploadPercents = 0 | ||
141 | this.videoUploadObservable = null | ||
142 | 183 | ||
143 | this.firstStepError.emit() | 184 | if (!file) return |
144 | this.enableRetryAfterError = false | ||
145 | this.error = '' | ||
146 | 185 | ||
147 | this.notifier.info($localize`Upload cancelled`) | 186 | if (!this.checkGlobalUserQuota(file)) return |
148 | } | 187 | if (!this.checkDailyUserQuota(file)) return |
149 | 188 | ||
150 | uploadFirstStep (clickedOnButton = false) { | 189 | if (this.isAudioFile(file.name)) { |
151 | const videofile = this.getVideoFile() | ||
152 | if (!videofile) return | ||
153 | |||
154 | if (!this.checkGlobalUserQuota(videofile)) return | ||
155 | if (!this.checkDailyUserQuota(videofile)) return | ||
156 | |||
157 | if (clickedOnButton === false && this.isAudioFile(videofile.name)) { | ||
158 | this.isUploadingAudioFile = true | 190 | this.isUploadingAudioFile = true |
159 | return | 191 | return |
160 | } | 192 | } |
161 | 193 | ||
162 | // Build name field | ||
163 | const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '') | ||
164 | let name: string | ||
165 | |||
166 | // If the name of the file is very small, keep the extension | ||
167 | if (nameWithoutExtension.length < 3) name = videofile.name | ||
168 | else name = nameWithoutExtension | ||
169 | |||
170 | const nsfw = this.serverConfig.instance.isNSFW | ||
171 | const waitTranscoding = true | ||
172 | const commentsEnabled = true | ||
173 | const downloadEnabled = true | ||
174 | const channelId = this.firstStepChannelId.toString() | ||
175 | |||
176 | this.formData = new FormData() | ||
177 | this.formData.append('name', name) | ||
178 | // Put the video "private" -> we are waiting the user validation of the second step | ||
179 | this.formData.append('privacy', VideoPrivacy.PRIVATE.toString()) | ||
180 | this.formData.append('nsfw', '' + nsfw) | ||
181 | this.formData.append('commentsEnabled', '' + commentsEnabled) | ||
182 | this.formData.append('downloadEnabled', '' + downloadEnabled) | ||
183 | this.formData.append('waitTranscoding', '' + waitTranscoding) | ||
184 | this.formData.append('channelId', '' + channelId) | ||
185 | this.formData.append('videofile', videofile) | ||
186 | |||
187 | if (this.previewfileUpload) { | ||
188 | this.formData.append('previewfile', this.previewfileUpload) | ||
189 | this.formData.append('thumbnailfile', this.previewfileUpload) | ||
190 | } | ||
191 | |||
192 | this.isUploadingVideo = true | 194 | this.isUploadingVideo = true |
193 | this.firstStepDone.emit(name) | 195 | this.fileToUpload = file |
194 | |||
195 | this.form.patchValue({ | ||
196 | name, | ||
197 | privacy: this.firstStepPrivacyId, | ||
198 | nsfw, | ||
199 | channelId: this.firstStepChannelId, | ||
200 | previewfile: this.previewfileUpload | ||
201 | }) | ||
202 | 196 | ||
203 | this.uploadVideo() | 197 | this.uploadFile(file) |
204 | } | 198 | } |
205 | 199 | ||
206 | uploadVideo () { | 200 | uploadAudio () { |
207 | this.videoUploadObservable = this.videoService.uploadVideo(this.formData).subscribe( | 201 | this.uploadFile(this.getInputVideoFile(), this.previewfileUpload) |
208 | event => { | 202 | } |
209 | if (event.type === HttpEventType.UploadProgress) { | ||
210 | this.videoUploadPercents = Math.round(100 * event.loaded / event.total) | ||
211 | } else if (event instanceof HttpResponse) { | ||
212 | this.videoUploaded = true | ||
213 | |||
214 | this.videoUploadedIds = event.body.video | ||
215 | |||
216 | this.videoUploadObservable = null | ||
217 | } | ||
218 | }, | ||
219 | 203 | ||
220 | (err: HttpErrorResponse) => { | 204 | retryUpload () { |
221 | // Reset progress (but keep isUploadingVideo true) | 205 | this.enableRetryAfterError = false |
222 | this.videoUploadPercents = 0 | 206 | this.error = '' |
223 | this.videoUploadObservable = null | 207 | this.uploadFile(this.fileToUpload) |
224 | this.enableRetryAfterError = true | 208 | } |
225 | |||
226 | this.error = uploadErrorHandler({ | ||
227 | err, | ||
228 | name: $localize`video`, | ||
229 | notifier: this.notifier, | ||
230 | sticky: false | ||
231 | }) | ||
232 | 209 | ||
233 | if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413 || | 210 | cancelUpload () { |
234 | err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) { | 211 | this.resumableUploadService.control({ action: 'cancel' }) |
235 | this.cancelUpload() | ||
236 | } | ||
237 | } | ||
238 | ) | ||
239 | } | 212 | } |
240 | 213 | ||
241 | isPublishingButtonDisabled () { | 214 | isPublishingButtonDisabled () { |
@@ -245,6 +218,13 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView | |||
245 | !this.videoUploadedIds.id | 218 | !this.videoUploadedIds.id |
246 | } | 219 | } |
247 | 220 | ||
221 | getAudioUploadLabel () { | ||
222 | const videofile = this.getInputVideoFile() | ||
223 | if (!videofile) return $localize`Upload` | ||
224 | |||
225 | return $localize`Upload ${videofile.name}` | ||
226 | } | ||
227 | |||
248 | updateSecondStep () { | 228 | updateSecondStep () { |
249 | if (this.isPublishingButtonDisabled() || !this.checkForm()) { | 229 | if (this.isPublishingButtonDisabled() || !this.checkForm()) { |
250 | return | 230 | return |
@@ -275,6 +255,62 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView | |||
275 | ) | 255 | ) |
276 | } | 256 | } |
277 | 257 | ||
258 | private getInputVideoFile () { | ||
259 | return this.videofileInput.nativeElement.files[0] | ||
260 | } | ||
261 | |||
262 | private uploadFile (file: File, previewfile?: File) { | ||
263 | const metadata = { | ||
264 | waitTranscoding: true, | ||
265 | commentsEnabled: true, | ||
266 | downloadEnabled: true, | ||
267 | channelId: this.firstStepChannelId, | ||
268 | nsfw: this.serverConfig.instance.isNSFW, | ||
269 | privacy: VideoPrivacy.PRIVATE.toString(), | ||
270 | filename: file.name, | ||
271 | previewfile: previewfile as any | ||
272 | } | ||
273 | |||
274 | this.resumableUploadService.handleFiles(file, { | ||
275 | ...this.uploadxOptions, | ||
276 | metadata | ||
277 | }) | ||
278 | |||
279 | this.isUploadingVideo = true | ||
280 | } | ||
281 | |||
282 | private handleUploadError (err: HttpErrorResponse) { | ||
283 | // Reset progress (but keep isUploadingVideo true) | ||
284 | this.videoUploadPercents = 0 | ||
285 | this.enableRetryAfterError = true | ||
286 | |||
287 | this.error = genericUploadErrorHandler({ | ||
288 | err, | ||
289 | name: $localize`video`, | ||
290 | notifier: this.notifier, | ||
291 | sticky: false | ||
292 | }) | ||
293 | |||
294 | if (err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) { | ||
295 | this.cancelUpload() | ||
296 | } | ||
297 | } | ||
298 | |||
299 | private closeFirstStep (filename: string) { | ||
300 | const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '') | ||
301 | const name = nameWithoutExtension.length < 3 ? filename : nameWithoutExtension | ||
302 | |||
303 | this.form.patchValue({ | ||
304 | name, | ||
305 | privacy: this.firstStepPrivacyId, | ||
306 | nsfw: this.serverConfig.instance.isNSFW, | ||
307 | channelId: this.firstStepChannelId, | ||
308 | previewfile: this.previewfileUpload | ||
309 | }) | ||
310 | |||
311 | this.firstStepDone.emit(name) | ||
312 | } | ||
313 | |||
278 | private checkGlobalUserQuota (videofile: File) { | 314 | private checkGlobalUserQuota (videofile: File) { |
279 | const bytePipes = new BytesPipe() | 315 | const bytePipes = new BytesPipe() |
280 | 316 | ||
@@ -285,8 +321,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView | |||
285 | const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0) | 321 | const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0) |
286 | const videoQuotaBytes = bytePipes.transform(videoQuota, 0) | 322 | const videoQuotaBytes = bytePipes.transform(videoQuota, 0) |
287 | 323 | ||
288 | const msg = $localize`Your video quota is exceeded with this video ( | 324 | const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})` |
289 | video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})` | ||
290 | this.notifier.error(msg) | 325 | this.notifier.error(msg) |
291 | 326 | ||
292 | return false | 327 | return false |
@@ -304,9 +339,7 @@ video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuota | |||
304 | const videoSizeBytes = bytePipes.transform(videofile.size, 0) | 339 | const videoSizeBytes = bytePipes.transform(videofile.size, 0) |
305 | const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0) | 340 | const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0) |
306 | const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0) | 341 | const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0) |
307 | 342 | const msg = $localize`Your daily video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})` | |
308 | const msg = $localize`Your daily video quota is exceeded with this video ( | ||
309 | video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})` | ||
310 | this.notifier.error(msg) | 343 | this.notifier.error(msg) |
311 | 344 | ||
312 | return false | 345 | return false |
diff --git a/client/src/app/+videos/+video-edit/video-add.module.ts b/client/src/app/+videos/+video-edit/video-add.module.ts index da651119b..e836cf81e 100644 --- a/client/src/app/+videos/+video-edit/video-add.module.ts +++ b/client/src/app/+videos/+video-edit/video-add.module.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { CanDeactivateGuard } from '@app/core' | 2 | import { CanDeactivateGuard } from '@app/core' |
3 | import { UploadxModule } from 'ngx-uploadx' | ||
3 | import { VideoEditModule } from './shared/video-edit.module' | 4 | import { VideoEditModule } from './shared/video-edit.module' |
4 | import { DragDropDirective } from './video-add-components/drag-drop.directive' | 5 | import { DragDropDirective } from './video-add-components/drag-drop.directive' |
5 | import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' | 6 | import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' |
@@ -13,7 +14,9 @@ import { VideoAddComponent } from './video-add.component' | |||
13 | imports: [ | 14 | imports: [ |
14 | VideoAddRoutingModule, | 15 | VideoAddRoutingModule, |
15 | 16 | ||
16 | VideoEditModule | 17 | VideoEditModule, |
18 | |||
19 | UploadxModule | ||
17 | ], | 20 | ], |
18 | 21 | ||
19 | declarations: [ | 22 | declarations: [ |
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts index 276548b79..9172b78a8 100644 --- a/client/src/app/+videos/+video-edit/video-update.resolver.ts +++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts | |||
@@ -2,7 +2,9 @@ import { forkJoin, of } from 'rxjs' | |||
2 | import { map, switchMap } from 'rxjs/operators' | 2 | import { map, switchMap } from 'rxjs/operators' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router' | 4 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router' |
5 | import { VideoCaptionService, VideoChannelService, VideoDetails, VideoService } from '@app/shared/shared-main' | 5 | import { AuthService } from '@app/core' |
6 | import { listUserChannels } from '@app/helpers' | ||
7 | import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' | ||
6 | import { LiveVideoService } from '@app/shared/shared-video-live' | 8 | import { LiveVideoService } from '@app/shared/shared-video-live' |
7 | 9 | ||
8 | @Injectable() | 10 | @Injectable() |
@@ -10,7 +12,7 @@ export class VideoUpdateResolver implements Resolve<any> { | |||
10 | constructor ( | 12 | constructor ( |
11 | private videoService: VideoService, | 13 | private videoService: VideoService, |
12 | private liveVideoService: LiveVideoService, | 14 | private liveVideoService: LiveVideoService, |
13 | private videoChannelService: VideoChannelService, | 15 | private authService: AuthService, |
14 | private videoCaptionService: VideoCaptionService | 16 | private videoCaptionService: VideoCaptionService |
15 | ) { | 17 | ) { |
16 | } | 18 | } |
@@ -31,17 +33,7 @@ export class VideoUpdateResolver implements Resolve<any> { | |||
31 | .loadCompleteDescription(video.descriptionPath) | 33 | .loadCompleteDescription(video.descriptionPath) |
32 | .pipe(map(description => Object.assign(video, { description }))), | 34 | .pipe(map(description => Object.assign(video, { description }))), |
33 | 35 | ||
34 | this.videoChannelService | 36 | listUserChannels(this.authService), |
35 | .listAccountVideoChannels(video.account) | ||
36 | .pipe( | ||
37 | map(result => result.data), | ||
38 | map(videoChannels => videoChannels.map(c => ({ | ||
39 | id: c.id, | ||
40 | label: c.displayName, | ||
41 | support: c.support, | ||
42 | avatarPath: c.avatar?.path | ||
43 | }))) | ||
44 | ), | ||
45 | 37 | ||
46 | this.videoCaptionService | 38 | this.videoCaptionService |
47 | .listCaptions(video.id) | 39 | .listCaptions(video.id) |
diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss index e21ada0f1..0543564b4 100644 --- a/client/src/app/app.component.scss +++ b/client/src/app/app.component.scss | |||
@@ -40,8 +40,10 @@ | |||
40 | } | 40 | } |
41 | 41 | ||
42 | .icon-menu { | 42 | .icon-menu { |
43 | background-color: pvar(--mainForegroundColor); | ||
44 | mask-image: url('../assets/images/misc/menu.svg'); | 43 | mask-image: url('../assets/images/misc/menu.svg'); |
44 | -webkit-mask-image: url('../assets/images/misc/menu.svg'); | ||
45 | |||
46 | background-color: pvar(--mainForegroundColor); | ||
45 | margin: 0 18px 0 20px; | 47 | margin: 0 18px 0 20px; |
46 | 48 | ||
47 | @media screen and (max-width: $mobile-view) { | 49 | @media screen and (max-width: $mobile-view) { |
diff --git a/client/src/app/helpers/utils.ts b/client/src/app/helpers/utils.ts index a1747af3c..94f6def26 100644 --- a/client/src/app/helpers/utils.ts +++ b/client/src/app/helpers/utils.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { map } from 'rxjs/operators' | 1 | import { first, map } from 'rxjs/operators' |
2 | import { SelectChannelItem } from 'src/types/select-options-item.model' | 2 | import { SelectChannelItem } from 'src/types/select-options-item.model' |
3 | import { DatePipe } from '@angular/common' | 3 | import { DatePipe } from '@angular/common' |
4 | import { HttpErrorResponse } from '@angular/common/http' | 4 | import { HttpErrorResponse } from '@angular/common/http' |
@@ -23,20 +23,29 @@ function getParameterByName (name: string, url: string) { | |||
23 | 23 | ||
24 | function listUserChannels (authService: AuthService) { | 24 | function listUserChannels (authService: AuthService) { |
25 | return authService.userInformationLoaded | 25 | return authService.userInformationLoaded |
26 | .pipe(map(() => { | 26 | .pipe( |
27 | const user = authService.getUser() | 27 | first(), |
28 | if (!user) return undefined | 28 | map(() => { |
29 | 29 | const user = authService.getUser() | |
30 | const videoChannels = user.videoChannels | 30 | if (!user) return undefined |
31 | if (Array.isArray(videoChannels) === false) return undefined | 31 | |
32 | 32 | const videoChannels = user.videoChannels | |
33 | return videoChannels.map(c => ({ | 33 | if (Array.isArray(videoChannels) === false) return undefined |
34 | id: c.id, | 34 | |
35 | label: c.displayName, | 35 | return videoChannels |
36 | support: c.support, | 36 | .sort((a, b) => { |
37 | avatarPath: c.avatar?.path | 37 | if (a.updatedAt < b.updatedAt) return 1 |
38 | }) as SelectChannelItem) | 38 | if (a.updatedAt > b.updatedAt) return -1 |
39 | })) | 39 | return 0 |
40 | }) | ||
41 | .map(c => ({ | ||
42 | id: c.id, | ||
43 | label: c.displayName, | ||
44 | support: c.support, | ||
45 | avatarPath: c.avatar?.path | ||
46 | }) as SelectChannelItem) | ||
47 | }) | ||
48 | ) | ||
40 | } | 49 | } |
41 | 50 | ||
42 | function getAbsoluteAPIUrl () { | 51 | function getAbsoluteAPIUrl () { |
@@ -167,8 +176,8 @@ function isXPercentInViewport (el: HTMLElement, percentVisible: number) { | |||
167 | ) | 176 | ) |
168 | } | 177 | } |
169 | 178 | ||
170 | function uploadErrorHandler (parameters: { | 179 | function genericUploadErrorHandler (parameters: { |
171 | err: HttpErrorResponse | 180 | err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'> |
172 | name: string | 181 | name: string |
173 | notifier: Notifier | 182 | notifier: Notifier |
174 | sticky?: boolean | 183 | sticky?: boolean |
@@ -180,6 +189,9 @@ function uploadErrorHandler (parameters: { | |||
180 | if (err instanceof ErrorEvent) { // network error | 189 | if (err instanceof ErrorEvent) { // network error |
181 | message = $localize`The connection was interrupted` | 190 | message = $localize`The connection was interrupted` |
182 | notifier.error(message, title, null, sticky) | 191 | notifier.error(message, title, null, sticky) |
192 | } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) { | ||
193 | message = $localize`The server encountered an error` | ||
194 | notifier.error(message, title, null, sticky) | ||
183 | } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) { | 195 | } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) { |
184 | message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)` | 196 | message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)` |
185 | notifier.error(message, title, null, sticky) | 197 | notifier.error(message, title, null, sticky) |
@@ -210,5 +222,5 @@ export { | |||
210 | isInViewport, | 222 | isInViewport, |
211 | isXPercentInViewport, | 223 | isXPercentInViewport, |
212 | listUserChannels, | 224 | listUserChannels, |
213 | uploadErrorHandler | 225 | genericUploadErrorHandler |
214 | } | 226 | } |
diff --git a/client/src/app/shared/shared-main/account/account.model.ts b/client/src/app/shared/shared-main/account/account.model.ts index 6d9f0ee65..7b5611f35 100644 --- a/client/src/app/shared/shared-main/account/account.model.ts +++ b/client/src/app/shared/shared-main/account/account.model.ts | |||
@@ -4,8 +4,12 @@ import { Actor } from './actor.model' | |||
4 | export class Account extends Actor implements ServerAccount { | 4 | export class Account extends Actor implements ServerAccount { |
5 | displayName: string | 5 | displayName: string |
6 | description: string | 6 | description: string |
7 | |||
8 | updatedAt: Date | string | ||
9 | |||
7 | nameWithHost: string | 10 | nameWithHost: string |
8 | nameWithHostForced: string | 11 | nameWithHostForced: string |
12 | |||
9 | mutedByUser: boolean | 13 | mutedByUser: boolean |
10 | mutedByInstance: boolean | 14 | mutedByInstance: boolean |
11 | mutedServerByUser: boolean | 15 | mutedServerByUser: boolean |
@@ -30,6 +34,8 @@ export class Account extends Actor implements ServerAccount { | |||
30 | this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) | 34 | this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) |
31 | this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true) | 35 | this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true) |
32 | 36 | ||
37 | if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString()) | ||
38 | |||
33 | this.mutedByUser = false | 39 | this.mutedByUser = false |
34 | this.mutedByInstance = false | 40 | this.mutedByInstance = false |
35 | this.mutedServerByUser = false | 41 | this.mutedServerByUser = false |
diff --git a/client/src/app/shared/shared-main/account/actor.model.ts b/client/src/app/shared/shared-main/account/actor.model.ts index 6ba0bb09e..2fccc472a 100644 --- a/client/src/app/shared/shared-main/account/actor.model.ts +++ b/client/src/app/shared/shared-main/account/actor.model.ts | |||
@@ -12,7 +12,6 @@ export abstract class Actor implements ServerActor { | |||
12 | followersCount: number | 12 | followersCount: number |
13 | 13 | ||
14 | createdAt: Date | string | 14 | createdAt: Date | string |
15 | updatedAt: Date | string | ||
16 | 15 | ||
17 | avatar: ActorImage | 16 | avatar: ActorImage |
18 | 17 | ||
@@ -55,7 +54,6 @@ export abstract class Actor implements ServerActor { | |||
55 | this.followersCount = hash.followersCount | 54 | this.followersCount = hash.followersCount |
56 | 55 | ||
57 | if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString()) | 56 | if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString()) |
58 | if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString()) | ||
59 | 57 | ||
60 | this.avatar = hash.avatar | 58 | this.avatar = hash.avatar |
61 | this.isLocal = Actor.IS_LOCAL(this.host) | 59 | this.isLocal = Actor.IS_LOCAL(this.host) |
diff --git a/client/src/app/shared/shared-main/buttons/button.component.scss b/client/src/app/shared/shared-main/buttons/button.component.scss index 09b5f95d7..22b24c853 100644 --- a/client/src/app/shared/shared-main/buttons/button.component.scss +++ b/client/src/app/shared/shared-main/buttons/button.component.scss | |||
@@ -30,7 +30,7 @@ span[class$=-button] { | |||
30 | 30 | ||
31 | .action-button { | 31 | .action-button { |
32 | @include peertube-button-link; | 32 | @include peertube-button-link; |
33 | @include button-with-icon(21px, 0, -1px); | 33 | @include button-with-icon(21px); |
34 | } | 34 | } |
35 | 35 | ||
36 | .orange-button { | 36 | .orange-button { |
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts index c40dd5311..a9dcf2fa2 100644 --- a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts +++ b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts | |||
@@ -16,6 +16,8 @@ export class VideoChannel extends Actor implements ServerVideoChannel { | |||
16 | banner: ActorImage | 16 | banner: ActorImage |
17 | bannerUrl: string | 17 | bannerUrl: string |
18 | 18 | ||
19 | updatedAt: Date | string | ||
20 | |||
19 | ownerAccount?: ServerAccount | 21 | ownerAccount?: ServerAccount |
20 | ownerBy?: string | 22 | ownerBy?: string |
21 | 23 | ||
@@ -59,6 +61,8 @@ export class VideoChannel extends Actor implements ServerVideoChannel { | |||
59 | 61 | ||
60 | this.videosCount = hash.videosCount | 62 | this.videosCount = hash.videosCount |
61 | 63 | ||
64 | if (hash.updatedAt) this.updatedAt = new Date(hash.updatedAt.toString()) | ||
65 | |||
62 | if (hash.viewsPerDay) { | 66 | if (hash.viewsPerDay) { |
63 | this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) })) | 67 | this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) })) |
64 | } | 68 | } |
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts index e65261763..a89f1065a 100644 --- a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts +++ b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts | |||
@@ -40,23 +40,24 @@ export class VideoChannelService { | |||
40 | ) | 40 | ) |
41 | } | 41 | } |
42 | 42 | ||
43 | listAccountVideoChannels ( | 43 | listAccountVideoChannels (options: { |
44 | account: Account, | 44 | account: Account |
45 | componentPagination?: ComponentPaginationLight, | 45 | componentPagination?: ComponentPaginationLight |
46 | withStats = false, | 46 | withStats?: boolean |
47 | sort?: string | ||
47 | search?: string | 48 | search?: string |
48 | ): Observable<ResultList<VideoChannel>> { | 49 | }): Observable<ResultList<VideoChannel>> { |
50 | const { account, componentPagination, withStats = false, sort, search } = options | ||
51 | |||
49 | const pagination = componentPagination | 52 | const pagination = componentPagination |
50 | ? this.restService.componentPaginationToRestPagination(componentPagination) | 53 | ? this.restService.componentPaginationToRestPagination(componentPagination) |
51 | : { start: 0, count: 20 } | 54 | : { start: 0, count: 20 } |
52 | 55 | ||
53 | let params = new HttpParams() | 56 | let params = new HttpParams() |
54 | params = this.restService.addRestGetParams(params, pagination) | 57 | params = this.restService.addRestGetParams(params, pagination, sort) |
55 | params = params.set('withStats', withStats + '') | 58 | params = params.set('withStats', withStats + '') |
56 | 59 | ||
57 | if (search) { | 60 | if (search) params = params.set('search', search) |
58 | params = params.set('search', search) | ||
59 | } | ||
60 | 61 | ||
61 | const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels' | 62 | const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels' |
62 | return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params }) | 63 | return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params }) |
diff --git a/client/src/app/shared/shared-search/advanced-search.model.ts b/client/src/app/shared/shared-search/advanced-search.model.ts index 0e3924841..2c83f53b6 100644 --- a/client/src/app/shared/shared-search/advanced-search.model.ts +++ b/client/src/app/shared/shared-search/advanced-search.model.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { BooleanBothQuery, SearchTargetType } from '@shared/models' | 1 | import { BooleanBothQuery, BooleanQuery, SearchTargetType, VideosSearchQuery } from '@shared/models' |
2 | 2 | ||
3 | export class AdvancedSearch { | 3 | export class AdvancedSearch { |
4 | startDate: string // ISO 8601 | 4 | startDate: string // ISO 8601 |
@@ -21,6 +21,8 @@ export class AdvancedSearch { | |||
21 | durationMin: number // seconds | 21 | durationMin: number // seconds |
22 | durationMax: number // seconds | 22 | durationMax: number // seconds |
23 | 23 | ||
24 | isLive: BooleanQuery | ||
25 | |||
24 | sort: string | 26 | sort: string |
25 | 27 | ||
26 | searchTarget: SearchTargetType | 28 | searchTarget: SearchTargetType |
@@ -41,6 +43,8 @@ export class AdvancedSearch { | |||
41 | tagsOneOf?: any | 43 | tagsOneOf?: any |
42 | tagsAllOf?: any | 44 | tagsAllOf?: any |
43 | 45 | ||
46 | isLive?: BooleanQuery | ||
47 | |||
44 | durationMin?: string | 48 | durationMin?: string |
45 | durationMax?: string | 49 | durationMax?: string |
46 | sort?: string | 50 | sort?: string |
@@ -54,6 +58,8 @@ export class AdvancedSearch { | |||
54 | this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined | 58 | this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined |
55 | 59 | ||
56 | this.nsfw = options.nsfw || undefined | 60 | this.nsfw = options.nsfw || undefined |
61 | this.isLive = options.isLive || undefined | ||
62 | |||
57 | this.categoryOneOf = options.categoryOneOf || undefined | 63 | this.categoryOneOf = options.categoryOneOf || undefined |
58 | this.licenceOneOf = options.licenceOneOf || undefined | 64 | this.licenceOneOf = options.licenceOneOf || undefined |
59 | this.languageOneOf = options.languageOneOf || undefined | 65 | this.languageOneOf = options.languageOneOf || undefined |
@@ -94,6 +100,7 @@ export class AdvancedSearch { | |||
94 | this.tagsAllOf = undefined | 100 | this.tagsAllOf = undefined |
95 | this.durationMin = undefined | 101 | this.durationMin = undefined |
96 | this.durationMax = undefined | 102 | this.durationMax = undefined |
103 | this.isLive = undefined | ||
97 | 104 | ||
98 | this.sort = '-match' | 105 | this.sort = '-match' |
99 | } | 106 | } |
@@ -112,12 +119,16 @@ export class AdvancedSearch { | |||
112 | tagsAllOf: this.tagsAllOf, | 119 | tagsAllOf: this.tagsAllOf, |
113 | durationMin: this.durationMin, | 120 | durationMin: this.durationMin, |
114 | durationMax: this.durationMax, | 121 | durationMax: this.durationMax, |
122 | isLive: this.isLive, | ||
115 | sort: this.sort, | 123 | sort: this.sort, |
116 | searchTarget: this.searchTarget | 124 | searchTarget: this.searchTarget |
117 | } | 125 | } |
118 | } | 126 | } |
119 | 127 | ||
120 | toAPIObject () { | 128 | toAPIObject (): VideosSearchQuery { |
129 | let isLive: boolean | ||
130 | if (this.isLive) isLive = this.isLive === 'true' | ||
131 | |||
121 | return { | 132 | return { |
122 | startDate: this.startDate, | 133 | startDate: this.startDate, |
123 | endDate: this.endDate, | 134 | endDate: this.endDate, |
@@ -131,6 +142,7 @@ export class AdvancedSearch { | |||
131 | tagsAllOf: this.tagsAllOf, | 142 | tagsAllOf: this.tagsAllOf, |
132 | durationMin: this.durationMin, | 143 | durationMin: this.durationMin, |
133 | durationMax: this.durationMax, | 144 | durationMax: this.durationMax, |
145 | isLive, | ||
134 | sort: this.sort, | 146 | sort: this.sort, |
135 | searchTarget: this.searchTarget | 147 | searchTarget: this.searchTarget |
136 | } | 148 | } |
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss index 5df89d019..0bbdff1e6 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss | |||
@@ -95,6 +95,7 @@ my-actor-avatar { | |||
95 | .video-bottom { | 95 | .video-bottom { |
96 | display: flex; | 96 | display: flex; |
97 | width: 100%; | 97 | width: 100%; |
98 | min-width: 1px; | ||
98 | } | 99 | } |
99 | 100 | ||
100 | .video-miniature-name { | 101 | .video-miniature-name { |
@@ -145,6 +146,7 @@ my-actor-avatar { | |||
145 | 146 | ||
146 | .video-bottom { | 147 | .video-bottom { |
147 | display: flex; | 148 | display: flex; |
149 | min-width: 1px; | ||
148 | } | 150 | } |
149 | 151 | ||
150 | // We don't display avatar in row mode | 152 | // We don't display avatar in row mode |
diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss index 89b6f0c4c..ae511aa02 100644 --- a/client/src/sass/application.scss +++ b/client/src/sass/application.scss | |||
@@ -402,7 +402,26 @@ ngx-loading-bar { | |||
402 | } | 402 | } |
403 | 403 | ||
404 | .admin-sub-header { | 404 | .admin-sub-header { |
405 | @include admin-sub-header-responsive; | 405 | flex-direction: column; |
406 | |||
407 | .form-sub-title { | ||
408 | margin-right: 0 !important; | ||
409 | margin-bottom: 10px; | ||
410 | text-align: center; | ||
411 | } | ||
412 | |||
413 | .admin-sub-nav { | ||
414 | display: block; | ||
415 | overflow-x: auto; | ||
416 | white-space: nowrap; | ||
417 | height: 50px; | ||
418 | padding: 10px 0; | ||
419 | width: 100%; | ||
420 | |||
421 | a { | ||
422 | margin-left: 5px; | ||
423 | } | ||
424 | } | ||
406 | } | 425 | } |
407 | 426 | ||
408 | my-markdown-textarea { | 427 | my-markdown-textarea { |
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index b2083e516..06e55532a 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss | |||
@@ -336,20 +336,6 @@ | |||
336 | cursor: pointer; | 336 | cursor: pointer; |
337 | } | 337 | } |
338 | 338 | ||
339 | @mixin select-arrow-down { | ||
340 | top: 50%; | ||
341 | right: calc(0% + 15px); | ||
342 | content: ' '; | ||
343 | height: 0; | ||
344 | width: 0; | ||
345 | position: absolute; | ||
346 | pointer-events: none; | ||
347 | border: 5px solid rgba(0, 0, 0, 0); | ||
348 | border-top-color: #000; | ||
349 | margin-top: -2px; | ||
350 | z-index: 100; | ||
351 | } | ||
352 | |||
353 | @mixin responsive-width ($width) { | 339 | @mixin responsive-width ($width) { |
354 | width: $width; | 340 | width: $width; |
355 | 341 | ||
@@ -381,7 +367,17 @@ | |||
381 | } | 367 | } |
382 | 368 | ||
383 | &::after { | 369 | &::after { |
384 | @include select-arrow-down; | 370 | top: 50%; |
371 | right: calc(0% + 15px); | ||
372 | content: ' '; | ||
373 | height: 0; | ||
374 | width: 0; | ||
375 | position: absolute; | ||
376 | pointer-events: none; | ||
377 | border: 5px solid rgba(0, 0, 0, 0); | ||
378 | border-top-color: #000; | ||
379 | margin-top: -2px; | ||
380 | z-index: 100; | ||
385 | } | 381 | } |
386 | 382 | ||
387 | select { | 383 | select { |
@@ -849,29 +845,6 @@ | |||
849 | } | 845 | } |
850 | } | 846 | } |
851 | 847 | ||
852 | @mixin admin-sub-header-responsive { | ||
853 | flex-direction: column; | ||
854 | |||
855 | .form-sub-title { | ||
856 | margin-right: 0 !important; | ||
857 | margin-bottom: 10px; | ||
858 | text-align: center; | ||
859 | } | ||
860 | |||
861 | .admin-sub-nav { | ||
862 | display: block; | ||
863 | overflow-x: auto; | ||
864 | white-space: nowrap; | ||
865 | height: 50px; | ||
866 | padding: 10px 0; | ||
867 | width: 100%; | ||
868 | |||
869 | a { | ||
870 | margin-left: 5px; | ||
871 | } | ||
872 | } | ||
873 | } | ||
874 | |||
875 | // applies ratio (default to 16:9) to a child element (using $selector) only using | 848 | // applies ratio (default to 16:9) to a child element (using $selector) only using |
876 | // an immediate's parent size. This allows to set a ratio without explicit | 849 | // an immediate's parent size. This allows to set a ratio without explicit |
877 | // dimensions, as width/height cannot be computed from each other. | 850 | // dimensions, as width/height cannot be computed from each other. |
diff --git a/client/src/sass/player/context-menu.scss b/client/src/sass/player/context-menu.scss index 45cee3e77..1738f486d 100644 --- a/client/src/sass/player/context-menu.scss +++ b/client/src/sass/player/context-menu.scss | |||
@@ -47,6 +47,7 @@ $context-menu-width: 350px; | |||
47 | @each $icon in $icons { | 47 | @each $icon in $icons { |
48 | &[class$="-#{$icon}"] { | 48 | &[class$="-#{$icon}"] { |
49 | mask-image: url('#{$assets-path}/player/images/#{$icon}.svg'); | 49 | mask-image: url('#{$assets-path}/player/images/#{$icon}.svg'); |
50 | -webkit-mask-image: url('#{$assets-path}/player/images/#{$icon}.svg'); | ||
50 | } | 51 | } |
51 | } | 52 | } |
52 | 53 | ||
diff --git a/client/src/sass/player/peertube-skin.scss b/client/src/sass/player/peertube-skin.scss index 8fe2e054d..c010f7297 100644 --- a/client/src/sass/player/peertube-skin.scss +++ b/client/src/sass/player/peertube-skin.scss | |||
@@ -346,6 +346,8 @@ body { | |||
346 | &.icon-next, | 346 | &.icon-next, |
347 | &.icon-previous { | 347 | &.icon-previous { |
348 | mask-image: url('#{$assets-path}/player/images/next.svg'); | 348 | mask-image: url('#{$assets-path}/player/images/next.svg'); |
349 | -webkit-mask-image: url('#{$assets-path}/player/images/next.svg'); | ||
350 | |||
349 | background-color: #fff; | 351 | background-color: #fff; |
350 | mask-size: cover; | 352 | mask-size: cover; |
351 | width: 11px; | 353 | width: 11px; |
diff --git a/client/src/sass/player/playlist.scss b/client/src/sass/player/playlist.scss index 8558fc837..3279bd263 100644 --- a/client/src/sass/player/playlist.scss +++ b/client/src/sass/player/playlist.scss | |||
@@ -40,10 +40,12 @@ $playlist-menu-width: 350px; | |||
40 | } | 40 | } |
41 | 41 | ||
42 | .cross { | 42 | .cross { |
43 | mask-image: url('#{$assets-path}/images/feather/x.svg'); | ||
44 | -webkit-mask-image: url('#{$assets-path}/images/feather/x.svg'); | ||
45 | |||
43 | cursor: pointer; | 46 | cursor: pointer; |
44 | width: 20px; | 47 | width: 20px; |
45 | height: 20px; | 48 | height: 20px; |
46 | mask-image: url('#{$assets-path}/images/feather/x.svg'); | ||
47 | background-color: #fff; | 49 | background-color: #fff; |
48 | mask-size: cover; | 50 | mask-size: cover; |
49 | } | 51 | } |
@@ -85,9 +87,11 @@ $playlist-menu-width: 350px; | |||
85 | } | 87 | } |
86 | 88 | ||
87 | .vjs-playlist-icon { | 89 | .vjs-playlist-icon { |
90 | mask-image: url('#{$assets-path}/images/feather/list.svg'); | ||
91 | -webkit-mask-image: url('#{$assets-path}/images/feather/list.svg'); | ||
92 | |||
88 | width: 22px; | 93 | width: 22px; |
89 | height: 22px; | 94 | height: 22px; |
90 | mask-image: url('#{$assets-path}/images/feather/list.svg'); | ||
91 | background-color: #fff; | 95 | background-color: #fff; |
92 | mask-size: cover; | 96 | mask-size: cover; |
93 | margin-bottom: 3px; | 97 | margin-bottom: 3px; |
diff --git a/client/yarn.lock b/client/yarn.lock index 571314f22..1b1455cc8 100644 --- a/client/yarn.lock +++ b/client/yarn.lock | |||
@@ -7793,6 +7793,13 @@ next-tick@~1.0.0: | |||
7793 | resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" | 7793 | resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" |
7794 | integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= | 7794 | integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= |
7795 | 7795 | ||
7796 | ngx-uploadx@^4.1.0: | ||
7797 | version "4.1.0" | ||
7798 | resolved "https://registry.yarnpkg.com/ngx-uploadx/-/ngx-uploadx-4.1.0.tgz#b3ed4566a2505239026bbdc10c2345aae28d67df" | ||
7799 | integrity sha512-KCG0NT4SBc/5MRl8aR6joHHg+WeTdrkhLeC1DrNgVxrTBuuenlEwOVDpkLJMPX/8HE6Bq33rx1U2NNZYVl9NMQ== | ||
7800 | dependencies: | ||
7801 | tslib "^1.9.0" | ||
7802 | |||
7796 | nice-try@^1.0.4: | 7803 | nice-try@^1.0.4: |
7797 | version "1.0.5" | 7804 | version "1.0.5" |
7798 | resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" | 7805 | resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" |