Browse Source

harmonize search for libraries

Rigel Kent 3 years ago
parent
commit
4f5d045960
32 changed files with 295 additions and 102 deletions
  1. 4 2
      client/src/app/+admin/users/user-list/user-list.component.html
  2. 12 5
      client/src/app/+admin/users/user-list/user-list.component.ts
  3. 13 6
      client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.html
  4. 4 5
      client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.scss
  5. 24 2
      client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.ts
  6. 3 3
      client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html
  7. 17 1
      client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts
  8. 5 1
      client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html
  9. 4 0
      client/src/app/+my-account/my-account-ownership/my-account-ownership.component.scss
  10. 12 1
      client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts
  11. 12 3
      client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.html
  12. 4 0
      client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss
  13. 32 11
      client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts
  14. 6 1
      client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html
  15. 5 1
      client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.scss
  16. 13 0
      client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts
  17. 11 8
      client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html
  18. 4 10
      client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss
  19. 5 0
      client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts
  20. 12 7
      client/src/app/+my-account/my-account-videos/my-account-videos.component.html
  21. 2 8
      client/src/app/+my-account/my-account-videos/my-account-videos.component.scss
  22. 6 2
      client/src/app/+my-account/my-account-videos/my-account-videos.component.ts
  23. 5 5
      client/src/app/core/users/user.service.ts
  24. 8 3
      client/src/app/shared/shared-user-subscription/user-subscription.service.ts
  25. 1 0
      client/src/sass/bootstrap.scss
  26. 1 2
      client/src/sass/include/_mixins.scss
  27. 2 1
      server/controllers/api/accounts.ts
  28. 9 2
      server/controllers/api/users/my-subscriptions.ts
  29. 1 2
      server/controllers/api/video-channel.ts
  30. 13 0
      server/middlewares/validators/user-subscriptions.ts
  31. 25 6
      server/models/activitypub/actor-follow.ts
  32. 20 4
      server/models/video/video-channel.ts

+ 4 - 2
client/src/app/+admin/users/user-list/user-list.component.html

@@ -112,8 +112,10 @@
         </a>
       </td>
 
-      <td *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus" [title]="user.email">
-        <a class="table-email" [href]="'mailto:' + user.email">{{ user.email }}</a>
+      <td *ngIf="getColumn('email')" [title]="user.email">
+        <ng-container *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus">
+          <a class="table-email" [href]="'mailto:' + user.email">{{ user.email }}</a>
+        </ng-container>
       </td>
 
       <ng-template #emailWithVerificationStatus>

+ 12 - 5
client/src/app/+admin/users/user-list/user-list.component.ts

@@ -7,6 +7,13 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
 import { ServerConfig, User, UserRole } from '@shared/models'
 import { Params, Router, ActivatedRoute } from '@angular/router'
 
