Allow users/visitors to search through an account's videos (#3589)
* WIP: account search * add search to account view * add tests for account search
This commit is contained in:
parent
2264c1ceed
commit
370240824e
|
@ -0,0 +1,104 @@
|
|||
import { Subscription } from 'rxjs'
|
||||
import { first, tap } from 'rxjs/operators'
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
|
||||
import { immutableAssign } from '@app/helpers'
|
||||
import { Account, AccountService, VideoService } from '@app/shared/shared-main'
|
||||
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
|
||||
import { VideoFilter } from '@shared/models'
|
||||
|
||||
@Component({
|
||||
selector: 'my-account-search',
|
||||
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
|
||||
styleUrls: [
|
||||
'../../shared/shared-video-miniature/abstract-video-list.scss'
|
||||
]
|
||||
})
|
||||
export class AccountSearchComponent extends AbstractVideoList implements OnInit, OnDestroy {
|
||||
titlePage: string
|
||||
loadOnInit = false
|
||||
|
||||
search = ''
|
||||
filter: VideoFilter = null
|
||||
|
||||
private account: Account
|
||||
private accountSub: Subscription
|
||||
|
||||
constructor (
|
||||
protected router: Router,
|
||||
protected serverService: ServerService,
|
||||
protected route: ActivatedRoute,
|
||||
protected authService: AuthService,
|
||||
protected userService: UserService,
|
||||
protected notifier: Notifier,
|
||||
protected confirmService: ConfirmService,
|
||||
protected screenService: ScreenService,
|
||||
protected storageService: LocalStorageService,
|
||||
private accountService: AccountService,
|
||||
private videoService: VideoService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
super.ngOnInit()
|
||||
|
||||
this.enableAllFilterIfPossible()
|
||||
|
||||
// Parent get the account for us
|
||||
this.accountSub = this.accountService.accountLoaded
|
||||
.pipe(first())
|
||||
.subscribe(account => {
|
||||
this.account = account
|
||||
|
||||
this.reloadVideos()
|
||||
this.generateSyndicationList()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.accountSub) this.accountSub.unsubscribe()
|
||||
|
||||
super.ngOnDestroy()
|
||||
}
|
||||
|
||||
updateSearch (value: string) {
|
||||
if (value === '') this.router.navigate(['../videos'], { relativeTo: this.route })
|
||||
this.search = value
|
||||
|
||||
this.reloadVideos()
|
||||
}
|
||||
|
||||
getVideosObservable (page: number) {
|
||||
const newPagination = immutableAssign(this.pagination, { currentPage: page })
|
||||
const options = {
|
||||
account: this.account,
|
||||
videoPagination: newPagination,
|
||||
sort: this.sort,
|
||||
nsfwPolicy: this.nsfwPolicy,
|
||||
videoFilter: this.filter,
|
||||
search: this.search
|
||||
}
|
||||
|
||||
return this.videoService
|
||||
.getAccountVideos(options)
|
||||
.pipe(
|
||||
tap(({ total }) => {
|
||||
this.titlePage = this.search
|
||||
? $localize`Published ${total} videos matching "${this.search}"`
|
||||
: $localize`Published ${total} videos`
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
toggleModerationDisplay () {
|
||||
this.filter = this.buildLocalFilter(this.filter, null)
|
||||
|
||||
this.reloadVideos()
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
/* disable syndication */
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import { AccountsComponent } from './accounts.component'
|
|||
import { AccountVideosComponent } from './account-videos/account-videos.component'
|
||||
import { AccountAboutComponent } from './account-about/account-about.component'
|
||||
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
|
||||
import { AccountSearchComponent } from './account-search/account-search.component'
|
||||
|
||||
const accountsRoutes: Routes = [
|
||||
{
|
||||
|
@ -21,19 +22,6 @@ const accountsRoutes: Routes = [
|
|||
redirectTo: 'video-channels',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'videos',
|
||||
component: AccountVideosComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Account videos`
|
||||
},
|
||||
reuse: {
|
||||
enabled: true,
|
||||
key: 'account-videos-list'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'video-channels',
|
||||
component: AccountVideoChannelsComponent,
|
||||
|
@ -51,6 +39,28 @@ const accountsRoutes: Routes = [
|
|||
title: $localize`About account`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'videos',
|
||||
component: AccountVideosComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Account videos`
|
||||
},
|
||||
reuse: {
|
||||
enabled: true,
|
||||
key: 'account-videos-list'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
component: AccountSearchComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Search videos within account`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -44,11 +44,13 @@
|
|||
</ng-template>
|
||||
|
||||
<list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow>
|
||||
|
||||
<simple-search-input (searchChanged)="searchChanged($event)" name="search-videos" i18n-placeholder placeholder="Search videos"></simple-search-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="margin-content">
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet (activate)="onOutletLoaded($event)"></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Account, AccountService, DropdownAction, ListOverflowItem, VideoChannel
|
|||
import { AccountReportComponent } from '@app/shared/shared-moderation'
|
||||
import { User, UserRight } from '@shared/models'
|
||||
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
|
||||
import { AccountSearchComponent } from './account-search/account-search.component'
|
||||
|
||||
@Component({
|
||||
templateUrl: './accounts.component.html',
|
||||
|
@ -14,6 +15,7 @@ import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
|
|||
})
|
||||
export class AccountsComponent implements OnInit, OnDestroy {
|
||||
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent
|
||||
accountSearch: AccountSearchComponent
|
||||
|
||||
account: Account
|
||||
accountUser: User
|
||||
|
@ -99,6 +101,18 @@ export class AccountsComponent implements OnInit, OnDestroy {
|
|||
return $localize`${count} subscribers`
|
||||
}
|
||||
|
||||
onOutletLoaded (component: Component) {
|
||||
if (component instanceof AccountSearchComponent) {
|
||||
this.accountSearch = component
|
||||
} else {
|
||||
this.accountSearch = undefined
|
||||
}
|
||||
}
|
||||
|
||||
searchChanged (search: string) {
|
||||
if (this.accountSearch) this.accountSearch.updateSearch(search)
|
||||
}
|
||||
|
||||
private onAccount (account: Account) {
|
||||
this.prependModerationActions = undefined
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
|
|||
import { AccountAboutComponent } from './account-about/account-about.component'
|
||||
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
|
||||
import { AccountVideosComponent } from './account-videos/account-videos.component'
|
||||
import { AccountSearchComponent } from './account-search/account-search.component'
|
||||
import { AccountsRoutingModule } from './accounts-routing.module'
|
||||
import { AccountsComponent } from './accounts.component'
|
||||
|
||||
|
@ -27,7 +28,8 @@ import { AccountsComponent } from './accounts.component'
|
|||
AccountsComponent,
|
||||
AccountVideosComponent,
|
||||
AccountVideoChannelsComponent,
|
||||
AccountAboutComponent
|
||||
AccountAboutComponent,
|
||||
AccountSearchComponent
|
||||
],
|
||||
|
||||
exports: [
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './help.component'
|
||||
export * from './list-overflow.component'
|
||||
export * from './top-menu-dropdown.component'
|
||||
export * from './simple-search-input.component'
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<span>
|
||||
<my-global-icon iconName="search" aria-label="Search" role="button" (click)="showInput()"></my-global-icon>
|
||||
|
||||
<input
|
||||
#ref
|
||||
type="text"
|
||||
[(ngModel)]="value"
|
||||
(focusout)="focusLost()"
|
||||
(keyup.enter)="searchChange()"
|
||||
[hidden]="!shown"
|
||||
[name]="name"
|
||||
[placeholder]="placeholder"
|
||||
>
|
||||
</span>
|
|
@ -0,0 +1,29 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
span {
|
||||
opacity: .6;
|
||||
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
my-global-icon {
|
||||
height: 18px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
input {
|
||||
@include peertube-input-text(150px);
|
||||
|
||||
height: 22px; // maximum height for the account/video-channels links
|
||||
padding-left: 10px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
&::placeholder {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { Subject } from 'rxjs'
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
|
||||
|
||||
@Component({
|
||||
selector: 'simple-search-input',
|
||||
templateUrl: './simple-search-input.component.html',
|
||||
styleUrls: [ './simple-search-input.component.scss' ]
|
||||
})
|
||||
export class SimpleSearchInputComponent implements OnInit {
|
||||
@ViewChild('ref') input: ElementRef
|
||||
|
||||
@Input() name = 'search'
|
||||
@Input() placeholder = $localize`Search`
|
||||
|
||||
@Output() searchChanged = new EventEmitter<string>()
|
||||
|
||||
value = ''
|
||||
shown: boolean
|
||||
|
||||
private searchSubject = new Subject<string>()
|
||||
|
||||
constructor (
|
||||
private router: Router,
|
||||
private route: ActivatedRoute
|
||||
) {}
|
||||
|
||||
ngOnInit () {
|
||||
this.searchSubject
|
||||
.pipe(
|
||||
debounceTime(400),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe(value => this.searchChanged.emit(value))
|
||||
|
||||
this.searchSubject.next(this.value)
|
||||
}
|
||||
|
||||
showInput () {
|
||||
this.shown = true
|
||||
setTimeout(() => this.input.nativeElement.focus())
|
||||
}
|
||||
|
||||
focusLost () {
|
||||
if (this.value !== '') return
|
||||
this.shown = false
|
||||
}
|
||||
|
||||
searchChange () {
|
||||
this.router.navigate(['./search'], { relativeTo: this.route })
|
||||
this.searchSubject.next(this.value)
|
||||
}
|
||||
}
|
|
@ -30,7 +30,7 @@ import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditBu
|
|||
import { DateToggleComponent } from './date'
|
||||
import { FeedComponent } from './feeds'
|
||||
import { LoaderComponent, SmallLoaderComponent } from './loaders'
|
||||
import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc'
|
||||
import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent, SimpleSearchInputComponent } from './misc'
|
||||
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
|
||||
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
|
||||
import { VideoCaptionService } from './video-caption'
|
||||
|
@ -88,6 +88,7 @@ import { VideoChannelService } from './video-channel'
|
|||
HelpComponent,
|
||||
ListOverflowComponent,
|
||||
TopMenuDropdownComponent,
|
||||
SimpleSearchInputComponent,
|
||||
|
||||
UserQuotaComponent,
|
||||
UserNotificationsComponent
|
||||
|
@ -140,6 +141,7 @@ import { VideoChannelService } from './video-channel'
|
|||
HelpComponent,
|
||||
ListOverflowComponent,
|
||||
TopMenuDropdownComponent,
|
||||
SimpleSearchInputComponent,
|
||||
|
||||
UserQuotaComponent,
|
||||
UserNotificationsComponent
|
||||
|
|
|
@ -140,8 +140,9 @@ export class VideoService implements VideosProvider {
|
|||
sort: VideoSortField
|
||||
nsfwPolicy?: NSFWPolicyType
|
||||
videoFilter?: VideoFilter
|
||||
search?: string
|
||||
}): Observable<ResultList<Video>> {
|
||||
const { account, videoPagination, sort, videoFilter, nsfwPolicy } = parameters
|
||||
const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters
|
||||
|
||||
const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
|
||||
|
||||
|
@ -156,6 +157,10 @@ export class VideoService implements VideosProvider {
|
|||
params = params.set('filter', videoFilter)
|
||||
}
|
||||
|
||||
if (search) {
|
||||
params = params.set('search', search)
|
||||
}
|
||||
|
||||
return this.authHttp
|
||||
.get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
|
||||
.pipe(
|
||||
|
|
|
@ -665,6 +665,11 @@
|
|||
font-size: 130%;
|
||||
}
|
||||
}
|
||||
|
||||
list-overflow {
|
||||
display: inline-block;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -175,7 +175,8 @@ async function listAccountVideos (req: express.Request, res: express.Response) {
|
|||
withFiles: false,
|
||||
accountId: account.id,
|
||||
user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
|
||||
countVideos
|
||||
countVideos,
|
||||
search: req.query.search
|
||||
}, 'filter:api.accounts.videos.list.params')
|
||||
|
||||
const resultList = await Hooks.wrapPromiseFun(
|
||||
|
|
|
@ -6,6 +6,7 @@ import { MVideoFullLight } from '@server/types/models'
|
|||
import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
|
||||
import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
|
||||
import {
|
||||
exists,
|
||||
isBooleanValid,
|
||||
isDateValid,
|
||||
isFileFieldValid,
|
||||
|
@ -444,6 +445,9 @@ const commonVideosFiltersValidator = [
|
|||
.optional()
|
||||
.customSanitizer(toBooleanOrNull)
|
||||
.custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
|
||||
query('search')
|
||||
.optional()
|
||||
.custom(exists).withMessage('Should have a valid search'),
|
||||
|
||||
(req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking commons video filters query', { parameters: req.query })
|
||||
|
|
|
@ -34,7 +34,7 @@ describe('Test users with multiple servers', function () {
|
|||
let userAvatarFilename: string
|
||||
|
||||
before(async function () {
|
||||
this.timeout(120000)
|
||||
this.timeout(120_000)
|
||||
|
||||
servers = await flushAndRunMultipleServers(3)
|
||||
|
||||
|
@ -92,7 +92,7 @@ describe('Test users with multiple servers', function () {
|
|||
})
|
||||
|
||||
it('Should be able to update my description', async function () {
|
||||
this.timeout(10000)
|
||||
this.timeout(10_000)
|
||||
|
||||
await updateMyUser({
|
||||
url: servers[0].url,
|
||||
|
@ -109,7 +109,7 @@ describe('Test users with multiple servers', function () {
|
|||
})
|
||||
|
||||
it('Should be able to update my avatar', async function () {
|
||||
this.timeout(10000)
|
||||
this.timeout(10_000)
|
||||
|
||||
const fixture = 'avatar2.png'
|
||||
|
||||
|
@ -164,8 +164,27 @@ describe('Test users with multiple servers', function () {
|
|||
}
|
||||
})
|
||||
|
||||
it('Should search through account videos', async function () {
|
||||
this.timeout(10_000)
|
||||
|
||||
const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: 'Kami no chikara' })
|
||||
|
||||
await waitJobs(servers)
|
||||
|
||||
for (const server of servers) {
|
||||
const res = await getAccountVideos(server.url, server.accessToken, 'user1@localhost:' + servers[0].port, 0, 5, undefined, {
|
||||
search: 'Kami'
|
||||
})
|
||||
|
||||
expect(res.body.total).to.equal(1)
|
||||
expect(res.body.data).to.be.an('array')
|
||||
expect(res.body.data).to.have.lengthOf(1)
|
||||
expect(res.body.data[0].uuid).to.equal(resVideo.body.video.uuid)
|
||||
}
|
||||
})
|
||||
|
||||
it('Should remove the user', async function () {
|
||||
this.timeout(10000)
|
||||
this.timeout(10_000)
|
||||
|
||||
for (const server of servers) {
|
||||
const resAccounts = await getAccountsList(server.url, '-createdAt')
|
||||
|
|
|
@ -194,7 +194,10 @@ function getAccountVideos (
|
|||
start: number,
|
||||
count: number,
|
||||
sort?: string,
|
||||
query: { nsfw?: boolean } = {}
|
||||
query: {
|
||||
nsfw?: boolean
|
||||
search?: string
|
||||
} = {}
|
||||
) {
|
||||
const path = '/api/v1/accounts/' + accountName + '/videos'
|
||||
|
||||
|
|
Loading…
Reference in New Issue