aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/+search
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2020-06-23 14:49:20 +0200
committerChocobozzz <chocobozzz@cpy.re>2020-06-23 16:00:49 +0200
commit1942f11d5ee6926ad93dc1b79fae18325ba5de18 (patch)
tree3f2a3cd9466a56c419d197ac832a3e9cbc86bec4 /client/src/app/+search
parent67ed6552b831df66713bac9e672738796128d33f (diff)
downloadPeerTube-1942f11d5ee6926ad93dc1b79fae18325ba5de18.tar.gz
PeerTube-1942f11d5ee6926ad93dc1b79fae18325ba5de18.tar.zst
PeerTube-1942f11d5ee6926ad93dc1b79fae18325ba5de18.zip
Lazy load all routes
Diffstat (limited to 'client/src/app/+search')
-rw-r--r--client/src/app/+search/channel-lazy-load.resolver.ts43
-rw-r--r--client/src/app/+search/search-filters.component.html193
-rw-r--r--client/src/app/+search/search-filters.component.scss69
-rw-r--r--client/src/app/+search/search-filters.component.ts269
-rw-r--r--client/src/app/+search/search-routing.module.ts41
-rw-r--r--client/src/app/+search/search.component.html63
-rw-r--r--client/src/app/+search/search.component.scss191
-rw-r--r--client/src/app/+search/search.component.ts259
-rw-r--r--client/src/app/+search/search.module.ts44
-rw-r--r--client/src/app/+search/video-lazy-load.resolver.ts43
10 files changed, 1215 insertions, 0 deletions
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..17a212829
--- /dev/null
+++ b/client/src/app/+search/channel-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 '@app/shared/shared-search'
5
6@Injectable()
7export class ChannelLazyLoadResolver 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.searchVideoChannels({ 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 channel = result.data[0]
38
39 return this.router.navigateByUrl('/video-channels/' + channel.nameWithHost)
40 })
41 )
42 }
43}
diff --git a/client/src/app/+search/search-filters.component.html b/client/src/app/+search/search-filters.component.html
new file mode 100644
index 000000000..e20aef8fb
--- /dev/null
+++ b/client/src/app/+search/search-filters.component.html
@@ -0,0 +1,193 @@
1<form role="form" (ngSubmit)="formUpdated()">
2
3 <div class="row">
4 <div class="col-lg-4 col-md-6 col-xs-12">
5 <div class="form-group">
6 <div class="radio-label label-container">
7 <label i18n>Sort</label>
8 <button i18n class="reset-button reset-button-small" (click)="resetField('sort', '-match')" *ngIf="advancedSearch.sort !== '-match'">
9 Reset
10 </button>
11 </div>
12
13 <div class="peertube-radio-container" *ngFor="let sort of sorts">
14 <input type="radio" name="sort" [id]="sort.id" [value]="sort.id" [(ngModel)]="advancedSearch.sort">
15 <label [for]="sort.id" class="radio">{{ sort.label }}</label>
16 </div>
17 </div>
18
19 <div class="form-group">
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">
40 <label i18n>Published date</label>
41 <button i18n class="reset-button reset-button-small" (click)="resetLocalField('publishedDateRange')" *ngIf="publishedDateRange !== undefined">
42 Reset
43 </button>
44 </div>
45
46 <div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
47 <input type="radio" (change)="inputUpdated()" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
48 <label [for]="date.id" class="radio">{{ date.label }}</label>
49 </div>
50 </div>
51
52 <div class="form-group">
53 <div class="label-container">
54 <label i18n for="original-publication-after">Original publication year</label>
55 <button i18n class="reset-button reset-button-small" (click)="resetOriginalPublicationYears()" *ngIf="originallyPublishedStartYear || originallyPublishedEndYear">
56 Reset
57 </button>
58 </div>
59
60 <div class="row">
61 <div class="pl-0 col-sm-6">
62 <input
63 (change)="inputUpdated()"
64 (keydown.enter)="$event.preventDefault()"
65 type="text" id="original-publication-after" name="original-publication-after"
66 i18n-placeholder placeholder="After..."
67 [(ngModel)]="originallyPublishedStartYear"
68 class="form-control"
69 >
70 </div>
71 <div class="pr-0 col-sm-6">
72 <input
73 (change)="inputUpdated()"
74 (keydown.enter)="$event.preventDefault()"
75 type="text" id="original-publication-before" name="original-publication-before"
76 i18n-placeholder placeholder="Before..."
77 [(ngModel)]="originallyPublishedEndYear"
78 class="form-control"
79 >
80 </div>
81 </div>
82 </div>
83
84 </div>
85
86 <div class="col-lg-4 col-md-6 col-xs-12">
87 <div class="form-group">
88 <div class="radio-label label-container">
89 <label i18n>Duration</label>
90 <button i18n class="reset-button reset-button-small" (click)="resetLocalField('durationRange')" *ngIf="durationRange !== undefined">
91 Reset
92 </button>
93 </div>
94
95 <div class="peertube-radio-container" *ngFor="let duration of durationRanges">
96 <input type="radio" (change)="inputUpdated()" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
97 <label [for]="duration.id" class="radio">{{ duration.label }}</label>
98 </div>
99 </div>
100
101 <div class="form-group">
102 <label i18n for="category">Category</label>
103 <button i18n class="reset-button reset-button-small" (click)="resetField('categoryOneOf')" *ngIf="advancedSearch.categoryOneOf !== undefined">
104 Reset
105 </button>
106 <div class="peertube-select-container">
107 <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf" class="form-control">
108 <option [value]="undefined" i18n>Display all categories</option>
109 <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option>
110 </select>
111 </div>
112 </div>
113
114 <div class="form-group">
115 <label i18n for="licence">Licence</label>
116 <button i18n class="reset-button reset-button-small" (click)="resetField('licenceOneOf')" *ngIf="advancedSearch.licenceOneOf !== undefined">
117 Reset
118 </button>
119 <div class="peertube-select-container">
120 <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf" class="form-control">
121 <option [value]="undefined" i18n>Display all licenses</option>
122 <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option>
123 </select>
124 </div>
125 </div>
126
127 <div class="form-group">
128 <label i18n for="language">Language</label>
129 <button i18n class="reset-button reset-button-small" (click)="resetField('languageOneOf')" *ngIf="advancedSearch.languageOneOf !== undefined">
130 Reset
131 </button>
132 <div class="peertube-select-container">
133 <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf" class="form-control">
134 <option [value]="undefined" i18n>Display all languages</option>
135 <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option>
136 </select>
137 </div>
138 </div>
139 </div>
140
141 <div class="col-lg-4 col-md-6 col-xs-12">
142 <div class="form-group">
143 <label i18n for="tagsAllOf">All of these tags</label>
144 <button i18n class="reset-button reset-button-small" (click)="resetField('tagsAllOf')" *ngIf="advancedSearch.tagsAllOf">
145 Reset
146 </button>
147 <tag-input
148 [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf"
149 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
150 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
151 [maxItems]="5" [modelAsStrings]="true"
152 ></tag-input>
153 </div>
154
155 <div class="form-group">
156 <label i18n for="tagsOneOf">One of these tags</label>
157 <button i18n class="reset-button reset-button-small" (click)="resetField('tagsOneOf')" *ngIf="advancedSearch.tagsOneOf">
158 Reset
159 </button>
160 <tag-input
161 [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf"
162 [validators]="tagValidators" [errorMessages]="tagValidatorsMessages"
163 i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag"
164 [maxItems]="5" [modelAsStrings]="true"
165 ></tag-input>
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>
183 </div>
184 </div>
185
186 <div class="submit-button">
187 <button i18n class="reset-button" (click)="reset()" *ngIf="advancedSearch.size()">
188 Reset
189 </button>
190
191 <input type="submit" i18n-value value="Filter">
192 </div>
193</form>
diff --git a/client/src/app/+search/search-filters.component.scss b/client/src/app/+search/search-filters.component.scss
new file mode 100644
index 000000000..a88a1c0b0
--- /dev/null
+++ b/client/src/app/+search/search-filters.component.scss
@@ -0,0 +1,69 @@
1@import '_variables';
2@import '_mixins';
3
4form {
5 margin-top: 40px;
6}
7
8.radio-label {
9 font-size: 15px;
10 font-weight: $font-bold;
11}
12
13.peertube-radio-container {
14 @include peertube-radio-container;
15
16 display: inline-block;
17 margin-right: 30px;
18}
19
20.peertube-select-container {
21 @include peertube-select-container(auto);
22
23 margin-bottom: 1rem;
24}
25
26.form-group {
27 margin-bottom: 25px;
28}
29
30input[type=text] {
31 @include peertube-input-text(100%);
32 display: block;
33}
34
35input[type=submit] {
36 @include peertube-button-link;
37 @include orange-button;
38}
39
40.submit-button {
41 text-align: right;
42}
43
44.reset-button {
45 @include peertube-button;
46
47 font-weight: $font-semibold;
48 display: inline-block;
49 padding: 0 10px 0 10px;
50 white-space: nowrap;
51 background: transparent;
52
53 margin-right: 1rem;
54}
55
56.reset-button-small {
57 font-size: 80%;
58 height: unset;
59 line-height: unset;
60 margin: unset;
61 margin-bottom: 0.5rem;
62}
63
64.label-container {
65 display: flex;
66 white-space: nowrap;
67}
68
69@include ng2-tags;
diff --git a/client/src/app/+search/search-filters.component.ts b/client/src/app/+search/search-filters.component.ts
new file mode 100644
index 000000000..fc1db3258
--- /dev/null
+++ b/client/src/app/+search/search-filters.component.ts
@@ -0,0 +1,269 @@
1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
2import { ValidatorFn } from '@angular/forms'
3import { ServerService } from '@app/core'
4import { VideoValidatorsService } from '@app/shared/shared-forms'
5import { AdvancedSearch } from '@app/shared/shared-search'
6import { I18n } from '@ngx-translate/i18n-polyfill'
7import { ServerConfig, VideoConstant } from '@shared/models'
8
9@Component({
10 selector: 'my-search-filters',
11 styleUrls: [ './search-filters.component.scss' ],
12 templateUrl: './search-filters.component.html'
13})
14export class SearchFiltersComponent implements OnInit {
15 @Input() advancedSearch: AdvancedSearch = new AdvancedSearch()
16
17 @Output() filtered = new EventEmitter<AdvancedSearch>()
18
19 videoCategories: VideoConstant<number>[] = []
20 videoLicences: VideoConstant<number>[] = []
21 videoLanguages: VideoConstant<string>[] = []
22
23 tagValidators: ValidatorFn[]
24 tagValidatorsMessages: { [ name: string ]: string }
25
26 publishedDateRanges: { id: string, label: string }[] = []
27 sorts: { id: string, label: string }[] = []
28 durationRanges: { id: string, label: string }[] = []
29
30 publishedDateRange: string
31 durationRange: string
32
33 originallyPublishedStartYear: string
34 originallyPublishedEndYear: string
35
36 private serverConfig: ServerConfig
37
38 constructor (
39 private i18n: I18n,
40 private videoValidatorsService: VideoValidatorsService,
41 private serverService: ServerService
42 ) {
43 this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS
44 this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES
45 this.publishedDateRanges = [
46 {
47 id: 'any_published_date',
48 label: this.i18n('Any')
49 },
50 {
51 id: 'today',
52 label: this.i18n('Today')
53 },
54 {
55 id: 'last_7days',
56 label: this.i18n('Last 7 days')
57 },
58 {
59 id: 'last_30days',
60 label: this.i18n('Last 30 days')
61 },
62 {
63 id: 'last_365days',
64 label: this.i18n('Last 365 days')
65 }
66 ]
67
68 this.durationRanges = [
69 {
70 id: 'any_duration',
71 label: this.i18n('Any')
72 },
73 {
74 id: 'short',
75 label: this.i18n('Short (< 4 min)')
76 },
77 {
78 id: 'medium',
79 label: this.i18n('Medium (4-10 min)')
80 },
81 {
82 id: 'long',
83 label: this.i18n('Long (> 10 min)')
84 }
85 ]
86
87 this.sorts = [
88 {
89 id: '-match',
90 label: this.i18n('Relevance')
91 },
92 {
93 id: '-publishedAt',
94 label: this.i18n('Publish date')
95 },
96 {
97 id: '-views',
98 label: this.i18n('Views')
99 }
100 ]
101 }
102
103 ngOnInit () {
104 this.serverConfig = this.serverService.getTmpConfig()
105 this.serverService.getConfig()
106 .subscribe(config => this.serverConfig = config)
107
108 this.serverService.getVideoCategories().subscribe(categories => this.videoCategories = categories)
109 this.serverService.getVideoLicences().subscribe(licences => this.videoLicences = licences)
110 this.serverService.getVideoLanguages().subscribe(languages => this.videoLanguages = languages)
111
112 this.loadFromDurationRange()
113 this.loadFromPublishedRange()
114 this.loadOriginallyPublishedAtYears()
115 }
116
117 inputUpdated () {
118 this.updateModelFromDurationRange()
119 this.updateModelFromPublishedRange()
120 this.updateModelFromOriginallyPublishedAtYears()
121 }
122
123 formUpdated () {
124 this.inputUpdated()
125 this.filtered.emit(this.advancedSearch)
126 }
127
128 reset () {
129 this.advancedSearch.reset()
130 this.durationRange = undefined
131 this.publishedDateRange = undefined
132 this.originallyPublishedStartYear = undefined
133 this.originallyPublishedEndYear = undefined
134 this.inputUpdated()
135 }
136
137 resetField (fieldName: string, value?: any) {
138 this.advancedSearch[fieldName] = value
139 }
140
141 resetLocalField (fieldName: string, value?: any) {
142 this[fieldName] = value
143 this.inputUpdated()
144 }
145
146 resetOriginalPublicationYears () {
147 this.originallyPublishedStartYear = this.originallyPublishedEndYear = undefined
148 }
149
150 isSearchTargetEnabled () {
151 return this.serverConfig.search.searchIndex.enabled && this.serverConfig.search.searchIndex.disableLocalSearch !== true
152 }
153
154 private loadOriginallyPublishedAtYears () {
155 this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate
156 ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString()
157 : null
158
159 this.originallyPublishedEndYear = this.advancedSearch.originallyPublishedEndDate
160 ? new Date(this.advancedSearch.originallyPublishedEndDate).getFullYear().toString()
161 : null
162 }
163
164 private loadFromDurationRange () {
165 if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) {
166 const fourMinutes = 60 * 4
167 const tenMinutes = 60 * 10
168
169 if (this.advancedSearch.durationMin === fourMinutes && this.advancedSearch.durationMax === tenMinutes) {
170 this.durationRange = 'medium'
171 } else if (this.advancedSearch.durationMax === fourMinutes) {
172 this.durationRange = 'short'
173 } else if (this.advancedSearch.durationMin === tenMinutes) {
174 this.durationRange = 'long'
175 }
176 }
177 }
178
179 private loadFromPublishedRange () {
180 if (this.advancedSearch.startDate) {
181 const date = new Date(this.advancedSearch.startDate)
182 const now = new Date()
183
184 const diff = Math.abs(date.getTime() - now.getTime())
185
186 const dayMS = 1000 * 3600 * 24
187 const numberOfDays = diff / dayMS
188
189 if (numberOfDays >= 365) this.publishedDateRange = 'last_365days'
190 else if (numberOfDays >= 30) this.publishedDateRange = 'last_30days'
191 else if (numberOfDays >= 7) this.publishedDateRange = 'last_7days'
192 else if (numberOfDays >= 0) this.publishedDateRange = 'today'
193 }
194 }
195
196 private updateModelFromOriginallyPublishedAtYears () {
197 const baseDate = new Date()
198 baseDate.setHours(0, 0, 0, 0)
199 baseDate.setMonth(0, 1)
200
201 if (this.originallyPublishedStartYear) {
202 const year = parseInt(this.originallyPublishedStartYear, 10)
203 const start = new Date(baseDate)
204 start.setFullYear(year)
205
206 this.advancedSearch.originallyPublishedStartDate = start.toISOString()
207 } else {
208 this.advancedSearch.originallyPublishedStartDate = null
209 }
210
211 if (this.originallyPublishedEndYear) {
212 const year = parseInt(this.originallyPublishedEndYear, 10)
213 const end = new Date(baseDate)
214 end.setFullYear(year)
215
216 this.advancedSearch.originallyPublishedEndDate = end.toISOString()
217 } else {
218 this.advancedSearch.originallyPublishedEndDate = null
219 }
220 }
221
222 private updateModelFromDurationRange () {
223 if (!this.durationRange) return
224
225 const fourMinutes = 60 * 4
226 const tenMinutes = 60 * 10
227
228 switch (this.durationRange) {
229 case 'short':
230 this.advancedSearch.durationMin = undefined
231 this.advancedSearch.durationMax = fourMinutes
232 break
233
234 case 'medium':
235 this.advancedSearch.durationMin = fourMinutes
236 this.advancedSearch.durationMax = tenMinutes
237 break
238
239 case 'long':
240 this.advancedSearch.durationMin = tenMinutes
241 this.advancedSearch.durationMax = undefined
242 break
243 }
244 }
245
246 private updateModelFromPublishedRange () {
247 if (!this.publishedDateRange) return
248
249 // today
250 const date = new Date()
251 date.setHours(0, 0, 0, 0)
252
253 switch (this.publishedDateRange) {
254 case 'last_7days':
255 date.setDate(date.getDate() - 7)
256 break
257
258 case 'last_30days':
259 date.setDate(date.getDate() - 30)
260 break
261
262 case 'last_365days':
263 date.setDate(date.getDate() - 365)
264 break
265 }
266
267 this.advancedSearch.startDate = date.toISOString()
268 }
269}
diff --git a/client/src/app/+search/search-routing.module.ts b/client/src/app/+search/search-routing.module.ts
new file mode 100644
index 000000000..14a0d0a13
--- /dev/null
+++ b/client/src/app/+search/search-routing.module.ts
@@ -0,0 +1,41 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { MetaGuard } from '@ngx-meta/core'
4import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
5import { SearchComponent } from './search.component'
6import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
7
8const searchRoutes: Routes = [
9 {
10 path: '',
11 component: SearchComponent,
12 canActivate: [ MetaGuard ],
13 data: {
14 meta: {
15 title: 'Search'
16 }
17 }
18 },
19 {
20 path: 'lazy-load-video',
21 component: SearchComponent,
22 canActivate: [ MetaGuard ],
23 resolve: {
24 data: VideoLazyLoadResolver
25 }
26 },
27 {
28 path: 'lazy-load-channel',
29 component: SearchComponent,
30 canActivate: [ MetaGuard ],
31 resolve: {
32 data: ChannelLazyLoadResolver
33 }
34 }
35]
36
37@NgModule({
38 imports: [ RouterModule.forChild(searchRoutes) ],
39 exports: [ RouterModule ]
40})
41export class SearchRoutingModule {}
diff --git a/client/src/app/+search/search.component.html b/client/src/app/+search/search.component.html
new file mode 100644
index 000000000..9bff024ad
--- /dev/null
+++ b/client/src/app/+search/search.component.html
@@ -0,0 +1,63 @@
1<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
2 <div class="results-header">
3 <div class="first-line">
4 <div class="results-counter" *ngIf="pagination.totalItems">
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
10 <span *ngIf="currentSearch" i18n>
11 for <span class="search-value">{{ currentSearch }}</span>
12 </span>
13 </div>
14
15 <div
16 class="results-filter-button ml-auto" (click)="isSearchFilterCollapsed = !isSearchFilterCollapsed" role="button"
17 [attr.aria-expanded]="!isSearchFilterCollapsed" aria-controls="collapseBasic"
18 >
19 <span class="icon icon-filter"></span>
20 <ng-container i18n>
21 Filters
22 <span *ngIf="numberOfFilters() > 0" class="badge badge-secondary">{{ numberOfFilters() }}</span>
23 </ng-container>
24 </div>
25 </div>
26
27 <div class="results-filter collapse-transition" [ngbCollapse]="isSearchFilterCollapsed">
28 <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters>
29 </div>
30 </div>
31
32 <div i18n *ngIf="pagination.totalItems === 0 && results.length === 0" class="no-results">
33 No results found
34 </div>
35
36 <ng-container *ngFor="let result of results">
37 <div *ngIf="isVideoChannel(result)" class="entry video-channel">
38 <a [routerLink]="getChannelUrl(result)">
39 <img [src]="result.avatarUrl" alt="Avatar" />
40 </a>
41
42 <div class="video-channel-info">
43 <a [routerLink]="getChannelUrl(result)" class="video-channel-names">
44 <div class="video-channel-display-name">{{ result.displayName }}</div>
45 <div class="video-channel-name">{{ result.nameWithHost }}</div>
46 </a>
47
48 <div i18n class="video-channel-followers">{{ result.followersCount }} subscribers</div>
49 </div>
50
51 <my-subscribe-button *ngIf="!hideActions()" [videoChannels]="[result]"></my-subscribe-button>
52 </div>
53
54 <div *ngIf="isVideo(result)" class="entry video">
55 <my-video-miniature
56 [video]="result" [user]="userMiniature" [displayAsRow]="true" [displayVideoActions]="!hideActions()"
57 [displayOptions]="videoDisplayOptions" [useLazyLoadUrl]="advancedSearch.searchTarget === 'search-index'"
58 (videoBlocked)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)"
59 ></my-video-miniature>
60 </div>
61 </ng-container>
62
63</div>
diff --git a/client/src/app/+search/search.component.scss b/client/src/app/+search/search.component.scss
new file mode 100644
index 000000000..6e59adb60
--- /dev/null
+++ b/client/src/app/+search/search.component.scss
@@ -0,0 +1,191 @@
1@import '_variables';
2@import '_mixins';
3
4.search-result {
5 padding: 40px;
6
7 .results-header {
8 font-size: 16px;
9 padding-bottom: 20px;
10 margin-bottom: 30px;
11 border-bottom: 1px solid #DADADA;
12
13 .first-line {
14 display: flex;
15 flex-direction: row;
16
17 .results-counter {
18 flex-grow: 1;
19
20 .search-value {
21 font-weight: $font-semibold;
22 }
23 }
24
25 .results-filter-button {
26 cursor: pointer;
27
28 .icon.icon-filter {
29 @include icon(20px);
30
31 position: relative;
32 top: -1px;
33 margin-right: 5px;
34 background-image: url('../../assets/images/search/filter.svg');
35 }
36 }
37 }
38 }
39
40 .entry {
41 display: flex;
42 min-height: 130px;
43 padding-bottom: 20px;
44 margin-bottom: 20px;
45
46 &.video-channel {
47 img {
48 $image-size: 130px;
49 $margin-size: ($video-thumbnail-width - $image-size) / 2; // So we have the same width than the video miniature
50
51 @include avatar($image-size);
52
53 margin: 0 ($margin-size + 10) 0 $margin-size;
54 }
55
56 .video-channel-info {
57 flex-grow: 1;
58 width: fit-content;
59
60 .video-channel-names {
61 @include disable-default-a-behaviour;
62
63 display: flex;
64 align-items: baseline;
65 color: pvar(--mainForegroundColor);
66 width: fit-content;
67
68 .video-channel-display-name {
69 font-weight: $font-semibold;
70 font-size: 18px;
71 }
72
73 .video-channel-name {
74 font-size: 14px;
75 color: $grey-actor-name;
76 margin-left: 5px;
77 }
78 }
79 }
80 }
81 }
82}
83
84@media screen and (min-width: $small-view) and (max-width: breakpoint(xl)) {
85 .video-channel-info .video-channel-names {
86 flex-direction: column !important;
87
88 .video-channel-name {
89 @include ellipsis; // Ellipsis and max-width on channel-name to not break screen
90
91 max-width: 250px;
92 margin-left: 0 !important;
93 }
94 }
95
96 :host-context(.main-col:not(.expanded)) {
97 // Override the min-width: 500px to not break screen
98 ::ng-deep .video-miniature-information {
99 min-width: 300px !important;
100 }
101 }
102}
103
104@media screen and (min-width: $small-view) and (max-width: breakpoint(lg)) {
105 :host-context(.main-col:not(.expanded)) {
106 .video-channel-info .video-channel-names {
107 .video-channel-name {
108 max-width: 160px;
109 }
110 }
111
112 // Override the min-width: 500px to not break screen
113 ::ng-deep .video-miniature-information {
114 min-width: $video-thumbnail-width !important;
115 }
116 }
117
118 :host-context(.expanded) {
119 // Override the min-width: 500px to not break screen
120 ::ng-deep .video-miniature-information {
121 min-width: 300px !important;
122 }
123 }
124}
125
126@media screen and (max-width: $small-view) {
127 .search-result {
128 .entry.video-channel,
129 .entry.video {
130 flex-direction: column;
131 height: auto;
132 justify-content: center;
133 align-items: center;
134 text-align: center;
135
136 img {
137 margin: 0;
138 }
139
140 img {
141 margin: 0;
142 }
143
144 .video-channel-info .video-channel-names {
145 align-items: center;
146 flex-direction: column !important;
147
148 .video-channel-name {
149 margin-left: 0 !important;
150 }
151 }
152
153 my-subscribe-button {
154 margin-top: 5px;
155 }
156 }
157 }
158}
159
160@media screen and (max-width: $mobile-view) {
161 .search-result {
162 padding: 20px 10px;
163
164 .results-header {
165 font-size: 15px !important;
166 }
167
168 .entry {
169 &.video {
170 .video-info-name,
171 .video-info-account {
172 margin: auto;
173 }
174
175 my-video-thumbnail {
176 margin-right: 0 !important;
177
178 ::ng-deep .video-thumbnail {
179 width: 100%;
180 height: auto;
181
182 img {
183 width: 100%;
184 height: auto;
185 }
186 }
187 }
188 }
189 }
190 }
191}
diff --git a/client/src/app/+search/search.component.ts b/client/src/app/+search/search.component.ts
new file mode 100644
index 000000000..1ed54937b
--- /dev/null
+++ b/client/src/app/+search/search.component.ts
@@ -0,0 +1,259 @@
1import { forkJoin, of, Subscription } from 'rxjs'
2import { Component, OnDestroy, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, ComponentPagination, HooksService, Notifier, ServerService, User, UserService } from '@app/core'
5import { immutableAssign } from '@app/helpers'
6import { Video, VideoChannel } from '@app/shared/shared-main'
7import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
8import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
9import { MetaService } from '@ngx-meta/core'
10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { SearchTargetType, ServerConfig } from '@shared/models'
12
13@Component({
14 selector: 'my-search',
15 styleUrls: [ './search.component.scss' ],
16 templateUrl: './search.component.html'
17})
18export class SearchComponent implements OnInit, OnDestroy {
19 results: (Video | VideoChannel)[] = []
20
21 pagination: ComponentPagination = {
22 currentPage: 1,
23 itemsPerPage: 10, // Only for videos, use another variable for channels
24 totalItems: null
25 }
26 advancedSearch: AdvancedSearch = new AdvancedSearch()
27 isSearchFilterCollapsed = true
28 currentSearch: string
29
30 videoDisplayOptions: MiniatureDisplayOptions = {
31 date: true,
32 views: true,
33 by: true,
34 avatar: false,
35 privacyLabel: false,
36 privacyText: false,
37 state: false,
38 blacklistInfo: false
39 }
40
41 errorMessage: string
42 serverConfig: ServerConfig
43
44 userMiniature: User
45
46 private subActivatedRoute: Subscription
47 private isInitialLoad = false // set to false to show the search filters on first arrival
48 private firstSearch = true
49
50 private channelsPerPage = 2
51
52 private lastSearchTarget: SearchTargetType
53
54 constructor (
55 private i18n: I18n,
56 private route: ActivatedRoute,
57 private router: Router,
58 private metaService: MetaService,
59 private notifier: Notifier,
60 private searchService: SearchService,
61 private authService: AuthService,
62 private userService: UserService,
63 private hooks: HooksService,
64 private serverService: ServerService
65 ) { }
66
67 ngOnInit () {
68 this.serverService.getConfig()
69 .subscribe(config => this.serverConfig = config)
70
71 this.subActivatedRoute = this.route.queryParams.subscribe(
72 async queryParams => {
73 const querySearch = queryParams['search']
74 const searchTarget = queryParams['searchTarget']
75
76 // Search updated, reset filters
77 if (this.currentSearch !== querySearch || searchTarget !== this.advancedSearch.searchTarget) {
78 this.resetPagination()
79 this.advancedSearch.reset()
80
81 this.currentSearch = querySearch || undefined
82 this.updateTitle()
83 }
84
85 this.advancedSearch = new AdvancedSearch(queryParams)
86 if (!this.advancedSearch.searchTarget) {
87 this.advancedSearch.searchTarget = await this.serverService.getDefaultSearchTarget()
88 }
89
90 // Don't hide filters if we have some of them AND the user just came on the webpage
91 this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues()
92 this.isInitialLoad = false
93
94 this.search()
95 },
96
97 err => this.notifier.error(err.text)
98 )
99
100 this.userService.getAnonymousOrLoggedUser()
101 .subscribe(user => this.userMiniature = user)
102
103 this.hooks.runAction('action:search.init', 'search')
104 }
105
106 ngOnDestroy () {
107 if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe()
108 }
109
110 isVideoChannel (d: VideoChannel | Video): d is VideoChannel {
111 return d instanceof VideoChannel
112 }
113
114 isVideo (v: VideoChannel | Video): v is Video {
115 return v instanceof Video
116 }
117
118 isUserLoggedIn () {
119 return this.authService.isLoggedIn()
120 }
121
122 search () {
123 forkJoin([
124 this.getVideosObs(),
125 this.getVideoChannelObs()
126 ]).subscribe(
127 ([videosResult, videoChannelsResult]) => {
128 this.results = this.results
129 .concat(videoChannelsResult.data)
130 .concat(videosResult.data)
131
132 this.pagination.totalItems = videosResult.total + videoChannelsResult.total
133 this.lastSearchTarget = this.advancedSearch.searchTarget
134
135 // Focus on channels if there are no enough videos
136 if (this.firstSearch === true && videosResult.data.length < this.pagination.itemsPerPage) {
137 this.resetPagination()
138 this.firstSearch = false
139
140 this.channelsPerPage = 10
141 this.search()
142 }
143
144 this.firstSearch = false
145 },
146
147 err => {
148 if (this.advancedSearch.searchTarget !== 'search-index') {
149 this.notifier.error(err.message)
150 return
151 }
152
153 this.notifier.error(
154 this.i18n('Search index is unavailable. Retrying with instance results instead.'),
155 this.i18n('Search error')
156 )
157 this.advancedSearch.searchTarget = 'local'
158 this.search()
159 }
160 )
161 }
162
163 onNearOfBottom () {
164 // Last page
165 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
166
167 this.pagination.currentPage += 1
168 this.search()
169 }
170
171 onFiltered () {
172 this.resetPagination()
173
174 this.updateUrlFromAdvancedSearch()
175 }
176
177 numberOfFilters () {
178 return this.advancedSearch.size()
179 }
180
181 // Add VideoChannel for typings, but the template already checks "video" argument is a video
182 removeVideoFromArray (video: Video | VideoChannel) {
183 this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id)
184 }
185
186 getChannelUrl (channel: VideoChannel) {
187 if (this.advancedSearch.searchTarget === 'search-index' && channel.url) {
188 const remoteUriConfig = this.serverConfig.search.remoteUri
189
190 // Redirect on the external instance if not allowed to fetch remote data
191 const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
192 const fromPath = window.location.pathname + window.location.search
193
194 return [ '/search/lazy-load-channel', { url: channel.url, externalRedirect, fromPath } ]
195 }
196
197 return [ '/video-channels', channel.nameWithHost ]
198 }
199
200 hideActions () {
201 return this.lastSearchTarget === 'search-index'
202 }
203
204 private resetPagination () {
205 this.pagination.currentPage = 1
206 this.pagination.totalItems = null
207 this.channelsPerPage = 2
208
209 this.results = []
210 }
211
212 private updateTitle () {
213 const suffix = this.currentSearch ? ' ' + this.currentSearch : ''
214 this.metaService.setTitle(this.i18n('Search') + suffix)
215 }
216
217 private updateUrlFromAdvancedSearch () {
218 const search = this.currentSearch || undefined
219
220 this.router.navigate([], {
221 relativeTo: this.route,
222 queryParams: Object.assign({}, this.advancedSearch.toUrlObject(), { search })
223 })
224 }
225
226 private getVideosObs () {
227 const params = {
228 search: this.currentSearch,
229 componentPagination: this.pagination,
230 advancedSearch: this.advancedSearch
231 }
232
233 return this.hooks.wrapObsFun(
234 this.searchService.searchVideos.bind(this.searchService),
235 params,
236 'search',
237 'filter:api.search.videos.list.params',
238 'filter:api.search.videos.list.result'
239 )
240 }
241
242 private getVideoChannelObs () {
243 if (!this.currentSearch) return of({ data: [], total: 0 })
244
245 const params = {
246 search: this.currentSearch,
247 componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }),
248 searchTarget: this.advancedSearch.searchTarget
249 }
250
251 return this.hooks.wrapObsFun(
252 this.searchService.searchVideoChannels.bind(this.searchService),
253 params,
254 'search',
255 'filter:api.search.video-channels.list.params',
256 'filter:api.search.video-channels.list.result'
257 )
258 }
259}
diff --git a/client/src/app/+search/search.module.ts b/client/src/app/+search/search.module.ts
new file mode 100644
index 000000000..ee4f07ad1
--- /dev/null
+++ b/client/src/app/+search/search.module.ts
@@ -0,0 +1,44 @@
1import { TagInputModule } from 'ngx-chips'
2import { NgModule } from '@angular/core'
3import { SharedFormModule } from '@app/shared/shared-forms'
4import { SharedMainModule } from '@app/shared/shared-main'
5import { SharedSearchModule } from '@app/shared/shared-search'
6import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
7import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
8import { SearchService } from '../shared/shared-search/search.service'
9import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
10import { SearchFiltersComponent } from './search-filters.component'
11import { SearchRoutingModule } from './search-routing.module'
12import { SearchComponent } from './search.component'
13import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
14
15@NgModule({
16 imports: [
17 TagInputModule,
18
19 SearchRoutingModule,
20
21 SharedMainModule,
22 SharedSearchModule,
23 SharedFormModule,
24 SharedUserSubscriptionModule,
25 SharedVideoMiniatureModule
26 ],
27
28 declarations: [
29 SearchComponent,
30 SearchFiltersComponent
31 ],
32
33 exports: [
34 TagInputModule,
35 SearchComponent
36 ],
37
38 providers: [
39 SearchService,
40 VideoLazyLoadResolver,
41 ChannelLazyLoadResolver
42 ]
43})
44export class SearchModule { }
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..e8b2b8c74
--- /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 '@app/shared/shared-search'
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}