Add ability to filter by host in search page
This commit is contained in:
parent
9c9a236b54
commit
af7fd04a67
|
@ -63,7 +63,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
|
<div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
|
||||||
<input type="radio" (change)="onInputUpdated()" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
|
<input type="radio" (change)="onDurationOrPublishedUpdated()" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
|
||||||
<label [for]="date.id" class="radio">{{ date.label }}</label>
|
<label [for]="date.id" class="radio">{{ date.label }}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -79,7 +79,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="pl-0 col-sm-6">
|
<div class="pl-0 col-sm-6">
|
||||||
<input
|
<input
|
||||||
(change)="onInputUpdated()"
|
(change)="onDurationOrPublishedUpdated()"
|
||||||
(keydown.enter)="$event.preventDefault()"
|
(keydown.enter)="$event.preventDefault()"
|
||||||
type="text" id="original-publication-after" name="original-publication-after"
|
type="text" id="original-publication-after" name="original-publication-after"
|
||||||
i18n-placeholder placeholder="After..."
|
i18n-placeholder placeholder="After..."
|
||||||
|
@ -89,7 +89,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="pr-0 col-sm-6">
|
<div class="pr-0 col-sm-6">
|
||||||
<input
|
<input
|
||||||
(change)="onInputUpdated()"
|
(change)="onDurationOrPublishedUpdated()"
|
||||||
(keydown.enter)="$event.preventDefault()"
|
(keydown.enter)="$event.preventDefault()"
|
||||||
type="text" id="original-publication-before" name="original-publication-before"
|
type="text" id="original-publication-before" name="original-publication-before"
|
||||||
i18n-placeholder placeholder="Before..."
|
i18n-placeholder placeholder="Before..."
|
||||||
|
@ -112,7 +112,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="peertube-radio-container" *ngFor="let duration of durationRanges">
|
<div class="peertube-radio-container" *ngFor="let duration of durationRanges">
|
||||||
<input type="radio" (change)="onInputUpdated()" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
|
<input type="radio" (change)="onDurationOrPublishedUpdated()" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
|
||||||
<label [for]="duration.id" class="radio">{{ duration.label }}</label>
|
<label [for]="duration.id" class="radio">{{ duration.label }}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -174,6 +174,14 @@
|
||||||
<my-select-tags name="tagsOneOf" labelForId="tagsOneOf" id="tagsOneOf" [(ngModel)]="advancedSearch.tagsOneOf"></my-select-tags>
|
<my-select-tags name="tagsOneOf" labelForId="tagsOneOf" id="tagsOneOf" [(ngModel)]="advancedSearch.tagsOneOf"></my-select-tags>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="host">PeerTube instance host</label>
|
||||||
|
|
||||||
|
<input (change)="onDurationOrPublishedUpdated()" (keydown.enter)="$event.preventDefault()" type="text" id="host" name="host"
|
||||||
|
placeholder="example.com" [(ngModel)]="advancedSearch.host" class="form-control"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group" *ngIf="isSearchTargetEnabled()">
|
<div class="form-group" *ngIf="isSearchTargetEnabled()">
|
||||||
<div class="radio-label label-container">
|
<div class="radio-label label-container">
|
||||||
<label i18n>Search target</label>
|
<label i18n>Search target</label>
|
||||||
|
|
|
@ -108,14 +108,14 @@ export class SearchFiltersComponent implements OnInit {
|
||||||
this.loadOriginallyPublishedAtYears()
|
this.loadOriginallyPublishedAtYears()
|
||||||
}
|
}
|
||||||
|
|
||||||
onInputUpdated () {
|
onDurationOrPublishedUpdated () {
|
||||||
this.updateModelFromDurationRange()
|
this.updateModelFromDurationRange()
|
||||||
this.updateModelFromPublishedRange()
|
this.updateModelFromPublishedRange()
|
||||||
this.updateModelFromOriginallyPublishedAtYears()
|
this.updateModelFromOriginallyPublishedAtYears()
|
||||||
}
|
}
|
||||||
|
|
||||||
formUpdated () {
|
formUpdated () {
|
||||||
this.onInputUpdated()
|
this.onDurationOrPublishedUpdated()
|
||||||
this.filtered.emit(this.advancedSearch)
|
this.filtered.emit(this.advancedSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,7 +127,7 @@ export class SearchFiltersComponent implements OnInit {
|
||||||
this.durationRange = undefined
|
this.durationRange = undefined
|
||||||
this.publishedDateRange = undefined
|
this.publishedDateRange = undefined
|
||||||
|
|
||||||
this.onInputUpdated()
|
this.onDurationOrPublishedUpdated()
|
||||||
}
|
}
|
||||||
|
|
||||||
resetField (fieldName: string, value?: any) {
|
resetField (fieldName: string, value?: any) {
|
||||||
|
@ -136,7 +136,7 @@ export class SearchFiltersComponent implements OnInit {
|
||||||
|
|
||||||
resetLocalField (fieldName: string, value?: any) {
|
resetLocalField (fieldName: string, value?: any) {
|
||||||
this[fieldName] = value
|
this[fieldName] = value
|
||||||
this.onInputUpdated()
|
this.onDurationOrPublishedUpdated()
|
||||||
}
|
}
|
||||||
|
|
||||||
resetOriginalPublicationYears () {
|
resetOriginalPublicationYears () {
|
||||||
|
|
|
@ -24,6 +24,8 @@
|
||||||
|
|
||||||
<div class="results-filter collapse-transition" [ngbCollapse]="isSearchFilterCollapsed">
|
<div class="results-filter collapse-transition" [ngbCollapse]="isSearchFilterCollapsed">
|
||||||
<my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters>
|
<my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters>
|
||||||
|
|
||||||
|
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,10 @@
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.results-header {
|
.results-header {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { AuthService, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core'
|
import { AuthService, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core'
|
||||||
import { immutableAssign } from '@app/helpers'
|
import { immutableAssign } from '@app/helpers'
|
||||||
|
import { validateHost } from '@app/shared/form-validators/host-validators'
|
||||||
import { Video, VideoChannel } from '@app/shared/shared-main'
|
import { Video, VideoChannel } from '@app/shared/shared-main'
|
||||||
import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
|
import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
|
||||||
import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
|
import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
|
||||||
|
@ -16,6 +17,8 @@ import { HTMLServerConfig, SearchTargetType } from '@shared/models'
|
||||||
templateUrl: './search.component.html'
|
templateUrl: './search.component.html'
|
||||||
})
|
})
|
||||||
export class SearchComponent implements OnInit, OnDestroy {
|
export class SearchComponent implements OnInit, OnDestroy {
|
||||||
|
error: string
|
||||||
|
|
||||||
results: (Video | VideoChannel)[] = []
|
results: (Video | VideoChannel)[] = []
|
||||||
|
|
||||||
pagination = {
|
pagination = {
|
||||||
|
@ -89,8 +92,10 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
this.advancedSearch.searchTarget = this.getDefaultSearchTarget()
|
this.advancedSearch.searchTarget = this.getDefaultSearchTarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't hide filters if we have some of them AND the user just came on the webpage
|
this.error = this.checkFieldsAndGetError()
|
||||||
this.isSearchFilterCollapsed = this.isInitialLoad === false || !this.advancedSearch.containsValues()
|
|
||||||
|
// Don't hide filters if we have some of them AND the user just came on the webpage, or we have an error
|
||||||
|
this.isSearchFilterCollapsed = !this.error && (this.isInitialLoad === false || !this.advancedSearch.containsValues())
|
||||||
this.isInitialLoad = false
|
this.isInitialLoad = false
|
||||||
|
|
||||||
this.search()
|
this.search()
|
||||||
|
@ -126,6 +131,9 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
search () {
|
search () {
|
||||||
|
this.error = this.checkFieldsAndGetError()
|
||||||
|
if (this.error) return
|
||||||
|
|
||||||
this.isSearching = true
|
this.isSearching = true
|
||||||
|
|
||||||
forkJoin([
|
forkJoin([
|
||||||
|
@ -280,7 +288,7 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
const params = {
|
const params = {
|
||||||
search: this.currentSearch,
|
search: this.currentSearch,
|
||||||
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }),
|
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.channelsPerPage }),
|
||||||
searchTarget: this.advancedSearch.searchTarget
|
advancedSearch: this.advancedSearch
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.hooks.wrapObsFun(
|
return this.hooks.wrapObsFun(
|
||||||
|
@ -298,7 +306,7 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
const params = {
|
const params = {
|
||||||
search: this.currentSearch,
|
search: this.currentSearch,
|
||||||
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.playlistsPerPage }),
|
componentPagination: immutableAssign(this.pagination, { itemsPerPage: this.playlistsPerPage }),
|
||||||
searchTarget: this.advancedSearch.searchTarget
|
advancedSearch: this.advancedSearch
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.hooks.wrapObsFun(
|
return this.hooks.wrapObsFun(
|
||||||
|
@ -319,4 +327,12 @@ export class SearchComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
return 'local'
|
return 'local'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private checkFieldsAndGetError () {
|
||||||
|
if (this.advancedSearch.host && !validateHost(this.advancedSearch.host)) {
|
||||||
|
return $localize`PeerTube instance host filter is invalid`
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { AbstractControl, ValidatorFn, Validators } from '@angular/forms'
|
import { AbstractControl, ValidatorFn, Validators } from '@angular/forms'
|
||||||
import { BuildFormValidator } from './form-validator.model'
|
import { BuildFormValidator } from './form-validator.model'
|
||||||
|
|
||||||
function validateHost (value: string) {
|
export function validateHost (value: string) {
|
||||||
// Thanks to http://stackoverflow.com/a/106223
|
// Thanks to http://stackoverflow.com/a/106223
|
||||||
const HOST_REGEXP = new RegExp(
|
const HOST_REGEXP = new RegExp(
|
||||||
'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'
|
'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'
|
||||||
|
@ -10,7 +10,7 @@ function validateHost (value: string) {
|
||||||
return HOST_REGEXP.test(value)
|
return HOST_REGEXP.test(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateHandle (value: string) {
|
export function validateHandle (value: string) {
|
||||||
if (!value) return false
|
if (!value) return false
|
||||||
|
|
||||||
return value.includes('@')
|
return value.includes('@')
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
import { BooleanBothQuery, BooleanQuery, SearchTargetType, VideosSearchQuery } from '@shared/models'
|
import {
|
||||||
|
BooleanBothQuery,
|
||||||
|
BooleanQuery,
|
||||||
|
SearchTargetType,
|
||||||
|
VideoChannelsSearchQuery,
|
||||||
|
VideoPlaylistsSearchQuery,
|
||||||
|
VideosSearchQuery
|
||||||
|
} from '@shared/models'
|
||||||
|
|
||||||
export class AdvancedSearch {
|
export class AdvancedSearch {
|
||||||
startDate: string // ISO 8601
|
startDate: string // ISO 8601
|
||||||
|
@ -23,6 +30,8 @@ export class AdvancedSearch {
|
||||||
|
|
||||||
isLive: BooleanQuery
|
isLive: BooleanQuery
|
||||||
|
|
||||||
|
host: string
|
||||||
|
|
||||||
sort: string
|
sort: string
|
||||||
|
|
||||||
searchTarget: SearchTargetType
|
searchTarget: SearchTargetType
|
||||||
|
@ -45,6 +54,8 @@ export class AdvancedSearch {
|
||||||
|
|
||||||
isLive?: BooleanQuery
|
isLive?: BooleanQuery
|
||||||
|
|
||||||
|
host?: string
|
||||||
|
|
||||||
durationMin?: string
|
durationMin?: string
|
||||||
durationMax?: string
|
durationMax?: string
|
||||||
sort?: string
|
sort?: string
|
||||||
|
@ -68,6 +79,8 @@ export class AdvancedSearch {
|
||||||
this.durationMin = parseInt(options.durationMin, 10)
|
this.durationMin = parseInt(options.durationMin, 10)
|
||||||
this.durationMax = parseInt(options.durationMax, 10)
|
this.durationMax = parseInt(options.durationMax, 10)
|
||||||
|
|
||||||
|
this.host = options.host || undefined
|
||||||
|
|
||||||
this.searchTarget = options.searchTarget || undefined
|
this.searchTarget = options.searchTarget || undefined
|
||||||
|
|
||||||
if (isNaN(this.durationMin)) this.durationMin = undefined
|
if (isNaN(this.durationMin)) this.durationMin = undefined
|
||||||
|
@ -101,6 +114,7 @@ export class AdvancedSearch {
|
||||||
this.durationMin = undefined
|
this.durationMin = undefined
|
||||||
this.durationMax = undefined
|
this.durationMax = undefined
|
||||||
this.isLive = undefined
|
this.isLive = undefined
|
||||||
|
this.host = undefined
|
||||||
|
|
||||||
this.sort = '-match'
|
this.sort = '-match'
|
||||||
}
|
}
|
||||||
|
@ -120,12 +134,13 @@ export class AdvancedSearch {
|
||||||
durationMin: this.durationMin,
|
durationMin: this.durationMin,
|
||||||
durationMax: this.durationMax,
|
durationMax: this.durationMax,
|
||||||
isLive: this.isLive,
|
isLive: this.isLive,
|
||||||
|
host: this.host,
|
||||||
sort: this.sort,
|
sort: this.sort,
|
||||||
searchTarget: this.searchTarget
|
searchTarget: this.searchTarget
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toAPIObject (): VideosSearchQuery {
|
toVideosAPIObject (): VideosSearchQuery {
|
||||||
let isLive: boolean
|
let isLive: boolean
|
||||||
if (this.isLive) isLive = this.isLive === 'true'
|
if (this.isLive) isLive = this.isLive === 'true'
|
||||||
|
|
||||||
|
@ -142,12 +157,27 @@ export class AdvancedSearch {
|
||||||
tagsAllOf: this.tagsAllOf,
|
tagsAllOf: this.tagsAllOf,
|
||||||
durationMin: this.durationMin,
|
durationMin: this.durationMin,
|
||||||
durationMax: this.durationMax,
|
durationMax: this.durationMax,
|
||||||
|
host: this.host,
|
||||||
isLive,
|
isLive,
|
||||||
sort: this.sort,
|
sort: this.sort,
|
||||||
searchTarget: this.searchTarget
|
searchTarget: this.searchTarget
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toPlaylistAPIObject (): VideoPlaylistsSearchQuery {
|
||||||
|
return {
|
||||||
|
host: this.host,
|
||||||
|
searchTarget: this.searchTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toChannelAPIObject (): VideoChannelsSearchQuery {
|
||||||
|
return {
|
||||||
|
host: this.host,
|
||||||
|
searchTarget: this.searchTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
size () {
|
size () {
|
||||||
let acc = 0
|
let acc = 0
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/sha
|
||||||
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
|
||||||
import {
|
import {
|
||||||
ResultList,
|
ResultList,
|
||||||
SearchTargetType,
|
|
||||||
Video as VideoServerModel,
|
Video as VideoServerModel,
|
||||||
VideoChannel as VideoChannelServerModel,
|
VideoChannel as VideoChannelServerModel,
|
||||||
VideoPlaylist as VideoPlaylistServerModel
|
VideoPlaylist as VideoPlaylistServerModel
|
||||||
|
@ -33,8 +32,8 @@ export class SearchService {
|
||||||
}
|
}
|
||||||
|
|
||||||
searchVideos (parameters: {
|
searchVideos (parameters: {
|
||||||
search: string,
|
search: string
|
||||||
componentPagination?: ComponentPaginationLight,
|
componentPagination?: ComponentPaginationLight
|
||||||
advancedSearch?: AdvancedSearch
|
advancedSearch?: AdvancedSearch
|
||||||
}): Observable<ResultList<Video>> {
|
}): Observable<ResultList<Video>> {
|
||||||
const { search, componentPagination, advancedSearch } = parameters
|
const { search, componentPagination, advancedSearch } = parameters
|
||||||
|
@ -52,7 +51,7 @@ export class SearchService {
|
||||||
if (search) params = params.append('search', search)
|
if (search) params = params.append('search', search)
|
||||||
|
|
||||||
if (advancedSearch) {
|
if (advancedSearch) {
|
||||||
const advancedSearchObject = advancedSearch.toAPIObject()
|
const advancedSearchObject = advancedSearch.toVideosAPIObject()
|
||||||
params = this.restService.addObjectParams(params, advancedSearchObject)
|
params = this.restService.addObjectParams(params, advancedSearchObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,11 +64,11 @@ export class SearchService {
|
||||||
}
|
}
|
||||||
|
|
||||||
searchVideoChannels (parameters: {
|
searchVideoChannels (parameters: {
|
||||||
search: string,
|
search: string
|
||||||
searchTarget?: SearchTargetType,
|
advancedSearch?: AdvancedSearch
|
||||||
componentPagination?: ComponentPaginationLight
|
componentPagination?: ComponentPaginationLight
|
||||||
}): Observable<ResultList<VideoChannel>> {
|
}): Observable<ResultList<VideoChannel>> {
|
||||||
const { search, componentPagination, searchTarget } = parameters
|
const { search, advancedSearch, componentPagination } = parameters
|
||||||
|
|
||||||
const url = SearchService.BASE_SEARCH_URL + 'video-channels'
|
const url = SearchService.BASE_SEARCH_URL + 'video-channels'
|
||||||
|
|
||||||
|
@ -82,8 +81,9 @@ export class SearchService {
|
||||||
params = this.restService.addRestGetParams(params, pagination)
|
params = this.restService.addRestGetParams(params, pagination)
|
||||||
params = params.append('search', search)
|
params = params.append('search', search)
|
||||||
|
|
||||||
if (searchTarget) {
|
if (advancedSearch) {
|
||||||
params = params.append('searchTarget', searchTarget as string)
|
const advancedSearchObject = advancedSearch.toChannelAPIObject()
|
||||||
|
params = this.restService.addObjectParams(params, advancedSearchObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
|
@ -95,11 +95,11 @@ export class SearchService {
|
||||||
}
|
}
|
||||||
|
|
||||||
searchVideoPlaylists (parameters: {
|
searchVideoPlaylists (parameters: {
|
||||||
search: string,
|
search: string
|
||||||
searchTarget?: SearchTargetType,
|
advancedSearch?: AdvancedSearch
|
||||||
componentPagination?: ComponentPaginationLight
|
componentPagination?: ComponentPaginationLight
|
||||||
}): Observable<ResultList<VideoPlaylist>> {
|
}): Observable<ResultList<VideoPlaylist>> {
|
||||||
const { search, componentPagination, searchTarget } = parameters
|
const { search, advancedSearch, componentPagination } = parameters
|
||||||
|
|
||||||
const url = SearchService.BASE_SEARCH_URL + 'video-playlists'
|
const url = SearchService.BASE_SEARCH_URL + 'video-playlists'
|
||||||
|
|
||||||
|
@ -112,8 +112,9 @@ export class SearchService {
|
||||||
params = this.restService.addRestGetParams(params, pagination)
|
params = this.restService.addRestGetParams(params, pagination)
|
||||||
params = params.append('search', search)
|
params = params.append('search', search)
|
||||||
|
|
||||||
if (searchTarget) {
|
if (advancedSearch) {
|
||||||
params = params.append('searchTarget', searchTarget as string)
|
const advancedSearchObject = advancedSearch.toPlaylistAPIObject()
|
||||||
|
params = this.restService.addObjectParams(params, advancedSearchObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
|
|
Loading…
Reference in New Issue
Block a user