diff options
Diffstat (limited to 'client/src/app/header')
-rw-r--r-- | client/src/app/header/header.component.html | 6 | ||||
-rw-r--r-- | client/src/app/header/header.component.scss | 49 | ||||
-rw-r--r-- | client/src/app/header/header.component.ts | 60 | ||||
-rw-r--r-- | client/src/app/header/index.ts | 3 | ||||
-rw-r--r-- | client/src/app/header/search-typeahead.component.html | 53 | ||||
-rw-r--r-- | client/src/app/header/search-typeahead.component.scss | 145 | ||||
-rw-r--r-- | client/src/app/header/search-typeahead.component.ts | 179 | ||||
-rw-r--r-- | client/src/app/header/suggestion.component.html | 22 | ||||
-rw-r--r-- | client/src/app/header/suggestion.component.scss | 32 | ||||
-rw-r--r-- | client/src/app/header/suggestion.component.ts | 37 | ||||
-rw-r--r-- | client/src/app/header/suggestions.component.html | 6 | ||||
-rw-r--r-- | client/src/app/header/suggestions.component.ts | 24 |
12 files changed, 505 insertions, 111 deletions
diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html index 4fd18f9bd..49e219187 100644 --- a/client/src/app/header/header.component.html +++ b/client/src/app/header/header.component.html | |||
@@ -1,8 +1,4 @@ | |||
1 | <input | 1 | <my-search-typeahead class="w-100 d-flex justify-content-end"></my-search-typeahead> |
2 | type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…" | ||
3 | [(ngModel)]="searchValue" (keyup.enter)="doSearch()" | ||
4 | > | ||
5 | <span (click)="doSearch()" class="icon icon-search"></span> | ||
6 | 2 | ||
7 | <a class="upload-button" routerLink="/videos/upload"> | 3 | <a class="upload-button" routerLink="/videos/upload"> |
8 | <my-global-icon iconName="upload"></my-global-icon> | 4 | <my-global-icon iconName="upload"></my-global-icon> |
diff --git a/client/src/app/header/header.component.scss b/client/src/app/header/header.component.scss index 2bbde74bc..91b390773 100644 --- a/client/src/app/header/header.component.scss +++ b/client/src/app/header/header.component.scss | |||
@@ -1,51 +1,8 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | #search-video { | 4 | my-search-typeahead { |
5 | @include peertube-input-text($search-input-width); | ||
6 | padding-left: 10px; | ||
7 | margin-right: 15px; | 5 | margin-right: 15px; |
8 | padding-right: 40px; // For the search icon | ||
9 | font-size: 14px; | ||
10 | |||
11 | transition: box-shadow .3s ease; | ||
12 | |||
13 | /* light border style */ | ||
14 | border: 1px solid var(--mainBackgroundColor); | ||
15 | box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px; | ||
16 | |||
17 | &:focus { | ||
18 | box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px; | ||
19 | } | ||
20 | |||
21 | &::placeholder { | ||
22 | color: var(--inputPlaceholderColor); | ||
23 | } | ||
24 | |||
25 | &:focus::placeholder { | ||
26 | opacity: 0 !important; | ||
27 | } | ||
28 | |||
29 | @media screen and (max-width: 800px) { | ||
30 | width: calc(100% - 150px); | ||
31 | } | ||
32 | |||
33 | @media screen and (max-width: 600px) { | ||
34 | width: calc(100% - 70px); | ||
35 | } | ||
36 | } | ||
37 | |||
38 | .icon.icon-search { | ||
39 | @include icon(25px); | ||
40 | height: 21px; | ||
41 | |||
42 | background-color: var(--mainForegroundColor); | ||
43 | mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%; | ||
44 | |||
45 | // yolo | ||
46 | position: absolute; | ||
47 | margin-left: -50px; | ||
48 | margin-top: 5px; | ||
49 | } | 6 | } |
50 | 7 | ||
51 | .upload-button { | 8 | .upload-button { |
@@ -56,10 +13,6 @@ | |||
56 | color: var(--mainBackgroundColor) !important; | 13 | color: var(--mainBackgroundColor) !important; |
57 | margin-right: 25px; | 14 | margin-right: 25px; |
58 | 15 | ||
59 | @media screen and (max-width: 800px) { | ||
60 | margin-right: 0; | ||
61 | } | ||
62 | |||
63 | @media screen and (max-width: 600px) { | 16 | @media screen and (max-width: 600px) { |
64 | margin-right: 10px; | 17 | margin-right: 10px; |
65 | padding: 0 10px; | 18 | padding: 0 10px; |
diff --git a/client/src/app/header/header.component.ts b/client/src/app/header/header.component.ts index 92a7eded6..cce76b0d1 100644 --- a/client/src/app/header/header.component.ts +++ b/client/src/app/header/header.component.ts | |||
@@ -1,10 +1,4 @@ | |||
1 | import { filter, first, map, tap } from 'rxjs/operators' | 1 | import { Component } from '@angular/core' |
2 | import { Component, OnInit } from '@angular/core' | ||
3 | import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router' | ||
4 | import { getParameterByName } from '../shared/misc/utils' | ||
5 | import { AuthService, Notifier, ServerService } from '@app/core' | ||
6 | import { of } from 'rxjs' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | 2 | ||
9 | @Component({ | 3 | @Component({ |
10 | selector: 'my-header', | 4 | selector: 'my-header', |
@@ -12,54 +6,4 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
12 | styleUrls: [ './header.component.scss' ] | 6 | styleUrls: [ './header.component.scss' ] |
13 | }) | 7 | }) |
14 | 8 | ||
15 | export class HeaderComponent implements OnInit { | 9 | export class HeaderComponent {} |
16 | searchValue = '' | ||
17 | ariaLabelTextForSearch = '' | ||
18 | |||
19 | constructor ( | ||
20 | private router: Router, | ||
21 | private route: ActivatedRoute, | ||
22 | private auth: AuthService, | ||
23 | private serverService: ServerService, | ||
24 | private authService: AuthService, | ||
25 | private notifier: Notifier, | ||
26 | private i18n: I18n | ||
27 | ) {} | ||
28 | |||
29 | ngOnInit () { | ||
30 | this.ariaLabelTextForSearch = this.i18n('Search videos, channels') | ||
31 | |||
32 | this.router.events | ||
33 | .pipe( | ||
34 | filter(e => e instanceof NavigationEnd), | ||
35 | map(() => getParameterByName('search', window.location.href)) | ||
36 | ) | ||
37 | .subscribe(searchQuery => this.searchValue = searchQuery || '') | ||
38 | } | ||
39 | |||
40 | doSearch () { | ||
41 | const queryParams: Params = {} | ||
42 | |||
43 | if (window.location.pathname === '/search' && this.route.snapshot.queryParams) { | ||
44 | Object.assign(queryParams, this.route.snapshot.queryParams) | ||
45 | } | ||
46 | |||
47 | Object.assign(queryParams, { search: this.searchValue }) | ||
48 | |||
49 | const o = this.auth.isLoggedIn() | ||
50 | ? this.loadUserLanguagesIfNeeded(queryParams) | ||
51 | : of(true) | ||
52 | |||
53 | o.subscribe(() => this.router.navigate([ '/search' ], { queryParams })) | ||
54 | } | ||
55 | |||
56 | private loadUserLanguagesIfNeeded (queryParams: any) { | ||
57 | if (queryParams && queryParams.languageOneOf) return of(queryParams) | ||
58 | |||
59 | return this.auth.userInformationLoaded | ||
60 | .pipe( | ||
61 | first(), | ||
62 | tap(() => Object.assign(queryParams, { languageOneOf: this.auth.getUser().videoLanguages })) | ||
63 | ) | ||
64 | } | ||
65 | } | ||
diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts index d98d2d00a..a882d4d1f 100644 --- a/client/src/app/header/index.ts +++ b/client/src/app/header/index.ts | |||
@@ -1 +1,4 @@ | |||
1 | export * from './header.component' | 1 | export * from './header.component' |
2 | export * from './search-typeahead.component' | ||
3 | export * from './suggestions.component' | ||
4 | export * from './suggestion.component' | ||
diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html new file mode 100644 index 000000000..710268664 --- /dev/null +++ b/client/src/app/header/search-typeahead.component.html | |||
@@ -0,0 +1,53 @@ | |||
1 | <div class="d-inline-flex position-relative" id="typeahead-container"> | ||
2 | <input | ||
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()" | ||
5 | > | ||
6 | <span class="icon icon-search" (click)="doSearch()"></span> | ||
7 | |||
8 | <div class="position-absolute jump-to-suggestions"> | ||
9 | <!-- suggestions --> | ||
10 | <my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></my-suggestions> | ||
11 | |||
12 | <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion --> | ||
13 | <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden"> | ||
14 | <ng-container *ngIf="activeResult.type === 'search-global'"> | ||
15 | <div class="d-flex justify-content-between"> | ||
16 | <label class="small-title" i18n>GLOBAL SEARCH</label> | ||
17 | <div class="advanced-search-status text-muted"> | ||
18 | <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span> | ||
19 | <i class="glyphicon glyphicon-globe"></i> | ||
20 | </div> | ||
21 | </div> | ||
22 | <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> | ||
23 | </ng-container> | ||
24 | </div> | ||
25 | |||
26 | <!-- search instructions, when search input is empty --> | ||
27 | <div *ngIf="areInstructionsDisplayed" id="typeahead-instructions" class="overflow-hidden"> | ||
28 | <div class="d-flex justify-content-between"> | ||
29 | <label class="small-title" i18n>ADVANCED SEARCH</label> | ||
30 | <div class="advanced-search-status c-help"> | ||
31 | <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."> | ||
32 | <span *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span> | ||
33 | <span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span> | ||
34 | <i [ngClass]="canSearchAnyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i> | ||
35 | </span> | ||
36 | </div> | ||
37 | </div> | ||
38 | <ul> | ||
39 | <li> | ||
40 | <em>@channel_id@domain</em> <span class="flex-auto text-muted" i18n>channel</span> | ||
41 | </li> | ||
42 | <li> | ||
43 | <em>URL</em> <span class="text-muted" i18n>channel</span> | ||
44 | </li> | ||
45 | <li> | ||
46 | <em>UUID</em> <span class="text-muted" i18n>video</span> | ||
47 | </li> | ||
48 | </ul> | ||
49 | <span class="text-muted" i18n>Any other text will return matching video or channel names.</span> | ||
50 | </div> | ||
51 | </div> | ||
52 | |||
53 | </div> | ||
diff --git a/client/src/app/header/search-typeahead.component.scss b/client/src/app/header/search-typeahead.component.scss new file mode 100644 index 000000000..33b88825f --- /dev/null +++ b/client/src/app/header/search-typeahead.component.scss | |||
@@ -0,0 +1,145 @@ | |||
1 | @import '_mixins'; | ||
2 | @import '_variables'; | ||
3 | @import '_bootstrap-variables'; | ||
4 | @import '~bootstrap/scss/mixins/_breakpoints'; | ||
5 | |||
6 | #search-video { | ||
7 | @include peertube-input-text($search-input-width); | ||
8 | padding-left: 10px; | ||
9 | padding-right: 40px; // For the search icon | ||
10 | font-size: 14px; | ||
11 | |||
12 | &::placeholder { | ||
13 | color: var(--inputPlaceholderColor); | ||
14 | } | ||
15 | } | ||
16 | |||
17 | .icon.icon-search { | ||
18 | @include icon(25px); | ||
19 | height: 21px; | ||
20 | |||
21 | background-color: var(--mainForegroundColor); | ||
22 | mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%; | ||
23 | |||
24 | // yolo | ||
25 | position: absolute; | ||
26 | margin-left: -35px; | ||
27 | margin-top: 5px; | ||
28 | } | ||
29 | |||
30 | .jump-to-suggestions { | ||
31 | top: 100%; | ||
32 | left: 0; | ||
33 | z-index: z(typeahead); | ||
34 | width: 100%; | ||
35 | } | ||
36 | |||
37 | #typeahead-help, | ||
38 | #typeahead-instructions, | ||
39 | my-suggestions ::ng-deep ul { | ||
40 | border: 1px solid var(--mainBackgroundColor); | ||
41 | border-bottom-right-radius: 3px; | ||
42 | border-bottom-left-radius: 3px; | ||
43 | background: var(--mainBackgroundColor); | ||
44 | transition: .3s ease; | ||
45 | transition-property: box-shadow; | ||
46 | } | ||
47 | |||
48 | #typeahead-help, | ||
49 | #typeahead-instructions { | ||
50 | margin-top: 10px; | ||
51 | width: 100%; | ||
52 | padding: .5rem 1rem; | ||
53 | white-space: normal; | ||
54 | |||
55 | ul { | ||
56 | list-style: none; | ||
57 | padding: 0; | ||
58 | margin-bottom: .5rem; | ||
59 | |||
60 | em { | ||
61 | font-weight: 600; | ||
62 | margin-right: 0.2rem; | ||
63 | font-style: normal; | ||
64 | } | ||
65 | } | ||
66 | } | ||
67 | |||
68 | #typeahead-container { | ||
69 | input { | ||
70 | border: 1px solid var(--mainBackgroundColor) !important; | ||
71 | box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px; | ||
72 | flex-grow: 1; | ||
73 | transition: box-shadow .3s ease, width .2s ease; | ||
74 | } | ||
75 | |||
76 | @media screen and (min-width: $mobile-view) { | ||
77 | margin-left: 10px; | ||
78 | } | ||
79 | |||
80 | @media screen and (max-width: $small-view) { | ||
81 | flex: 1; | ||
82 | |||
83 | input { | ||
84 | width: unset; | ||
85 | } | ||
86 | } | ||
87 | |||
88 | span { | ||
89 | right: 10px; | ||
90 | } | ||
91 | |||
92 | & > div:last-child { | ||
93 | // we have to switch the display and not the opacity, | ||
94 | // to avoid clashing with the rest of the interface. | ||
95 | display: none; | ||
96 | } | ||
97 | |||
98 | &:focus, | ||
99 | ::ng-deep &:focus-within { | ||
100 | & > div:last-child { | ||
101 | @media screen and (min-width: $mobile-view) { | ||
102 | display: initial !important; | ||
103 | } | ||
104 | |||
105 | #typeahead-help, | ||
106 | #typeahead-instructions, | ||
107 | my-suggestions ::ng-deep ul { | ||
108 | box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px; | ||
109 | } | ||
110 | } | ||
111 | |||
112 | ::ng-deep input { | ||
113 | box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px; | ||
114 | border-end-start-radius: 0; | ||
115 | border-end-end-radius: 0; | ||
116 | |||
117 | @include media-breakpoint-up(lg) { | ||
118 | width: 500px; | ||
119 | } | ||
120 | } | ||
121 | } | ||
122 | } | ||
123 | |||
124 | .glyphicon { | ||
125 | top: 3px; | ||
126 | } | ||
127 | |||
128 | .advanced-search-status { | ||
129 | height: max-content; | ||
130 | cursor: default; | ||
131 | |||
132 | &.c-help { | ||
133 | cursor: help; | ||
134 | } | ||
135 | } | ||
136 | |||
137 | .small-title { | ||
138 | @include in-content-small-title; | ||
139 | |||
140 | margin-bottom: .5rem; | ||
141 | } | ||
142 | |||
143 | ::ng-deep my-suggestion { | ||
144 | width: 100%; | ||
145 | } | ||
diff --git a/client/src/app/header/search-typeahead.component.ts b/client/src/app/header/search-typeahead.component.ts new file mode 100644 index 000000000..2bf1072f4 --- /dev/null +++ b/client/src/app/header/search-typeahead.component.ts | |||
@@ -0,0 +1,179 @@ | |||
1 | import { Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core' | ||
2 | import { ActivatedRoute, Params, Router } from '@angular/router' | ||
3 | import { AuthService, ServerService } from '@app/core' | ||
4 | import { first, tap } from 'rxjs/operators' | ||
5 | import { ListKeyManager } from '@angular/cdk/a11y' | ||
6 | import { Result, SuggestionComponent } from './suggestion.component' | ||
7 | import { of } from 'rxjs' | ||
8 | import { ServerConfig } from '@shared/models' | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-search-typeahead', | ||
12 | templateUrl: './search-typeahead.component.html', | ||
13 | styleUrls: [ './search-typeahead.component.scss' ] | ||
14 | }) | ||
15 | export class SearchTypeaheadComponent implements OnInit, OnDestroy { | ||
16 | @ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement> | ||
17 | |||
18 | hasChannel = false | ||
19 | inChannel = false | ||
20 | newSearch = true | ||
21 | |||
22 | search = '' | ||
23 | serverConfig: ServerConfig | ||
24 | |||
25 | inThisChannelText: string | ||
26 | |||
27 | keyboardEventsManager: ListKeyManager<SuggestionComponent> | ||
28 | results: Result[] = [] | ||
29 | |||
30 | constructor ( | ||
31 | private authService: AuthService, | ||
32 | private router: Router, | ||
33 | private route: ActivatedRoute, | ||
34 | private serverService: ServerService | ||
35 | ) {} | ||
36 | |||
37 | ngOnInit () { | ||
38 | this.route.queryParams | ||
39 | .pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null)) | ||
40 | .subscribe(params => this.search = params.search) | ||
41 | this.serverService.getConfig() | ||
42 | .subscribe(config => this.serverConfig = config) | ||
43 | } | ||
44 | |||
45 | ngOnDestroy () { | ||
46 | if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() | ||
47 | } | ||
48 | |||
49 | get activeResult () { | ||
50 | return this.keyboardEventsManager?.activeItem?.result | ||
51 | } | ||
52 | |||
53 | get areInstructionsDisplayed () { | ||
54 | return !this.search | ||
55 | } | ||
56 | |||
57 | get showHelp () { | ||
58 | return this.search && this.newSearch && this.activeResult?.type === 'search-global' | ||
59 | } | ||
60 | |||
61 | get canSearchAnyURI () { | ||
62 | if (!this.serverConfig) return false | ||
63 | return this.authService.isLoggedIn() | ||
64 | ? this.serverConfig.search.remoteUri.users | ||
65 | : this.serverConfig.search.remoteUri.anonymous | ||
66 | } | ||
67 | |||
68 | onSearchChange () { | ||
69 | this.computeResults() | ||
70 | } | ||
71 | |||
72 | computeResults () { | ||
73 | this.newSearch = true | ||
74 | let results: Result[] = [] | ||
75 | |||
76 | if (this.search) { | ||
77 | results = [ | ||
78 | /* Channel search is still unimplemented. Uncomment when it is. | ||
79 | { | ||
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 | } | ||
98 | |||
99 | this.results = results.filter( | ||
100 | (result: Result) => { | ||
101 | // if we're not in a channel or one of its videos/playlits, show all channel-related results | ||
102 | if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel') | ||
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 | ) | ||
109 | } | ||
110 | |||
111 | setEventItems (event: { items: QueryList<SuggestionComponent>, index?: number }) { | ||
112 | event.items.forEach(e => { | ||
113 | if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) { | ||
114 | this.keyboardEventsManager.activeItem.active = true | ||
115 | } else { | ||
116 | e.active = false | ||
117 | } | ||
118 | }) | ||
119 | } | ||
120 | |||
121 | initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) { | ||
122 | if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() | ||
123 | |||
124 | this.keyboardEventsManager = new ListKeyManager(event.items) | ||
125 | |||
126 | if (event.index !== undefined) { | ||
127 | this.keyboardEventsManager.setActiveItem(event.index) | ||
128 | } else { | ||
129 | this.keyboardEventsManager.setFirstItemActive() | ||
130 | } | ||
131 | |||
132 | this.keyboardEventsManager.change.subscribe( | ||
133 | _ => this.setEventItems(event) | ||
134 | ) | ||
135 | } | ||
136 | |||
137 | handleKey (event: KeyboardEvent) { | ||
138 | event.stopImmediatePropagation() | ||
139 | if (!this.keyboardEventsManager) return | ||
140 | |||
141 | switch (event.key) { | ||
142 | case 'ArrowDown': | ||
143 | case 'ArrowUp': | ||
144 | this.keyboardEventsManager.onKeydown(event) | ||
145 | break | ||
146 | } | ||
147 | } | ||
148 | |||
149 | isOnSearch () { | ||
150 | return window.location.pathname === '/search' | ||
151 | } | ||
152 | |||
153 | doSearch () { | ||
154 | this.newSearch = false | ||
155 | const queryParams: Params = {} | ||
156 | |||
157 | if (this.isOnSearch() && this.route.snapshot.queryParams) { | ||
158 | Object.assign(queryParams, this.route.snapshot.queryParams) | ||
159 | } | ||
160 | |||
161 | Object.assign(queryParams, { search: this.search }) | ||
162 | |||
163 | const o = this.authService.isLoggedIn() | ||
164 | ? this.loadUserLanguagesIfNeeded(queryParams) | ||
165 | : of(true) | ||
166 | |||
167 | o.subscribe(() => this.router.navigate([ '/search' ], { queryParams })) | ||
168 | } | ||
169 | |||
170 | private loadUserLanguagesIfNeeded (queryParams: any) { | ||
171 | if (queryParams && queryParams.languageOneOf) return of(queryParams) | ||
172 | |||
173 | return this.authService.userInformationLoaded | ||
174 | .pipe( | ||
175 | first(), | ||
176 | tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages })) | ||
177 | ) | ||
178 | } | ||
179 | } | ||
diff --git a/client/src/app/header/suggestion.component.html b/client/src/app/header/suggestion.component.html new file mode 100644 index 000000000..d7ae3450a --- /dev/null +++ b/client/src/app/header/suggestion.component.html | |||
@@ -0,0 +1,22 @@ | |||
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"> | ||
3 | <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon> | ||
4 | <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon> | ||
5 | </div> | ||
6 | |||
7 | <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28"> | ||
8 | |||
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> | ||
10 | |||
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"> | ||
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-global'" 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> | ||
17 | |||
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.scss b/client/src/app/header/suggestion.component.scss new file mode 100644 index 000000000..1de2f43bd --- /dev/null +++ b/client/src/app/header/suggestion.component.scss | |||
@@ -0,0 +1,32 @@ | |||
1 | @import '_mixins'; | ||
2 | |||
3 | a { | ||
4 | @include disable-default-a-behaviour; | ||
5 | width: 100%; | ||
6 | |||
7 | &, &:hover { | ||
8 | color: var(--mainForegroundColor); | ||
9 | |||
10 | &.focus-visible { | ||
11 | background-color: var(--mainHoverColor); | ||
12 | color: var(--mainBackgroundColor); | ||
13 | } | ||
14 | } | ||
15 | } | ||
16 | |||
17 | .bg-gray { | ||
18 | background-color: var(--mainBackgroundColor); | ||
19 | } | ||
20 | |||
21 | .text-gray-light { | ||
22 | color: var(--mainForegroundColor); | ||
23 | } | ||
24 | |||
25 | my-global-icon { | ||
26 | width: 17px; | ||
27 | position: relative; | ||
28 | top: -2px; | ||
29 | margin: 5px; | ||
30 | |||
31 | @include apply-svg-color(var(--mainForegroundColor)); | ||
32 | } | ||
diff --git a/client/src/app/header/suggestion.component.ts b/client/src/app/header/suggestion.component.ts new file mode 100644 index 000000000..69641b511 --- /dev/null +++ b/client/src/app/header/suggestion.component.ts | |||
@@ -0,0 +1,37 @@ | |||
1 | import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core' | ||
2 | import { RouterLink } from '@angular/router' | ||
3 | import { ListKeyManagerOption } from '@angular/cdk/a11y' | ||
4 | |||
5 | export type Result = { | ||
6 | text: string | ||
7 | type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any' | ||
8 | routerLink?: RouterLink, | ||
9 | default?: boolean | ||
10 | } | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-suggestion', | ||
14 | templateUrl: './suggestion.component.html', | ||
15 | styleUrls: [ './suggestion.component.scss' ], | ||
16 | changeDetection: ChangeDetectionStrategy.OnPush | ||
17 | }) | ||
18 | export class SuggestionComponent implements OnInit, ListKeyManagerOption { | ||
19 | @Input() result: Result | ||
20 | @Input() highlight: string | ||
21 | @Output() selected = new EventEmitter() | ||
22 | |||
23 | disabled = false | ||
24 | active = false | ||
25 | |||
26 | getLabel () { | ||
27 | return this.result.text | ||
28 | } | ||
29 | |||
30 | ngOnInit () { | ||
31 | if (this.result.default) this.active = true | ||
32 | } | ||
33 | |||
34 | selectItem () { | ||
35 | this.selected.emit(this.result) | ||
36 | } | ||
37 | } | ||
diff --git a/client/src/app/header/suggestions.component.html b/client/src/app/header/suggestions.component.html new file mode 100644 index 000000000..8d017d78d --- /dev/null +++ b/client/src/app/header/suggestions.component.html | |||
@@ -0,0 +1,6 @@ | |||
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 new file mode 100644 index 000000000..ee3ef73c2 --- /dev/null +++ b/client/src/app/header/suggestions.component.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core' | ||
2 | import { SuggestionComponent } from './suggestion.component' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-suggestions', | ||
6 | templateUrl: './suggestions.component.html', | ||
7 | changeDetection: ChangeDetectionStrategy.OnPush | ||
8 | }) | ||
9 | export 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 | } | ||