harmonize search for libraries

This commit is contained in:
Rigel Kent 2020-07-23 21:30:04 +02:00 committed by Rigel Kent
parent bc99dfe54e
commit 4f5d045960
32 changed files with 295 additions and 102 deletions

View File

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

View File

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

View File

@ -1,14 +1,21 @@
<h1> <h1 class="d-flex justify-content-between">
<my-global-icon iconName="channel" aria-hidden="true"></my-global-icon> <span>
<ng-container i18n>My channels</ng-container> <my-global-icon iconName="channel" aria-hidden="true"></my-global-icon>
</h1> <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"> <a class="create-button" routerLink="create">
<my-global-icon iconName="add" aria-hidden="true"></my-global-icon> <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
<ng-container i18n>Create video channel</ng-container> <ng-container i18n>Create video channel</ng-container>
</a> </a>
</div> </h1>
<div class="video-channels"> <div class="video-channels">
<div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel"> <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">

View File

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

View File

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

View File

@ -6,10 +6,10 @@
</a> </a>
<div class="peertube-select-container peertube-select-button ml-2"> <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="undefined" disabled>Sort by</option>
<option value="created" i18n>Newest first</option> <option value="createdAt" i18n>Newest first</option>
<option value="unread-created" i18n>Unread first</option> <option value="read" [disabled]="!hasUnreadNotifications()" i18n>Unread first</option>
</select> </select>
</div> </div>

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
import { Component, OnInit, ViewChild } from '@angular/core' import { Component, OnInit, ViewChild } from '@angular/core'
import { Notifier, RestPagination, RestTable } from '@app/core' import { Notifier, RestPagination, RestTable } from '@app/core'
import { VideoOwnershipService, Actor, Video, Account } from '@app/shared/shared-main' 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 { MyAccountAcceptOwnershipComponent } from './my-account-accept-ownership/my-account-accept-ownership.component'
import { getAbsoluteAPIUrl } from '@app/helpers' import { getAbsoluteAPIUrl } from '@app/helpers'
@ -34,6 +34,17 @@ export class MyAccountOwnershipComponent extends RestTable implements OnInit {
return 'MyAccountOwnershipComponent' 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) { switchToDefaultAvatar ($event: Event) {
($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
} }

View File

@ -1,6 +1,15 @@
<h1> <h1 class="d-flex justify-content-between">
<my-global-icon iconName="subscriptions" aria-hidden="true"></my-global-icon> <span>
<ng-container i18n>My subscriptions</ng-container> <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> </h1>
<div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscriptions yet.</div> <div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscriptions yet.</div>

View File

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

View File

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

View File

@ -45,7 +45,12 @@
<ng-container *ngIf="isVideoImportFailed(videoImport)"></ng-container> <ng-container *ngIf="isVideoImportFailed(videoImport)"></ng-container>
</td> </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>{{ videoImport.createdAt | date: 'short' }}</td>
<td class="action-cell"> <td class="action-cell">

View File

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

View File

@ -30,6 +30,19 @@ export class MyAccountVideoImportsComponent extends RestTable implements OnInit
return 'MyAccountVideoImportsComponent' 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) { isVideoImportSuccess (videoImport: VideoImport) {
return videoImport.state.id === VideoImportState.SUCCESS return videoImport.state.id === VideoImportState.SUCCESS
} }

View File

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

View File

