aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app
diff options
context:
space:
mode:
authorRigel Kent <sendmemail@rigelk.eu>2020-01-20 15:12:51 +0100
committerRigel Kent <sendmemail@rigelk.eu>2020-02-13 16:32:21 +0100
commitf409f0c3b91d85c66b4841d72ea65b5fbe1483d8 (patch)
tree72c2ab403f6b0708921b64bafae5ef971dfbde3e /client/src/app
parent36f2981f7d586cea206e4c143c18cf866a4e3acd (diff)
downloadPeerTube-f409f0c3b91d85c66b4841d72ea65b5fbe1483d8.tar.gz
PeerTube-f409f0c3b91d85c66b4841d72ea65b5fbe1483d8.tar.zst
PeerTube-f409f0c3b91d85c66b4841d72ea65b5fbe1483d8.zip
Search typeahead initial design
Diffstat (limited to 'client/src/app')
-rw-r--r--client/src/app/app.module.ts3
-rw-r--r--client/src/app/header/header.component.html12
-rw-r--r--client/src/app/header/header.component.scss21
-rw-r--r--client/src/app/header/index.ts1
-rw-r--r--client/src/app/header/search-typeahead.component.html69
-rw-r--r--client/src/app/header/search-typeahead.component.scss121
-rw-r--r--client/src/app/header/search-typeahead.component.ts111
-rw-r--r--client/src/app/shared/angular/highlight.pipe.ts52
-rw-r--r--client/src/app/shared/shared.module.ts3
9 files changed, 371 insertions, 22 deletions
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index d11dba34d..2db33d638 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -9,7 +9,7 @@ import 'focus-visible'
9import { AppRoutingModule } from './app-routing.module' 9import { AppRoutingModule } from './app-routing.module'
10import { AppComponent } from './app.component' 10import { AppComponent } from './app.component'
11import { CoreModule } from './core' 11import { CoreModule } from './core'
12import { HeaderComponent } from './header' 12import { HeaderComponent, SearchTypeaheadComponent } from './header'
13import { LoginModule } from './login' 13import { LoginModule } from './login'
14import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' 14import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
15import { SharedModule } from './shared' 15import { SharedModule } from './shared'
@@ -41,6 +41,7 @@ export function metaFactory (serverService: ServerService): MetaLoader {
41 LanguageChooserComponent, 41 LanguageChooserComponent,
42 AvatarNotificationComponent, 42 AvatarNotificationComponent,
43 HeaderComponent, 43 HeaderComponent,
44 SearchTypeaheadComponent,
44 45
45 WelcomeModalComponent, 46 WelcomeModalComponent,
46 InstanceConfigWarningModalComponent 47 InstanceConfigWarningModalComponent
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
4my-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 @@
1export * from './header.component' 1export * from './header.component'
2export * 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
79a.focus-visible {
80 background-color: var(--mainHoverColor);
81}
82
83a {
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
114my-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 @@
1import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core'
2import { Router, NavigationEnd } from '@angular/router'
3import { AuthService } from '@app/core'
4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { filter } from 'rxjs/operators'
6import { ListKeyManager, ListKeyManagerOption } from '@angular/cdk/a11y'
7import { 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})
14export 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}
diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts
new file mode 100644
index 000000000..4199d833e
--- /dev/null
+++ b/client/src/app/shared/angular/highlight.pipe.ts
@@ -0,0 +1,52 @@
1import { PipeTransform, Pipe } from '@angular/core'
2import { SafeHtml } from '@angular/platform-browser'
3
4// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
5@Pipe({ name: 'highlight' })
6export class HighlightPipe implements PipeTransform {
7 /* use this for single match search */
8 static SINGLE_MATCH: string = "Single-Match"
9 /* use this for single match search with a restriction that target should start with search string */
10 static SINGLE_AND_STARTS_WITH_MATCH: string = "Single-And-StartsWith-Match"
11 /* use this for global search */
12 static MULTI_MATCH: string = "Multi-Match"
13
14 constructor() {}
15 transform(
16 contentString: string = null,
17 stringToHighlight: string = null,
18 option: string = "Single-And-StartsWith-Match",
19 caseSensitive: boolean = false,
20 highlightStyleName: string = "search-highlight"
21 ): SafeHtml {
22 if (stringToHighlight && contentString && option) {
23 let regex: any = ""
24 let caseFlag: string = !caseSensitive ? "i" : ""
25 switch (option) {
26 case "Single-Match": {
27 regex = new RegExp(stringToHighlight, caseFlag)
28 break
29 }
30 case "Single-And-StartsWith-Match": {
31 regex = new RegExp("^" + stringToHighlight, caseFlag)
32 break
33 }
34 case "Multi-Match": {
35 regex = new RegExp(stringToHighlight, "g" + caseFlag)
36 break
37 }
38 default: {
39 // default will be a global case-insensitive match
40 regex = new RegExp(stringToHighlight, "gi")
41 }
42 }
43 const replaced = contentString.replace(
44 regex,
45 (match) => `<span class="${highlightStyleName}">${match}</span>`
46 )
47 return replaced
48 } else {
49 return contentString
50 }
51 }
52}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 98211c727..090a5b7f9 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -89,6 +89,7 @@ import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
89import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe' 89import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe'
90import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' 90import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
91import { FromNowPipe } from '@app/shared/angular/from-now.pipe' 91import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
92import { HighlightPipe }from '@app/shared/angular/highlight.pipe'
92import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' 93import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
93import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' 94import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component'
94import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' 95import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component'
@@ -149,6 +150,7 @@ import { ClipboardModule } from '@angular/cdk/clipboard'
149 NumberFormatterPipe, 150 NumberFormatterPipe,
150 ObjectLengthPipe, 151 ObjectLengthPipe,
151 FromNowPipe, 152 FromNowPipe,
153 HighlightPipe,
152 PeerTubeTemplateDirective, 154 PeerTubeTemplateDirective,
153 VideoDurationPipe, 155 VideoDurationPipe,
154 156
@@ -254,6 +256,7 @@ import { ClipboardModule } from '@angular/cdk/clipboard'
254 NumberFormatterPipe, 256 NumberFormatterPipe,
255 ObjectLengthPipe, 257 ObjectLengthPipe,
256 FromNowPipe, 258 FromNowPipe,
259 HighlightPipe,
257 PeerTubeTemplateDirective, 260 PeerTubeTemplateDirective,
258 VideoDurationPipe 261 VideoDurationPipe
259 ], 262 ],