diff options
Diffstat (limited to 'client/src/app/header')
-rw-r--r-- | client/src/app/header/header.component.html | 12 | ||||
-rw-r--r-- | client/src/app/header/header.component.scss | 21 | ||||
-rw-r--r-- | client/src/app/header/index.ts | 1 | ||||
-rw-r--r-- | client/src/app/header/search-typeahead.component.html | 69 | ||||
-rw-r--r-- | client/src/app/header/search-typeahead.component.scss | 121 | ||||
-rw-r--r-- | client/src/app/header/search-typeahead.component.ts | 111 |
6 files changed, 314 insertions, 21 deletions
diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html index 4fd18f9bd..38c87c642 100644 --- a/client/src/app/header/header.component.html +++ b/client/src/app/header/header.component.html | |||
@@ -1,8 +1,10 @@ | |||
1 | <input | 1 | <my-search-typeahead> |
2 | type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…" | 2 | <input |
3 | [(ngModel)]="searchValue" (keyup.enter)="doSearch()" | 3 | type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…" |
4 | > | 4 | [(ngModel)]="searchValue" (keyup.enter)="doSearch()" |
5 | <span (click)="doSearch()" class="icon icon-search"></span> | 5 | > |
6 | <span (click)="doSearch()" class="icon icon-search"></span> | ||
7 | </my-search-typeahead> | ||
6 | 8 | ||
7 | <a class="upload-button" routerLink="/videos/upload"> | 9 | <a class="upload-button" routerLink="/videos/upload"> |
8 | <my-global-icon iconName="upload"></my-global-icon> | 10 | <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..b602cf0a8 100644 --- a/client/src/app/header/header.component.scss +++ b/client/src/app/header/header.component.scss | |||
@@ -1,31 +1,20 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | my-search-typeahead { | ||
5 | margin-right: 15px; | ||
6 | } | ||
7 | |||
4 | #search-video { | 8 | #search-video { |
5 | @include peertube-input-text($search-input-width); | 9 | @include peertube-input-text($search-input-width); |
6 | padding-left: 10px; | 10 | padding-left: 10px; |
7 | margin-right: 15px; | ||
8 | padding-right: 40px; // For the search icon | 11 | padding-right: 40px; // For the search icon |
9 | font-size: 14px; | 12 | font-size: 14px; |
10 | 13 | ||
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 { | 14 | &::placeholder { |
22 | color: var(--inputPlaceholderColor); | 15 | color: var(--inputPlaceholderColor); |
23 | } | 16 | } |
24 | 17 | ||
25 | &:focus::placeholder { | ||
26 | opacity: 0 !important; | ||
27 | } | ||
28 | |||
29 | @media screen and (max-width: 800px) { | 18 | @media screen and (max-width: 800px) { |
30 | width: calc(100% - 150px); | 19 | width: calc(100% - 150px); |
31 | } | 20 | } |
@@ -44,7 +33,7 @@ | |||
44 | 33 | ||
45 | // yolo | 34 | // yolo |
46 | position: absolute; | 35 | position: absolute; |
47 | margin-left: -50px; | 36 | margin-left: -35px; |
48 | margin-top: 5px; | 37 | margin-top: 5px; |
49 | } | 38 | } |
50 | 39 | ||
diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts index d98d2d00a..bf1787103 100644 --- a/client/src/app/header/index.ts +++ b/client/src/app/header/index.ts | |||
@@ -1 +1,2 @@ | |||
1 | export * from './header.component' | 1 | export * from './header.component' |
2 | export * from './search-typeahead.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..fe3f6ff4d --- /dev/null +++ b/client/src/app/header/search-typeahead.component.html | |||
@@ -0,0 +1,69 @@ | |||
1 | <div class="d-inline-flex position-relative" id="typeahead-container" #contentWrapper> | ||
2 | <ng-content></ng-content> | ||
3 | |||
4 | <div class="position-absolute jump-to-suggestions"> | ||
5 | <!-- suggestions --> | ||
6 | <ul id="jump-to-results" role="listbox" class="p-0 m-0" #optionsList> | ||
7 | <li *ngFor="let res of results" class="d-flex flex-justify-start flex-items-center p-0 f5" role="option" aria-selected="true"> | ||
8 | <ng-container *ngTemplateOutlet="result; context: {$implicit: res}"></ng-container> | ||
9 | </li> | ||
10 | </ul> | ||
11 | |||
12 | <!-- search instructions, when search input is empty --> | ||
13 | <div *ngIf="!hasSearch" id="typeahead-instructions" class="overflow-hidden"> | ||
14 | <div class="d-flex justify-content-between"> | ||
15 | <label class="small-title" i18n>Advanced search</label> | ||
16 | <div class="advanced-search-status"> | ||
17 | <span [ngClass]="URIPolicy === 'any' ? 'text-success' : 'text-muted'" [title]="URIPolicyText"> | ||
18 | <span class="mr-1" i18n>{URIPolicy, select, only-followed {only followed instances} other {any instance}} </span> | ||
19 | <i [ngClass]="URIPolicy === 'any' ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i> | ||
20 | </span> | ||
21 | </div> | ||
22 | </div> | ||
23 | <ul> | ||
24 | <li> | ||
25 | <em>@username@domain</em> <span class="flex-auto text-muted" i18n>account or channel</span> | ||
26 | </li> | ||
27 | <li> | ||
28 | <em>URL</em> <span class="text-muted" i18n>account or channel</span> | ||
29 | </li> | ||
30 | <li> | ||
31 | <em>URL</em> <span class="text-muted" i18n>video</span> | ||
32 | </li> | ||
33 | </ul> | ||
34 | <span class="text-muted" i18n>Any other text will return matching video, channel or account names.</span> | ||
35 | </div> | ||
36 | </div> | ||
37 | |||
38 | </div> | ||
39 | |||
40 | <ng-template #result let-result> | ||
41 | <a tabindex="0" class="d-flex flex-auto flex-items-center p-2" | ||
42 | data-target-type="Repository" | ||
43 | [routerLink]="result.routerLink" | ||
44 | > | ||
45 | <div class="flex-shrink-0 mr-2 text-center"> | ||
46 | <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon> | ||
47 | <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon> | ||
48 | </div> | ||
49 | |||
50 | <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28"> | ||
51 | |||
52 | <div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : searchInput.value"></div> | ||
53 | |||
54 | <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6"> | ||
55 | <span *ngIf="result.type === 'search-channel'" [attr.aria-label]="inThisChannelText"> | ||
56 | {{ inThisChannelText }} | ||
57 | </span> | ||
58 | <span *ngIf="result.type === 'search-global'" [attr.aria-label]="inAllText"> | ||
59 | {{ inAllText }} | ||
60 | </span> | ||
61 | <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span> | ||
62 | </div> | ||
63 | |||
64 | <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> | ||
65 | Jump to channel | ||
66 | <span class="d-inline-block ml-1 v-align-middle">↵</span> | ||
67 | </div> | ||
68 | </a> | ||
69 | </ng-template> \ No newline at end of file | ||
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..93f021e33 --- /dev/null +++ b/client/src/app/header/search-typeahead.component.scss | |||
@@ -0,0 +1,121 @@ | |||
1 | @import '_mixins'; | ||
2 | |||
3 | .jump-to-suggestions { | ||
4 | top: 100%; | ||
5 | left: 0; | ||
6 | z-index: 35; | ||
7 | width: 100%; | ||
8 | } | ||
9 | |||
10 | #typeahead-instructions, | ||
11 | #jump-to-results { | ||
12 | border: 1px solid var(--mainBackgroundColor); | ||
13 | border-bottom-right-radius: 3px; | ||
14 | border-bottom-left-radius: 3px; | ||
15 | background: var(--mainBackgroundColor); | ||
16 | transition: .3s ease; | ||
17 | transition-property: box-shadow; | ||
18 | } | ||
19 | |||
20 | #typeahead-instructions { | ||
21 | margin-top: 10px; | ||
22 | width: 100%; | ||
23 | padding: .5rem 1rem; | ||
24 | |||
25 | ul { | ||
26 | list-style: none; | ||
27 | padding: 0; | ||
28 | margin-bottom: .5rem; | ||
29 | |||
30 | em { | ||
31 | font-weight: 600; | ||
32 | margin-right: 0.2rem; | ||
33 | font-style: normal; | ||
34 | } | ||
35 | } | ||
36 | } | ||
37 | |||
38 | #typeahead-container { | ||
39 | ::ng-deep input { | ||
40 | border: 1px solid var(--mainBackgroundColor) !important; | ||
41 | box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px; | ||
42 | flex-grow: 1; | ||
43 | transition: box-shadow .3s ease, width .2s ease; | ||
44 | } | ||
45 | |||
46 | ::ng-deep span { | ||
47 | right: 10px; | ||
48 | } | ||
49 | |||
50 | & > div:last-child { | ||
51 | // we have to switch the display and not the opacity, | ||
52 | // to avoid clashing with the rest of the interface. | ||
53 | display: none; | ||
54 | } | ||
55 | |||
56 | &:focus, | ||
57 | ::ng-deep &:focus-within { | ||
58 | & > div:last-child { | ||
59 | display: initial !important; | ||
60 | |||
61 | #typeahead-instructions, | ||
62 | #jump-to-results { | ||
63 | box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px; | ||
64 | } | ||
65 | } | ||
66 | |||
67 | ::ng-deep input { | ||
68 | box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px; | ||
69 | border-end-start-radius: 0; | ||
70 | border-end-end-radius: 0; | ||
71 | |||
72 | @media screen and (min-width: 900px) { | ||
73 | width: 500px; | ||
74 | } | ||
75 | } | ||
76 | } | ||
77 | } | ||
78 | |||
79 | a.focus-visible { | ||
80 | background-color: var(--mainHoverColor); | ||
81 | } | ||
82 | |||
83 | a { | ||
84 | @include disable-default-a-behaviour; | ||
85 | width: 100%; | ||
86 | |||
87 | &, &:hover { | ||
88 | color: var(--mainForegroundColor); | ||
89 | } | ||
90 | } | ||
91 | |||
92 | .bg-gray { | ||
93 | background-color: var(--mainBackgroundColor); | ||
94 | } | ||
95 | |||
96 | .text-gray-light { | ||
97 | color: var(--mainForegroundColor); | ||
98 | } | ||
99 | |||
100 | .glyphicon { | ||
101 | top: 3px; | ||
102 | } | ||
103 | |||
104 | .advanced-search-status { | ||
105 | cursor: help; | ||
106 | } | ||
107 | |||
108 | .small-title { | ||
109 | @include in-content-small-title; | ||
110 | |||
111 | margin-bottom: .5rem; | ||
112 | } | ||
113 | |||
114 | my-global-icon { | ||
115 | width: 17px; | ||
116 | position: relative; | ||
117 | top: -2px; | ||
118 | margin: 5px; | ||
119 | |||
120 | @include apply-svg-color(var(--mainForegroundColor)) | ||
121 | } | ||
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..d12a9682e --- /dev/null +++ b/client/src/app/header/search-typeahead.component.ts | |||
@@ -0,0 +1,111 @@ | |||
1 | import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core' | ||
2 | import { Router, NavigationEnd } from '@angular/router' | ||
3 | import { AuthService } from '@app/core' | ||
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
5 | import { filter } from 'rxjs/operators' | ||
6 | import { ListKeyManager, ListKeyManagerOption } from '@angular/cdk/a11y' | ||
7 | import { UP_ARROW, DOWN_ARROW, ENTER } from '@angular/cdk/keycodes' | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-search-typeahead', | ||
11 | templateUrl: './search-typeahead.component.html', | ||
12 | styleUrls: [ './search-typeahead.component.scss' ] | ||
13 | }) | ||
14 | export class SearchTypeaheadComponent implements OnInit, AfterViewInit { | ||
15 | @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef | ||
16 | @ViewChild('optionsList', { static: true }) optionsList: ElementRef | ||
17 | |||
18 | hasChannel = false | ||
19 | inChannel = false | ||
20 | keyboardEventsManager: ListKeyManager<ListKeyManagerOption> | ||
21 | |||
22 | searchInput: HTMLInputElement | ||
23 | URIPolicy: 'only-followed' | 'any' = 'any' | ||
24 | |||
25 | URIPolicyText: string | ||
26 | inAllText: string | ||
27 | inThisChannelText: string | ||
28 | |||
29 | results: any[] = [] | ||
30 | |||
31 | constructor ( | ||
32 | private authService: AuthService, | ||
33 | private router: Router, | ||
34 | private i18n: I18n | ||
35 | ) { | ||
36 | this.URIPolicyText = this.i18n('Determines whether you can resolve any distant content from its URL, or if your instance only allows doing so for instances it follows.') | ||
37 | this.inAllText = this.i18n('In all PeerTube') | ||
38 | this.inThisChannelText = this.i18n('In this channel') | ||
39 | } | ||
40 | |||
41 | ngOnInit () { | ||
42 | this.router.events | ||
43 | .pipe(filter(event => event instanceof NavigationEnd)) | ||
44 | .subscribe((event: NavigationEnd) => { | ||
45 | this.hasChannel = event.url.startsWith('/videos/watch') | ||
46 | this.inChannel = event.url.startsWith('/video-channels') | ||
47 | this.computeResults() | ||
48 | }) | ||
49 | } | ||
50 | |||
51 | ngAfterViewInit () { | ||
52 | this.searchInput = this.contentWrapper.nativeElement.childNodes[0] | ||
53 | this.searchInput.addEventListener('input', this.computeResults.bind(this)) | ||
54 | } | ||
55 | |||
56 | get hasSearch () { | ||
57 | return !!this.searchInput && !!this.searchInput.value | ||
58 | } | ||
59 | |||
60 | computeResults () { | ||
61 | let results = [ | ||
62 | { | ||
63 | text: 'Maître poney', | ||
64 | type: 'channel' | ||
65 | } | ||
66 | ] | ||
67 | |||
68 | if (this.hasSearch) { | ||
69 | results = [ | ||
70 | { | ||
71 | text: this.searchInput.value, | ||
72 | type: 'search-channel' | ||
73 | }, | ||
74 | { | ||
75 | text: this.searchInput.value, | ||
76 | type: 'search-global' | ||
77 | }, | ||
78 | ...results | ||
79 | ] | ||
80 | } | ||
81 | |||
82 | this.results = results.filter( | ||
83 | result => { | ||
84 | // if we're not in a channel or one of its videos/playlits, show all channel-related results | ||
85 | if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel') | ||
86 | // if we're in a channel, show all channel-related results except for the channel redirection itself | ||
87 | if (this.inChannel) return !(result.type === 'channel') | ||
88 | return true | ||
89 | } | ||
90 | ) | ||
91 | } | ||
92 | |||
93 | isUserLoggedIn () { | ||
94 | return this.authService.isLoggedIn() | ||
95 | } | ||
96 | |||
97 | handleKeyUp (event: KeyboardEvent) { | ||
98 | event.stopImmediatePropagation() | ||
99 | if (this.keyboardEventsManager) { | ||
100 | if (event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) { | ||
101 | // passing the event to key manager so we get a change fired | ||
102 | this.keyboardEventsManager.onKeydown(event) | ||
103 | return false | ||
104 | } else if (event.keyCode === ENTER) { | ||
105 | // when we hit enter, the keyboardManager should call the selectItem method of the `ListItemComponent` | ||
106 | // this.keyboardEventsManager.activeItem | ||
107 | return false | ||
108 | } | ||
109 | } | ||
110 | } | ||
111 | } | ||