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'