]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
add ng-select for templatable select options
authorRigel Kent <sendmemail@rigelk.eu>
Tue, 4 Aug 2020 22:50:07 +0000 (00:50 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Tue, 11 Aug 2020 07:03:39 +0000 (09:03 +0200)
- create select-tags component to replace ngx-chips
- create select-options to factorize option selection in forms
- create select-channel to simplify channel selection
- refactor tags validation

36 files changed:
client/package.json
client/src/app/+search/search-filters.component.html
client/src/app/+search/search-filters.component.scss
client/src/app/+search/search-filters.component.ts
client/src/app/+search/search.module.ts
client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.html
client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.scss
client/src/app/+videos/+video-edit/shared/video-edit.component.html
client/src/app/+videos/+video-edit/shared/video-edit.component.scss
client/src/app/+videos/+video-edit/shared/video-edit.component.ts
client/src/app/+videos/+video-edit/shared/video-edit.module.ts
client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.html
client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.html
client/src/app/+videos/+video-edit/video-add-components/video-send.scss
client/src/app/+videos/+video-edit/video-add-components/video-send.ts
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html
client/src/app/+videos/+video-edit/video-update.component.ts
client/src/app/+videos/+video-edit/video-update.resolver.ts
client/src/app/helpers/utils.ts
client/src/app/shared/shared-forms/form-validators/video-validators.service.ts
client/src/app/shared/shared-forms/select-channel.component.html [new file with mode: 0644]
client/src/app/shared/shared-forms/select-channel.component.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/select-options.component.html [new file with mode: 0644]
client/src/app/shared/shared-forms/select-options.component.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/select-shared.component.scss [new file with mode: 0644]
client/src/app/shared/shared-forms/select-tags.component.html [new file with mode: 0644]
client/src/app/shared/shared-forms/select-tags.component.scss [new file with mode: 0644]
client/src/app/shared/shared-forms/select-tags.component.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/shared-form.module.ts
client/src/app/shared/shared-main/shared-main.module.ts
client/src/app/shared/shared-main/video/video.service.ts
client/src/sass/application.scss
client/src/sass/include/_mixins.scss
client/src/sass/ng-select.scss [new file with mode: 0644]
client/yarn.lock
shared/models/videos/video-constant.model.ts

index 946cd109c3b8214c51f08e68872a6a948410fdbb..50b33d7c33dcb749fc8bd1a25219282497733474 100644 (file)
@@ -44,6 +44,7 @@
     "@angularclass/hmr": "^2.1.3",
     "@neos21/bootstrap3-glyphicons": "^1.0.1",
     "@ng-bootstrap/ng-bootstrap": "^7.0.0",
+    "@ng-select/ng-select": "^5.0.0",
     "@ngx-i18nsupport/ngx-i18nsupport": "^1.1.6",
     "@ngx-i18nsupport/tooling": "^8.0.3",
     "@ngx-loading-bar/core": "^5.0.0",
@@ -95,7 +96,6 @@
     "linkifyjs": "^2.1.5",
     "lodash-es": "^4.17.4",
     "markdown-it": "^11.0.0",
-    "ngx-chips": "2.1.0",
     "ngx-pipes": "^2.6.0",
     "node-sass": "^4.9.3",
     "npm-font-source-sans-pro": "^1.0.2",
     "webtorrent": "^0.108.1",
     "whatwg-fetch": "^3.0.0",
     "zone.js": "~0.10.2"
-  },
-  "dependencies": {}
+  }
 }
index e20aef8fbad8f942306a60dbdc85ba26165cb677..b36b1d2aefe158d00018a404999665d3a8a579c7 100644 (file)
         <button i18n class="reset-button reset-button-small" (click)="resetField('tagsAllOf')" *ngIf="advancedSearch.tagsAllOf">
           Reset
         </button>
-        <tag-input
-          [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf"
-          [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
-          i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
-          [maxItems]="5" [modelAsStrings]="true"
-        ></tag-input>
+        <my-select-tags labelForId="tagsAllOf" id="tagsAllOf" [(ngModel)]="advancedSearch.tagsAllOf"></my-select-tags>
       </div>
 
       <div class="form-group">
         <button i18n class="reset-button reset-button-small" (click)="resetField('tagsOneOf')" *ngIf="advancedSearch.tagsOneOf">
           Reset
         </button>
-        <tag-input
-          [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf"
-          [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
-          i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
-          [maxItems]="5" [modelAsStrings]="true"
-        ></tag-input>
+        <my-select-tags labelForId="tagsOneOf" id="tagsOneOf" [(ngModel)]="advancedSearch.tagsOneOf"></my-select-tags>
       </div>
 
       <div class="form-group" *ngIf="isSearchTargetEnabled()">
index a88a1c0b008579c73729b16726f79f6884941684..68ac6d02144d8c5b3076f96f352a5f561dba3f95 100644 (file)
@@ -65,5 +65,3 @@ input[type=submit] {
   display: flex;
   white-space: nowrap;
 }
-
-@include ng2-tags;
index fc1db3258a59e747b5cb83f9c58aada5b38cce3b..13ad61647412d449e64a54450fdd07b474c14060 100644 (file)
@@ -1,5 +1,4 @@
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
-import { ValidatorFn } from '@angular/forms'
 import { ServerService } from '@app/core'
 import { VideoValidatorsService } from '@app/shared/shared-forms'
 import { AdvancedSearch } from '@app/shared/shared-search'
@@ -20,9 +19,6 @@ export class SearchFiltersComponent implements OnInit {
   videoLicences: VideoConstant<number>[] = []
   videoLanguages: VideoConstant<string>[] = []
 
-  tagValidators: ValidatorFn[]
-  tagValidatorsMessages: { [ name: string ]: string }
-
   publishedDateRanges: { id: string, label: string }[] = []
   sorts: { id: string, label: string }[] = []
   durationRanges: { id: string, label: string }[] = []
@@ -40,8 +36,6 @@ export class SearchFiltersComponent implements OnInit {
     private videoValidatorsService: VideoValidatorsService,
     private serverService: ServerService
   ) {
-    this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS
-    this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
     this.publishedDateRanges = [
       {
         id: 'any_published_date',
index ee4f07ad190c3ee81f5c8d59ce935ecbbb8aaf89..e85ae07d00d3b0ab876e7d9bc8d7171dbf8f7514 100644 (file)
@@ -1,4 +1,3 @@
-import { TagInputModule } from 'ngx-chips'
 import { NgModule } from '@angular/core'
 import { SharedFormModule } from '@app/shared/shared-forms'
 import { SharedMainModule } from '@app/shared/shared-main'
@@ -14,8 +13,6 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
 
 @NgModule({
   imports: [
-    TagInputModule,
-
     SearchRoutingModule,
 
     SharedMainModule,
@@ -31,7 +28,6 @@ import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
   ],
 
   exports: [
-    TagInputModule,
     SearchComponent
   ],
 
index 6a9e31b5ac1282b2a0e907b34e766a49068bee68..6a07dafa7b626871eafd3b4bc0e96f5d886f158e 100644 (file)
@@ -8,11 +8,11 @@
 
     <div class="modal-body">
       <label i18n for="language">Language</label>
-      <div class="peertube-select-container">
-        <select id="language" formControlName="language" class="form-control">
-          <option></option>
-          <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option>
-        </select>
+      <div class="peertube-ng-select-container">
+        <ng-select
+          labelForId="language" [items]="videoCaptionLanguages" formControlName="language"
+          bindLabel="label" bindValue="id"
+        ></ng-select>
       </div>
 
       <div *ngIf="formErrors.language" class="form-error">
index b257a16a94025f4afe5b10247c78659d179d6cbe..0958b5f80837b0490b24f7ec87081e5ba9a1b661 100644 (file)
@@ -1,8 +1,9 @@
 @import '_variables';
 @import '_mixins';
 
-.peertube-select-container {
-  @include peertube-select-container(auto);
+label {
+  font-weight: $font-regular;
+  font-size: 100%;
 }
 
 .caption-file {
index 4d3b84626f7c739e59a389f757b7ab6caf9e0990..ae3413e79b8223b9ad03ea6ac11c20a651a5e097 100644 (file)
                 </ng-template>
               </my-help>
 
-              <tag-input
-                [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
-                i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag"
-                formControlName="tags" [maxItems]="5" [modelAsStrings]="true"
-              ></tag-input>
+              <my-select-tags labelForId="label-tags" formControlName="tags"></my-select-tags>
+              <div *ngIf="formErrors.tags" class="form-error">
+                {{ formErrors.tags }}
+              </div>
             </div>
 
             <div class="form-group">
 
           <div class="col-video-edit">
             <div class="form-group">
-              <label i18n>Channel</label>
-              <div class="peertube-select-container">
-                <select formControlName="channelId" class="form-control">
-                  <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
-                </select>
-              </div>
+              <label i18n for="channel">Channel</label>
+              <my-select-channel labelForId="channel" [items]="userVideoChannels" formControlName="channelId"></my-select-channel>
             </div>
 
             <div class="form-group">
               <label i18n for="category">Category</label>
-              <div class="peertube-select-container">
-                <select id="category" formControlName="category" class="form-control">
-                  <option></option>
-                  <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
-                </select>
-              </div>
+              <my-select-options
+                labelForId="category" [items]="videoCategories" formControlName="category" [clearable]="true"
+              ></my-select-options>
 
               <div *ngIf="formErrors.category" class="form-error">
                 {{ formErrors.category }}
 
             <div class="form-group">
               <label i18n for="licence">Licence</label>
-              <div class="peertube-select-container">
-                <select id="licence" formControlName="licence" class="form-control">
-                  <option></option>
-                  <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
-                </select>
-              </div>
+              <my-select-options
+                labelForId="licence" [items]="videoLicences" formControlName="licence" [clearable]="true"
+              ></my-select-options>
 
               <div *ngIf="formErrors.licence" class="form-error">
                 {{ formErrors.licence }}
 
             <div class="form-group">
               <label i18n for="language">Language</label>
-              <div class="peertube-select-container">
-                <select id="language" formControlName="language" class="form-control">
-                  <option></option>
-                  <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
-                </select>
-              </div>
+              <my-select-options
+                labelForId="language" [items]="videoLanguages" formControlName="language"
+                [clearable]="true" [searchable]="true" [groupBy]="'group'"
+              ></my-select-options>
 
               <div *ngIf="formErrors.language" class="form-error">
                 {{ formErrors.language }}
 
             <div class="form-group">
               <label i18n for="privacy">Privacy</label>
-              <div class="peertube-select-container">
-                <select id="privacy" formControlName="privacy" class="form-control">
-                  <option></option>
-                  <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
-                  <option *ngIf="schedulePublicationPossible" [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
-                </select>
-              </div>
+              <my-select-options
+                labelForId="privacy" [items]="videoPrivacies" formControlName="privacy" [clearable]="false"
+              ></my-select-options>
 
               <div *ngIf="formErrors.privacy" class="form-error">
                 {{ formErrors.privacy }}
 
             <my-peertube-checkbox inputName="nsfw" formControlName="nsfw" helpPlacement="bottom-right">
               <ng-template ptTemplate="label">
-                <ng-container i18n>This video contains mature or explicit content</ng-container>
+                <ng-container i18n>Contains sensitive content</ng-container>
               </ng-template>
 
               <ng-template ptTemplate="help">
 
             <my-peertube-checkbox *ngIf="waitTranscodingEnabled" inputName="waitTranscoding" formControlName="waitTranscoding" helpPlacement="bottom-right">
               <ng-template ptTemplate="label">
-                <ng-container i18n>Wait transcoding before publishing the video</ng-container>
+                <ng-container i18n>Publish after transcoding</ng-container>
               </ng-template>
 
               <ng-template ptTemplate="help">
index 69b9072883ac33625d35b43c384ff5f9ae5db815..9caf009c5c28bfe441818a3dd06f9bdffd0a5341 100644 (file)
@@ -161,8 +161,6 @@ p-calendar {
   }
 }
 
-@include ng2-tags;
-
 // columns for the video
 .col-video-edit {
   @include make-col-ready();
index 239e453ada3de5e0d1db5e32307ba87f4704643a..4cd3838dede60c1bd6518245ac21c8bd0f1b232f 100644 (file)
@@ -8,6 +8,11 @@ import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-ma
 import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
 import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
 import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { forkJoin } from 'rxjs'
+import { InstanceService } from '@app/shared/shared-instance'
+
+type VideoLanguages = VideoConstant<string> & { group?: string }
 
 @Component({
   selector: 'my-video-edit',
@@ -31,7 +36,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
   videoPrivacies: VideoConstant<VideoPrivacy>[] = []
   videoCategories: VideoConstant<number>[] = []
   videoLicences: VideoConstant<number>[] = []
-  videoLanguages: VideoConstant<string>[] = []
+  videoLanguages: VideoLanguages[] = []
 
   tagValidators: ValidatorFn[]
   tagValidatorsMessages: { [ name: string ]: string }
@@ -56,12 +61,11 @@ export class VideoEditComponent implements OnInit, OnDestroy {
     private videoValidatorsService: VideoValidatorsService,
     private videoService: VideoService,
     private serverService: ServerService,
+    private instanceService: InstanceService,
     private i18nPrimengCalendarService: I18nPrimengCalendarService,
+    private i18n: I18n,
     private ngZone: NgZone
   ) {
-    this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS
-    this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
-
     this.calendarLocale = this.i18nPrimengCalendarService.getCalendarLocale()
     this.calendarTimezone = this.i18nPrimengCalendarService.getTimezone()
     this.calendarDateFormat = this.i18nPrimengCalendarService.getDateFormat()
@@ -93,7 +97,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
       licence: this.videoValidatorsService.VIDEO_LICENCE,
       language: this.videoValidatorsService.VIDEO_LANGUAGE,
       description: this.videoValidatorsService.VIDEO_DESCRIPTION,
-      tags: null,
+      tags: this.videoValidatorsService.VIDEO_TAGS_ARRAY,
       previewfile: null,
       support: this.videoValidatorsService.VIDEO_SUPPORT,
       schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT,
@@ -126,11 +130,29 @@ export class VideoEditComponent implements OnInit, OnDestroy {
         .subscribe(res => this.videoCategories = res)
     this.serverService.getVideoLicences()
         .subscribe(res => this.videoLicences = res)
-    this.serverService.getVideoLanguages()
-      .subscribe(res => this.videoLanguages = res)
+    forkJoin([
+      this.instanceService.getAbout(),
+      this.serverService.getVideoLanguages()
+    ]).pipe(map(([ about, languages ]) => ({ about, languages })))
+      .subscribe(res => {
+        this.videoLanguages = res.languages
+          .map(l => res.about.instance.languages.includes(l.id)
+            ? { ...l, group: this.i18n('Instance languages'), groupOrder: 0 }
+            : { ...l, group: this.i18n('All languages'), groupOrder: 1 })
+          .sort((a, b) => a.groupOrder - b.groupOrder)
+      })
 
     this.serverService.getVideoPrivacies()
-      .subscribe(privacies => this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies))
+      .subscribe(privacies => {
+        this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies)
+        if (this.schedulePublicationPossible) {
+          this.videoPrivacies.push({
+            id: this.SPECIAL_SCHEDULED_PRIVACY,
+            label: this.i18n('Scheduled'),
+            description: this.i18n('Hide the video until a specific date')
+          })
+        }
+      })
 
     this.serverConfig = this.serverService.getTmpConfig()
     this.serverService.getConfig()
index d1bbbafe9d42f09eaa20b1aadd9976320fbc08c9..5931141814290533f5c185b18f10f56d55377f32 100644 (file)
@@ -1,4 +1,3 @@
-import { TagInputModule } from 'ngx-chips'
 import { CalendarModule } from 'primeng/calendar'
 import { NgModule } from '@angular/core'
 import { SharedFormModule } from '@app/shared/shared-forms'
@@ -10,7 +9,6 @@ import { VideoEditComponent } from './video-edit.component'
 
 @NgModule({
   imports: [
-    TagInputModule,
     CalendarModule,
 
     SharedMainModule,
@@ -24,7 +22,6 @@ import { VideoEditComponent } from './video-edit.component'
   ],
 
   exports: [
-    TagInputModule,
     CalendarModule,
 
     SharedMainModule,
index 5678f548f1e36b359d0c65cddcf33d9f69faec12..8db37a2934e9eac6b3ee78cd88ac23dcb6dc3477 100644 (file)
 
     <div class="form-group">
       <label i18n for="first-step-channel">Channel</label>
-      <div class="peertube-select-container">
-        <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
-          <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
-        </select>
-      </div>
+      <my-select-channel
+        labelForId="first-step-channel" [items]="userVideoChannels" [(ngModel)]="firstStepChannelId"
+      ></my-select-channel>
     </div>
 
     <div class="form-group">
       <label i18n for="first-step-privacy">Privacy</label>
-      <div class="peertube-select-container">
-        <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
-          <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
-        </select>
-      </div>
+      <my-select-options
+        labelForId="first-step-privacy" [items]="videoPrivacies" [(ngModel)]="firstStepPrivacyId"
+      ></my-select-options>
     </div>
 
     <input
index 2e434271e38f5bd182de59f58c586d0a0b7e5c7e..9b5cc3361a731314ef83259a749f28a08e97c7ed 100644 (file)
 
     <div class="form-group">
       <label i18n for="first-step-channel">Channel</label>
-      <div class="peertube-select-container">
-        <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
-          <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
-        </select>
-      </div>
+      <my-select-channel
+        labelForId="first-step-channel" [items]="userVideoChannels" [(ngModel)]="firstStepChannelId"
+      ></my-select-channel>
     </div>
 
     <div class="form-group">
       <label i18n for="first-step-privacy">Privacy</label>
-      <div class="peertube-select-container">
-        <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
-          <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
-        </select>
-      </div>
+      <my-select-options
+        labelForId="first-step-privacy" [items]="videoPrivacies" [(ngModel)]="firstStepPrivacyId"
+      ></my-select-options>
     </div>
 
     <input
index cdb7c828015e25f886c7e2c613d716855ac761cd..17c5f63e9f96751a2f0595c7e8a1cbcd4faf0d42 100644 (file)
@@ -26,6 +26,13 @@ $width-size: 190px;
   .peertube-select-container {
     @include peertube-select-container($width-size);
   }
+  my-select-options ::ng-deep ng-select,
+  my-select-channel ::ng-deep ng-select {
+    width: $width-size;
+    @media screen and (max-width: $width-size) {
+      width: 100%;
+    }
+  }
 
   input[type=text] {
     @include peertube-input-text($width-size);
index 2e658dfaecb6fdd76fdaf877f58c245d35300c63..86f2b376f645258ef057200d7dd4220aa9c89dc7 100644 (file)
@@ -10,7 +10,7 @@ import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
 @Directive()
 // tslint:disable-next-line: directive-class-suffix
 export abstract class VideoSend extends FormReactive implements OnInit {
-  userVideoChannels: { id: number, label: string, support: string }[] = []
+  userVideoChannels: { id: number, label: string, support: string, avatarPath?: string }[] = []
   videoPrivacies: VideoConstant<VideoPrivacy>[] = []
   videoCaptions: VideoCaptionEdit[] = []
 
@@ -44,7 +44,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
     this.serverService.getVideoPrivacies()
         .subscribe(
           privacies => {
-            this.videoPrivacies = privacies
+            this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies)
 
             this.firstStepPrivacyId = this.DEFAULT_VIDEO_PRIVACY
           })
index 95b6628f6c74811444e6a6c21de069ae9c12b0cf..ed697c25bd7a1f6fd896c13f5f8885e046eeeffb 100644 (file)
@@ -9,21 +9,16 @@
 
     <div class="form-group form-group-channel">
       <label i18n for="first-step-channel">Channel</label>
-      <div class="peertube-select-container">
-        <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control">
-          <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
-        </select>
-      </div>
+      <my-select-channel
+        labelForId="first-step-channel" [items]="userVideoChannels" [(ngModel)]="firstStepChannelId"
+      ></my-select-channel>
     </div>
 
     <div class="form-group">
       <label i18n for="first-step-privacy">Privacy</label>
-      <div class="peertube-select-container">
-        <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control">
-          <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
-          <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
-        </select>
-      </div>
+      <my-select-options
+        labelForId="first-step-privacy" [items]="videoPrivacies" [(ngModel)]="firstStepPrivacyId"
+      ></my-select-options>
     </div>
 
     <ng-container *ngIf="isUploadingAudioFile">
index 581199d651d1df0f15d90f78e7c817571d5bc2cc..de4f65df3ee56d31f04dae494ecd346825cbc3e4 100644 (file)
@@ -17,7 +17,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
   video: VideoEdit
 
   isUpdatingVideo = false
-  userVideoChannels: { id: number, label: string, support: string }[] = []
+  userVideoChannels: { id: number, label: string, support: string, avatar?: string }[] = []
   schedulePublicationPossible = false
   videoCaptions: VideoCaptionEdit[] = []
   waitTranscodingEnabled = true
index 30bcf4d74535ca0dd520d7eac3c83d26b2125cc5..a391913d82df1d18ebf2904ebe1ff671ec834fdd 100644 (file)
@@ -28,7 +28,12 @@ export class VideoUpdateResolver implements Resolve<any> {
                          .listAccountVideoChannels(video.account)
                          .pipe(
                            map(result => result.data),
-                           map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support })))
+                           map(videoChannels => videoChannels.map(c => ({
+                             id: c.id,
+                             label: c.displayName,
+                             support: c.support,
+                             avatarPath: c.avatar?.path
+                           })))
                          ),
 
                      this.videoCaptionService
index 825b6ca9620efe118e2c0c593b5bbd6bd959b290..aa37fdd46b8dfd70bbcbcca66621bca66d561c1a 100644 (file)
@@ -16,7 +16,10 @@ function getParameterByName (name: string, url: string) {
   return decodeURIComponent(results[2].replace(/\+/g, ' '))
 }
 
-function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support?: string }[]) {
+function populateAsyncUserVideoChannels (
+  authService: AuthService,
+  channel: { id: number, label: string, support?: string, avatarPath?: string, recent?: boolean }[]
+) {
   return new Promise(res => {
     authService.userInformationLoaded
       .subscribe(
@@ -27,7 +30,12 @@ function populateAsyncUserVideoChannels (authService: AuthService, channel: { id
           const videoChannels = user.videoChannels
           if (Array.isArray(videoChannels) === false) return
 
-          videoChannels.forEach(c => channel.push({ id: c.id, label: c.displayName, support: c.support }))
+          videoChannels.forEach(c => channel.push({
+            id: c.id,
+            label: c.displayName,
+            support: c.support,
+            avatarPath: c.avatar?.path
+          }))
 
           return res()
         }
index 9b24e4f6296b748174a3950aadf345423ba83fb4..c96e4ef66b37243871a0a60250493ad42b6e71e3 100644 (file)
@@ -1,5 +1,5 @@
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
+import { Validators, ValidatorFn, ValidationErrors, AbstractControl } from '@angular/forms'
 import { Injectable } from '@angular/core'
 import { BuildFormValidator } from './form-validator.service'
 
@@ -13,7 +13,8 @@ export class VideoValidatorsService {
   readonly VIDEO_IMAGE: BuildFormValidator
   readonly VIDEO_CHANNEL: BuildFormValidator
   readonly VIDEO_DESCRIPTION: BuildFormValidator
-  readonly VIDEO_TAGS: BuildFormValidator
+  readonly VIDEO_TAGS_ARRAY: BuildFormValidator
+  readonly VIDEO_TAG: BuildFormValidator
   readonly VIDEO_SUPPORT: BuildFormValidator
   readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator
   readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator
@@ -71,7 +72,7 @@ export class VideoValidatorsService {
       }
     }
 
-    this.VIDEO_TAGS = {
+    this.VIDEO_TAG = {
       VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ],
       MESSAGES: {
         'minlength': this.i18n('A tag should be more than 2 characters long.'),
@@ -79,6 +80,14 @@ export class VideoValidatorsService {
       }
     }
 
+    this.VIDEO_TAGS_ARRAY = {
+      VALIDATORS: [ Validators.maxLength(5), this.arrayTagLengthValidator() ],
+      MESSAGES: {
+        'maxlength': this.i18n('A maximum of 5 tags can be used on a video.'),
+        'arrayTagLength': this.i18n('A tag should be more than 2, and less than 30 characters long.')
+      }
+    }
+
     this.VIDEO_SUPPORT = {
       VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ],
       MESSAGES: {
@@ -99,4 +108,16 @@ export class VideoValidatorsService {
       MESSAGES: {}
     }
   }
+
+  arrayTagLengthValidator (min = 2, max = 30): ValidatorFn {
+    return (control: AbstractControl): ValidationErrors => {
+      const array = control.value as Array<string>
+
+      if (array.every(e => e.length > min && e.length < max)) {
+        return null
+      }
+
+      return { 'arrayTagLength': true }
+    }
+  }
 }
diff --git a/client/src/app/shared/shared-forms/select-channel.component.html b/client/src/app/shared/shared-forms/select-channel.component.html
new file mode 100644 (file)
index 0000000..897d13e
--- /dev/null
@@ -0,0 +1,16 @@
+<ng-select
+  [(ngModel)]="selectedId"
+  (ngModelChange)="onModelChange()"
+  [bindLabel]="bindLabel"
+  [bindValue]="bindValue"
+  [clearable]="clearable"
+  [searchable]="searchable"
+>
+  <ng-option *ngFor="let channel of channels" [value]="channel.id">
+    <img
+      class="avatar mr-1"
+      [src]="channel.avatarPath"
+    />
+    {{ channel.label }}
+  </ng-option>
+</ng-select>
diff --git a/client/src/app/shared/shared-forms/select-channel.component.ts b/client/src/app/shared/shared-forms/select-channel.component.ts
new file mode 100644 (file)
index 0000000..de98c8c
--- /dev/null
@@ -0,0 +1,51 @@
+import { Component, Input, forwardRef, ViewChild } from '@angular/core'
+import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'
+import { Actor } from '../shared-main'
+
+@Component({
+  selector: 'my-select-channel',
+  styleUrls: [ './select-shared.component.scss' ],
+  templateUrl: './select-channel.component.html',
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => SelectChannelComponent),
+      multi: true
+    }
+  ]
+})
+export class SelectChannelComponent implements ControlValueAccessor {
+  @Input() items: { id: number, label: string, support: string, avatarPath?: string }[] = []
+
+  selectedId: number
+
+  // ng-select options
+  bindLabel = 'label'
+  bindValue = 'id'
+  clearable = false
+  searchable = false
+
+  get channels () {
+    return this.items.map(c => Object.assign(c, {
+      avatarPath: c.avatarPath ? c.avatarPath : Actor.GET_DEFAULT_AVATAR_URL()
+    }))
+  }
+
+  propagateChange = (_: any) => { /* empty */ }
+
+  writeValue (id: number) {
+    this.selectedId = id
+  }
+
+  registerOnChange (fn: (_: any) => void) {
+    this.propagateChange = fn
+  }
+
+  registerOnTouched () {
+    // Unused
+  }
+
+  onModelChange () {
+    this.propagateChange(this.selectedId)
+  }
+}
diff --git a/client/src/app/shared/shared-forms/select-options.component.html b/client/src/app/shared/shared-forms/select-options.component.html
new file mode 100644 (file)
index 0000000..fda0c2c
--- /dev/null
@@ -0,0 +1,18 @@
+<ng-select
+  [items]="items"
+  [groupBy]="groupBy"
+  [(ngModel)]="selectedId"
+  (ngModelChange)="onModelChange()"
+  [bindLabel]="bindLabel"
+  [bindValue]="bindValue"
+  [clearable]="clearable"
+  [searchable]="searchable"
+>
+  <ng-template ng-option-tmp let-item="item" let-index="index">
+    {{ item.label }}
+    <ng-container *ngIf="item.description">
+      <br>
+      <span [title]="item.description" class="text-muted">{{ item.description }}</span>
+    </ng-container>
+  </ng-template>
+</ng-select>
diff --git a/client/src/app/shared/shared-forms/select-options.component.ts b/client/src/app/shared/shared-forms/select-options.component.ts
new file mode 100644 (file)
index 0000000..09f7df5
--- /dev/null
@@ -0,0 +1,47 @@
+import { Component, Input, forwardRef } from '@angular/core'
+import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'
+
+export type SelectOptionsItem = { id: number | string, label: string, description?: string }
+
+@Component({
+  selector: 'my-select-options',
+  styleUrls: [ './select-shared.component.scss' ],
+  templateUrl: './select-options.component.html',
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => SelectOptionsComponent),
+      multi: true
+    }
+  ]
+})
+export class SelectOptionsComponent implements ControlValueAccessor {
+  @Input() items: SelectOptionsItem[] = []
+  @Input() clearable = false
+  @Input() searchable = false
+  @Input() bindValue = 'id'
+  @Input() groupBy: string
+
+  selectedId: number | string
+
+  // ng-select options
+  bindLabel = 'label'
+
+  propagateChange = (_: any) => { /* empty */ }
+
+  writeValue (id: number | string) {
+    this.selectedId = id
+  }
+
+  registerOnChange (fn: (_: any) => void) {
+    this.propagateChange = fn
+  }
+
+  registerOnTouched () {
+    // Unused
+  }
+
+  onModelChange () {
+    this.propagateChange(this.selectedId)
+  }
+}
diff --git a/client/src/app/shared/shared-forms/select-shared.component.scss b/client/src/app/shared/shared-forms/select-shared.component.scss
new file mode 100644 (file)
index 0000000..4f231d2
--- /dev/null
@@ -0,0 +1,20 @@
+$width-size: auto;
+
+ng-select {
+  width: $width-size;
+  @media screen and (max-width: $width-size) {
+    width: 100%;
+  }
+}
+
+// make sure the image is vertically adjusted
+ng-select ::ng-deep .ng-value-label img {
+  position: relative;
+  top: -1px;
+}
+
+ng-select ::ng-deep img {
+  border-radius: 50%;
+  height: 20px;
+  width: 20px;
+}
diff --git a/client/src/app/shared/shared-forms/select-tags.component.html b/client/src/app/shared/shared-forms/select-tags.component.html
new file mode 100644 (file)
index 0000000..0609c9d
--- /dev/null
@@ -0,0 +1,13 @@
+<ng-select
+  [items]="items"
+  [(ngModel)]="_items"
+  (ngModelChange)="onModelChange()"
+  i18n-placeholder placeholder="Enter a new tag"
+  [maxSelectedItems]="5"
+  [clearable]="true"
+  [addTag]="true"
+  [multiple]="true"
+  [isOpen]="false"
+  [searchable]="true"
+>
+</ng-select>
diff --git a/client/src/app/shared/shared-forms/select-tags.component.scss b/client/src/app/shared/shared-forms/select-tags.component.scss
new file mode 100644 (file)
index 0000000..ad76bc7
--- /dev/null
@@ -0,0 +1,3 @@
+ng-select ::ng-deep .ng-arrow-wrapper {
+  display: none;
+}
diff --git a/client/src/app/shared/shared-forms/select-tags.component.ts b/client/src/app/shared/shared-forms/select-tags.component.ts
new file mode 100644 (file)
index 0000000..2e07d7e
--- /dev/null
@@ -0,0 +1,38 @@
+import { Component, Input, forwardRef } from '@angular/core'
+import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'
+
+@Component({
+  selector: 'my-select-tags',
+  styleUrls: [ './select-shared.component.scss', './select-tags.component.scss' ],
+  templateUrl: './select-tags.component.html',
+  providers: [
+    {
+      provide: NG_VALUE_ACCESSOR,
+      useExisting: forwardRef(() => SelectTagsComponent),
+      multi: true
+    }
+  ]
+})
+export class SelectTagsComponent implements ControlValueAccessor {
+  @Input() items: string[] = []
+  @Input() _items: string[] = []
+
+  propagateChange = (_: any) => { /* empty */ }
+
+  writeValue (items: string[]) {
+    this._items = items
+    this.propagateChange(this._items)
+  }
+
+  registerOnChange (fn: (_: any) => void) {
+    this.propagateChange = fn
+  }
+
+  registerOnTouched () {
+    // Unused
+  }
+
+  onModelChange () {
+    this.propagateChange(this._items)
+  }
+}
index ba33704cf23c9c27fa8fd4a59061481619448d12..19d812948661b609895084130bae4c1f1906f527 100644 (file)
@@ -28,6 +28,9 @@ import { PreviewUploadComponent } from './preview-upload.component'
 import { ReactiveFileComponent } from './reactive-file.component'
 import { TextareaAutoResizeDirective } from './textarea-autoresize.directive'
 import { TimestampInputComponent } from './timestamp-input.component'
+import { SelectChannelComponent } from './select-channel.component'
+import { SelectOptionsComponent } from './select-options.component'
+import { SelectTagsComponent } from './select-tags.component'
 
 @NgModule({
   imports: [
@@ -45,7 +48,10 @@ import { TimestampInputComponent } from './timestamp-input.component'
     PreviewUploadComponent,
     ReactiveFileComponent,
     TextareaAutoResizeDirective,
-    TimestampInputComponent
+    TimestampInputComponent,
+    SelectChannelComponent,
+    SelectOptionsComponent,
+    SelectTagsComponent
   ],
 
   exports: [
@@ -58,7 +64,10 @@ import { TimestampInputComponent } from './timestamp-input.component'
     PreviewUploadComponent,
     ReactiveFileComponent,
     TextareaAutoResizeDirective,
-    TimestampInputComponent
+    TimestampInputComponent,
+    SelectChannelComponent,
+    SelectOptionsComponent,
+    SelectTagsComponent
   ],
 
   providers: [
index 22a207e51009fe3e7b031f18b319138dd66fb731..a4d18d562a14aa985582923c211a5ab6ed4e0a2a 100644 (file)
@@ -17,6 +17,7 @@ import {
   NgbPopoverModule,
   NgbTooltipModule
 } from '@ng-bootstrap/ng-bootstrap'
+import { NgSelectModule } from '@ng-select/ng-select'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { SharedGlobalIconModule } from '../shared-icons'
 import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account'
@@ -55,6 +56,8 @@ import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
     MultiSelectModule,
     InputSwitchModule,
 
+    NgSelectModule,
+
     SharedGlobalIconModule
   ],
 
@@ -134,7 +137,9 @@ import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
     TopMenuDropdownComponent,
 
     UserQuotaComponent,
-    UserNotificationsComponent
+    UserNotificationsComponent,
+
+    NgSelectModule
   ],
 
   providers: [
index edaefa9f21c2b780939680941db7df374c4133fa..978f775bf9af426c127609dcaecf55de7b75e8ff 100644 (file)
@@ -339,23 +339,25 @@ export class VideoService implements VideosProvider {
     const base = [
       {
         id: VideoPrivacy.PRIVATE,
-        label: this.i18n('Only I can see this video')
+        description: this.i18n('Only I can see this video')
       },
       {
         id: VideoPrivacy.UNLISTED,
-        label: this.i18n('Only people with the private link can see this video')
+        description: this.i18n('Only shareable via a private link')
       },
       {
         id: VideoPrivacy.PUBLIC,
-        label: this.i18n('Anyone can see this video')
+        description: this.i18n('Anyone can see this video')
       },
       {
         id: VideoPrivacy.INTERNAL,
-        label: this.i18n('Only users of this instance can see this video')
+        description: this.i18n('Only users of this instance can see this video')
       }
     ]
 
-    return base.filter(o => !!privacies.find(p => p.id === o.id))
+    return base
+      .filter(o => !!privacies.find(p => p.id === o.id)) // filter down to privacies that where in the input
+      .map(o => ({ ...privacies[o.id - 1], ...o })) // merge the input privacies that contain a label, and extend them with a description
   }
 
   nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) {
index 30960393f2ccc4e17e47774e8d4690f9429b2f52..d2811d24a0f68b3bff35e7c6237b0f3720f360e7 100644 (file)
@@ -13,6 +13,7 @@ $assets-path: '../../assets/';
 
 @import './bootstrap';
 @import './primeng-custom';
+@import './ng-select.scss';
 
 [hidden] {
   display: none !important;
index e4c2dffa0f0e83794621bb9055ce9a5772b4967f..ae2b99a5b5f21bc98e6141004843e8db99ad8468 100644 (file)
   }
 }
 
-@mixin ng2-tags {
-  ::ng-deep {
-    .ng2-tag-input {
-      border: none !important;
-    }
-
-    .ng2-tags-container {
-      display: flex;
-      align-items: center;
-      border: 1px solid #C6C6C6;
-      border-radius: 3px;
-      padding: 5px !important;
-      height: max-content;
-
-      &:focus-within {
-        box-shadow: #{$focus-box-shadow-form} pvar(--mainColorLightest);
-      }
-    }
-
-    tag-input-form {
-      input {
-        height: 30px !important;
-        font-size: 12px !important;
-
-        background-color: pvar(--mainBackgroundColor) !important;
-        color: pvar(--mainForegroundColor) !important;
-      }
-    }
-
-    tag {
-      background-color: $grey-background-color !important;
-      color: #000 !important;
-      border-radius: 3px !important;
-      font-size: 12px !important;
-      height: 30px !important;
-      line-height: 30px !important;
-      margin: 0 5px 0 0 !important;
-      cursor: default !important;
-      padding: 0 8px 0 10px !important;
-
-      div {
-        height: 100% !important;
-      }
-    }
-
-    delete-icon {
-      cursor: pointer !important;
-      height: auto !important;
-      vertical-align: middle !important;
-      padding-left: 6px !important;
-
-      svg {
-        position: relative;
-        top: -1px;
-        height: auto !important;
-        vertical-align: middle !important;
-
-        path  {
-          fill: pvar(--greyForegroundColor) !important;
-        }
-      }
-
-      &:hover {
-        transform: none !important;
-      }
-    }
-  }
-}
-
 @mixin divider($color: pvar(--submenuColor), $background: pvar(--mainBackgroundColor)) {
   width: 95%;
   border-top: .05rem solid $color;
diff --git a/client/src/sass/ng-select.scss b/client/src/sass/ng-select.scss
new file mode 100644 (file)
index 0000000..f836e20
--- /dev/null
@@ -0,0 +1,29 @@
+@import '_variables';
+
+$ng-select-highlight: #f2690d;
+// $ng-select-primary-text: #333 !default;
+// $ng-select-disabled-text: #f9f9f9 !default;
+// $ng-select-border: #ccc !default;
+// $ng-select-border-radius: 4px !default;
+// $ng-select-bg: #ffffff !default;
+// $ng-select-selected: lighten($ng-select-highlight, 46) !default;
+// $ng-select-marke d: lighten($ng-select-highlight, 48) !default;
+$ng-select-box-shadow: #{$focus-box-shadow-form} pvar(--mainColorLightest);
+// $ng-select-placeholder: lighten($ng-select-primary-text, 40) !default;
+$ng-select-height: 30px;
+// $ng-select-value-padding-left: 10px !default;
+// $ng-select-value-font-size: 0.9em !default;
+
+@import "~@ng-select/ng-select/scss/default.theme.scss";
+
+.ng-input {
+  font-size: .9em;
+}
+
+.ng-select {
+  &.ng-select-focused {
+    &:not(.ng-select-opened) > .ng-select-container {
+      border-color: #ccc !important;
+    }
+  }
+}
index 843ca4f104e6cb3edc399bcc797020d624514208..38aea725e487d6ccdb7364e1fad5d79bd50b5b88 100644 (file)
   dependencies:
     tslib "^2.0.0"
 
+"@ng-select/ng-select@^5.0.0":
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/@ng-select/ng-select/-/ng-select-5.0.0.tgz#db021e5ca7be3fe6be346c3b63b3f2e109afa0f0"
+  integrity sha512-1mdGNh5xGriSnCLcLF/GWxqCgCehB3Stu7mxhB9K/+BxNCRE365Q1MgpzO61XQBgL0L6ktxiXRhCQWXXtFoq9w==
+  dependencies:
+    tslib "^2.0.0"
+
 "@ngtools/webpack@10.1.0-next.4":
   version "10.1.0-next.4"
   resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-10.1.0-next.4.tgz#5397417820d110e29c7dc99db8adb538de98676e"
@@ -7866,21 +7873,6 @@ next-tick@~1.0.0:
   resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
   integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
 
-ng2-material-dropdown@0.11.0:
-  version "0.11.0"
-  resolved "https://registry.yarnpkg.com/ng2-material-dropdown/-/ng2-material-dropdown-0.11.0.tgz#27a402ef3cbdcaf6791ef4cfd4b257e31db7546f"
-  integrity sha512-wptBo09qKecY0QPTProAThrc4A3ajJTcHE9LTpCG5XZZUhXLBzhnGK8OW33TN8A+K/jqcs7OB74ppYJiqs3nhQ==
-  dependencies:
-    tslib "^1.9.0"
-
-ngx-chips@2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/ngx-chips/-/ngx-chips-2.1.0.tgz#aa299bcf40dc3e1f6288bf1d29e2fdfe9a132ed3"
-  integrity sha512-OQV4dTfD3nXm5d2mGKUSgwOtJOaMnZ4F+lwXOtd7DWRSUne0JQWwoZNHdOpuS6saBGhqCPDAwq6KxdR5XSgZUQ==
-  dependencies:
-    ng2-material-dropdown "0.11.0"
-    tslib "^1.9.0"
-
 ngx-pipes@^2.6.0:
   version "2.7.5"
   resolved "https://registry.yarnpkg.com/ngx-pipes/-/ngx-pipes-2.7.5.tgz#22e2e4b7015ae9103210dfa2dacd6f4ae4411639"
index 342a7c0cf641d65f7711db0dffce7413ba364c34..353a2953548a0416228d697e07229b53b33217b2 100644 (file)
@@ -1,4 +1,5 @@
 export interface VideoConstant<T> {
   id: T
   label: string
+  description?: string
 }