--- /dev/null
+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 */
+ }
+}
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 = [
{
redirectTo: 'video-channels',
pathMatch: 'full'
},
+ {
+ path: 'video-channels',
+ component: AccountVideoChannelsComponent,
+ data: {
+ meta: {
+ title: $localize`Account video channels`
+ }
+ }
+ },
+ {
+ path: 'about',
+ component: AccountAboutComponent,
+ data: {
+ meta: {
+ title: $localize`About account`
+ }
+ }
+ },
{
path: 'videos',
component: AccountVideosComponent,
}
},
{
- path: 'video-channels',
- component: AccountVideoChannelsComponent,
+ path: 'search',
+ component: AccountSearchComponent,
data: {
meta: {
- title: $localize`Account video channels`
- }
- }
- },
- {
- path: 'about',
- component: AccountAboutComponent,
- data: {
- meta: {
- title: $localize`About account`
+ title: $localize`Search videos within account`
}
}
}
</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>
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',
})
export class AccountsComponent implements OnInit, OnDestroy {
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent
+ accountSearch: AccountSearchComponent
account: Account
accountUser: User
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
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'
AccountsComponent,
AccountVideosComponent,
AccountVideoChannelsComponent,
- AccountAboutComponent
+ AccountAboutComponent,
+ AccountSearchComponent
],
exports: [
export * from './help.component'
export * from './list-overflow.component'
export * from './top-menu-dropdown.component'
+export * from './simple-search-input.component'
--- /dev/null
+<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>
--- /dev/null
+@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;
+ }
+}
--- /dev/null
+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)
+ }
+}
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'
HelpComponent,
ListOverflowComponent,
TopMenuDropdownComponent,
+ SimpleSearchInputComponent,
UserQuotaComponent,
UserNotificationsComponent
HelpComponent,
ListOverflowComponent,
TopMenuDropdownComponent,
+ SimpleSearchInputComponent,
UserQuotaComponent,
UserNotificationsComponent
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)
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(
font-size: 130%;
}
}
+
+ list-overflow {
+ display: inline-block;
+ width: max-content;
+ }
}
}
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(
import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
import {
+ exists,
isBooleanValid,
isDateValid,
isFileFieldValid,
.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 })
let userAvatarFilename: string
before(async function () {
- this.timeout(120000)
+ this.timeout(120_000)
servers = await flushAndRunMultipleServers(3)
})
it('Should be able to update my description', async function () {
- this.timeout(10000)
+ this.timeout(10_000)
await updateMyUser({
url: servers[0].url,
})
it('Should be able to update my avatar', async function () {
- this.timeout(10000)
+ this.timeout(10_000)
const fixture = 'avatar2.png'
}
})
+ 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')
start: number,
count: number,
sort?: string,
- query: { nsfw?: boolean } = {}
+ query: {
+ nsfw?: boolean
+ search?: string
+ } = {}
) {
const path = '/api/v1/accounts/' + accountName + '/videos'