import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { CoreModule } from './core'
-import { HeaderComponent } from './header'
+import { HeaderComponent, SearchTypeaheadComponent } from './header'
import { LoginModule } from './login'
import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
import { SharedModule } from './shared'
LanguageChooserComponent,
AvatarNotificationComponent,
HeaderComponent,
+ SearchTypeaheadComponent,
WelcomeModalComponent,
InstanceConfigWarningModalComponent
-<input
- type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…"
- [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
->
-<span (click)="doSearch()" class="icon icon-search"></span>
+<my-search-typeahead>
+ <input
+ type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…"
+ [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
+ >
+ <span (click)="doSearch()" class="icon icon-search"></span>
+</my-search-typeahead>
<a class="upload-button" routerLink="/videos/upload">
<my-global-icon iconName="upload"></my-global-icon>
@import '_variables';
@import '_mixins';
+my-search-typeahead {
+ margin-right: 15px;
+}
+
#search-video {
@include peertube-input-text($search-input-width);
padding-left: 10px;
- margin-right: 15px;
padding-right: 40px; // For the search icon
font-size: 14px;
- transition: box-shadow .3s ease;
-
- /* light border style */
- border: 1px solid var(--mainBackgroundColor);
- box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px;
-
- &:focus {
- box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px;
- }
-
&::placeholder {
color: var(--inputPlaceholderColor);
}
- &:focus::placeholder {
- opacity: 0 !important;
- }
-
@media screen and (max-width: 800px) {
width: calc(100% - 150px);
}
// yolo
position: absolute;
- margin-left: -50px;
+ margin-left: -35px;
margin-top: 5px;
}
export * from './header.component'
+export * from './search-typeahead.component'
--- /dev/null
+<div class="d-inline-flex position-relative" id="typeahead-container" #contentWrapper>
+ <ng-content></ng-content>
+
+ <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>
+
+ <!-- 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">
+ <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>
+ </span>
+ </div>
+ </div>
+ <ul>
+ <li>
+ <em>@username@domain</em> <span class="flex-auto text-muted" i18n>account or channel</span>
+ </li>
+ <li>
+ <em>URL</em> <span class="text-muted" i18n>account or channel</span>
+ </li>
+ <li>
+ <em>URL</em> <span class="text-muted" i18n>video</span>
+ </li>
+ </ul>
+ <span class="text-muted" i18n>Any other text will return matching video, channel or account names.</span>
+ </div>
+ </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
--- /dev/null
+@import '_mixins';
+
+.jump-to-suggestions {
+ top: 100%;
+ left: 0;
+ z-index: 35;
+ width: 100%;
+}
+
+#typeahead-instructions,
+#jump-to-results {
+ border: 1px solid var(--mainBackgroundColor);
+ border-bottom-right-radius: 3px;
+ border-bottom-left-radius: 3px;
+ background: var(--mainBackgroundColor);
+ transition: .3s ease;
+ transition-property: box-shadow;
+}
+
+#typeahead-instructions {
+ margin-top: 10px;
+ width: 100%;
+ padding: .5rem 1rem;
+
+ ul {
+ list-style: none;
+ padding: 0;
+ margin-bottom: .5rem;
+
+ em {
+ font-weight: 600;
+ margin-right: 0.2rem;
+ font-style: normal;
+ }
+ }
+}
+
+#typeahead-container {
+ ::ng-deep input {
+ border: 1px solid var(--mainBackgroundColor) !important;
+ box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px;
+ flex-grow: 1;
+ transition: box-shadow .3s ease, width .2s ease;
+ }
+
+ ::ng-deep span {
+ right: 10px;
+ }
+
+ & > div:last-child {
+ // we have to switch the display and not the opacity,
+ // to avoid clashing with the rest of the interface.
+ display: none;
+ }
+
+ &:focus,
+ ::ng-deep &:focus-within {
+ & > div:last-child {
+ display: initial !important;
+
+ #typeahead-instructions,
+ #jump-to-results {
+ box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px;
+ }
+ }
+
+ ::ng-deep input {
+ box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px;
+ border-end-start-radius: 0;
+ border-end-end-radius: 0;
+
+ @media screen and (min-width: 900px) {
+ width: 500px;
+ }
+ }
+ }
+}
+
+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;
+}
+
+.small-title {
+ @include in-content-small-title;
+
+ margin-bottom: .5rem;
+}
+
+my-global-icon {
+ width: 17px;
+ position: relative;
+ top: -2px;
+ margin: 5px;
+
+ @include apply-svg-color(var(--mainForegroundColor))
+}
--- /dev/null
+import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } 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'
+
+@Component({
+ selector: 'my-search-typeahead',
+ templateUrl: './search-typeahead.component.html',
+ styleUrls: [ './search-typeahead.component.scss' ]
+})
+export class SearchTypeaheadComponent implements OnInit, AfterViewInit {
+ @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef
+ @ViewChild('optionsList', { static: true }) optionsList: ElementRef
+
+ hasChannel = false
+ inChannel = false
+ keyboardEventsManager: ListKeyManager<ListKeyManagerOption>
+
+ searchInput: HTMLInputElement
+ URIPolicy: 'only-followed' | 'any' = 'any'
+
+ URIPolicyText: string
+ inAllText: string
+ inThisChannelText: string
+
+ results: any[] = []
+
+ constructor (
+ private authService: AuthService,
+ 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.inAllText = this.i18n('In all PeerTube')
+ this.inThisChannelText = this.i18n('In this channel')
+ }
+
+ ngOnInit () {
+ this.router.events
+ .pipe(filter(event => event instanceof NavigationEnd))
+ .subscribe((event: NavigationEnd) => {
+ this.hasChannel = event.url.startsWith('/videos/watch')
+ this.inChannel = event.url.startsWith('/video-channels')
+ this.computeResults()
+ })
+ }
+
+ ngAfterViewInit () {
+ this.searchInput = this.contentWrapper.nativeElement.childNodes[0]
+ this.searchInput.addEventListener('input', this.computeResults.bind(this))
+ }
+
+ get hasSearch () {
+ return !!this.searchInput && !!this.searchInput.value
+ }
+
+ computeResults () {
+ let results = [
+ {
+ text: 'Maître poney',
+ type: 'channel'
+ }
+ ]
+
+ if (this.hasSearch) {
+ results = [
+ {
+ text: this.searchInput.value,
+ type: 'search-channel'
+ },
+ {
+ text: this.searchInput.value,
+ type: 'search-global'
+ },
+ ...results
+ ]
+ }
+
+ this.results = results.filter(
+ result => {
+ // if we're not in a channel or one of its videos/playlits, show all channel-related results
+ if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel')
+ // if we're in a channel, show all channel-related results except for the channel redirection itself
+ if (this.inChannel) return !(result.type === 'channel')
+ return true
+ }
+ )
+ }
+
+ isUserLoggedIn () {
+ return this.authService.isLoggedIn()
+ }
+
+ handleKeyUp (event: KeyboardEvent) {
+ 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
+ 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
+ return false
+ }
+ }
+ }
+}
--- /dev/null
+import { PipeTransform, Pipe } from '@angular/core'
+import { SafeHtml } from '@angular/platform-browser'
+
+// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
+@Pipe({ name: 'highlight' })
+export class HighlightPipe implements PipeTransform {
+ /* use this for single match search */
+ static SINGLE_MATCH: string = "Single-Match"
+ /* use this for single match search with a restriction that target should start with search string */
+ static SINGLE_AND_STARTS_WITH_MATCH: string = "Single-And-StartsWith-Match"
+ /* use this for global search */
+ static MULTI_MATCH: string = "Multi-Match"
+
+ constructor() {}
+ transform(
+ contentString: string = null,
+ stringToHighlight: string = null,
+ option: string = "Single-And-StartsWith-Match",
+ caseSensitive: boolean = false,
+ highlightStyleName: string = "search-highlight"
+ ): SafeHtml {
+ if (stringToHighlight && contentString && option) {
+ let regex: any = ""
+ let caseFlag: string = !caseSensitive ? "i" : ""
+ switch (option) {
+ case "Single-Match": {
+ regex = new RegExp(stringToHighlight, caseFlag)
+ break
+ }
+ case "Single-And-StartsWith-Match": {
+ regex = new RegExp("^" + stringToHighlight, caseFlag)
+ break
+ }
+ case "Multi-Match": {
+ regex = new RegExp(stringToHighlight, "g" + caseFlag)
+ break
+ }
+ default: {
+ // default will be a global case-insensitive match
+ regex = new RegExp(stringToHighlight, "gi")
+ }
+ }
+ const replaced = contentString.replace(
+ regex,
+ (match) => `<span class="${highlightStyleName}">${match}</span>`
+ )
+ return replaced
+ } else {
+ return contentString
+ }
+ }
+}
import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe'
import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
+import { HighlightPipe }from '@app/shared/angular/highlight.pipe'
import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component'
import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component'
NumberFormatterPipe,
ObjectLengthPipe,
FromNowPipe,
+ HighlightPipe,
PeerTubeTemplateDirective,
VideoDurationPipe,
NumberFormatterPipe,
ObjectLengthPipe,
FromNowPipe,
+ HighlightPipe,
PeerTubeTemplateDirective,
VideoDurationPipe
],
animation: spin .7s infinite linear;
}
+.flex-auto {
+ flex: auto;
+}
+
@keyframes spin {
from {
transform: scale(1) rotate(0deg);
&:focus:not(.focus-visible) {
outline: none;
}
-
- &::-moz-focus-inner {
- border: 0;
- padding: 0
- }
}