import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { CoreModule } from './core'
-import { HeaderComponent, SearchTypeaheadComponent } from './header'
+import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header'
import { LoginModule } from './login'
import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
import { SharedModule } from './shared'
AvatarNotificationComponent,
HeaderComponent,
SearchTypeaheadComponent,
+ SuggestionsComponent,
+ SuggestionComponent,
WelcomeModalComponent,
InstanceConfigWarningModalComponent
<my-search-typeahead>
<input
- type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…"
+ type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search locally videos, channels…"
[(ngModel)]="searchValue" (keyup.enter)="doSearch()"
>
<span (click)="doSearch()" class="icon icon-search"></span>
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router'
import { getParameterByName } from '../shared/misc/utils'
-import { AuthService, Notifier, ServerService } from '@app/core'
+import { AuthService } from '@app/core'
import { of } from 'rxjs'
import { I18n } from '@ngx-translate/i18n-polyfill'
private router: Router,
private route: ActivatedRoute,
private auth: AuthService,
- private serverService: ServerService,
- private authService: AuthService,
- private notifier: Notifier,
private i18n: I18n
) {}
export * from './header.component'
export * from './search-typeahead.component'
+export * from './suggestions.component'
+export * from './suggestion.component'
<div class="position-absolute jump-to-suggestions">
<!-- suggestions -->
- <ul id="jump-to-results" role="listbox" class="p-0 m-0" #optionsList>
- <li *ngFor="let res of results" class="d-flex flex-justify-start flex-items-center p-0 f5" role="option" aria-selected="true">
- <ng-container *ngTemplateOutlet="result; context: {$implicit: res}"></ng-container>
- </li>
- </ul>
+ <my-suggestions *ngIf="hasSearch && newSearch" [results]="results" [highlight]="searchInput.value" (init)="initKeyboardEventsManager($event)" #suggestions></my-suggestions>
+
+ <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion -->
+ <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden">
+ <ng-container *ngIf="activeResult.type === 'search-global'">
+ <div class="d-flex justify-content-between">
+ <label class="small-title" i18n>Global search</label>
+ <div class="advanced-search-status text-muted">
+ <span class="mr-1" i18n>using {{ globalSearchIndex }}</span>
+ <i class="glyphicon glyphicon-globe"></i>
+ </div>
+ </div>
+ <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>
+ </ng-container>
+ </div>
<!-- search instructions, when search input is empty -->
<div *ngIf="!hasSearch" id="typeahead-instructions" class="overflow-hidden">
<div class="d-flex justify-content-between">
<label class="small-title" i18n>Advanced search</label>
- <div class="advanced-search-status">
+ <div class="advanced-search-status c-help">
<span [ngClass]="URIPolicy === 'any' ? 'text-success' : 'text-muted'" [title]="URIPolicyText">
<span class="mr-1" i18n>{URIPolicy, select, only-followed {only followed instances} other {any instance}} </span>
<i [ngClass]="URIPolicy === 'any' ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
</div>
</div>
-
-<ng-template #result let-result>
- <a tabindex="0" class="d-flex flex-auto flex-items-center p-2"
- data-target-type="Repository"
- [routerLink]="result.routerLink"
- >
- <div class="flex-shrink-0 mr-2 text-center">
- <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon>
- <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
- </div>
-
- <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
-
- <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>
-
- <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
- <span *ngIf="result.type === 'search-channel'" [attr.aria-label]="inThisChannelText">
- {{ inThisChannelText }}
- </span>
- <span *ngIf="result.type === 'search-global'" [attr.aria-label]="inAllText">
- {{ inAllText }}
- </span>
- <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
- </div>
-
- <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>
- Jump to channel
- <span class="d-inline-block ml-1 v-align-middle">↵</span>
- </div>
- </a>
-</ng-template>
\ No newline at end of file
width: 100%;
}
+#typeahead-help,
#typeahead-instructions,
-#jump-to-results {
+my-suggestions ::ng-deep ul {
border: 1px solid var(--mainBackgroundColor);
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
transition-property: box-shadow;
}
+#typeahead-help,
#typeahead-instructions {
margin-top: 10px;
width: 100%;
padding: .5rem 1rem;
+ white-space: normal;
ul {
list-style: none;
& > div:last-child {
display: initial !important;
+ #typeahead-help,
#typeahead-instructions,
- #jump-to-results {
+ my-suggestions ::ng-deep ul {
box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px;
}
}
}
}
-a.focus-visible {
- background-color: var(--mainHoverColor);
-}
-
-a {
- @include disable-default-a-behaviour;
- width: 100%;
-
- &, &:hover {
- color: var(--mainForegroundColor);
- }
-}
-
-.bg-gray {
- background-color: var(--mainBackgroundColor);
-}
-
-.text-gray-light {
- color: var(--mainForegroundColor);
-}
-
.glyphicon {
top: 3px;
}
.advanced-search-status {
- cursor: help;
+ height: max-content;
+ cursor: default;
+
+ &.c-help {
+ cursor: help;
+ }
}
.small-title {
margin-bottom: .5rem;
}
-my-global-icon {
- width: 17px;
- position: relative;
- top: -2px;
- margin: 5px;
-
- @include apply-svg-color(var(--mainForegroundColor))
+::ng-deep my-suggestion {
+ width: 100%;
}
-import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core'
+import {
+ Component,
+ ViewChild,
+ ElementRef,
+ AfterViewInit,
+ OnInit,
+ OnDestroy,
+ QueryList
+} from '@angular/core'
import { Router, NavigationEnd } from '@angular/router'
import { AuthService } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { filter } from 'rxjs/operators'
-import { ListKeyManager, ListKeyManagerOption } from '@angular/cdk/a11y'
-import { UP_ARROW, DOWN_ARROW, ENTER } from '@angular/cdk/keycodes'
+import { ListKeyManager } from '@angular/cdk/a11y'
+import { UP_ARROW, DOWN_ARROW, ENTER, TAB } from '@angular/cdk/keycodes'
+import { SuggestionComponent } from './suggestion.component'
@Component({
selector: 'my-search-typeahead',
templateUrl: './search-typeahead.component.html',
styleUrls: [ './search-typeahead.component.scss' ]
})
-export class SearchTypeaheadComponent implements OnInit, AfterViewInit {
+export class SearchTypeaheadComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef
- @ViewChild('optionsList', { static: true }) optionsList: ElementRef
hasChannel = false
inChannel = false
- keyboardEventsManager: ListKeyManager<ListKeyManagerOption>
+ newSearch = true
searchInput: HTMLInputElement
URIPolicy: 'only-followed' | 'any' = 'any'
URIPolicyText: string
inAllText: string
inThisChannelText: string
+ globalSearchIndex = 'https://index.joinpeertube.org'
+ keyboardEventsManager: ListKeyManager<SuggestionComponent>
results: any[] = []
constructor (
private router: Router,
private i18n: I18n
) {
- 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.')
+ this.URIPolicyText = this.i18n('Determines whether you can resolve any distant content, or if your instance only allows doing so for instances it follows.')
this.inAllText = this.i18n('In all PeerTube')
this.inThisChannelText = this.i18n('In this channel')
}
})
}
+ ngOnDestroy () {
+ if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
+ }
+
ngAfterViewInit () {
this.searchInput = this.contentWrapper.nativeElement.childNodes[0]
this.searchInput.addEventListener('input', this.computeResults.bind(this))
+ this.searchInput.addEventListener('keyup', this.handleKeyUp.bind(this))
}
get hasSearch () {
return !!this.searchInput && !!this.searchInput.value
}
+ get activeResult () {
+ return this.keyboardEventsManager && this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem.result
+ }
+
+ get showHelp () {
+ return this.hasSearch && this.newSearch && this.activeResult && this.activeResult.type === 'search-global' || false
+ }
+
computeResults () {
+ this.newSearch = true
let results = [
{
text: 'Maître poney',
text: this.searchInput.value,
type: 'search-channel'
},
+ {
+ text: this.searchInput.value,
+ type: 'search-instance'
+ },
{
text: this.searchInput.value,
type: 'search-global'
)
}
+ initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) {
+ if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
+ this.keyboardEventsManager = new ListKeyManager(event.items)
+ if (event.index !== undefined) {
+ this.keyboardEventsManager.setActiveItem(event.index)
+ event.items.forEach(e => e.active = false)
+ this.keyboardEventsManager.activeItem.active = true
+ }
+ this.keyboardEventsManager.change.subscribe(
+ val => {
+ event.items.forEach(e => e.active = false)
+ this.keyboardEventsManager.activeItem.active = true
+ }
+ )
+ }
+
isUserLoggedIn () {
return this.authService.isLoggedIn()
}
- handleKeyUp (event: KeyboardEvent) {
+ handleKeyUp (event: KeyboardEvent, indexSelected?: number) {
event.stopImmediatePropagation()
if (this.keyboardEventsManager) {
- if (event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) {
- // passing the event to key manager so we get a change fired
+ if (event.keyCode === TAB) {
+ this.keyboardEventsManager.setNextItemActive()
+ return false
+ } else if (event.keyCode === DOWN_ARROW || event.keyCode === UP_ARROW) {
this.keyboardEventsManager.onKeydown(event)
return false
} else if (event.keyCode === ENTER) {
- // when we hit enter, the keyboardManager should call the selectItem method of the `ListItemComponent`
- // this.keyboardEventsManager.activeItem
+ this.newSearch = false
+ // this.router.navigate(this.keyboardEventsManager.activeItem.result)
return false
}
}
--- /dev/null
+<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active" [routerLink]="result.routerLink">
+ <div class="flex-shrink-0 mr-2 text-center">
+ <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon>
+ <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon>
+ </div>
+
+ <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28">
+
+ <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>
+
+ <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
+ <span *ngIf="result.type === 'search-channel'" [attr.aria-label]="inThisChannelText">
+ {{ inThisChannelText }}
+ </span>
+ <span *ngIf="result.type === 'search-instance'" [attr.aria-label]="inThisInstanceText">
+ {{ inThisInstanceText }}
+ </span>
+ <span *ngIf="result.type === 'search-global'" [attr.aria-label]="inAllText">
+ {{ inAllText }}
+ </span>
+ <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span>
+ </div>
+
+ <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>
+ Jump to channel
+ <span class="d-inline-block ml-1 v-align-middle">↵</span>
+ </div>
+</a>
\ No newline at end of file
--- /dev/null
+@import '_mixins';
+
+a {
+ @include disable-default-a-behaviour;
+ width: 100%;
+
+ &, &:hover {
+ color: var(--mainForegroundColor);
+
+ &.focus-visible {
+ background-color: var(--mainHoverColor);
+ color: var(--mainBackgroundColor);
+ }
+ }
+}
+
+.bg-gray {
+ background-color: var(--mainBackgroundColor);
+}
+
+.text-gray-light {
+ color: var(--mainForegroundColor);
+}
+
+my-global-icon {
+ width: 17px;
+ position: relative;
+ top: -2px;
+ margin: 5px;
+
+ @include apply-svg-color(var(--mainForegroundColor));
+}
--- /dev/null
+import { Input, Component, Output, EventEmitter, OnInit } from '@angular/core'
+import { RouterLink } from '@angular/router'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ListKeyManagerOption } from '@angular/cdk/a11y'
+
+type Result = {
+ text: string
+ type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any'
+ routerLink?: RouterLink
+}
+
+@Component({
+ selector: 'my-suggestion',
+ templateUrl: './suggestion.component.html',
+ styleUrls: [ './suggestion.component.scss' ]
+})
+export class SuggestionComponent implements OnInit, ListKeyManagerOption {
+ @Input() result: Result
+ @Input() highlight: string
+ @Output() selected = new EventEmitter()
+
+ inAllText: string
+ inThisChannelText: string
+ inThisInstanceText: string
+
+ disabled = false
+ active = false
+
+ constructor (
+ private i18n: I18n
+ ) {
+ this.inAllText = this.i18n('In the vidiverse')
+ this.inThisChannelText = this.i18n('In this channel')
+ this.inThisInstanceText = this.i18n('In this instance')
+ }
+
+ getLabel () {
+ return this.result.text
+ }
+
+ ngOnInit () {
+ this.active = false
+ }
+
+ selectItem () {
+ this.selected.emit(this.result)
+ }
+}
--- /dev/null
+import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren } from '@angular/core'
+import { SuggestionComponent } from './suggestion.component'
+
+@Component({
+ selector: 'my-suggestions',
+ template: `
+ <ul role="listbox" class="p-0 m-0">
+ <li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
+ role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
+ <my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
+ </li>
+ </ul>
+ `
+})
+export class SuggestionsComponent implements AfterViewInit {
+ @Input() results: any[]
+ @Input() highlight: string
+ @ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent>
+ @Output() init = new EventEmitter()
+
+ ngAfterViewInit () {
+ this.init.emit({ items: this.listItems })
+ this.listItems.changes.subscribe(
+ val => this.init.emit({ items: this.listItems })
+ )
+ }
+
+ hoverItem (index: number) {
+ this.init.emit({ items: this.listItems, index: index })
+ }
+}