@ -5,6 +5,10 @@
@include create-button; @include create-button;
} }
input[type=text] {
@include peertube-input-text(300px);
}
::ng-deep .action-button { ::ng-deep .action-button {
&.action-button-delete { &.action-button-delete {
margin-right: 10px; 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) { @media screen and (max-width: $small-view) {
.video-playlists-header { .video-playlists-header {
text-align: center; text-align: center;

View File

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

View File

@ -1,11 +1,16 @@
<h1> <h1 class="d-flex justify-content-between">
<my-global-icon iconName="videos" aria-hidden="true"></my-global-icon> <span>
<ng-container i18n>My videos</ng-container><span class="badge badge-secondary"> {{ pagination.totalItems }}</span> <my-global-icon iconName="videos" aria-hidden="true"></my-global-icon>
</h1> <ng-container i18n>My videos</ng-container>
<span class="badge badge-secondary"> {{ pagination.totalItems }}</span>
</span>
<div class="videos-header"> <div class="has-feedback has-clear">
<input type="text" placeholder="Search your videos" i18n-placeholder [(ngModel)]="videosSearch" (ngModelChange)="onVideosSearchChanged()" /> <input type="text" placeholder="Search your videos" i18n-placeholder [(ngModel)]="videosSearch" (ngModelChange)="onVideosSearchChanged()" />
</div> <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 <my-videos-selection
[pagination]="pagination" [pagination]="pagination"

View File

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

View File

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

View File

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

View File

@ -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 url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
const pagination = this.restService.componentPaginationToRestPagination(componentPagination) const restPagination = this.restService.componentPaginationToRestPagination(pagination)
let params = new HttpParams() 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 }) return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
.pipe( .pipe(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,18 @@ import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-v
import { toArray } from '../../helpers/custom-validators/misc' import { toArray } from '../../helpers/custom-validators/misc'
import { WEBSERVER } from '../../initializers/constants' 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 = [ const userSubscriptionAddValidator = [
body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'), body('uri').custom(isValidActorHandle).withMessage('Should have a valid URI to follow (username@domain)'),
@ -64,6 +76,7 @@ const userSubscriptionGetValidator = [
export { export {
areSubscriptionsExistValidator, areSubscriptionsExistValidator,
userSubscriptionListValidator,
userSubscriptionAddValidator, userSubscriptionAddValidator,
userSubscriptionGetValidator userSubscriptionGetValidator
} }

View File

@ -15,14 +15,15 @@ import {
Max, Max,
Model, Model,
Table, Table,
UpdatedAt UpdatedAt,
Sequelize
} from 'sequelize-typescript' } from 'sequelize-typescript'
import { FollowState } from '../../../shared/models/actors' import { FollowState } from '../../../shared/models/actors'
import { ActorFollow } from '../../../shared/models/actors/follow.model' import { ActorFollow } from '../../../shared/models/actors/follow.model'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { ACTOR_FOLLOW_SCORE, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants' import { ACTOR_FOLLOW_SCORE, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants'
import { ServerModel } from '../server/server' import { ServerModel } from '../server/server'
import { createSafeIn, getFollowsSort, getSort } from '../utils' import { createSafeIn, getFollowsSort, getSort, searchAttribute } from '../utils'
import { ActorModel, unusedActorAttributesForAPI } from './actor' import { ActorModel, unusedActorAttributesForAPI } from './actor'
import { VideoChannelModel } from '../video/video-channel' import { VideoChannelModel } from '../video/video-channel'
import { AccountModel } from '../account/account' 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 = { const query = {
attributes: [], attributes: [],
distinct: true, distinct: true,
offset: start, offset: start,
limit: count, limit: count,
order: getSort(sort), order: getSort(sort),
where: { where,
actorId: actorId
},
include: [ include: [
{ {
attributes: [ 'id' ], attributes: [ 'id' ],

View File

@ -315,9 +315,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
start: number start: number
count: number count: number
sort: string sort: string
search?: string
}) { }) {
const { actorId, search } = parameters const { actorId } = parameters
const query = { const query = {
offset: parameters.start, offset: parameters.start,
@ -326,7 +325,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
} }
const scopes = { const scopes = {
method: [ ScopeNames.FOR_API, { actorId, search } as AvailableForListOptions ] method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
} }
return VideoChannelModel return VideoChannelModel
.scope(scopes) .scope(scopes)
@ -405,7 +404,23 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
count: number count: number
sort: string sort: string
withStats?: boolean 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 = { const query = {
offset: options.start, offset: options.start,
limit: options.count, limit: options.count,
@ -418,7 +433,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
}, },
required: true required: true
} }
] ],
where
} }
const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ] const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ]