diff options
author | Chocobozzz <me@florianbigard.com> | 2020-06-23 14:49:20 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-06-23 16:00:49 +0200 |
commit | 1942f11d5ee6926ad93dc1b79fae18325ba5de18 (patch) | |
tree | 3f2a3cd9466a56c419d197ac832a3e9cbc86bec4 /client/src/app/+search | |
parent | 67ed6552b831df66713bac9e672738796128d33f (diff) | |
download | PeerTube-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.ts | 43 | ||||
-rw-r--r-- | client/src/app/+search/search-filters.component.html | 193 | ||||
-rw-r--r-- | client/src/app/+search/search-filters.component.scss | 69 | ||||
-rw-r--r-- | client/src/app/+search/search-filters.component.ts | 269 | ||||
-rw-r--r-- | client/src/app/+search/search-routing.module.ts | 41 | ||||
-rw-r--r-- | client/src/app/+search/search.component.html | 63 | ||||
-rw-r--r-- | client/src/app/+search/search.component.scss | 191 | ||||
-rw-r--r-- | client/src/app/+search/search.component.ts | 259 | ||||
-rw-r--r-- | client/src/app/+search/search.module.ts | 44 | ||||
-rw-r--r-- | client/src/app/+search/video-lazy-load.resolver.ts | 43 |
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 @@ | |||
1 | import { map } from 'rxjs/operators' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' | ||
4 | import { SearchService } from '@app/shared/shared-search' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 | |||
4 | form { | ||
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 | |||
30 | input[type=text] { | ||
31 | @include peertube-input-text(100%); | ||
32 | display: block; | ||
33 | } | ||
34 | |||
35 | input[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 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' | ||
2 | import { ValidatorFn } from '@angular/forms' | ||
3 | import { ServerService } from '@app/core' | ||
4 | import { VideoValidatorsService } from '@app/shared/shared-forms' | ||
5 | import { AdvancedSearch } from '@app/shared/shared-search' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { 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 | }) | ||
14 | export 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 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { MetaGuard } from '@ngx-meta/core' | ||
4 | import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' | ||
5 | import { SearchComponent } from './search.component' | ||
6 | import { VideoLazyLoadResolver } from './video-lazy-load.resolver' | ||
7 | |||
8 | const 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 | }) | ||
41 | export 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 @@ | |||
1 | import { forkJoin, of, Subscription } from 'rxjs' | ||
2 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
3 | import { ActivatedRoute, Router } from '@angular/router' | ||
4 | import { AuthService, ComponentPagination, HooksService, Notifier, ServerService, User, UserService } from '@app/core' | ||
5 | import { immutableAssign } from '@app/helpers' | ||
6 | import { Video, VideoChannel } from '@app/shared/shared-main' | ||
7 | import { AdvancedSearch, SearchService } from '@app/shared/shared-search' | ||
8 | import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' | ||
9 | import { MetaService } from '@ngx-meta/core' | ||
10 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
11 | import { SearchTargetType, ServerConfig } from '@shared/models' | ||
12 | |||
13 | @Component({ | ||
14 | selector: 'my-search', | ||
15 | styleUrls: [ './search.component.scss' ], | ||
16 | templateUrl: './search.component.html' | ||
17 | }) | ||
18 | export 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 @@ | |||
1 | import { TagInputModule } from 'ngx-chips' | ||
2 | import { NgModule } from '@angular/core' | ||
3 | import { SharedFormModule } from '@app/shared/shared-forms' | ||
4 | import { SharedMainModule } from '@app/shared/shared-main' | ||
5 | import { SharedSearchModule } from '@app/shared/shared-search' | ||
6 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' | ||
7 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' | ||
8 | import { SearchService } from '../shared/shared-search/search.service' | ||
9 | import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver' | ||
10 | import { SearchFiltersComponent } from './search-filters.component' | ||
11 | import { SearchRoutingModule } from './search-routing.module' | ||
12 | import { SearchComponent } from './search.component' | ||
13 | import { 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 | }) | ||
44 | export 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 @@ | |||
1 | import { map } from 'rxjs/operators' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router' | ||
4 | import { SearchService } from '@app/shared/shared-search' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 | } | ||