+type UserForList = User & {
+  rawVideoQuota: number
+  rawVideoQuotaUsed: number
+  rawVideoQuotaDaily: number
+  rawVideoQuotaUsedDaily: number
+}
+
 @Component({
   selector: 'my-user-list',
   templateUrl: './user-list.component.html',
@@ -24,8 +31,8 @@ export class UserListComponent extends RestTable implements OnInit {
   selectedUsers: User[] = []
   bulkUserActions: DropdownAction<User[]>[][] = []
   columns: { key: string, label: string }[]
-  _selectedColumns: { key: string, label: string }[]
 
+  private _selectedColumns: { key: string, label: string }[]
   private serverConfig: ServerConfig
 
   constructor (
@@ -111,7 +118,7 @@ export class UserListComponent extends RestTable implements OnInit {
       { key: 'role', label: 'Role' },
       { key: 'createdAt', label: 'Created' }
     ]
-    this.selectedColumns = [...this.columns]
+    this.selectedColumns = [ ...this.columns ] // make a full copy of the array
     this.columns.push({ key: 'quotaDaily', label: 'Daily quota' })
     this.columns.push({ key: 'pluginAuth', label: 'Auth plugin' })
     this.columns.push({ key: 'lastLoginDate', label: 'Last login' })
@@ -133,14 +140,14 @@ export class UserListComponent extends RestTable implements OnInit {
   }
 
   getColumn (key: string) {
-    return this.selectedColumns.find((col: any) => col.key === key)
+    return this.selectedColumns.find((col: { key: string }) => col.key === key)
   }
 
-  getUserVideoQuotaPercentage (user: User & { rawVideoQuota: number, rawVideoQuotaUsed: number}) {
+  getUserVideoQuotaPercentage (user: UserForList) {
     return user.rawVideoQuotaUsed * 100 / user.rawVideoQuota
   }
 
-  getUserVideoQuotaDailyPercentage (user: User & { rawVideoQuotaDaily: number, rawVideoQuotaUsedDaily: number}) {
+  getUserVideoQuotaDailyPercentage (user: UserForList) {
     return user.rawVideoQuotaUsedDaily * 100 / user.rawVideoQuotaDaily
   }
 

+ 13 - 6
client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.html

@@ -1,14 +1,21 @@
-<h1>
-  <my-global-icon iconName="channel" aria-hidden="true"></my-global-icon>
-  <ng-container i18n>My channels</ng-container>
-</h1>
+<h1 class="d-flex justify-content-between">
+  <span>
+    <my-global-icon iconName="channel" aria-hidden="true"></my-global-icon>
+    <ng-container i18n>My channels</ng-container>
+    <span class="badge badge-secondary">{{ totalItems }}</span>
+  </span>
+
+  <div class="has-feedback has-clear">
+    <input type="text" placeholder="Search your channels" i18n-placeholder [(ngModel)]="channelsSearch" (ngModelChange)="onChannelsSearchChanged()" />
+    <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
+    <span class="sr-only" i18n>Clear filters</span>
+  </div>
 
-<div class="video-channels-header">
   <a class="create-button" routerLink="create">
     <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
     <ng-container i18n>Create video channel</ng-container>
   </a>
-</div>
+</h1>
 
 <div class="video-channels">
   <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">

+ 4 - 5
client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.scss

@@ -5,6 +5,10 @@
   @include create-button;
 }
 
+input[type=text] {
+  @include peertube-input-text(300px);
+}
+
 ::ng-deep .action-button {
   &.action-button-edit {
     margin-right: 10px;
@@ -55,11 +59,6 @@
   }
 }
 
-.video-channels-header {
-  text-align: right;
-  margin: 20px 0 50px;
-}
-
 ::ng-deep .chartjs-render-monitor {
   position: relative;
   top: 1px;

+ 24 - 2
client/src/app/+my-account/+my-account-video-channels/my-account-video-channels.component.ts

@@ -1,10 +1,11 @@
 import { ChartData } from 'chart.js'
 import { max, maxBy, min, minBy } from 'lodash-es'
-import { flatMap } from 'rxjs/operators'
+import { flatMap, debounceTime } from 'rxjs/operators'
 import { Component, OnInit } from '@angular/core'
 import { AuthService, ConfirmService, Notifier, ScreenService, User } from '@app/core'
 import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
 import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Subject } from 'rxjs'
 
 @Component({
   selector: 'my-account-video-channels',
@@ -12,11 +13,16 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
   styleUrls: [ './my-account-video-channels.component.scss' ]
 })
 export class MyAccountVideoChannelsComponent implements OnInit {
+  totalItems: number
+
   videoChannels: VideoChannel[] = []
   videoChannelsChartData: ChartData[]
   videoChannelsMinimumDailyViews = 0
   videoChannelsMaximumDailyViews: number
 
+  channelsSearch: string
+  channelsSearchChanged = new Subject<string>()
+
   private user: User
 
   constructor (
@@ -32,6 +38,12 @@ export class MyAccountVideoChannelsComponent implements OnInit {
     this.user = this.authService.getUser()
 
     this.loadVideoChannels()
+
+    this.channelsSearchChanged
+      .pipe(debounceTime(500))
+      .subscribe(() => {
+        this.loadVideoChannels()
+      })
   }
 
   get isInSmallView () {
@@ -87,6 +99,15 @@ export class MyAccountVideoChannelsComponent implements OnInit {
     }
   }
 
+  resetSearch() {
+    this.channelsSearch = ''
+    this.onChannelsSearchChanged()
+  }
+
+  onChannelsSearchChanged () {
+    this.channelsSearchChanged.next()
+  }
+
   async deleteVideoChannel (videoChannel: VideoChannel) {
     const res = await this.confirmService.confirmWithInput(
       this.i18n(
@@ -118,9 +139,10 @@ export class MyAccountVideoChannelsComponent implements OnInit {
 
   private loadVideoChannels () {
     this.authService.userInformationLoaded
-        .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true)))
+        .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true, this.channelsSearch)))
         .subscribe(res => {
           this.videoChannels = res.data
+          this.totalItems = res.total
 
           // chart data
           this.videoChannelsChartData = this.videoChannels.map(v => ({

+ 3 - 3
client/src/app/+my-account/my-account-notifications/my-account-notifications.component.html

@@ -6,10 +6,10 @@
   </a>
 
   <div class="peertube-select-container peertube-select-button ml-2">
-    <select [(ngModel)]="notificationSortType" class="form-control">
+    <select [(ngModel)]="notificationSortType" (ngModelChange)="onChangeSortColumn()" class="form-control">
       <option value="undefined" disabled>Sort by</option>
-      <option value="created" i18n>Newest first</option>
-      <option value="unread-created" i18n>Unread first</option>
+      <option value="createdAt" i18n>Newest first</option>
+      <option value="read" [disabled]="!hasUnreadNotifications()" i18n>Unread first</option>
     </select>
   </div>
 

+ 17 - 1
client/src/app/+my-account/my-account-notifications/my-account-notifications.component.ts

@@ -1,6 +1,8 @@
 import { Component, ViewChild } from '@angular/core'
 import { UserNotificationsComponent } from '@app/shared/shared-main'
 
+type NotificationSortType = 'createdAt' | 'read'
+
 @Component({
   templateUrl: './my-account-notifications.component.html',
   styleUrls: [ './my-account-notifications.component.scss' ]
@@ -8,7 +10,17 @@ import { UserNotificationsComponent } from '@app/shared/shared-main'
 export class MyAccountNotificationsComponent {
   @ViewChild('userNotification', { static: true }) userNotification: UserNotificationsComponent
 
-  notificationSortType = 'created'
+  _notificationSortType: NotificationSortType = 'createdAt'
+
+  get notificationSortType () {
+    return !this.hasUnreadNotifications()
+      ? 'createdAt'
+      : this._notificationSortType
+  }
+
+  set notificationSortType (type: NotificationSortType) {
+    this._notificationSortType = type
+  }
 
   markAllAsRead () {
     this.userNotification.markAllAsRead()
@@ -17,4 +29,8 @@ export class MyAccountNotificationsComponent {
   hasUnreadNotifications () {
     return this.userNotification.notifications.filter(n => n.read === false).length !== 0
   }
+
+  onChangeSortColumn () {
+    this.userNotification.changeSortColumn(this.notificationSortType)
+  }
 }

+ 5 - 1
client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html

@@ -62,7 +62,11 @@
       </td>
 
       <td>{{ videoChangeOwnership.createdAt | date: 'short' }}</td>
-      <td i18n>{{ videoChangeOwnership.status }}</td>
+
+      <td>
+        <span class="badge" [ngClass]="getStatusClass(videoChangeOwnership.status)">{{ videoChangeOwnership.status }}</span>
+      </td>
+
       <td class="action-cell">
         <ng-container *ngIf="videoChangeOwnership.status === 'WAITING'">
           <my-button i18n-label label="Accept" icon="tick" (click)="openAcceptModal(videoChangeOwnership)"></my-button>

+ 4 - 0
client/src/app/+my-account/my-account-ownership/my-account-ownership.component.scss

@@ -5,6 +5,10 @@
   @include chip;
 }
 
+.badge {
+  @include table-badge;
+}
+
 .video-table-video {
   display: inline-flex;
 

+ 12 - 1
client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts

@@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
 import { Component, OnInit, ViewChild } from '@angular/core'
 import { Notifier, RestPagination, RestTable } from '@app/core'
 import { VideoOwnershipService, Actor, Video, Account } from '@app/shared/shared-main'
-import { VideoChangeOwnership } from '@shared/models'
+import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '@shared/models'
 import { MyAccountAcceptOwnershipComponent } from './my-account-accept-ownership/my-account-accept-ownership.component'
 import { getAbsoluteAPIUrl } from '@app/helpers'
 
@@ -34,6 +34,17 @@ export class MyAccountOwnershipComponent extends RestTable implements OnInit {
     return 'MyAccountOwnershipComponent'
   }
 
+  getStatusClass (status: VideoChangeOwnershipStatus) {
+    switch (status) {
+      case VideoChangeOwnershipStatus.ACCEPTED:
+        return 'badge-green'
+      case VideoChangeOwnershipStatus.REFUSED:
+        return 'badge-red'
+      default:
+        return 'badge-yellow'
+    }
+  }
+
   switchToDefaultAvatar ($event: Event) {
     ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
   }

+ 12 - 3
client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.html

@@ -1,6 +1,15 @@
-<h1>
-  <my-global-icon iconName="subscriptions" aria-hidden="true"></my-global-icon>
-  <ng-container i18n>My subscriptions</ng-container>
+<h1 class="d-flex justify-content-between">
+  <span>
+    <my-global-icon iconName="subscriptions" aria-hidden="true"></my-global-icon>
+    <ng-container i18n>My subscriptions</ng-container>
+    <span class="badge badge-secondary"> {{ pagination.totalItems }}</span>
+  </span>
+
+  <div class="has-feedback has-clear">
+    <input type="text" placeholder="Search your subscriptions" i18n-placeholder [(ngModel)]="subscriptionsSearch" (ngModelChange)="onSubscriptionsSearchChanged()" />
+    <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
+    <span class="sr-only" i18n>Clear filters</span>
+  </div>
 </h1>
 
 <div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscriptions yet.</div>

+ 4 - 0
client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss

@@ -1,6 +1,10 @@
 @import '_variables';
 @import '_mixins';
 
+input[type=text] {
+  @include peertube-input-text(300px);
+}
+
 .video-channel {
   @include row-blocks;
 

+ 32 - 11
client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts

@@ -3,6 +3,7 @@ import { Component, OnInit } from '@angular/core'
 import { ComponentPagination, Notifier } from '@app/core'
 import { VideoChannel } from '@app/shared/shared-main'
 import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
+import { debounceTime } from 'rxjs/operators'
 
 @Component({
   selector: 'my-account-subscriptions',
@@ -20,6 +21,9 @@ export class MyAccountSubscriptionsComponent implements OnInit {
 
   onDataSubject = new Subject<any[]>()
 
+  subscriptionsSearch: string
+  subscriptionsSearchChanged = new Subject<string>()
+
   constructor (
     private userSubscriptionService: UserSubscriptionService,
     private notifier: Notifier
@@ -27,20 +31,22 @@ export class MyAccountSubscriptionsComponent implements OnInit {
 
   ngOnInit () {
     this.loadSubscriptions()
-  }
 
-  loadSubscriptions () {
-    this.userSubscriptionService.listSubscriptions(this.pagination)
-        .subscribe(
-          res => {
-            this.videoChannels = this.videoChannels.concat(res.data)
-            this.pagination.totalItems = res.total
+    this.subscriptionsSearchChanged
+      .pipe(debounceTime(500))
+      .subscribe(() => {
+        this.pagination.currentPage = 1
+        this.loadSubscriptions(false)
+      })
+  }
 
-            this.onDataSubject.next(res.data)
-          },
+  resetSearch () {
+    this.subscriptionsSearch = ''
+    this.onSubscriptionsSearchChanged()
+  }
 
-          error => this.notifier.error(error.message)
-        )
+  onSubscriptionsSearchChanged () {
+    this.subscriptionsSearchChanged.next()
   }
 
   onNearOfBottom () {
@@ -51,4 +57,19 @@ export class MyAccountSubscriptionsComponent implements OnInit {
     this.loadSubscriptions()
   }
 
+  private loadSubscriptions (more = true) {
+    this.userSubscriptionService.listSubscriptions({ pagination: this.pagination, search: this.subscriptionsSearch })
+        .subscribe(
+          res => {
+            this.videoChannels = more
+              ? this.videoChannels.concat(res.data)
+              : res.data
+            this.pagination.totalItems = res.total
+
+            this.onDataSubject.next(res.data)
+          },
+
+          error => this.notifier.error(error.message)
+        )
+  }
 }

+ 6 - 1
client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html

@@ -45,7 +45,12 @@
         <ng-container *ngIf="isVideoImportFailed(videoImport)"></ng-container>
       </td>
 
-      <td>{{ videoImport.state.label }}</td>
+      <td>
+        <span class="badge" [ngClass]="getVideoImportStateClass(videoImport.state)">
+          {{ videoImport.state.label }}
+        </span>
+      </td>
+
       <td>{{ videoImport.createdAt | date: 'short' }}</td>
 
       <td class="action-cell">

+ 5 - 1
client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.scss

@@ -7,4 +7,8 @@ pre {
 
 .video-import-error {
   color: red;
-}
+}
+
+.badge {
+  @include table-badge;
+}

+ 13 - 0
client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts

@@ -30,6 +30,19 @@ export class MyAccountVideoImportsComponent extends RestTable implements OnInit
     return 'MyAccountVideoImportsComponent'
   }
 
+  getVideoImportStateClass (state: VideoImportState) {
+    switch (state) {
+      case VideoImportState.FAILED:
+        return 'badge-red'
+      case VideoImportState.REJECTED:
+        return 'badge-banned'
+      case VideoImportState.PENDING:
+        return 'badge-yellow'
+      default:
+        return 'badge-green'
+    }
+  }
+
   isVideoImportSuccess (videoImport: VideoImport) {
     return videoImport.state.id === VideoImportState.SUCCESS
   }

+ 11 - 8
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html

@@ -1,17 +1,20 @@
-<h1>
-  <my-global-icon iconName="playlists" aria-hidden="true"></my-global-icon>
-  <ng-container i18n>My playlists</ng-container> <span class="badge badge-secondary">{{ pagination.totalItems }}</span>
-</h1>
-
+<h1 class="d-flex justify-content-between">
+  <span>
+    <my-global-icon iconName="playlists" aria-hidden="true"></my-global-icon>
+    <ng-container i18n>My playlists</ng-container> <span class="badge badge-secondary">{{ pagination.totalItems }}</span>
+  </span>
 
-<div class="video-playlists-header">
-  <input type="text" placeholder="Search your playlists" i18n-placeholder [(ngModel)]="videoPlaylistsSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" />
+  <div class="has-feedback has-clear">
+    <input type="text" placeholder="Search your playlists" i18n-placeholder [(ngModel)]="videoPlaylistsSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" />
+    <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
+    <span class="sr-only" i18n>Clear filters</span>
+  </div>
 
   <a class="create-button" routerLink="create">
     <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
     <ng-container i18n>Create playlist</ng-container>
   </a>
-</div>
+</h1>
 
 <div class="video-playlists" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
   <div *ngFor="let playlist of videoPlaylists" class="video-playlist">

+ 4 - 10
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss

@@ -5,6 +5,10 @@
   @include create-button;
 }
 
+input[type=text] {
+  @include peertube-input-text(300px);
+}
+
 ::ng-deep .action-button {
   &.action-button-delete {
     margin-right: 10px;
@@ -33,16 +37,6 @@
   }
 }
 
-.video-playlists-header {
-  display: flex;
-  justify-content: space-between;
-  margin: 20px 0 50px;
-
-  input[type=text] {
-    @include peertube-input-text(300px);
-  }
-}
-
 @media screen and (max-width: $small-view) {
   .video-playlists-header {
     text-align: center;

+ 5 - 0
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts

@@ -84,6 +84,11 @@ export class MyAccountVideoPlaylistsComponent implements OnInit {
     this.loadVideoPlaylists()
   }
 
+  resetSearch () {
+    this.videoPlaylistsSearch = ''
+    this.onVideoPlaylistSearchChanged()
+  }
+
   onVideoPlaylistSearchChanged () {
     this.videoPlaylistSearchChanged.next()
   }

+ 12 - 7
client/src/app/+my-account/my-account-videos/my-account-videos.component.html

@@ -1,11 +1,16 @@
-<h1>
-  <my-global-icon iconName="videos" aria-hidden="true"></my-global-icon>
-  <ng-container i18n>My videos</ng-container><span class="badge badge-secondary"> {{ pagination.totalItems }}</span>
-</h1>
+<h1 class="d-flex justify-content-between">
+  <span>
+    <my-global-icon iconName="videos" aria-hidden="true"></my-global-icon>
+    <ng-container i18n>My videos</ng-container>
+    <span class="badge badge-secondary"> {{ pagination.totalItems }}</span>
+  </span>
 
-<div class="videos-header">
-  <input type="text" placeholder="Search your videos" i18n-placeholder [(ngModel)]="videosSearch" (ngModelChange)="onVideosSearchChanged()" />
-</div>
+  <div class="has-feedback has-clear">
+    <input type="text" placeholder="Search your videos" i18n-placeholder [(ngModel)]="videosSearch" (ngModelChange)="onVideosSearchChanged()" />
+    <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
+    <span class="sr-only" i18n>Clear filters</span>
+  </div>
+</h1>
 
 <my-videos-selection
   [pagination]="pagination"

+ 2 - 8
client/src/app/+my-account/my-account-videos/my-account-videos.component.scss

@@ -1,14 +1,8 @@
 @import '_variables';
 @import '_mixins';
 
-.videos-header {
-  display: flex;
-  justify-content: space-between;
-  margin: 20px 0 50px;
-
-  input[type=text] {
-    @include peertube-input-text(300px);
-  }
+input[type=text] {
+  @include peertube-input-text(300px);
 }
 
 .action-button-delete-selection {

+ 6 - 2
client/src/app/+my-account/my-account-videos/my-account-videos.component.ts

@@ -59,13 +59,17 @@ export class MyAccountVideosComponent implements OnInit, DisableForReuseHook {
 
   ngOnInit () {
     this.videosSearchChanged
-      .pipe(
-        debounceTime(500))
+      .pipe(debounceTime(500))
       .subscribe(() => {
         this.videosSelection.reloadVideos()
       })
   }
 
+  resetSearch () {
+    this.videosSearch = ''
+    this.onVideosSearchChanged()
+  }
+
   onVideosSearchChanged () {
     this.videosSearchChanged.next()
   }

+ 5 - 5
client/src/app/core/users/user.service.ts

@@ -381,14 +381,14 @@ export class UserService {
 
     const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
 
-    let videoQuotaDaily
-    let videoQuotaUsedDaily
+    let videoQuotaDaily: string
+    let videoQuotaUsedDaily: string
     if (user.videoQuotaDaily === -1) {
       videoQuotaDaily = '∞'
-      videoQuotaUsedDaily = this.bytesPipe.transform(0, 0)
+      videoQuotaUsedDaily = this.bytesPipe.transform(0, 0) + ''
     } else {
-      videoQuotaDaily = this.bytesPipe.transform(user.videoQuotaDaily, 0)
-      videoQuotaUsedDaily = this.bytesPipe.transform(user.videoQuotaUsedDaily || 0, 0)
+      videoQuotaDaily = this.bytesPipe.transform(user.videoQuotaDaily, 0) + ''
+      videoQuotaUsedDaily = this.bytesPipe.transform(user.videoQuotaUsedDaily || 0, 0) + ''
     }
 
     const roleLabels: { [ id in UserRole ]: string } = {

+ 8 - 3
client/src/app/shared/shared-user-subscription/user-subscription.service.ts

@@ -105,13 +105,18 @@ export class UserSubscriptionService {
                )
   }
 
-  listSubscriptions (componentPagination: ComponentPaginationLight): Observable<ResultList<VideoChannel>> {
+  listSubscriptions (parameters: {
+    pagination: ComponentPaginationLight
+    search: string
+  }): Observable<ResultList<VideoChannel>> {
+    const { pagination, search } = parameters
     const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
 
-    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+    const restPagination = this.restService.componentPaginationToRestPagination(pagination)
 
     let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination)
+    params = this.restService.addRestGetParams(params, restPagination)
+    if (search) params = params.append('search', search)
 
     return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
                .pipe(

+ 1 - 0
client/src/sass/bootstrap.scss

@@ -310,6 +310,7 @@ ngb-tooltip-window {
     position: absolute;
     right: .5rem;
     height: 95%;
+    font-size: 14px;
 
     &:hover {
       color: rgba(0, 0, 0, 0.7);

+ 1 - 2
client/src/sass/include/_mixins.scss

@@ -690,12 +690,11 @@
   overflow: hidden;
   font-size: 0.75rem;
   border-radius: 0.25rem;
-  isolation: isolate;
   position: relative;
 
   span {
     position: absolute;
-    color: rgb(92, 92, 92);
+    color: $grey-foreground-color;
     top: -1px;
 
     &:nth-of-type(1) {

+ 2 - 1
server/controllers/api/accounts.ts

@@ -120,7 +120,8 @@ async function listAccountChannels (req: express.Request, res: express.Response)
     start: req.query.start,
     count: req.query.count,
     sort: req.query.sort,
-    withStats: req.query.withStats
+    withStats: req.query.withStats,
+    search: req.query.search
   }
 
   const resultList = await VideoChannelModel.listByAccount(options)

+ 9 - 2
server/controllers/api/users/my-subscriptions.ts

@@ -13,7 +13,7 @@ import {
   userSubscriptionAddValidator,
   userSubscriptionGetValidator
 } from '../../../middlewares'
-import { areSubscriptionsExistValidator, userSubscriptionsSortValidator, videosSortValidator } from '../../../middlewares/validators'
+import { areSubscriptionsExistValidator, userSubscriptionsSortValidator, videosSortValidator, userSubscriptionListValidator } from '../../../middlewares/validators'
 import { VideoModel } from '../../../models/video/video'
 import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils'
 import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
@@ -45,6 +45,7 @@ mySubscriptionsRouter.get('/me/subscriptions',
   userSubscriptionsSortValidator,
   setDefaultSort,
   setDefaultPagination,
+  userSubscriptionListValidator,
   asyncMiddleware(getUserSubscriptions)
 )
 
@@ -141,7 +142,13 @@ async function getUserSubscriptions (req: express.Request, res: express.Response
   const user = res.locals.oauth.token.User
   const actorId = user.Account.Actor.id
 
-  const resultList = await ActorFollowModel.listSubscriptionsForApi(actorId, req.query.start, req.query.count, req.query.sort)
+  const resultList = await ActorFollowModel.listSubscriptionsForApi({
+    actorId,
+    start: req.query.start,
+    count: req.query.count,
+    sort: req.query.sort,
+    search: req.query.search
+  })
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))
 }

+ 1 - 2
server/controllers/api/video-channel.ts

@@ -119,8 +119,7 @@ async function listVideoChannels (req: express.Request, res: express.Response) {
     actorId: serverActor.id,
     start: req.query.start,
     count: req.query.count,
-    sort: req.query.sort,
-    search: req.query.search
+    sort: req.query.sort
   })
 
   return res.json(getFormattedObjects(resultList.data, resultList.total))

+ 13 - 0
server/middlewares/validators/user-subscriptions.ts

@@ -7,6 +7,18 @@ import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-v
 import { toArray } from '../../helpers/custom-validators/misc'
 import { WEBSERVER } from '../../initializers/constants'
 
+const userSubscriptionListValidator = [
+  query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking userSubscriptionListValidator parameters', { parameters: req.query })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
 const userSubscriptionAddValidator = [
   body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'),
 
@@ -64,6 +76,7 @@ const userSubscriptionGetValidator = [
 
 export {
   areSubscriptionsExistValidator,
+  userSubscriptionListValidator,
   userSubscriptionAddValidator,
   userSubscriptionGetValidator
 }

+ 25 - 6
server/models/activitypub/actor-follow.ts

@@ -15,14 +15,15 @@ import {
   Max,
   Model,
   Table,
-  UpdatedAt
+  UpdatedAt,
+  Sequelize
 } from 'sequelize-typescript'
 import { FollowState } from '../../../shared/models/actors'
 import { ActorFollow } from '../../../shared/models/actors/follow.model'
 import { logger } from '../../helpers/logger'
 import { ACTOR_FOLLOW_SCORE, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants'
 import { ServerModel } from '../server/server'
-import { createSafeIn, getFollowsSort, getSort } from '../utils'
+import { createSafeIn, getFollowsSort, getSort, searchAttribute } from '../utils'
 import { ActorModel, unusedActorAttributesForAPI } from './actor'
 import { VideoChannelModel } from '../video/video-channel'
 import { AccountModel } from '../account/account'
@@ -440,16 +441,34 @@ export class ActorFollowModel extends Model<ActorFollowModel> {
                            })
   }
 
-  static listSubscriptionsForApi (actorId: number, start: number, count: number, sort: string) {
+  static listSubscriptionsForApi (options: {
+    actorId: number
+    start: number
+    count: number
+    sort: string
+    search?: string
+  }) {
+    const { actorId, start, count, sort } = options
+    const where = {
+      actorId: actorId
+    }
+
+    if (options.search) {
+      Object.assign(where, {
+        [Op.or]: [
+          searchAttribute(options.search, '$ActorFollowing.preferredUsername$'),
+          searchAttribute(options.search, '$ActorFollowing.VideoChannel.name$')
+        ]
+      })
+    }
+
     const query = {
       attributes: [],
       distinct: true,
       offset: start,
       limit: count,
       order: getSort(sort),
-      where: {
-        actorId: actorId
-      },
+      where,
       include: [
         {
           attributes: [ 'id' ],

+ 20 - 4
server/models/video/video-channel.ts

@@ -315,9 +315,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
     start: number
     count: number
     sort: string
-    search?: string
   }) {
-    const { actorId, search } = parameters
+    const { actorId } = parameters
 
     const query = {
       offset: parameters.start,
@@ -326,7 +325,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
     }
 
     const scopes = {
-      method: [ ScopeNames.FOR_API, { actorId, search } as AvailableForListOptions ]
+      method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
     }
     return VideoChannelModel
       .scope(scopes)
@@ -405,7 +404,23 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
     count: number
     sort: string
     withStats?: boolean
+    search?: string
   }) {
+    const escapedSearch = VideoModel.sequelize.escape(options.search)
+    const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
+    const where = options.search
+      ? {
+        [Op.or]: [
+          Sequelize.literal(
+            'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
+          ),
+          Sequelize.literal(
+            'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
+          )
+        ]
+      }
+      : null
+
     const query = {
       offset: options.start,
       limit: options.count,
@@ -418,7 +433,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
           },
           required: true
         }
-      ]
+      ],
+      where
     }
 
     const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ]