diff --git a/client/src/app/+accounts/account-search/account-search.component.ts b/client/src/app/+accounts/account-search/account-search.component.ts new file mode 100644 index 000000000..10c7a12d8 --- /dev/null +++ b/client/src/app/+accounts/account-search/account-search.component.ts @@ -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 */ + } +} diff --git a/client/src/app/+accounts/accounts-routing.module.ts b/client/src/app/+accounts/accounts-routing.module.ts index d2ca784b0..15937a67b 100644 --- a/client/src/app/+accounts/accounts-routing.module.ts +++ b/client/src/app/+accounts/accounts-routing.module.ts @@ -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` + } + } } ] } diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html index 31c8e3a8e..5bd7b0824 100644 --- a/client/src/app/+accounts/accounts.component.html +++ b/client/src/app/+accounts/accounts.component.html @@ -44,11 +44,13 @@ + +
- +
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index 4820eaf32..1458ea59c 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts @@ -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 diff --git a/client/src/app/+accounts/accounts.module.ts b/client/src/app/+accounts/accounts.module.ts index 815360341..6da65cbc1 100644 --- a/client/src/app/+accounts/accounts.module.ts +++ b/client/src/app/+accounts/accounts.module.ts @@ -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: [ diff --git a/client/src/app/shared/shared-main/misc/index.ts b/client/src/app/shared/shared-main/misc/index.ts index e806fd2f2..dc8ef9754 100644 --- a/client/src/app/shared/shared-main/misc/index.ts +++ b/client/src/app/shared/shared-main/misc/index.ts @@ -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' diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.html b/client/src/app/shared/shared-main/misc/simple-search-input.component.html new file mode 100644 index 000000000..fb0d97122 --- /dev/null +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.html @@ -0,0 +1,14 @@ + + + + + diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.scss b/client/src/app/shared/shared-main/misc/simple-search-input.component.scss new file mode 100644 index 000000000..591b04fb2 --- /dev/null +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.scss @@ -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; + } +} diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.ts b/client/src/app/shared/shared-main/misc/simple-search-input.component.ts new file mode 100644 index 000000000..86ae9ab42 --- /dev/null +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.ts @@ -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() + + value = '' + shown: boolean + + private searchSubject = new Subject() + + 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) + } +} diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index 123b5a3e3..c69a4c8b2 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts @@ -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 diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 59860c5cb..0b708b692 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -140,8 +140,9 @@ export class VideoService implements VideosProvider { sort: VideoSortField nsfwPolicy?: NSFWPolicyType videoFilter?: VideoFilter + search?: string }): Observable> { - 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>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) .pipe( diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 51cf4c3ed..ca11488cb 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -665,6 +665,11 @@ font-size: 130%; } } + + list-overflow { + display: inline-block; + width: max-content; + } } } diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index e807b4f44..e31924a94 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -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( diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 8bc37b0ab..84e309bec 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -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 }) diff --git a/server/tests/api/users/users-multiple-servers.ts b/server/tests/api/users/users-multiple-servers.ts index 591ce4959..dcd03879b 100644 --- a/server/tests/api/users/users-multiple-servers.ts +++ b/server/tests/api/users/users-multiple-servers.ts @@ -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') diff --git a/shared/extra-utils/videos/videos.ts b/shared/extra-utils/videos/videos.ts index a2438d712..392eddcc5 100644 --- a/shared/extra-utils/videos/videos.ts +++ b/shared/extra-utils/videos/videos.ts @@ -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'