## License
-Copyright (C) 2015-2019 PeerTube Contributors
+Copyright (C) 2015-2020 PeerTube Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
<div class="actor-info">
<div class="actor-names">
<div class="actor-display-name">{{ account.displayName }}</div>
- <div class="actor-name">{{ account.nameWithHost }}
-
- <button [cdkCopyToClipboard]="account.nameWithHostForced" (click)="activateCopiedMessage()"
- class="btn btn-outline-secondary btn-sm copy-button"
- >
- <span class="glyphicon glyphicon-copy"></span>
- </button>
-
+ <div class="actor-name">
+ <span>{{ account.nameWithHost }}</span>
+ <button [cdkCopyToClipboard]="account.nameWithHostForced" (click)="activateCopiedMessage()"
+ class="btn btn-outline-secondary btn-sm copy-button"
+ >
+ <span class="glyphicon glyphicon-copy"></span>
+ </button>
</div>
<span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="badge badge-danger" i18n>Banned</span>
<span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span>
margin: 20px 0 50px;
}
-@media screen and (max-width: 800px) {
+@media screen and (max-width: $small-view) {
.video-channels-header {
text-align: center;
}
}
}
-@media screen and (max-width: 800px) {
+@media screen and (max-width: $small-view) {
.video-playlists-header {
text-align: center;
}
}
}
- @media screen and (max-width: 500px) {
+ @media screen and (max-width: $mobile-view) {
width: auto;
}
}
<div class="actor-info">
<div class="actor-names">
<div class="actor-display-name">{{ videoChannel.displayName }}</div>
- <div class="actor-name">{{ videoChannel.nameWithHost }}
+ <div class="actor-name">
+ <span>{{ videoChannel.nameWithHost }}</span>
<button [cdkCopyToClipboard]="videoChannel.nameWithHost" (click)="activateCopiedMessage()"
class="btn btn-outline-secondary btn-sm copy-button"
>
<span class="glyphicon glyphicon-copy"></span>
</button>
</div>
+ </div>
- <div class="right-buttons">
- <a *ngIf="isChannelManageable" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n>Manage</a>
- <my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button>
- </div>
+ <div class="right-buttons">
+ <a *ngIf="isChannelManageable" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n>Manage</a>
+ <my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button>
</div>
- <div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
- <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner">
- <span i18n>Created by {{ videoChannel.ownerBy }}</span>
- <img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" />
- </a>
+ <div class="actor-lower">
+ <div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
+
+ <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner">
+ <span i18n>Created by {{ videoChannel.ownerBy }}</span>
+ <img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" />
+ </a>
+ </div>
</div>
</div>
width: 100%;
}
+ .actor-info {
+ display: grid !important;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: 1fr auto / 1fr auto;
+ grid-template-areas: "name buttons"
+ "lower buttons";
+
+ @media screen and (max-width: #{map-get($grid-breakpoints, lg)}) {
+ grid-template-areas: "name name"
+ "lower buttons";
+ }
+ }
+
+ .actor-names {
+ grid-area: name;
+ }
+
.actor-name {
flex-grow: 1;
margin-left: auto;
margin-top: 20px;
+ grid-row: buttons-start / span buttons-end;
+ grid-column: buttons-start;
+
a {
@include peertube-button-outline;
line-height: 1.8;
</div>
<div class="header-right" [ngClass]="{ 'border-bottom': isMenuDisplayed === false }">
- <my-header></my-header>
+ <my-header class="w-100 d-flex justify-content-end"></my-header>
</div>
</div>
top: 0;
width: 100%;
background-color: var(--mainBackgroundColor);
- z-index: 1000;
+ z-index: z(header);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16);
display: flex;
.top-left-block {
- z-index: 1001;
+ z-index: z(headerLeft);
height: $header-height;
display: flex;
align-items: center;
}
}
- @media screen and (max-width: 500px) {
+ @media screen and (max-width: $mobile-view) {
width: 70px;
.peertube-title {
import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { CoreModule } from './core'
-import { HeaderComponent } from './header'
+import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header'
import { LoginModule } from './login'
import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
import { SharedModule } from './shared'
LanguageChooserComponent,
AvatarNotificationComponent,
HeaderComponent,
+ SearchTypeaheadComponent,
+ SuggestionsComponent,
+ SuggestionComponent,
WelcomeModalComponent,
InstanceConfigWarningModalComponent
+@import '_variables';
+@import '_mixins';
+
.cfp-hotkeys-container {
display: flex !important;
align-items: center;
}
.cfp-hotkeys-container.fade.in {
- z-index: 10002;
+ z-index: z(hotkeys);
visibility: visible;
opacity: 1;
}
cursor: pointer;
}
-@media all and (max-width: 500px) {
+@media all and (max-width: $mobile-view) {
.cfp-hotkeys {
font-size: 0.8em;
}
css: ''
}
},
+ search: {
+ remoteUri: {
+ users: true,
+ anonymous: false
+ }
+ },
plugin: {
registered: []
},
-<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 class="w-100 d-flex justify-content-end"></my-search-typeahead>
<a class="upload-button" routerLink="/videos/upload">
<my-global-icon iconName="upload"></my-global-icon>
@import '_variables';
@import '_mixins';
-#search-video {
- @include peertube-input-text($search-input-width);
- padding-left: 10px;
+my-search-typeahead {
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);
- }
-
- @media screen and (max-width: 600px) {
- width: calc(100% - 70px);
- }
-}
-
-.icon.icon-search {
- @include icon(25px);
- height: 21px;
-
- background-color: var(--mainForegroundColor);
- mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%;
-
- // yolo
- position: absolute;
- margin-left: -50px;
- margin-top: 5px;
}
.upload-button {
color: var(--mainBackgroundColor) !important;
margin-right: 25px;
- @media screen and (max-width: 800px) {
- margin-right: 0;
- }
-
@media screen and (max-width: 600px) {
margin-right: 10px;
padding: 0 10px;
-import { filter, first, map, tap } from 'rxjs/operators'
-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 { of } from 'rxjs'
-import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Component } from '@angular/core'
@Component({
selector: 'my-header',
styleUrls: [ './header.component.scss' ]
})
-export class HeaderComponent implements OnInit {
- searchValue = ''
- ariaLabelTextForSearch = ''
-
- constructor (
- private router: Router,
- private route: ActivatedRoute,
- private auth: AuthService,
- private serverService: ServerService,
- private authService: AuthService,
- private notifier: Notifier,
- private i18n: I18n
- ) {}
-
- ngOnInit () {
- this.ariaLabelTextForSearch = this.i18n('Search videos, channels')
-
- this.router.events
- .pipe(
- filter(e => e instanceof NavigationEnd),
- map(() => getParameterByName('search', window.location.href))
- )
- .subscribe(searchQuery => this.searchValue = searchQuery || '')
- }
-
- doSearch () {
- const queryParams: Params = {}
-
- if (window.location.pathname === '/search' && this.route.snapshot.queryParams) {
- Object.assign(queryParams, this.route.snapshot.queryParams)
- }
-
- Object.assign(queryParams, { search: this.searchValue })
-
- const o = this.auth.isLoggedIn()
- ? this.loadUserLanguagesIfNeeded(queryParams)
- : of(true)
-
- o.subscribe(() => this.router.navigate([ '/search' ], { queryParams }))
- }
-
- private loadUserLanguagesIfNeeded (queryParams: any) {
- if (queryParams && queryParams.languageOneOf) return of(queryParams)
-
- return this.auth.userInformationLoaded
- .pipe(
- first(),
- tap(() => Object.assign(queryParams, { languageOneOf: this.auth.getUser().videoLanguages }))
- )
- }
-}
+export class HeaderComponent {}
export * from './header.component'
+export * from './search-typeahead.component'
+export * from './suggestions.component'
+export * from './suggestion.component'
--- /dev/null
+<div class="d-inline-flex position-relative" id="typeahead-container">
+ <input
+ type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
+ [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keyup)="handleKeyUp($event)"
+ >
+ <span class="icon icon-search" (click)="doSearch()"></span>
+
+ <div class="position-absolute jump-to-suggestions">
+ <!-- suggestions -->
+ <my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></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 *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</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="areInstructionsDisplayed" 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 c-help">
+ <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.">
+ <span *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span>
+ <span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span>
+ <i [ngClass]="canSearchAnyURI ? '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>UUID</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>
--- /dev/null
+@import '_mixins';
+@import '_variables';
+@import '_bootstrap-variables';
+@import '~bootstrap/scss/mixins/_breakpoints';
+
+#search-video {
+ @include peertube-input-text($search-input-width);
+ padding-left: 10px;
+ padding-right: 40px; // For the search icon
+ font-size: 14px;
+
+ &::placeholder {
+ color: var(--inputPlaceholderColor);
+ }
+}
+
+.icon.icon-search {
+ @include icon(25px);
+ height: 21px;
+
+ background-color: var(--mainForegroundColor);
+ mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%;
+
+ // yolo
+ position: absolute;
+ margin-left: -35px;
+ margin-top: 5px;
+}
+
+.jump-to-suggestions {
+ top: 100%;
+ left: 0;
+ z-index: 35;
+ width: 100%;
+}
+
+#typeahead-help,
+#typeahead-instructions,
+my-suggestions ::ng-deep ul {
+ 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-help,
+#typeahead-instructions {
+ margin-top: 10px;
+ width: 100%;
+ padding: .5rem 1rem;
+ white-space: normal;
+
+ ul {
+ list-style: none;
+ padding: 0;
+ margin-bottom: .5rem;
+
+ em {
+ font-weight: 600;
+ margin-right: 0.2rem;
+ font-style: normal;
+ }
+ }
+}
+
+#typeahead-container {
+ 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;
+ }
+
+ @media screen and (min-width: $mobile-view) {
+ margin-left: 10px;
+ }
+
+ @media screen and (max-width: $small-view) {
+ flex: 1;
+
+ input {
+ width: unset;
+ }
+ }
+
+ 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 {
+ @media screen and (min-width: $mobile-view) {
+ display: initial !important;
+ }
+
+ #typeahead-help,
+ #typeahead-instructions,
+ my-suggestions ::ng-deep ul {
+ 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;
+
+ @include media-breakpoint-up(lg) {
+ width: 500px;
+ }
+ }
+ }
+}
+
+.glyphicon {
+ top: 3px;
+}
+
+.advanced-search-status {
+ height: max-content;
+ cursor: default;
+
+ &.c-help {
+ cursor: help;
+ }
+}
+
+.small-title {
+ @include in-content-small-title;
+
+ margin-bottom: .5rem;
+}
+
+::ng-deep my-suggestion {
+ width: 100%;
+}
--- /dev/null
+import {
+ Component,
+ OnInit,
+ OnDestroy,
+ QueryList,
+ ViewChild,
+ ElementRef
+} from '@angular/core'
+import { Router, Params, ActivatedRoute } from '@angular/router'
+import { AuthService, ServerService } from '@app/core'
+import { first, tap } from 'rxjs/operators'
+import { ListKeyManager } from '@angular/cdk/a11y'
+import { UP_ARROW, DOWN_ARROW, ENTER } from '@angular/cdk/keycodes'
+import { SuggestionComponent, Result } from './suggestion.component'
+import { of } from 'rxjs'
+import { ServerConfig } from '@shared/models'
+
+@Component({
+ selector: 'my-search-typeahead',
+ templateUrl: './search-typeahead.component.html',
+ styleUrls: [ './search-typeahead.component.scss' ]
+})
+export class SearchTypeaheadComponent implements OnInit, OnDestroy {
+ @ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement>
+
+ hasChannel = false
+ inChannel = false
+ newSearch = true
+
+ search = ''
+ serverConfig: ServerConfig
+
+ inThisChannelText: string
+
+ keyboardEventsManager: ListKeyManager<SuggestionComponent>
+ results: Result[] = []
+
+ constructor (
+ private authService: AuthService,
+ private router: Router,
+ private route: ActivatedRoute,
+ private serverService: ServerService
+ ) {}
+
+ ngOnInit () {
+ const query = this.route.snapshot.queryParams
+ if (query['search']) this.search = query['search']
+
+ this.serverService.getConfig()
+ .subscribe(config => this.serverConfig = config)
+ }
+
+ ngOnDestroy () {
+ if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe()
+ }
+
+ get activeResult () {
+ return this.keyboardEventsManager?.activeItem?.result
+ }
+
+ get areInstructionsDisplayed () {
+ return !this.search
+ }
+
+ get showHelp () {
+ return this.search && this.newSearch && this.activeResult?.type === 'search-global'
+ }
+
+ get canSearchAnyURI () {
+ if (!this.serverConfig) return false
+ return this.authService.isLoggedIn()
+ ? this.serverConfig.search.remoteUri.users
+ : this.serverConfig.search.remoteUri.anonymous
+ }
+
+ onSearchChange () {
+ this.computeResults()
+ }
+
+ computeResults () {
+ this.newSearch = true
+ let results: Result[] = []
+
+ if (this.search) {
+ results = [
+ /* Channel search is still unimplemented. Uncomment when it is.
+ {
+ text: this.search,
+ type: 'search-channel'
+ },
+ */
+ {
+ text: this.search,
+ type: 'search-instance',
+ default: true
+ },
+ /* Global search is still unimplemented. Uncomment when it is.
+ {
+ text: this.search,
+ type: 'search-global'
+ },
+ */
+ ...results
+ ]
+ }
+
+ this.results = results.filter(
+ (result: 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'
+ // all other result types are kept
+ return true
+ }
+ )
+ }
+
+ setEventItems (event: { items: QueryList<SuggestionComponent>, index?: number }) {
+ event.items.forEach(e => {
+ if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) {
+ this.keyboardEventsManager.activeItem.active = true
+ } else {
+ e.active = false
+ }
+ })
+ }
+
+ 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)
+ } else {
+ this.keyboardEventsManager.setFirstItemActive()
+ }
+
+ this.keyboardEventsManager.change.subscribe(
+ _ => this.setEventItems(event)
+ )
+ }
+
+ handleKeyUp (event: KeyboardEvent) {
+ event.stopImmediatePropagation()
+ if (!this.keyboardEventsManager) return
+
+ switch (event.key) {
+ case "ArrowDown":
+ case "ArrowUp":
+ this.keyboardEventsManager.onKeydown(event)
+ break
+ case "Enter":
+ this.newSearch = false
+ this.doSearch()
+ break
+ }
+ }
+
+ doSearch () {
+ const queryParams: Params = {}
+
+ if (window.location.pathname === '/search' && this.route.snapshot.queryParams) {
+ Object.assign(queryParams, this.route.snapshot.queryParams)
+ }
+
+ Object.assign(queryParams, { search: this.search })
+
+ const o = this.authService.isLoggedIn()
+ ? this.loadUserLanguagesIfNeeded(queryParams)
+ : of(true)
+
+ o.subscribe(() => this.router.navigate([ '/search' ], { queryParams }))
+ }
+
+ private loadUserLanguagesIfNeeded (queryParams: any) {
+ if (queryParams && queryParams.languageOneOf) return of(queryParams)
+
+ return this.authService.userInformationLoaded
+ .pipe(
+ first(),
+ tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages }))
+ )
+ }
+}
--- /dev/null
+<a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active">
+ <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'" i18n>In this channel</span>
+ <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span>
+ <span *ngIf="result.type === 'search-global'" i18n>In the vidiverse</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, ChangeDetectionStrategy } from '@angular/core'
+import { RouterLink } from '@angular/router'
+import { ListKeyManagerOption } from '@angular/cdk/a11y'
+
+export type Result = {
+ text: string
+ type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any'
+ routerLink?: RouterLink,
+ default?: boolean
+}
+
+@Component({
+ selector: 'my-suggestion',
+ templateUrl: './suggestion.component.html',
+ styleUrls: [ './suggestion.component.scss' ],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SuggestionComponent implements OnInit, ListKeyManagerOption {
+ @Input() result: Result
+ @Input() highlight: string
+ @Output() selected = new EventEmitter()
+
+ disabled = false
+ active = false
+
+ getLabel () {
+ return this.result.text
+ }
+
+ ngOnInit () {
+ if (this.result.default) this.active = true
+ }
+
+ selectItem () {
+ this.selected.emit(this.result)
+ }
+}
--- /dev/null
+<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>
\ No newline at end of file
--- /dev/null
+import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core'
+import { SuggestionComponent } from './suggestion.component'
+
+@Component({
+ selector: 'my-suggestions',
+ templateUrl: './suggestions.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SuggestionsComponent implements AfterViewInit {
+ @Input() results: any[]
+ @Input() highlight: string
+ @ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent>
+ @Output() init = new EventEmitter()
+
+ ngAfterViewInit () {
+ this.listItems.changes.subscribe(
+ _ => this.init.emit({ items: this.listItems })
+ )
+ }
+
+ hoverItem (index: number) {
+ this.init.emit({ items: this.listItems, index: index })
+ }
+}
height: calc(100vh - #{$header-height});
padding: 0;
width: $menu-width;
- z-index: 11000;
+ z-index: z(menu);
+ scrollbar-color: var(--actionButtonColor) var(--menuBackgroundColor);
}
menu {
--- /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 = 'Single-Match'
+ /* use this for single match search with a restriction that target should start with search string */
+ static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match'
+ /* use this for global search */
+ static MULTI_MATCH = 'Multi-Match'
+
+ // tslint:disable-next-line:no-empty
+ constructor () {}
+
+ transform (
+ contentString: string = null,
+ stringToHighlight: string = null,
+ option = 'Single-And-StartsWith-Match',
+ caseSensitive = false,
+ highlightStyleName = 'search-highlight'
+ ): SafeHtml {
+ if (stringToHighlight && contentString && option) {
+ let regex: any = ''
+ const 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
+ }
+ }
+}
<my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean>
</td>
</tr>
+
+ <tr>
+ <td i18n class="label" colspan="2">Search</td>
+ </tr>
+
+ <tr>
+ <td i18n class="sub-label">Users can resolve distant content</td>
+ <td>
+ <my-feature-boolean [value]="serverConfig.search.remoteUri.users"></my-feature-boolean>
+ </td>
+ </tr>
</table>
</div>
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
],
$icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
+@import '_bootstrap-variables';
@import '_variables';
@import '_mixins';
font-size: 14px;
}
+::selection {
+ color: var(--mainBackgroundColor);
+ background-color: var(--mainHoverColor);
+}
+
#incompatible-browser {
display: none;
text-align: center;
color: var(--mainForegroundColor);
}
- @media screen and (max-width: 500px) {
+ @media screen and (max-width: $mobile-view) {
margin-right: 15px;
}
}
}
}
-@media screen and (max-width: 1600px) {
+@media screen and (max-width: #{map-get($grid-breakpoints, xxl)}) {
.main-col {
&.expanded {
.margin-content {
}
}
-@media screen and (max-width: 900px) {
+@media screen and (max-width: #{map-get($grid-breakpoints, lg)}) {
.main-col {
&.expanded {
.margin-content {
animation: spin .7s infinite linear;
}
+.flex-auto {
+ flex: auto;
+}
+
@keyframes spin {
from {
transform: scale(1) rotate(0deg);
}
.dropdown {
- z-index: 10001 !important;
+ z-index: z(dropdown) !important;
}
.dropdown-menu {
}
-@media screen and (min-width: 768px) {
+@media screen and (min-width: #{map-get($grid-breakpoints, md)}) {
.modal:before {
vertical-align: middle;
content: " ";
}
ngb-modal-backdrop {
- z-index: 10000 !important;
+ z-index: z(modal) - 1 !important;
+}
+
+ngb-modal-window {
+ z-index: z(modal) !important;
}
.btn-outline-tertiary {
md: 768px,
// Large screen / desktop
lg: 900px,
- // Extra large screen / wide desktop
- xl: 1200px
+ // Extra large screens / wide desktops
+ xl: 1200px,
+ xxl: 1600px
);
$container-max-widths: (
width: $video-miniature-width * 2;
}
- @media screen and (max-width: 500px) {
+ @media screen and (max-width: $mobile-view) {
width: auto;
margin: 0 !important;
&:focus:not(.focus-visible) {
outline: none;
}
-
- &::-moz-focus-inner {
- border: 0;
- padding: 0
- }
}
@mixin actor-owner {
@include disable-default-a-behaviour;
- display: inline-table;
font-size: 13px;
margin-top: 4px;
color: var(--mainForegroundColor);
.actor-names {
display: flex;
align-items: center;
+ flex-wrap: wrap;
.actor-display-name {
font-size: 23px;
font-weight: $font-bold;
+ margin-right: 7px;
}
.actor-name {
- margin-left: 7px;
position: relative;
top: 3px;
font-size: 14px;
}
}
+ .actor-lower {
+ grid-area: lower;
+ }
+
.actor-followers {
font-size: 15px;
}
margin-bottom: 0;
text-transform: uppercase;
font-weight: 600;
+ font-size: 110%;
+
+ @media screen and (max-width: $mobile-view) {
+ font-size: 130%;
+ }
}
}
}
+@import '_bootstrap-variables';
+
$small-view: 800px;
$mobile-view: 500px;
--embedBigPlayBackgroundColor: var(--embedBigPlayBackgroundColor)
);
-/*** theme helper ***/
-
@function var($variable) {
@return map-get($variables, $variable);
}
+
+/*** z-index groups ***/
+
+$zindex: (
+ header : 1000,
+ /* header context */
+ headerLeft : 10,
+ menu : 11000,
+ dropdown : 12000,
+ loadbar : 13000,
+ modal : 14000,
+ notification : 15000,
+ hotkeys : 16000
+);
+
+@function z($label) {
+ @return map-get($zindex, $label);
+}
+@import '_mixins';
// Thanks: https://github.com/aitboudad/ngx-loading-bar/blob/master/loading-bar.css
/* Make clicks pass-through */
background: var(--mainColor);
position: fixed;
- z-index: 10002;
+ z-index: z(loadbar);
top: 0;
left: 0;
width: 100%;
#loading-bar-spinner {
display: block;
position: fixed;
- z-index: 10002;
+ z-index: z(loadbar);
top: 10px;
left: 10px;
}
p-toast {
.ui-toast {
- // Modal is 10005
- z-index: 10010 !important;
+ z-index: z(notification) !important;
}
.ui-toast-message {
# Don't build other languages if --light arg is provided
if [ -z ${1+x} ] || [ "$1" != "--light" ]; then
- if [ ! -z ${1+x} ] && [ "$1" == "--light-fr" ]; then
+ if [ ! -z ${1+x} ] && [ "$1" == "--light-hu" ]; then
+ languages=(["hu"]="hu-HU")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-th" ]; then
+ languages=(["th"]="th-TH")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-fi" ]; then
+ languages=(["fi"]="fi-FI")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-nl" ]; then
+ languages=(["nl"]="nl-NL")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-gd" ]; then
+ languages=(["gd"]="gd")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-el" ]; then
+ languages=(["el"]="el-GR")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-es" ]; then
+ languages=(["es"]="es-ES")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-oc" ]; then
+ languages=(["oc"]="oc")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-pt" ]; then
+ languages=(["pt"]="pt-BR")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-pt-PT" ]; then
+ languages=(["pt-PT"]="pt-PT")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-sv" ]; then
+ languages=(["sv"]="sv-SE")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-pl" ]; then
+ languages=(["pl"]="pl-PL")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-ru" ]; then
+ languages=(["ru"]="ru-RU")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-zh-Hans" ]; then
+ languages=(["zh-Hans"]="zh-Hans-CN")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-zh-Hant" ]; then
+ languages=(["zh-Hant"]="zh-Hant-TW")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-fr" ]; then
languages=(["fr"]="fr-FR")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-ja" ]; then
+ languages=(["ja"]="ja-JP")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-eu" ]; then
+ languages=(["eu"]="eu-ES")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-ca" ]; then
+ languages=(["ca"]="ca-ES")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-cs" ]; then
+ languages=(["cs"]="cs-CZ")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-eo" ]; then
+ languages=(["eo"]="eo")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-de" ]; then
+ languages=(["de"]="de-DE")
+ elif [ ! -z ${1+x} ] && [ "$1" == "--light-it" ]; then
+ languages=(["it"]="it-IT")
else
# Supported languages
languages=(
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
}
},
+ search: {
+ remoteUri: {
+ users: CONFIG.SEARCH.REMOTE_URI.USERS,
+ anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
+ }
+ },
plugin: {
registered: getRegisteredPlugins()
},
nodeName: CONFIG.INSTANCE.NAME,
nodeDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
nodeConfig: {
+ search: {
+ remoteUri: {
+ users: CONFIG.SEARCH.REMOTE_URI.USERS,
+ anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
+ }
+ },
plugin: {
registered: getRegisteredPlugins()
},
}
}
+ search: {
+ remoteUri: {
+ users: boolean
+ anonymous: boolean
+ }
+ }
+
plugin: {
registered: ServerConfigPlugin[]
}
$ touch ./docker-volume/traefik/acme.json && chmod 600 ./docker-volume/traefik/acme.json
$ curl -s "https://raw.githubusercontent.com/chocobozzz/PeerTube/master/support/docker/production/docker-compose.yml" -o docker-compose.yml "https://raw.githubusercontent.com/Chocobozzz/PeerTube/master/support/docker/production/.env" -o .env
```
-View the source of the files you're about to download: [docker-compose.yml](https://github.com/Chocobozzz/PeerTube/blob/develop/support/docker/production/docker-compose.yml) and the [traefik.toml](https://github.com/Chocobozzz/PeerTube/blob/develop/support/docker/production/config/traefik.toml) and the [.env]
-(https://github.com/Chocobozzz/PeerTube/blob/develop/support/docker/production/.env)
+View the source of the files you're about to download: [docker-compose.yml](https://github.com/Chocobozzz/PeerTube/blob/develop/support/docker/production/docker-compose.yml) and the [traefik.toml](https://github.com/Chocobozzz/PeerTube/blob/develop/support/docker/production/config/traefik.toml) and the [.env](https://github.com/Chocobozzz/PeerTube/blob/develop/support/docker/production/.env)
Update the reverse proxy configuration: