aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2020-05-29 16:16:24 +0200
committerChocobozzz <chocobozzz@cpy.re>2020-06-10 14:02:41 +0200
commit5fb2e2888ce032c638e4b75d07458642f0833e52 (patch)
tree8830d873569316889b8134027e9a43b198cca38f /client/src
parent62e7be634bc189f942ae51cb4b080079ab503ff0 (diff)
downloadPeerTube-5fb2e2888ce032c638e4b75d07458642f0833e52.tar.gz
PeerTube-5fb2e2888ce032c638e4b75d07458642f0833e52.tar.zst
PeerTube-5fb2e2888ce032c638e4b75d07458642f0833e52.zip
First implem global search
Diffstat (limited to 'client/src')
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html84
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss6
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts16
-rw-r--r--client/src/app/app.module.ts3
-rw-r--r--client/src/app/core/server/server.service.ts45
-rw-r--r--client/src/app/header/index.ts1
-rw-r--r--client/src/app/header/search-typeahead.component.html41
-rw-r--r--client/src/app/header/search-typeahead.component.scss8
-rw-r--r--client/src/app/header/search-typeahead.component.ts196
-rw-r--r--client/src/app/header/suggestion.component.html21
-rw-r--r--client/src/app/header/suggestion.component.ts22
-rw-r--r--client/src/app/header/suggestions.component.html6
-rw-r--r--client/src/app/header/suggestions.component.ts24
-rw-r--r--client/src/app/search/advanced-search.model.ts21
-rw-r--r--client/src/app/search/channel-lazy-load.resolver.ts45
-rw-r--r--client/src/app/search/search-filters.component.html64
-rw-r--r--client/src/app/search/search-filters.component.ts8
-rw-r--r--client/src/app/search/search-routing.module.ts20
-rw-r--r--client/src/app/search/search.component.html15
-rw-r--r--client/src/app/search/search.component.ts98
-rw-r--r--client/src/app/search/search.module.ts14
-rw-r--r--client/src/app/search/search.service.ts48
-rw-r--r--client/src/app/search/video-lazy-load.resolver.ts43
-rw-r--r--client/src/app/shared/actor/actor.model.ts10
-rw-r--r--client/src/app/shared/angular/highlight.pipe.ts20
-rw-r--r--client/src/app/shared/forms/form-validators/custom-config-validators.service.ts8
-rw-r--r--client/src/app/shared/users/user-notification.model.ts4
-rw-r--r--client/src/app/shared/video/video-miniature.component.html4
-rw-r--r--client/src/app/shared/video/video-miniature.component.ts38
-rw-r--r--client/src/app/shared/video/video.model.ts13
30 files changed, 660 insertions, 286 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index 4ee573696..b8682ffe0 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -396,9 +396,9 @@
396 </div> 396 </div>
397 </div> 397 </div>
398 398
399 <div class="form-row mt-4"> <!-- new videos grid --> 399 <div class="form-row mt-4"> <!-- videos grid -->
400 <div class="form-group col-12 col-lg-4 col-xl-3"> 400 <div class="form-group col-12 col-lg-4 col-xl-3">
401 <div i18n class="inner-form-title">NEW VIDEOS</div> 401 <div i18n class="inner-form-title">VIDEOS</div>
402 </div> 402 </div>
403 403
404 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> 404 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
@@ -445,6 +445,86 @@
445 </div> 445 </div>
446 </div> 446 </div>
447 447
448 <div class="form-row mt-4"> <!-- search grid -->
449 <div class="form-group col-12 col-lg-4 col-xl-3">
450 <div i18n class="inner-form-title">SEARCH</div>
451 </div>
452
453 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
454
455 <ng-container formGroupName="search">
456 <ng-container formGroupName="remoteUri">
457
458 <div class="form-group">
459 <my-peertube-checkbox
460 inputName="searchRemoteUriUsers" formControlName="users"
461 i18n-labelText labelText="Allow users to do remote URI/handle search"
462 >
463 <ng-container ngProjectAs="description">
464 <span i18n>Add ability for <strong>your users</strong> to fetch remote videos/actors by their URI, that may not be federated with your instance</span>
465 </ng-container>
466 </my-peertube-checkbox>
467 </div>
468
469 <div class="form-group">
470 <my-peertube-checkbox
471 inputName="searchRemoteUriAnonymous" formControlName="anonymous"
472 i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
473 >
474 <ng-container ngProjectAs="description">
475 <span i18n>Add ability for <strong>anonymous</strong> to fetch remote videos/actors by their URI, that may not be federated with your instance</span>
476 </ng-container>
477 </my-peertube-checkbox>
478 </div>
479
480 </ng-container>
481
482 <ng-container formGroupName="searchIndex">
483 <div class="form-group">
484 <my-peertube-checkbox
485 inputName="searchIndexEnabled" formControlName="enabled"
486 i18n-labelText labelText="Enable search index"
487 >
488
489 <ng-container ngProjectAs="extra">
490 <div [ngClass]="{ 'disabled-checkbox-extra': !isSearchIndexEnabled() }">
491 <label i18n for="searchIndexUrl">Search index URL</label>
492 <input
493 type="text" id="searchIndexUrl" class="form-control"
494 formControlName="url" [ngClass]="{ 'input-error': formErrors['search.searchIndex.url'] }"
495 >
496 <div *ngIf="formErrors.search.searchIndex.url" class="form-error">{{ formErrors.search.searchIndex.url }}</div>
497 </div>
498
499 <div class="mt-3">
500 <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSearchIndexEnabled() }"
501 inputName="searchIndexDisableLocalSearch" formControlName="disableLocalSearch"
502 i18n-labelText labelText="Disable local search"
503 ></my-peertube-checkbox>
504 </div>
505
506 <div class="mt-3">
507 <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSearchIndexEnabled() }"
508 inputName="searchIndexIsDefaultSearch" formControlName="isDefaultSearch"
509 i18n-labelText labelText="Set search index as default"
510 >
511 <ng-container ngProjectAs="description">
512 <span i18n>The local search is used by default</span>
513 </ng-container>
514 </my-peertube-checkbox>
515 </div>
516
517 </ng-container>
518 </my-peertube-checkbox>
519 </div>
520
521 </ng-container>
522
523 </ng-container>
524
525 </div>
526 </div>
527
448 <div class="form-row mt-4"> <!-- federation grid --> 528 <div class="form-row mt-4"> <!-- federation grid -->
449 <div class="form-group col-12 col-lg-4 col-xl-3"> 529 <div class="form-group col-12 col-lg-4 col-xl-3">
450 <div i18n class="inner-form-title">FEDERATION</div> 530 <div i18n class="inner-form-title">FEDERATION</div>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
index 2bfa92da4..9618100b5 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss
@@ -64,8 +64,10 @@ textarea {
64} 64}
65 65
66.disabled-checkbox-extra { 66.disabled-checkbox-extra {
67 opacity: .5; 67 &, ::ng-deep label {
68 pointer-events: none; 68 opacity: .5;
69 pointer-events: none;
70 }
69} 71}
70 72
71.form-group-right { 73.form-group-right {
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 6d59494c8..3a47ba25e 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -221,6 +221,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
221 level: null, 221 level: null,
222 dismissable: null, 222 dismissable: null,
223 message: null 223 message: null
224 },
225 search: {
226 remoteUri: {
227 users: null,
228 anonymous: null
229 },
230 searchIndex: {
231 enabled: null,
232 url: this.customConfigValidatorsService.SEARCH_INDEX_URL,
233 disableLocalSearch: null,
234 isDefaultSearch: null
235 }
224 } 236 }
225 } 237 }
226 238
@@ -254,6 +266,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
254 return this.form.value['signup']['enabled'] === true 266 return this.form.value['signup']['enabled'] === true
255 } 267 }
256 268
269 isSearchIndexEnabled () {
270 return this.form.value['search']['searchIndex']['enabled'] === true
271 }
272
257 isAutoFollowIndexEnabled () { 273 isAutoFollowIndexEnabled () {
258 return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true 274 return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
259 } 275 }
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index e61346dac..89332ec5f 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -8,7 +8,7 @@ import 'focus-visible'
8import { AppRoutingModule } from './app-routing.module' 8import { AppRoutingModule } from './app-routing.module'
9import { AppComponent } from './app.component' 9import { AppComponent } from './app.component'
10import { CoreModule } from './core' 10import { CoreModule } from './core'
11import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header' 11import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header'
12import { LoginModule } from './login' 12import { LoginModule } from './login'
13import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' 13import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
14import { SharedModule } from './shared' 14import { SharedModule } from './shared'
@@ -35,7 +35,6 @@ registerLocaleData(localeOc, 'oc')
35 AvatarNotificationComponent, 35 AvatarNotificationComponent,
36 HeaderComponent, 36 HeaderComponent,
37 SearchTypeaheadComponent, 37 SearchTypeaheadComponent,
38 SuggestionsComponent,
39 SuggestionComponent, 38 SuggestionComponent,
40 39
41 CustomModalComponent, 40 CustomModalComponent,
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index fdfbe4c02..a804efd28 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -1,15 +1,16 @@
1import { Observable, of, Subject } from 'rxjs'
1import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators' 2import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http' 3import { HttpClient } from '@angular/common/http'
3import { Inject, Injectable, LOCALE_ID } from '@angular/core' 4import { Inject, Injectable, LOCALE_ID } from '@angular/core'
4import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
5import { Observable, of, Subject } from 'rxjs'
6import { getCompleteLocale, ServerConfig } from '../../../../../shared'
7import { environment } from '../../../environments/environment'
8import { VideoConstant } from '../../../../../shared/models/videos'
9import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
10import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' 5import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
6import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
11import { sortBy } from '@app/shared/misc/utils' 7import { sortBy } from '@app/shared/misc/utils'
8import { SearchTargetType } from '@shared/models/search/search-target-query.model'
12import { ServerStats } from '@shared/models/server' 9import { ServerStats } from '@shared/models/server'
10import { getCompleteLocale, ServerConfig } from '../../../../../shared'
11import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
12import { VideoConstant } from '../../../../../shared/models/videos'
13import { environment } from '../../../environments/environment'
13 14
14@Injectable() 15@Injectable()
15export class ServerService { 16export class ServerService {
@@ -47,12 +48,6 @@ export class ServerService {
47 css: '' 48 css: ''
48 } 49 }
49 }, 50 },
50 search: {
51 remoteUri: {
52 users: true,
53 anonymous: false
54 }
55 },
56 plugin: { 51 plugin: {
57 registered: [], 52 registered: [],
58 registeredExternalAuths: [], 53 registeredExternalAuths: [],
@@ -145,6 +140,18 @@ export class ServerService {
145 message: '', 140 message: '',
146 level: 'info', 141 level: 'info',
147 dismissable: false 142 dismissable: false
143 },
144 search: {
145 remoteUri: {
146 users: true,
147 anonymous: false
148 },
149 searchIndex: {
150 enabled: false,
151 url: '',
152 disableLocalSearch: false,
153 isDefaultSearch: false
154 }
148 } 155 }
149 } 156 }
150 157
@@ -264,6 +271,20 @@ export class ServerService {
264 return this.http.get<ServerStats>(ServerService.BASE_STATS_URL) 271 return this.http.get<ServerStats>(ServerService.BASE_STATS_URL)
265 } 272 }
266 273
274 getDefaultSearchTarget (): Promise<SearchTargetType> {
275 return this.getConfig().pipe(
276 map(config => {
277 const searchIndexConfig = config.search.searchIndex
278
279 if (searchIndexConfig.enabled && (searchIndexConfig.isDefaultSearch || searchIndexConfig.disableLocalSearch)) {
280 return 'search-index'
281 }
282
283 return 'local'
284 })
285 ).toPromise()
286 }
287
267 private loadAttributeEnum <T extends string | number> ( 288 private loadAttributeEnum <T extends string | number> (
268 baseUrl: string, 289 baseUrl: string,
269 attributeName: 'categories' | 'licences' | 'languages' | 'privacies', 290 attributeName: 'categories' | 'licences' | 'languages' | 'privacies',
diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts
index a882d4d1f..005e0c97d 100644
--- a/client/src/app/header/index.ts
+++ b/client/src/app/header/index.ts
@@ -1,4 +1,3 @@
1export * from './header.component' 1export * from './header.component'
2export * from './search-typeahead.component' 2export * from './search-typeahead.component'
3export * from './suggestions.component'
4export * from './suggestion.component' 3export * from './suggestion.component'
diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html
index bbf3834c5..4355b67af 100644
--- a/client/src/app/header/search-typeahead.component.html
+++ b/client/src/app/header/search-typeahead.component.html
@@ -1,38 +1,43 @@
1<div class="d-inline-flex position-relative" id="typeahead-container"> 1<div class="d-inline-flex position-relative" id="typeahead-container">
2 <input 2 <input
3 type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…" 3 type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
4 [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keyup)="handleKey($event)" (keydown.enter)="doSearch()" 4 [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keydown)="handleKey($event)" (keydown.enter)="doSearch()"
5 aria-label="Search" 5 aria-label="Search" autocomplete="off"
6 > 6 >
7 <span class="icon icon-search" (click)="doSearch()"></span> 7 <span class="icon icon-search" (click)="doSearch()"></span>
8 8
9 <div class="position-absolute jump-to-suggestions"> 9 <div class="position-absolute jump-to-suggestions">
10 <!-- suggestions --> 10
11 <my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></my-suggestions> 11 <ul [hidden]="!search || !areSuggestionsOpened" role="listbox" class="p-0 m-0">
12 <li
13 *ngFor="let result of results; let i = index" class="suggestion d-flex flex-justify-start flex-items-center p-0 f5"
14 role="option" aria-selected="true" (mouseenter)="onSuggestionHover(i)" (click)="onSuggestionlicked(result)"
15 >
16 <my-suggestion [result]="result" [highlight]="search"></my-suggestion>
17 </li>
18 </ul>
12 19
13 <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion --> 20 <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion -->
14 <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden"> 21 <div *ngIf="showSearchGlobalHelp()" id="typeahead-help" class="overflow-hidden">
15 <ng-container *ngIf="activeResult.type === 'search-global'"> 22 <div class="d-flex justify-content-between">
16 <div class="d-flex justify-content-between"> 23 <label class="small-title" i18n>GLOBAL SEARCH</label>
17 <label class="small-title" i18n>GLOBAL SEARCH</label> 24 <div class="advanced-search-status text-muted">
18 <div class="advanced-search-status text-muted"> 25 <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.search.searchIndex.url }}</span>
19 <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span> 26 <i class="glyphicon glyphicon-globe"></i>
20 <i class="glyphicon glyphicon-globe"></i>
21 </div>
22 </div> 27 </div>
23 <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div> 28 </div>
24 </ng-container> 29 <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div>
25 </div> 30 </div>
26 31
27 <!-- search instructions, when search input is empty --> 32 <!-- search instructions, when search input is empty -->
28 <div *ngIf="areInstructionsDisplayed" id="typeahead-instructions" class="overflow-hidden"> 33 <div *ngIf="areInstructionsDisplayed()" id="typeahead-instructions" class="overflow-hidden">
29 <div class="d-flex justify-content-between"> 34 <div class="d-flex justify-content-between">
30 <label class="small-title" i18n>ADVANCED SEARCH</label> 35 <label class="small-title" i18n>ADVANCED SEARCH</label>
31 <div class="advanced-search-status c-help"> 36 <div class="advanced-search-status c-help">
32 <span [ngClass]="canSearchAnyURI ? 'text-success' : 'text-muted'" i18n-title title="Determines whether you can resolve any distant content, or if this instance only allows doing so for instances it follows."> 37 <span [ngClass]="canSearchAnyURI ? 'text-success' : 'text-muted'" i18n-title title="Determines whether you can resolve any distant content, or if this instance only allows doing so for instances it follows.">
33 <span *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span> 38 <span *ngIf="canSearchAnyURI()" class="mr-1" i18n>any instance</span>
34 <span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span> 39 <span *ngIf="!canSearchAnyURI()" class="mr-1" i18n>only followed instances</span>
35 <i [ngClass]="canSearchAnyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i> 40 <i [ngClass]="canSearchAnyURI() ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
36 </span> 41 </span>
37 </div> 42 </div>
38 </div> 43 </div>
diff --git a/client/src/app/header/search-typeahead.component.scss b/client/src/app/header/search-typeahead.component.scss
index 0a30ebd55..4b56fd93a 100644
--- a/client/src/app/header/search-typeahead.component.scss
+++ b/client/src/app/header/search-typeahead.component.scss
@@ -36,7 +36,7 @@
36 36
37#typeahead-help, 37#typeahead-help,
38#typeahead-instructions, 38#typeahead-instructions,
39my-suggestions ::ng-deep ul { 39li.suggestion {
40 border: 1px solid pvar(--mainBackgroundColor); 40 border: 1px solid pvar(--mainBackgroundColor);
41 border-bottom-right-radius: 3px; 41 border-bottom-right-radius: 3px;
42 border-bottom-left-radius: 3px; 42 border-bottom-left-radius: 3px;
@@ -90,7 +90,7 @@ my-suggestions ::ng-deep ul {
90 } 90 }
91 91
92 & > div:last-child { 92 & > div:last-child {
93 // we have to switch the display and not the opacity, 93 // we have to switch the display and not the opacity,
94 // to avoid clashing with the rest of the interface. 94 // to avoid clashing with the rest of the interface.
95 display: none; 95 display: none;
96 } 96 }
@@ -101,10 +101,10 @@ my-suggestions ::ng-deep ul {
101 @media screen and (min-width: $mobile-view) { 101 @media screen and (min-width: $mobile-view) {
102 display: initial !important; 102 display: initial !important;
103 } 103 }
104 104
105 #typeahead-help, 105 #typeahead-help,
106 #typeahead-instructions, 106 #typeahead-instructions,
107 my-suggestions ::ng-deep ul { 107 li.suggestion {
108 box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px; 108 box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px;
109 } 109 }
110 } 110 }
diff --git a/client/src/app/header/search-typeahead.component.ts b/client/src/app/header/search-typeahead.component.ts
index 2bf1072f4..6c8b8efee 100644
--- a/client/src/app/header/search-typeahead.component.ts
+++ b/client/src/app/header/search-typeahead.component.ts
@@ -1,23 +1,24 @@
1import { Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core' 1import { of } from 'rxjs'
2import { first, tap, delay } from 'rxjs/operators'
3import { ListKeyManager } from '@angular/cdk/a11y'
4import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren, AfterViewChecked } from '@angular/core'
2import { ActivatedRoute, Params, Router } from '@angular/router' 5import { ActivatedRoute, Params, Router } from '@angular/router'
3import { AuthService, ServerService } from '@app/core' 6import { AuthService, ServerService } from '@app/core'
4import { first, tap } from 'rxjs/operators'
5import { ListKeyManager } from '@angular/cdk/a11y'
6import { Result, SuggestionComponent } from './suggestion.component'
7import { of } from 'rxjs'
8import { ServerConfig } from '@shared/models' 7import { ServerConfig } from '@shared/models'
8import { SearchTargetType } from '@shared/models/search/search-target-query.model'
9import { SuggestionComponent, SuggestionPayload, SuggestionPayloadType } from './suggestion.component'
9 10
10@Component({ 11@Component({
11 selector: 'my-search-typeahead', 12 selector: 'my-search-typeahead',
12 templateUrl: './search-typeahead.component.html', 13 templateUrl: './search-typeahead.component.html',
13 styleUrls: [ './search-typeahead.component.scss' ] 14 styleUrls: [ './search-typeahead.component.scss' ]
14}) 15})
15export class SearchTypeaheadComponent implements OnInit, OnDestroy { 16export class SearchTypeaheadComponent implements OnInit, AfterViewInit, AfterViewChecked, OnDestroy {
16 @ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement> 17 @ViewChildren(SuggestionComponent) suggestionItems: QueryList<SuggestionComponent>
17 18
18 hasChannel = false 19 hasChannel = false
19 inChannel = false 20 inChannel = false
20 newSearch = true 21 areSuggestionsOpened = true
21 22
22 search = '' 23 search = ''
23 serverConfig: ServerConfig 24 serverConfig: ServerConfig
@@ -25,7 +26,11 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
25 inThisChannelText: string 26 inThisChannelText: string
26 27
27 keyboardEventsManager: ListKeyManager<SuggestionComponent> 28 keyboardEventsManager: ListKeyManager<SuggestionComponent>
28 results: Result[] = [] 29 results: SuggestionPayload[] = []
30
31 activeSearch: SuggestionPayloadType
32
33 private scheduleKeyboardEventsInit = false
29 34
30 constructor ( 35 constructor (
31 private authService: AuthService, 36 private authService: AuthService,
@@ -38,109 +43,138 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
38 this.route.queryParams 43 this.route.queryParams
39 .pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null)) 44 .pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null))
40 .subscribe(params => this.search = params.search) 45 .subscribe(params => this.search = params.search)
46 }
47
48 ngAfterViewInit () {
41 this.serverService.getConfig() 49 this.serverService.getConfig()
42 .subscribe(config => this.serverConfig = config) 50 .subscribe(config => {
51 this.serverConfig = config
52
53 this.computeTypeahead()
54
55 this.serverService.configReloaded
56 .subscribe(config => {
57 this.serverConfig = config
58 this.computeTypeahead()
59 })
60 })
43 } 61 }
44 62
45 ngOnDestroy () { 63 ngAfterViewChecked () {
46 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() 64 if (this.scheduleKeyboardEventsInit && !this.keyboardEventsManager) {
65 // Avoid ExpressionChangedAfterItHasBeenCheckedError errors
66 setTimeout(() => this.initKeyboardEventsManager(), 0)
67 }
47 } 68 }
48 69
49 get activeResult () { 70 ngOnDestroy () {
50 return this.keyboardEventsManager?.activeItem?.result 71 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
51 } 72 }
52 73
53 get areInstructionsDisplayed () { 74 areInstructionsDisplayed () {
54 return !this.search 75 return !this.search
55 } 76 }
56 77
57 get showHelp () { 78 showSearchGlobalHelp () {
58 return this.search && this.newSearch && this.activeResult?.type === 'search-global' 79 return this.search && this.areSuggestionsOpened && this.keyboardEventsManager?.activeItem?.result?.type === 'search-index'
59 } 80 }
60 81
61 get canSearchAnyURI () { 82 canSearchAnyURI () {
62 if (!this.serverConfig) return false 83 if (!this.serverConfig) return false
84
63 return this.authService.isLoggedIn() 85 return this.authService.isLoggedIn()
64 ? this.serverConfig.search.remoteUri.users 86 ? this.serverConfig.search.remoteUri.users
65 : this.serverConfig.search.remoteUri.anonymous 87 : this.serverConfig.search.remoteUri.anonymous
66 } 88 }
67 89
68 onSearchChange () { 90 onSearchChange () {
69 this.computeResults() 91 this.computeTypeahead()
70 } 92 }
71 93
72 computeResults () { 94 initKeyboardEventsManager () {
73 this.newSearch = true 95 if (this.keyboardEventsManager) return
74 let results: Result[] = [] 96
75 97 this.keyboardEventsManager = new ListKeyManager(this.suggestionItems)
76 if (this.search) { 98
77 results = [ 99 const activeIndex = this.suggestionItems.toArray().findIndex(i => i.result.default === true)
78 /* Channel search is still unimplemented. Uncomment when it is. 100 if (activeIndex === -1) {
79 { 101 console.error('Cannot find active index.', { suggestionItems: this.suggestionItems })
80 text: this.search,
81 type: 'search-channel'
82 },
83 */
84 {
85 text: this.search,
86 type: 'search-instance',
87 default: true
88 },
89 /* Global search is still unimplemented. Uncomment when it is.
90 {
91 text: this.search,
92 type: 'search-global'
93 },
94 */
95 ...results
96 ]
97 } 102 }
98 103
99 this.results = results.filter( 104 this.updateItemsState(activeIndex)
100 (result: Result) => { 105
101 // if we're not in a channel or one of its videos/playlits, show all channel-related results 106 this.keyboardEventsManager.change.subscribe(
102 if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel') 107 _ => this.updateItemsState()
103 // if we're in a channel, show all channel-related results except for the channel redirection itself
104 if (this.inChannel) return result.type !== 'channel'
105 // all other result types are kept
106 return true
107 }
108 ) 108 )
109 } 109 }
110 110
111 setEventItems (event: { items: QueryList<SuggestionComponent>, index?: number }) { 111 computeTypeahead () {
112 event.items.forEach(e => { 112 const searchIndexConfig = this.serverConfig.search.searchIndex
113 if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) { 113
114 this.keyboardEventsManager.activeItem.active = true 114 if (!this.activeSearch) {
115 if (searchIndexConfig.enabled && searchIndexConfig.isDefaultSearch) {
116 this.activeSearch = 'search-instance'
115 } else { 117 } else {
116 e.active = false 118 this.activeSearch = 'search-index'
117 } 119 }
118 }) 120 }
121
122 this.areSuggestionsOpened = true
123 this.results = []
124
125 if (!this.search) return
126
127 if (searchIndexConfig.enabled === false || searchIndexConfig.disableLocalSearch !== true) {
128 this.results.push({
129 text: this.search,
130 type: 'search-instance',
131 default: this.activeSearch === 'search-instance'
132 })
133 }
134
135 if (searchIndexConfig.enabled) {
136 this.results.push({
137 text: this.search,
138 type: 'search-index',
139 default: this.activeSearch === 'search-index'
140 })
141 }
142
143 this.scheduleKeyboardEventsInit = true
119 } 144 }
120 145
121 initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) { 146 updateItemsState (index?: number) {
122 if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() 147 if (index !== undefined) {
148 this.keyboardEventsManager.setActiveItem(index)
149 }
123 150
124 this.keyboardEventsManager = new ListKeyManager(event.items) 151 for (const item of this.suggestionItems) {
152 if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === item) {
153 item.active = true
154 this.activeSearch = item.result.type
155 continue
156 }
125 157
126 if (event.index !== undefined) { 158 item.active = false
127 this.keyboardEventsManager.setActiveItem(event.index)
128 } else {
129 this.keyboardEventsManager.setFirstItemActive()
130 } 159 }
160 }
131 161
132 this.keyboardEventsManager.change.subscribe( 162 onSuggestionlicked (payload: SuggestionPayload) {
133 _ => this.setEventItems(event) 163 this.doSearch(this.buildSearchTarget(payload))
134 ) 164 }
165
166 onSuggestionHover (index: number) {
167 this.updateItemsState(index)
135 } 168 }
136 169
137 handleKey (event: KeyboardEvent) { 170 handleKey (event: KeyboardEvent) {
138 event.stopImmediatePropagation()
139 if (!this.keyboardEventsManager) return 171 if (!this.keyboardEventsManager) return
140 172
141 switch (event.key) { 173 switch (event.key) {
142 case 'ArrowDown': 174 case 'ArrowDown':
143 case 'ArrowUp': 175 case 'ArrowUp':
176 event.stopPropagation()
177
144 this.keyboardEventsManager.onKeydown(event) 178 this.keyboardEventsManager.onKeydown(event)
145 break 179 break
146 } 180 }
@@ -150,15 +184,19 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
150 return window.location.pathname === '/search' 184 return window.location.pathname === '/search'
151 } 185 }
152 186
153 doSearch () { 187 doSearch (searchTarget?: SearchTargetType) {
154 this.newSearch = false 188 this.areSuggestionsOpened = false
155 const queryParams: Params = {} 189 const queryParams: Params = {}
156 190
157 if (this.isOnSearch() && this.route.snapshot.queryParams) { 191 if (this.isOnSearch() && this.route.snapshot.queryParams) {
158 Object.assign(queryParams, this.route.snapshot.queryParams) 192 Object.assign(queryParams, this.route.snapshot.queryParams)
159 } 193 }
160 194
161 Object.assign(queryParams, { search: this.search }) 195 if (!searchTarget) {
196 searchTarget = this.buildSearchTarget(this.keyboardEventsManager.activeItem.result)
197 }
198
199 Object.assign(queryParams, { search: this.search, searchTarget })
162 200
163 const o = this.authService.isLoggedIn() 201 const o = this.authService.isLoggedIn()
164 ? this.loadUserLanguagesIfNeeded(queryParams) 202 ? this.loadUserLanguagesIfNeeded(queryParams)
@@ -176,4 +214,12 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy {
176 tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages })) 214 tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages }))
177 ) 215 )
178 } 216 }
217
218 private buildSearchTarget (result: SuggestionPayload): SearchTargetType {
219 if (result.type === 'search-index') {
220 return 'search-index'
221 }
222
223 return 'local'
224 }
179} 225}
diff --git a/client/src/app/header/suggestion.component.html b/client/src/app/header/suggestion.component.html
index d7ae3450a..ab4b4b678 100644
--- a/client/src/app/header/suggestion.component.html
+++ b/client/src/app/header/suggestion.component.html
@@ -1,22 +1,17 @@
1<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active"> 1<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active">
2 <div class="flex-shrink-0 mr-2 text-center"> 2 <div class="flex-shrink-0 mr-2 text-center">
3 <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon> 3 <my-global-icon iconName="search"></my-global-icon>
4 <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
5 </div> 4 </div>
6 5
7 <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28"> 6 <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
8 7
9 <div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"></div> 8 <div
9 class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target"
10 [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"
11 ></div>
10 12
11 <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6"> 13 <div class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
12 <span *ngIf="result.type === 'search-channel'" i18n>In this channel</span>
13 <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span> 14 <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span>
14 <span *ngIf="result.type === 'search-global'" i18n>In the vidiverse</span> 15 <span *ngIf="result.type === 'search-index'" i18n>In the vidiverse</span>
15 <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
16 </div> 16 </div>
17 17</a>
18 <div *ngIf="result.type === 'channel'" aria-hidden="true" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6 d-on-nav-focus" i18n>
19 Jump to channel
20 <span class="d-inline-block ml-1 v-align-middle">↵</span>
21 </div>
22</a> \ No newline at end of file
diff --git a/client/src/app/header/suggestion.component.ts b/client/src/app/header/suggestion.component.ts
index 69641b511..250a5411e 100644
--- a/client/src/app/header/suggestion.component.ts
+++ b/client/src/app/header/suggestion.component.ts
@@ -1,24 +1,24 @@
1import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core' 1import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy, OnChanges } from '@angular/core'
2import { RouterLink } from '@angular/router' 2import { RouterLink } from '@angular/router'
3import { ListKeyManagerOption } from '@angular/cdk/a11y' 3import { ListKeyManagerOption } from '@angular/cdk/a11y'
4 4
5export type Result = { 5export type SuggestionPayload = {
6 text: string 6 text: string
7 type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any' 7 type: SuggestionPayloadType
8 routerLink?: RouterLink, 8 routerLink?: RouterLink
9 default?: boolean 9 default: boolean
10} 10}
11 11
12export type SuggestionPayloadType = 'search-instance' | 'search-index'
13
12@Component({ 14@Component({
13 selector: 'my-suggestion', 15 selector: 'my-suggestion',
14 templateUrl: './suggestion.component.html', 16 templateUrl: './suggestion.component.html',
15 styleUrls: [ './suggestion.component.scss' ], 17 styleUrls: [ './suggestion.component.scss' ]
16 changeDetection: ChangeDetectionStrategy.OnPush
17}) 18})
18export class SuggestionComponent implements OnInit, ListKeyManagerOption { 19export class SuggestionComponent implements OnInit, ListKeyManagerOption {
19 @Input() result: Result 20 @Input() result: SuggestionPayload
20 @Input() highlight: string 21 @Input() highlight: string
21 @Output() selected = new EventEmitter()
22 22
23 disabled = false 23 disabled = false
24 active = false 24 active = false
@@ -30,8 +30,4 @@ export class SuggestionComponent implements OnInit, ListKeyManagerOption {
30 ngOnInit () { 30 ngOnInit () {
31 if (this.result.default) this.active = true 31 if (this.result.default) this.active = true
32 } 32 }
33
34 selectItem () {
35 this.selected.emit(this.result)
36 }
37} 33}
diff --git a/client/src/app/header/suggestions.component.html b/client/src/app/header/suggestions.component.html
deleted file mode 100644
index 8d017d78d..000000000
--- a/client/src/app/header/suggestions.component.html
+++ /dev/null
@@ -1,6 +0,0 @@
1<ul role="listbox" class="p-0 m-0">
2 <li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
3 role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
4 <my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
5 </li>
6</ul> \ No newline at end of file
diff --git a/client/src/app/header/suggestions.component.ts b/client/src/app/header/suggestions.component.ts
deleted file mode 100644
index ee3ef73c2..000000000
--- a/client/src/app/header/suggestions.component.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core'
2import { SuggestionComponent } from './suggestion.component'
3
4@Component({
5 selector: 'my-suggestions',
6 templateUrl: './suggestions.component.html',
7 changeDetection: ChangeDetectionStrategy.OnPush
8})
9export class SuggestionsComponent implements AfterViewInit {
10 @Input() results: any[]
11 @Input() highlight: string
12 @ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent>
13 @Output() init = new EventEmitter()
14
15 ngAfterViewInit () {
16 this.listItems.changes.subscribe(
17 _ => this.init.emit({ items: this.listItems })
18 )
19 }
20
21 hoverItem (index: number) {
22 this.init.emit({ items: this.listItems, index: index })
23 }
24}
diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts
index 50f00bc27..643cc9a29 100644
--- a/client/src/app/search/advanced-search.model.ts
+++ b/client/src/app/search/advanced-search.model.ts
@@ -1,3 +1,4 @@
1import { SearchTargetType } from '@shared/models/search/search-target-query.model'
1import { NSFWQuery } from '../../../../shared/models/search' 2import { NSFWQuery } from '../../../../shared/models/search'
2 3
3export class AdvancedSearch { 4export class AdvancedSearch {
@@ -23,6 +24,11 @@ export class AdvancedSearch {
23 24
24 sort: string 25 sort: string
25 26
27 searchTarget: SearchTargetType
28
29 // Filters we don't want to count, because they are mandatory
30 private silentFilters = new Set([ 'sort', 'searchTarget' ])
31
26 constructor (options?: { 32 constructor (options?: {
27 startDate?: string 33 startDate?: string
28 endDate?: string 34 endDate?: string
@@ -37,6 +43,7 @@ export class AdvancedSearch {
37 durationMin?: string 43 durationMin?: string
38 durationMax?: string 44 durationMax?: string
39 sort?: string 45 sort?: string
46 searchTarget?: SearchTargetType
40 }) { 47 }) {
41 if (!options) return 48 if (!options) return
42 49
@@ -54,6 +61,8 @@ export class AdvancedSearch {
54 this.durationMin = parseInt(options.durationMin, 10) 61 this.durationMin = parseInt(options.durationMin, 10)
55 this.durationMax = parseInt(options.durationMax, 10) 62 this.durationMax = parseInt(options.durationMax, 10)
56 63
64 this.searchTarget = options.searchTarget || undefined
65
57 if (isNaN(this.durationMin)) this.durationMin = undefined 66 if (isNaN(this.durationMin)) this.durationMin = undefined
58 if (isNaN(this.durationMax)) this.durationMax = undefined 67 if (isNaN(this.durationMax)) this.durationMax = undefined
59 68
@@ -61,9 +70,11 @@ export class AdvancedSearch {
61 } 70 }
62 71
63 containsValues () { 72 containsValues () {
73 const exceptions = new Set([ 'sort', 'searchTarget' ])
74
64 const obj = this.toUrlObject() 75 const obj = this.toUrlObject()
65 for (const k of Object.keys(obj)) { 76 for (const k of Object.keys(obj)) {
66 if (k === 'sort') continue // Exception 77 if (this.silentFilters.has(k)) continue
67 78
68 if (obj[k] !== undefined && obj[k] !== '') return true 79 if (obj[k] !== undefined && obj[k] !== '') return true
69 } 80 }
@@ -102,7 +113,8 @@ export class AdvancedSearch {
102 tagsAllOf: this.tagsAllOf, 113 tagsAllOf: this.tagsAllOf,
103 durationMin: this.durationMin, 114 durationMin: this.durationMin,
104 durationMax: this.durationMax, 115 durationMax: this.durationMax,
105 sort: this.sort 116 sort: this.sort,
117 searchTarget: this.searchTarget
106 } 118 }
107 } 119 }
108 120
@@ -120,7 +132,8 @@ export class AdvancedSearch {
120 tagsAllOf: this.intoArray(this.tagsAllOf), 132 tagsAllOf: this.intoArray(this.tagsAllOf),
121 durationMin: this.durationMin, 133 durationMin: this.durationMin,
122 durationMax: this.durationMax, 134 durationMax: this.durationMax,
123 sort: this.sort 135 sort: this.sort,
136 searchTarget: this.searchTarget
124 } 137 }
125 } 138 }
126 139
@@ -129,7 +142,7 @@ export class AdvancedSearch {
129 142
130 const obj = this.toUrlObject() 143 const obj = this.toUrlObject()
131 for (const k of Object.keys(obj)) { 144 for (const k of Object.keys(obj)) {
132 if (k === 'sort') continue // Exception 145 if (this.silentFilters.has(k)) continue
133 146
134 if (obj[k] !== undefined && obj[k] !== '') acc++ 147 if (obj[k] !== undefined && obj[k] !== '') acc++
135 } 148 }
diff --git a/client/src/app/search/channel-lazy-load.resolver.ts b/client/src/app/search/channel-lazy-load.resolver.ts
new file mode 100644
index 000000000..8be089cdd
--- /dev/null
+++ b/client/src/app/search/channel-lazy-load.resolver.ts
@@ -0,0 +1,45 @@
1import { map } from 'rxjs/operators'
2import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
4import { SearchService } from './search.service'
5import { RedirectService } from '@app/core'
6
7@Injectable()
8export class ChannelLazyLoadResolver implements Resolve<any> {
9 constructor (
10 private router: Router,
11 private searchService: SearchService,
12 private redirectService: RedirectService
13 ) { }
14
15 resolve (route: ActivatedRouteSnapshot) {
16 const url = route.params.url
17 const externalRedirect = route.params.externalRedirect
18 const fromPath = route.params.fromPath
19
20 if (!url) {
21 console.error('Could not find url param.', { params: route.params })
22 return this.router.navigateByUrl('/404')
23 }
24
25 if (externalRedirect === 'true') {
26 window.open(url)
27 this.router.navigateByUrl(fromPath)
28 return
29 }
30
31 return this.searchService.searchVideoChannels({ search: url })
32 .pipe(
33 map(result => {
34 if (result.data.length !== 1) {
35 console.error('Cannot find result for this URL')
36 return this.router.navigateByUrl('/404')
37 }
38
39 const channel = result.data[0]
40
41 return this.router.navigateByUrl('/video-channels/' + channel.nameWithHost)
42 })
43 )
44 }
45}
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html
index 54fc7338f..e20aef8fb 100644
--- a/client/src/app/search/search-filters.component.html
+++ b/client/src/app/search/search-filters.component.html
@@ -18,6 +18,25 @@
18 18
19 <div class="form-group"> 19 <div class="form-group">
20 <div class="radio-label label-container"> 20 <div class="radio-label label-container">
21 <label i18n>Display sensitive content</label>
22 <button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined">
23 Reset
24 </button>
25 </div>
26
27 <div class="peertube-radio-container">
28 <input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
29 <label i18n for="sensitiveContentYes" class="radio">Yes</label>
30 </div>
31
32 <div class="peertube-radio-container">
33 <input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
34 <label i18n for="sensitiveContentNo" class="radio">No</label>
35 </div>
36 </div>
37
38 <div class="form-group">
39 <div class="radio-label label-container">
21 <label i18n>Published date</label> 40 <label i18n>Published date</label>
22 <button i18n class="reset-button reset-button-small" (click)="resetLocalField('publishedDateRange')" *ngIf="publishedDateRange !== undefined"> 41 <button i18n class="reset-button reset-button-small" (click)="resetLocalField('publishedDateRange')" *ngIf="publishedDateRange !== undefined">
23 Reset 42 Reset
@@ -39,7 +58,7 @@
39 </div> 58 </div>
40 59
41 <div class="row"> 60 <div class="row">
42 <div class="col-sm-6"> 61 <div class="pl-0 col-sm-6">
43 <input 62 <input
44 (change)="inputUpdated()" 63 (change)="inputUpdated()"
45 (keydown.enter)="$event.preventDefault()" 64 (keydown.enter)="$event.preventDefault()"
@@ -49,7 +68,7 @@
49 class="form-control" 68 class="form-control"
50 > 69 >
51 </div> 70 </div>
52 <div class="col-sm-6"> 71 <div class="pr-0 col-sm-6">
53 <input 72 <input
54 (change)="inputUpdated()" 73 (change)="inputUpdated()"
55 (keydown.enter)="$event.preventDefault()" 74 (keydown.enter)="$event.preventDefault()"
@@ -62,6 +81,9 @@
62 </div> 81 </div>
63 </div> 82 </div>
64 83
84 </div>
85
86 <div class="col-lg-4 col-md-6 col-xs-12">
65 <div class="form-group"> 87 <div class="form-group">
66 <div class="radio-label label-container"> 88 <div class="radio-label label-container">
67 <label i18n>Duration</label> 89 <label i18n>Duration</label>
@@ -77,28 +99,6 @@
77 </div> 99 </div>
78 100
79 <div class="form-group"> 101 <div class="form-group">
80 <div class="radio-label label-container">
81 <label i18n>Display sensitive content</label>
82 <button i18n class="reset-button reset-button-small" (click)="resetField('nsfw')" *ngIf="advancedSearch.nsfw !== undefined">
83 Reset
84 </button>
85 </div>
86
87 <div class="peertube-radio-container">
88 <input type="radio" name="sensitiveContent" id="sensitiveContentYes" value="both" [(ngModel)]="advancedSearch.nsfw">
89 <label i18n for="sensitiveContentYes" class="radio">Yes</label>
90 </div>
91
92 <div class="peertube-radio-container">
93 <input type="radio" name="sensitiveContent" id="sensitiveContentNo" value="false" [(ngModel)]="advancedSearch.nsfw">
94 <label i18n for="sensitiveContentNo" class="radio">No</label>
95 </div>
96 </div>
97
98 </div>
99
100 <div class="col-lg-4 col-md-6 col-xs-12">
101 <div class="form-group">
102 <label i18n for="category">Category</label> 102 <label i18n for="category">Category</label>
103 <button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined"> 103 <button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined">
104 Reset 104 Reset
@@ -164,6 +164,22 @@
164 [maxItems]="5" [modelAsStrings]="true" 164 [maxItems]="5" [modelAsStrings]="true"
165 ></tag-input> 165 ></tag-input>
166 </div> 166 </div>
167
168 <div class="form-group" *ngIf="isSearchTargetEnabled()">
169 <div class="radio-label label-container">
170 <label i18n>Search target</label>
171 </div>
172
173 <div class="peertube-radio-container">
174 <input type="radio" name="searchTarget" id="searchTargetLocal" value="local" [(ngModel)]="advancedSearch.searchTarget">
175 <label i18n for="searchTargetLocal" class="radio">Instance</label>
176 </div>
177
178 <div class="peertube-radio-container">
179 <input type="radio" name="searchTarget" id="searchTargetSearchIndex" value="search-index" [(ngModel)]="advancedSearch.searchTarget">
180 <label i18n for="searchTargetSearchIndex" class="radio">Vidiverse</label>
181 </div>
182 </div>
167 </div> 183 </div>
168 </div> 184 </div>
169 185
diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts
index 344a260df..af76260a7 100644
--- a/client/src/app/search/search-filters.component.ts
+++ b/client/src/app/search/search-filters.component.ts
@@ -44,7 +44,7 @@ export class SearchFiltersComponent implements OnInit {
44 this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES 44 this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
45 this.publishedDateRanges = [ 45 this.publishedDateRanges = [
46 { 46 {
47 id: undefined, 47 id: 'any_published_date',
48 label: this.i18n('Any') 48 label: this.i18n('Any')
49 }, 49 },
50 { 50 {
@@ -67,7 +67,7 @@ export class SearchFiltersComponent implements OnInit {
67 67
68 this.durationRanges = [ 68 this.durationRanges = [
69 { 69 {
70 id: undefined, 70 id: 'any_duration',
71 label: this.i18n('Any') 71 label: this.i18n('Any')
72 }, 72 },
73 { 73 {
@@ -147,6 +147,10 @@ export class SearchFiltersComponent implements OnInit {
147 this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined 147 this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined
148 } 148 }
149 149
150 isSearchTargetEnabled () {
151 return this.serverConfig.search.searchIndex.enabled && this.serverConfig.search.searchIndex.disableLocalSearch !== true
152 }
153
150 private loadOriginallyPublishedAtYears () { 154 private loadOriginallyPublishedAtYears () {
151 this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate 155 this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate
152 ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString() 156 ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString()
diff --git a/client/src/app/search/search-routing.module.ts b/client/src/app/search/search-routing.module.ts
index 0ac9e6b57..9da900e9a 100644
--- a/client/src/app/search/search-routing.module.ts
+++ b/client/src/app/search/search-routing.module.ts
@@ -1,7 +1,9 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { MetaGuard } from '@ngx-meta/core'
4import { SearchComponent } from '@app/search/search.component' 3import { SearchComponent } from '@app/search/search.component'
4import { MetaGuard } from '@ngx-meta/core'
5import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
6import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
5 7
6const searchRoutes: Routes = [ 8const searchRoutes: Routes = [
7 { 9 {
@@ -13,6 +15,22 @@ const searchRoutes: Routes = [
13 title: 'Search' 15 title: 'Search'
14 } 16 }
15 } 17 }
18 },
19 {
20 path: 'search/lazy-load-video',
21 component: SearchComponent,
22 canActivate: [ MetaGuard ],
23 resolve: {
24 data: VideoLazyLoadResolver
25 }
26 },
27 {
28 path: 'search/lazy-load-channel',
29 component: SearchComponent,
30 canActivate: [ MetaGuard ],
31 resolve: {
32 data: ChannelLazyLoadResolver
33 }
16 } 34 }
17] 35]
18 36
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html
index a4a1d41b3..3cafc676d 100644
--- a/client/src/app/search/search.component.html
+++ b/client/src/app/search/search.component.html
@@ -2,7 +2,11 @@
2 <div class="results-header"> 2 <div class="results-header">
3 <div class="first-line"> 3 <div class="first-line">
4 <div class="results-counter" *ngIf="pagination.totalItems"> 4 <div class="results-counter" *ngIf="pagination.totalItems">
5 <span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}}</span> 5 <span i18n>{{ pagination.totalItems | myNumberFormatter }} {pagination.totalItems, plural, =1 {result} other {results}} </span>
6
7 <span i18n *ngIf="advancedSearch.searchTarget === 'local'">on this instance</span>
8 <span i18n *ngIf="advancedSearch.searchTarget === 'search-index'">on the vidiverse</span>
9
6 <span *ngIf="currentSearch" i18n> 10 <span *ngIf="currentSearch" i18n>
7 for <span class="search-value">{{ currentSearch }}</span> 11 for <span class="search-value">{{ currentSearch }}</span>
8 </span> 12 </span>
@@ -31,12 +35,12 @@
31 35
32 <ng-container *ngFor="let result of results"> 36 <ng-container *ngFor="let result of results">
33 <div *ngIf="isVideoChannel(result)" class="entry video-channel"> 37 <div *ngIf="isVideoChannel(result)" class="entry video-channel">
34 <a [routerLink]="[ '/video-channels', result.nameWithHost ]"> 38 <a [routerLink]="getChannelUrl(result)">
35 <img [src]="result.avatarUrl" alt="Avatar" /> 39 <img [src]="result.avatarUrl" alt="Avatar" />
36 </a> 40 </a>
37 41
38 <div class="video-channel-info"> 42 <div class="video-channel-info">
39 <a [routerLink]="[ '/video-channels', result.nameWithHost ]" class="video-channel-names"> 43 <a [routerLink]="getChannelUrl(result)" class="video-channel-names">
40 <div class="video-channel-display-name">{{ result.displayName }}</div> 44 <div class="video-channel-display-name">{{ result.displayName }}</div>
41 <div class="video-channel-name">{{ result.nameWithHost }}</div> 45 <div class="video-channel-name">{{ result.nameWithHost }}</div>
42 </a> 46 </a>
@@ -44,12 +48,13 @@
44 <div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div> 48 <div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div>
45 </div> 49 </div>
46 50
47 <my-subscribe-button [videoChannels]="[result]"></my-subscribe-button> 51 <my-subscribe-button *ngIf="!hideActions()" [videoChannels]="[result]"></my-subscribe-button>
48 </div> 52 </div>
49 53
50 <div *ngIf="isVideo(result)" class="entry video"> 54 <div *ngIf="isVideo(result)" class="entry video">
51 <my-video-miniature 55 <my-video-miniature
52 [video]="result" [user]="user" [displayAsRow]="true" 56 [video]="result" [user]="user" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
57 [useLazyLoadUrl]="advancedSearch.searchTarget === 'search-index'"
53 (videoBlacklisted)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)" 58 (videoBlacklisted)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
54 ></my-video-miniature> 59 ></my-video-miniature>
55 </div> 60 </div>
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts
index 075994dd3..d3c0761d7 100644
--- a/client/src/app/search/search.component.ts
+++ b/client/src/app/search/search.component.ts
@@ -1,16 +1,18 @@
1import { forkJoin, of, Subscription } from 'rxjs'
1import { Component, OnDestroy, OnInit } from '@angular/core' 2import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, Notifier } from '@app/core' 4import { AuthService, Notifier, ServerService } from '@app/core'
4import { forkJoin, of, Subscription } from 'rxjs' 5import { HooksService } from '@app/core/plugins/hooks.service'
6import { AdvancedSearch } from '@app/search/advanced-search.model'
5import { SearchService } from '@app/search/search.service' 7import { SearchService } from '@app/search/search.service'
8import { immutableAssign } from '@app/shared/misc/utils'
6import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 9import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { MetaService } from '@ngx-meta/core'
9import { AdvancedSearch } from '@app/search/advanced-search.model'
10import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 10import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
11import { immutableAssign } from '@app/shared/misc/utils'
12import { Video } from '@app/shared/video/video.model' 11import { Video } from '@app/shared/video/video.model'
13import { HooksService } from '@app/core/plugins/hooks.service' 12import { MetaService } from '@ngx-meta/core'
13import { I18n } from '@ngx-translate/i18n-polyfill'
14import { ServerConfig } from '@shared/models'
15import { UserService } from '@app/shared'
14 16
15@Component({ 17@Component({
16 selector: 'my-search', 18 selector: 'my-search',
@@ -29,6 +31,9 @@ export class SearchComponent implements OnInit, OnDestroy {
29 isSearchFilterCollapsed = true 31 isSearchFilterCollapsed = true
30 currentSearch: string 32 currentSearch: string
31 33
34 errorMessage: string
35 serverConfig: ServerConfig
36
32 private subActivatedRoute: Subscription 37 private subActivatedRoute: Subscription
33 private isInitialLoad = false // set to false to show the search filters on first arrival 38 private isInitialLoad = false // set to false to show the search filters on first arrival
34 private firstSearch = true 39 private firstSearch = true
@@ -43,7 +48,8 @@ export class SearchComponent implements OnInit, OnDestroy {
43 private notifier: Notifier, 48 private notifier: Notifier,
44 private searchService: SearchService, 49 private searchService: SearchService,
45 private authService: AuthService, 50 private authService: AuthService,
46 private hooks: HooksService 51 private hooks: HooksService,
52 private serverService: ServerService
47 ) { } 53 ) { }
48 54
49 get user () { 55 get user () {
@@ -51,8 +57,11 @@ export class SearchComponent implements OnInit, OnDestroy {
51 } 57 }
52 58
53 ngOnInit () { 59 ngOnInit () {
60 this.serverService.getConfig()
61 .subscribe(config => this.serverConfig = config)
62
54 this.subActivatedRoute = this.route.queryParams.subscribe( 63 this.subActivatedRoute = this.route.queryParams.subscribe(
55 queryParams => { 64 async queryParams => {
56 const querySearch = queryParams['search'] 65 const querySearch = queryParams['search']
57 66
58 // Search updated, reset filters 67 // Search updated, reset filters
@@ -65,6 +74,9 @@ export class SearchComponent implements OnInit, OnDestroy {
65 } 74 }
66 75
67 this.advancedSearch = new AdvancedSearch(queryParams) 76 this.advancedSearch = new AdvancedSearch(queryParams)
77 if (!this.advancedSearch.searchTarget) {
78 this.advancedSearch.searchTarget = await this.serverService.getDefaultSearchTarget()
79 }
68 80
69 // Don't hide filters if we have some of them AND the user just came on the webpage 81 // Don't hide filters if we have some of them AND the user just came on the webpage
70 this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues() 82 this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues()
@@ -99,28 +111,37 @@ export class SearchComponent implements OnInit, OnDestroy {
99 forkJoin([ 111 forkJoin([
100 this.getVideosObs(), 112 this.getVideosObs(),
101 this.getVideoChannelObs() 113 this.getVideoChannelObs()
102 ]) 114 ]).subscribe(
103 .subscribe( 115 ([videosResult, videoChannelsResult]) => {
104 ([ videosResult, videoChannelsResult ]) => { 116 this.results = this.results
105 this.results = this.results 117 .concat(videoChannelsResult.data)
106 .concat(videoChannelsResult.data) 118 .concat(videosResult.data)
107 .concat(videosResult.data) 119
108 this.pagination.totalItems = videosResult.total + videoChannelsResult.total 120 this.pagination.totalItems = videosResult.total + videoChannelsResult.total
109
110 // Focus on channels if there are no enough videos
111 if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
112 this.resetPagination()
113 this.firstSearch = false
114
115 this.channelsPerPage = 10
116 this.search()
117 }
118 121
122 // Focus on channels if there are no enough videos
123 if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
124 this.resetPagination()
119 this.firstSearch = false 125 this.firstSearch = false
120 },
121 126
122 err => this.notifier.error(err.message) 127 this.channelsPerPage = 10
123 ) 128 this.search()
129 }
130
131 this.firstSearch = false
132 },
133
134 err => {
135 if (this.advancedSearch.searchTarget !== 'search-index') this.notifier.error(err.message)
136
137 this.notifier.error(
138 this.i18n('Search index is unavailable. Retrying with instance results instead.'),
139 this.i18n('Search error')
140 )
141 this.advancedSearch.searchTarget = 'local'
142 this.search()
143 }
144 )
124 } 145 }
125 146
126 onNearOfBottom () { 147 onNearOfBottom () {
@@ -146,6 +167,24 @@ export class SearchComponent implements OnInit, OnDestroy {
146 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) 167 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
147 } 168 }
148 169
170 getChannelUrl (channel: VideoChannel) {
171 if (this.advancedSearch.searchTarget === 'search-index' && channel.url) {
172 const remoteUriConfig = this.serverConfig.search.remoteUri
173
174 // Redirect on the external instance if not allowed to fetch remote data
175 const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
176 const fromPath = window.location.pathname + window.location.search
177
178 return [ '/search/lazy-load-channel', { url: channel.url, externalRedirect, fromPath } ]
179 }
180
181 return [ '/video-channels', channel.nameWithHost ]
182 }
183
184 hideActions () {
185 return this.advancedSearch.searchTarget === 'search-index'
186 }
187
149 private resetPagination () { 188 private resetPagination () {
150 this.pagination.currentPage = 1 189 this.pagination.currentPage = 1
151 this.pagination.totalItems = null 190 this.pagination.totalItems = null
@@ -189,7 +228,8 @@ export class SearchComponent implements OnInit, OnDestroy {
189 228
190 const params = { 229 const params = {
191 search: this.currentSearch, 230 search: this.currentSearch,
192 componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }) 231 componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }),
232 searchTarget: this.advancedSearch.searchTarget
193 } 233 }
194 234
195 return this.hooks.wrapObsFun( 235 return this.hooks.wrapObsFun(
diff --git a/client/src/app/search/search.module.ts b/client/src/app/search/search.module.ts
index 3b0fd6ee2..df5459802 100644
--- a/client/src/app/search/search.module.ts
+++ b/client/src/app/search/search.module.ts
@@ -1,10 +1,12 @@
1import { NgModule } from '@angular/core'
2import { TagInputModule } from 'ngx-chips' 1import { TagInputModule } from 'ngx-chips'
3import { SharedModule } from '../shared' 2import { NgModule } from '@angular/core'
3import { SearchFiltersComponent } from '@app/search/search-filters.component'
4import { SearchRoutingModule } from '@app/search/search-routing.module'
4import { SearchComponent } from '@app/search/search.component' 5import { SearchComponent } from '@app/search/search.component'
5import { SearchService } from '@app/search/search.service' 6import { SearchService } from '@app/search/search.service'
6import { SearchRoutingModule } from '@app/search/search-routing.module' 7import { SharedModule } from '../shared'
7import { SearchFiltersComponent } from '@app/search/search-filters.component' 8import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
9import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
8 10
9@NgModule({ 11@NgModule({
10 imports: [ 12 imports: [
@@ -25,7 +27,9 @@ import { SearchFiltersComponent } from '@app/search/search-filters.component'
25 ], 27 ],
26 28
27 providers: [ 29 providers: [
28 SearchService 30 SearchService,
31 VideoLazyLoadResolver,
32 ChannelLazyLoadResolver
29 ] 33 ]
30}) 34})
31export class SearchModule { } 35export class SearchModule { }
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts
index 3cad5aaa7..fdb12ea2c 100644
--- a/client/src/app/search/search.service.ts
+++ b/client/src/app/search/search.service.ts
@@ -1,17 +1,18 @@
1import { Observable } from 'rxjs'
1import { catchError, map, switchMap } from 'rxjs/operators' 2import { catchError, map, switchMap } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http' 3import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
4import { Observable } from 'rxjs'
5import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
6import { VideoService } from '@app/shared/video/video.service'
7import { RestExtractor, RestService } from '@app/shared'
8import { environment } from '../../environments/environment'
9import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
10import { Video } from '@app/shared/video/video.model'
11import { AdvancedSearch } from '@app/search/advanced-search.model' 5import { AdvancedSearch } from '@app/search/advanced-search.model'
6import { RestExtractor, RestPagination, RestService } from '@app/shared'
7import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
8import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
12import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 9import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
13import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 10import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
14import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' 11import { Video } from '@app/shared/video/video.model'
12import { VideoService } from '@app/shared/video/video.service'
13import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
14import { environment } from '../../environments/environment'
15import { SearchTargetType } from '@shared/models/search/search-target-query.model'
15 16
16@Injectable() 17@Injectable()
17export class SearchService { 18export class SearchService {
@@ -30,21 +31,27 @@ export class SearchService {
30 31
31 searchVideos (parameters: { 32 searchVideos (parameters: {
32 search: string, 33 search: string,
33 componentPagination: ComponentPaginationLight, 34 componentPagination?: ComponentPaginationLight,
34 advancedSearch: AdvancedSearch 35 advancedSearch?: AdvancedSearch
35 }): Observable<ResultList<Video>> { 36 }): Observable<ResultList<Video>> {
36 const { search, componentPagination, advancedSearch } = parameters 37 const { search, componentPagination, advancedSearch } = parameters
37 38
38 const url = SearchService.BASE_SEARCH_URL + 'videos' 39 const url = SearchService.BASE_SEARCH_URL + 'videos'
39 const pagination = this.restService.componentPaginationToRestPagination(componentPagination) 40 let pagination: RestPagination
41
42 if (componentPagination) {
43 pagination = this.restService.componentPaginationToRestPagination(componentPagination)
44 }
40 45
41 let params = new HttpParams() 46 let params = new HttpParams()
42 params = this.restService.addRestGetParams(params, pagination) 47 params = this.restService.addRestGetParams(params, pagination)
43 48
44 if (search) params = params.append('search', search) 49 if (search) params = params.append('search', search)
45 50
46 const advancedSearchObject = advancedSearch.toAPIObject() 51 if (advancedSearch) {
47 params = this.restService.addObjectParams(params, advancedSearchObject) 52 const advancedSearchObject = advancedSearch.toAPIObject()
53 params = this.restService.addObjectParams(params, advancedSearchObject)
54 }
48 55
49 return this.authHttp 56 return this.authHttp
50 .get<ResultList<VideoServerModel>>(url, { params }) 57 .get<ResultList<VideoServerModel>>(url, { params })
@@ -56,17 +63,26 @@ export class SearchService {
56 63
57 searchVideoChannels (parameters: { 64 searchVideoChannels (parameters: {
58 search: string, 65 search: string,
59 componentPagination: ComponentPaginationLight 66 searchTarget?: SearchTargetType,
67 componentPagination?: ComponentPaginationLight
60 }): Observable<ResultList<VideoChannel>> { 68 }): Observable<ResultList<VideoChannel>> {
61 const { search, componentPagination } = parameters 69 const { search, componentPagination, searchTarget } = parameters
62 70
63 const url = SearchService.BASE_SEARCH_URL + 'video-channels' 71 const url = SearchService.BASE_SEARCH_URL + 'video-channels'
64 const pagination = this.restService.componentPaginationToRestPagination(componentPagination) 72
73 let pagination: RestPagination
74 if (componentPagination) {
75 pagination = this.restService.componentPaginationToRestPagination(componentPagination)
76 }
65 77
66 let params = new HttpParams() 78 let params = new HttpParams()
67 params = this.restService.addRestGetParams(params, pagination) 79 params = this.restService.addRestGetParams(params, pagination)
68 params = params.append('search', search) 80 params = params.append('search', search)
69 81
82 if (searchTarget) {
83 params = params.append('searchTarget', searchTarget as string)
84 }
85
70 return this.authHttp 86 return this.authHttp
71 .get<ResultList<VideoChannelServerModel>>(url, { params }) 87 .get<ResultList<VideoChannelServerModel>>(url, { params })
72 .pipe( 88 .pipe(
diff --git a/client/src/app/search/video-lazy-load.resolver.ts b/client/src/app/search/video-lazy-load.resolver.ts
new file mode 100644
index 000000000..8d846d367
--- /dev/null
+++ b/client/src/app/search/video-lazy-load.resolver.ts
@@ -0,0 +1,43 @@
1import { map } from 'rxjs/operators'
2import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'
4import { SearchService } from './search.service'
5
6@Injectable()
7export class VideoLazyLoadResolver implements Resolve<any> {
8 constructor (
9 private router: Router,
10 private searchService: SearchService
11 ) { }
12
13 resolve (route: ActivatedRouteSnapshot) {
14 const url = route.params.url
15 const externalRedirect = route.params.externalRedirect
16 const fromPath = route.params.fromPath
17
18 if (!url) {
19 console.error('Could not find url param.', { params: route.params })
20 return this.router.navigateByUrl('/404')
21 }
22
23 if (externalRedirect === 'true') {
24 window.open(url)
25 this.router.navigateByUrl(fromPath)
26 return
27 }
28
29 return this.searchService.searchVideos({ search: url })
30 .pipe(
31 map(result => {
32 if (result.data.length !== 1) {
33 console.error('Cannot find result for this URL')
34 return this.router.navigateByUrl('/404')
35 }
36
37 const video = result.data[0]
38
39 return this.router.navigateByUrl('/videos/watch/' + video.uuid)
40 })
41 )
42 }
43}
diff --git a/client/src/app/shared/actor/actor.model.ts b/client/src/app/shared/actor/actor.model.ts
index 0e5060f67..a78303a2f 100644
--- a/client/src/app/shared/actor/actor.model.ts
+++ b/client/src/app/shared/actor/actor.model.ts
@@ -15,10 +15,14 @@ export abstract class Actor implements ActorServer {
15 15
16 avatarUrl: string 16 avatarUrl: string
17 17
18 static GET_ACTOR_AVATAR_URL (actor: { avatar?: { path: string } }) { 18 static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) {
19 const absoluteAPIUrl = getAbsoluteAPIUrl() 19 if (actor?.avatar?.url) return actor.avatar.url
20
21 if (actor && actor.avatar) {
22 const absoluteAPIUrl = getAbsoluteAPIUrl()
20 23
21 if (actor && actor.avatar) return absoluteAPIUrl + actor.avatar.path 24 return absoluteAPIUrl + actor.avatar.path
25 }
22 26
23 return this.GET_DEFAULT_AVATAR_URL() 27 return this.GET_DEFAULT_AVATAR_URL()
24 } 28 }
diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts
index fb6042280..50ee5c1bd 100644
--- a/client/src/app/shared/angular/highlight.pipe.ts
+++ b/client/src/app/shared/angular/highlight.pipe.ts
@@ -11,19 +11,17 @@ export class HighlightPipe implements PipeTransform {
11 /* use this for global search */ 11 /* use this for global search */
12 static MULTI_MATCH = 'Multi-Match' 12 static MULTI_MATCH = 'Multi-Match'
13 13
14 // tslint:disable-next-line:no-empty
15 constructor () {}
16
17 transform ( 14 transform (
18 contentString: string = null, 15 contentString: string = null,
19 stringToHighlight: string = null, 16 stringToHighlight: string = null,
20 option = 'Single-And-StartsWith-Match', 17 option = 'Single-And-StartsWith-Match',
21 caseSensitive = false, 18 caseSensitive = false,
22 highlightStyleName = 'search-highlight' 19 highlightStyleName = 'search-highlight'
23 ): SafeHtml { 20 ): SafeHtml {
24 if (stringToHighlight && contentString && option) { 21 if (stringToHighlight && contentString && option) {
25 let regex: any = '' 22 let regex: any = ''
26 const caseFlag: string = !caseSensitive ? 'i' : '' 23 const caseFlag: string = !caseSensitive ? 'i' : ''
24
27 switch (option) { 25 switch (option) {
28 case 'Single-Match': { 26 case 'Single-Match': {
29 regex = new RegExp(stringToHighlight, caseFlag) 27 regex = new RegExp(stringToHighlight, caseFlag)
@@ -42,10 +40,12 @@ export class HighlightPipe implements PipeTransform {
42 regex = new RegExp(stringToHighlight, 'gi') 40 regex = new RegExp(stringToHighlight, 'gi')
43 } 41 }
44 } 42 }
43
45 const replaced = contentString.replace( 44 const replaced = contentString.replace(
46 regex, 45 regex,
47 (match) => `<span class="${highlightStyleName}">${match}</span>` 46 (match) => `<span class="${highlightStyleName}">${match}</span>`
48 ) 47 )
48
49 return replaced 49 return replaced
50 } else { 50 } else {
51 return contentString 51 return contentString
diff --git a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
index abcbca817..fdb19e06a 100644
--- a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
+++ b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts
@@ -14,6 +14,7 @@ export class CustomConfigValidatorsService {
14 readonly ADMIN_EMAIL: BuildFormValidator 14 readonly ADMIN_EMAIL: BuildFormValidator
15 readonly TRANSCODING_THREADS: BuildFormValidator 15 readonly TRANSCODING_THREADS: BuildFormValidator
16 readonly INDEX_URL: BuildFormValidator 16 readonly INDEX_URL: BuildFormValidator
17 readonly SEARCH_INDEX_URL: BuildFormValidator
17 18
18 constructor (private i18n: I18n) { 19 constructor (private i18n: I18n) {
19 this.INSTANCE_NAME = { 20 this.INSTANCE_NAME = {
@@ -86,5 +87,12 @@ export class CustomConfigValidatorsService {
86 'pattern': this.i18n('Index URL should be a URL') 87 'pattern': this.i18n('Index URL should be a URL')
87 } 88 }
88 } 89 }
90
91 this.SEARCH_INDEX_URL = {
92 VALIDATORS: [ Validators.pattern(/^https?:\/\//) ],
93 MESSAGES: {
94 'pattern': this.i18n('Search index URL should be a URL')
95 }
96 }
89 } 97 }
90} 98}
diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts
index ba29cb462..7b8368d87 100644
--- a/client/src/app/shared/users/user-notification.model.ts
+++ b/client/src/app/shared/users/user-notification.model.ts
@@ -1,4 +1,4 @@
1import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared' 1import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, Avatar } from '../../../../../shared'
2import { Actor } from '@app/shared/actor/actor.model' 2import { Actor } from '@app/shared/actor/actor.model'
3 3
4export class UserNotification implements UserNotificationServer { 4export class UserNotification implements UserNotificationServer {
@@ -178,7 +178,7 @@ export class UserNotification implements UserNotificationServer {
178 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName 178 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
179 } 179 }
180 180
181 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { path: string } }) { 181 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) {
182 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) 182 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
183 } 183 }
184} 184}
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html
index d354a2930..3e23cf18c 100644
--- a/client/src/app/shared/video/video-miniature.component.html
+++ b/client/src/app/shared/video/video-miniature.component.html
@@ -1,6 +1,6 @@
1<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()"> 1<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
2 <my-video-thumbnail 2 <my-video-thumbnail
3 [video]="video" [nsfw]="isVideoBlur" 3 [video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)" 4 [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
5 > 5 >
6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container> 6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
@@ -12,7 +12,7 @@
12 <a 12 <a
13 tabindex="-1" 13 tabindex="-1"
14 class="video-miniature-name" 14 class="video-miniature-name"
15 [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" 15 [routerLink]="videoLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
16 >{{ video.name }}</a> 16 >{{ video.name }}</a>
17 17
18 <div class="d-inline-flex"> 18 <div class="d-inline-flex">
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts
index a1d4f0e81..aa1726ca7 100644
--- a/client/src/app/shared/video/video-miniature.component.ts
+++ b/client/src/app/shared/video/video-miniature.component.ts
@@ -1,3 +1,4 @@
1import { switchMap } from 'rxjs/operators'
1import { 2import {
2 ChangeDetectionStrategy, 3 ChangeDetectionStrategy,
3 ChangeDetectorRef, 4 ChangeDetectorRef,
@@ -9,15 +10,14 @@ import {
9 OnInit, 10 OnInit,
10 Output 11 Output
11} from '@angular/core' 12} from '@angular/core'
12import { User } from '../users'
13import { Video } from './video.model'
14import { AuthService, ServerService } from '@app/core' 13import { AuthService, ServerService } from '@app/core'
15import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
16import { I18n } from '@ngx-translate/i18n-polyfill'
17import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
18import { ScreenService } from '@app/shared/misc/screen.service' 14import { ScreenService } from '@app/shared/misc/screen.service'
19import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 15import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
20import { switchMap } from 'rxjs/operators' 16import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
17import { I18n } from '@ngx-translate/i18n-polyfill'
18import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
19import { User } from '../users'
20import { Video } from './video.model'
21 21
22export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' 22export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
23export type MiniatureDisplayOptions = { 23export type MiniatureDisplayOptions = {
@@ -57,6 +57,8 @@ export class VideoMiniatureComponent implements OnInit {
57 @Input() displayVideoActions = true 57 @Input() displayVideoActions = true
58 @Input() fitWidth = false 58 @Input() fitWidth = false
59 59
60 @Input() useLazyLoadUrl = false
61
60 @Output() videoBlacklisted = new EventEmitter() 62 @Output() videoBlacklisted = new EventEmitter()
61 @Output() videoUnblacklisted = new EventEmitter() 63 @Output() videoUnblacklisted = new EventEmitter()
62 @Output() videoRemoved = new EventEmitter() 64 @Output() videoRemoved = new EventEmitter()
@@ -82,6 +84,8 @@ export class VideoMiniatureComponent implements OnInit {
82 playlistElementId?: number 84 playlistElementId?: number
83 } 85 }
84 86
87 videoLink: any[] = []
88
85 private ownerDisplayTypeChosen: 'account' | 'videoChannel' 89 private ownerDisplayTypeChosen: 'account' | 'videoChannel'
86 90
87 constructor ( 91 constructor (
@@ -103,7 +107,10 @@ export class VideoMiniatureComponent implements OnInit {
103 ngOnInit () { 107 ngOnInit () {
104 this.serverConfig = this.serverService.getTmpConfig() 108 this.serverConfig = this.serverService.getTmpConfig()
105 this.serverService.getConfig() 109 this.serverService.getConfig()
106 .subscribe(config => this.serverConfig = config) 110 .subscribe(config => {
111 this.serverConfig = config
112 this.buildVideoLink()
113 })
107 114
108 this.setUpBy() 115 this.setUpBy()
109 116
@@ -113,6 +120,21 @@ export class VideoMiniatureComponent implements OnInit {
113 } 120 }
114 } 121 }
115 122
123 buildVideoLink () {
124 if (this.useLazyLoadUrl && this.video.url) {
125 const remoteUriConfig = this.serverConfig.search.remoteUri
126
127 // Redirect on the external instance if not allowed to fetch remote data
128 const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
129 const fromPath = window.location.pathname + window.location.search
130
131 this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ]
132 return
133 }
134
135 this.videoLink = [ '/videos/watch', this.video.uuid ]
136 }
137
116 displayOwnerAccount () { 138 displayOwnerAccount () {
117 return this.ownerDisplayTypeChosen === 'account' 139 return this.ownerDisplayTypeChosen === 'account'
118 } 140 }
@@ -203,7 +225,7 @@ export class VideoMiniatureComponent implements OnInit {
203 } 225 }
204 226
205 isWatchLaterPlaylistDisplayed () { 227 isWatchLaterPlaylistDisplayed () {
206 return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined 228 return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
207 } 229 }
208 230
209 private setUpBy () { 231 private setUpBy () {
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 546518cca..97759f9c1 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -33,10 +33,15 @@ export class Video implements VideoServerModel {
33 serverHost: string 33 serverHost: string
34 thumbnailPath: string 34 thumbnailPath: string
35 thumbnailUrl: string 35 thumbnailUrl: string
36
36 previewPath: string 37 previewPath: string
37 previewUrl: string 38 previewUrl: string
39
38 embedPath: string 40 embedPath: string
39 embedUrl: string 41 embedUrl: string
42
43 url?: string
44
40 views: number 45 views: number
41 likes: number 46 likes: number
42 dislikes: number 47 dislikes: number
@@ -100,13 +105,15 @@ export class Video implements VideoServerModel {
100 this.name = hash.name 105 this.name = hash.name
101 106
102 this.thumbnailPath = hash.thumbnailPath 107 this.thumbnailPath = hash.thumbnailPath
103 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath 108 this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
104 109
105 this.previewPath = hash.previewPath 110 this.previewPath = hash.previewPath
106 this.previewUrl = absoluteAPIUrl + hash.previewPath 111 this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
107 112
108 this.embedPath = hash.embedPath 113 this.embedPath = hash.embedPath
109 this.embedUrl = absoluteAPIUrl + hash.embedPath 114 this.embedUrl = hash.embedUrl || (absoluteAPIUrl + hash.embedPath)
115
116 this.url = hash.url
110 117
111 this.views = hash.views 118 this.views = hash.views
112 this.likes = hash.likes 119 this.likes = hash.likes