Add search functionality to twitch-logs app

master
Nikola Forró 6 years ago
parent 47cbd5695c
commit 489ad86c10

@ -25,8 +25,10 @@ import { FlexLayoutModule } from "@angular/flex-layout";
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { VideosComponent } from './videos/videos.component'; import { VideosComponent } from './videos/videos.component';
import { CommentsComponent } from './comments/comments.component'; import { CommentsComponent } from './comments/comments.component';
import { SearchComponent } from './search/search.component';
import { SplitBadgesPipe } from './pipes/splitbadges.pipe'; import { SplitBadgesPipe } from './pipes/splitbadges.pipe';
import { SplitMessagePipe } from './pipes/splitmessage.pipe'; import { SplitMessagePipe } from './pipes/splitmessage.pipe';
import { ToTimePipe } from './pipes/totime.pipe';
import { ToHMSPipe } from './pipes/tohms.pipe'; import { ToHMSPipe } from './pipes/tohms.pipe';
import { CommentsService } from './services/comments.service'; import { CommentsService } from './services/comments.service';
import { ImagesService } from './services/images.service'; import { ImagesService } from './services/images.service';
@ -35,6 +37,7 @@ import { ImagesService } from './services/images.service';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: VideosComponent }, { path: '', component: VideosComponent },
{ path: 'videos/:id', component: CommentsComponent }, { path: 'videos/:id', component: CommentsComponent },
{ path: 'search', component: SearchComponent },
{ path: '**', redirectTo: '/' } { path: '**', redirectTo: '/' }
]; ];
@ -43,8 +46,10 @@ const routes: Routes = [
AppComponent, AppComponent,
VideosComponent, VideosComponent,
CommentsComponent, CommentsComponent,
SearchComponent,
SplitBadgesPipe, SplitBadgesPipe,
SplitMessagePipe, SplitMessagePipe,
ToTimePipe,
ToHMSPipe ToHMSPipe
], ],
imports: [ imports: [

@ -2,15 +2,15 @@
<mat-toolbar> <mat-toolbar>
<mat-input-container class="search"> <mat-input-container class="filter">
<input matInput placeholder="Search comments" #input> <input matInput placeholder="Filter comments" #input>
</mat-input-container> </mat-input-container>
<span class="spacer"></span> <span class="spacer"></span>
<button class="export-button" color="primary" mat-raised-button [routerLink]="['/']">Back</button> <button class="back-button" color="primary" mat-raised-button [routerLink]="['/']">Back</button>
</mat-toolbar> </mat-toolbar>
@ -25,13 +25,13 @@
<ng-container matColumnDef="offset"> <ng-container matColumnDef="offset">
<mat-header-cell fxFlex="15%" *matHeaderCellDef mat-sort-header>Offset</mat-header-cell> <mat-header-cell fxFlex="15%" *matHeaderCellDef mat-sort-header>Time</mat-header-cell>
<mat-cell fxFlex="15%" *matCellDef="let comment"> <mat-cell fxFlex="15%" *matCellDef="let comment">
<a mat-button href="https://www.twitch.tv/videos/{{videoID}}?comment={{comment.id}}&t={{comment.offset | tohms}}" target="_blank"> <a mat-button href="https://www.twitch.tv/videos/{{videoID}}?comment={{comment.id}}&t={{comment.offset | tohms}}" target="_blank">
{{comment.offset | tohms : 'colons'}} {{comment.offset | totime : comment.video_recorded_at | tohms : 'colons'}}
</a> </a>

@ -1,6 +1,7 @@
export interface Comment { export interface Comment {
id: string; id: string;
video_id: number; video_id: number;
video_recorded_at: Date;
offset: number; offset: number;
commenter_id: string; commenter_id: string;
commenter_name: string; commenter_name: string;

@ -0,0 +1,22 @@
export interface SearchResult {
id: string;
video_id: number;
video_title: string;
video_game: string;
video_length: number;
video_thumbnail_small: string;
video_thumbnail_medium: string;
video_thumbnail_large: string;
video_recorded_at: Date;
offset: number;
commenter_id: string;
commenter_name: string;
commenter_display_name: string;
commenter_logo: string;
source: string;
message_body: string;
message_user_color: string;
message_user_badges: string;
created_at: Date;
updated_at: Date;
}

@ -0,0 +1,19 @@
import {
Pipe,
PipeTransform
} from '@angular/core';
@Pipe({
name: 'totime'
})
export class ToTimePipe implements PipeTransform {
transform(offset: number, base: string): number {
let recorded = new Date(base + 'Z');
let result = new Date(recorded.getTime() + offset * 1000);
let ref = new Date(result.getTime());
ref.setHours(0, 0, 0, 0);
return (result.getTime() - ref.getTime()) / 1000 >> 0;
}
}

@ -0,0 +1,50 @@
.comments {
text-align: center;
}
.comments-table {
text-align: left;
}
.mat-column-video_title {
white-space: nowrap;
}
.mat-column-video_title img, .mat-column-video_title a {
vertical-align: middle;
}
.mat-column-commenter_display_name {
font-weight: 500;
}
.mat-column-commenter_display_name img {
vertical-align: middle;
}
.mat-column-commenter_display_name span.commenter {
vertical-align: middle;
}
.mat-column-message_body span, .mat-column-message_body img {
vertical-align: middle;
}
.minispacer {
flex: 0.1 1 auto;
}
.spacer {
flex: 1 1 auto;
}
.spinner-container {
position: fixed;
left: 50%;
top: 15%;
margin-left: -50px;
}
.spinner-container mat-spinner {
margin: 6em auto 0 auto;
}

@ -0,0 +1,129 @@
<div class="results">
<mat-toolbar>
<mat-input-container class="search">
<input matInput placeholder="Search commenter" #input1>
</mat-input-container>
<span class="minispacer"></span>
<mat-input-container class="search">
<input matInput placeholder="Search message" #input2>
</mat-input-container>
<span class="spacer"></span>
<button class="back-button" color="primary" mat-raised-button [routerLink]="['/']">Back</button>
</mat-toolbar>
<div class="spinner-container" *ngIf="dataSource.loading$ | async">
<mat-spinner></mat-spinner>
</div>
<mat-table class="results-table mat-elevation-z8" [dataSource]="dataSource"
matSort matSortActive="video_recorded_at" matSortDirection="desc" matSortDisableClear>
<ng-container matColumnDef="video_title">
<mat-header-cell fxFlex="20%" *matHeaderCellDef mat-sort-header>Video</mat-header-cell>
<mat-cell fxFlex="20%" *matCellDef="let result">
<img src="{{result.video_thumbnail_small}}" alt="{{result.video_title}}">
<a mat-button [routerLink]="['/videos', result.video_id]">
{{result.video_title}}
</a>
</mat-cell>
</ng-container>
<ng-container matColumnDef="video_recorded_at">
<mat-header-cell fxFlex="8%" *matHeaderCellDef mat-sort-header>Recorded</mat-header-cell>
<mat-cell fxFlex="8%" *matCellDef="let result">{{result.video_recorded_at | date : 'yyyy-MM-dd'}}</mat-cell>
</ng-container>
<ng-container matColumnDef="offset">
<mat-header-cell fxFlex="8%" *matHeaderCellDef mat-sort-header>Time</mat-header-cell>
<mat-cell fxFlex="8%" *matCellDef="let result">
<a mat-button href="https://www.twitch.tv/videos/{{videoID}}?comment={{result.id}}&t={{result.offset | tohms}}" target="_blank">
{{result.offset | totime : result.video_recorded_at | tohms : 'colons'}}
</a>
</mat-cell>
</ng-container>
<ng-container matColumnDef="commenter_display_name">
<mat-header-cell fxFlex="18%" *matHeaderCellDef mat-sort-header>Commenter</mat-header-cell>
<mat-cell fxFlex="18%" *matCellDef="let result">
<span *ngIf="result.message_user_badges">
<ng-template ngFor let-token [ngForOf]="badges$ | async | splitbadges : result.message_user_badges">
<img src="{{token.url}}" alt="{{token.title}}" *ngIf="token.hasOwnProperty('url')">
</ng-template>
</span>
<span class="commenter" [ngStyle]="{color: result.message_user_color}">
{{result.commenter_display_name}}
</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="message_body">
<mat-header-cell fxFlex="46%" *matHeaderCellDef mat-sort-header>Message</mat-header-cell>
<mat-cell fxFlex="46%" *matCellDef="let result">
<ng-template ngFor let-token [ngForOf]="emotes$ | async | splitmessage : result.message_body">
<img src="{{token.url}}" alt="{{token.title}}" *ngIf="token.hasOwnProperty('url')">
<span *ngIf="!token.hasOwnProperty('url')">{{token}}</span>
</ng-template>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
<mat-paginator [length]="dataSource?.count$ | async" [pageSize]="20"
[pageSizeOptions]="[10, 20, 50, 100, 200]"></mat-paginator>
</div>

@ -0,0 +1,124 @@
import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
MatPaginator,
MatSort,
MatTableDataSource
} from '@angular/material';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import {
debounceTime,
delay,
distinctUntilChanged,
startWith,
tap
} from 'rxjs/operators';
import { merge } from 'rxjs/observable/merge';
import { fromEvent } from 'rxjs/observable/fromEvent';
import { CommentsService } from '../services/comments.service';
import { ImagesService } from '../services/images.service';
import { SearchDataSource } from '../services/search.datasource';
@Component({
selector: 'search',
templateUrl: './search.component.html',
styleUrls: [ './search.component.css' ]
})
export class SearchComponent implements OnInit, OnDestroy, AfterViewInit {
private badgesSubject = new BehaviorSubject<any[]>([]);
private emotesSubject = new BehaviorSubject<any[]>([]);
dataSource: SearchDataSource;
displayedColumns = [
'video_title',
'video_recorded_at',
'offset',
'commenter_display_name',
'message_body'
];
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
@ViewChild('input1') input1: ElementRef;
@ViewChild('input2') input2: ElementRef;
public badges$ = this.badgesSubject.asObservable();
public emotes$ = this.emotesSubject.asObservable();
constructor(private route: ActivatedRoute,
private commentsService: CommentsService,
private imagesService: ImagesService) { }
ngOnInit() {
this.dataSource = new SearchDataSource(this.commentsService);
this.dataSource.loadComments('', '', 'video_recorded_at', 'desc', 0, 20);
this.imagesService.getBadges().subscribe(
(data: any[]) => this.badgesSubject.next(data)
);
this.imagesService.getEmotes().subscribe(
(data: any[]) => this.emotesSubject.next(data)
);
}
ngOnDestroy() {
this.badgesSubject.complete();
this.emotesSubject.complete();
}
ngAfterViewInit() {
this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0);
fromEvent(this.input1.nativeElement, 'keyup')
.pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => {
this.paginator.pageIndex = 0;
this.loadCommentsPage();
})
)
.subscribe();
fromEvent(this.input2.nativeElement, 'keyup')
.pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => {
this.paginator.pageIndex = 0;
this.loadCommentsPage();
})
)
.subscribe();
merge(this.sort.sortChange, this.paginator.page)
.pipe(
tap(() => this.loadCommentsPage())
)
.subscribe();
}
loadCommentsPage() {
this.dataSource.loadComments(
this.input1.nativeElement.value,
this.input2.nativeElement.value,
this.sort.active,
this.sort.direction,
this.paginator.pageIndex,
this.paginator.pageSize);
}
}

@ -11,6 +11,7 @@ import { map } from 'rxjs/operators';
import { Video } from '../models/video'; import { Video } from '../models/video';
import { Comment } from '../models/comment'; import { Comment } from '../models/comment';
import { SearchResult } from '../models/searchresult';
@Injectable() @Injectable()
@ -56,4 +57,25 @@ export class CommentsService {
})) }))
); );
} }
searchComments(
commenter = '', term = '', sortBy = 'video_recorded_at', sortOrder = 'desc',
pageNumber = 0, pageSize = 20): Observable<any> {
return this.http.get('/twitch-logs/api/search', {
observe: 'response',
params: new HttpParams()
.set('commenter', commenter)
.set('term', term)
.set('sort_by', sortBy)
.set('sort_order', sortOrder)
.set('page_number', pageNumber.toString())
.set('page_size', pageSize.toString())
}).pipe(
map((res: any) => ({
results: <SearchResult[]>res.body,
totalCount: parseInt(res.headers.get('X-Total-Count'))
}))
);
}
} }

@ -0,0 +1,61 @@
import {
CollectionViewer,
DataSource
} from '@angular/cdk/collections';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { of } from 'rxjs/observable/of';
import {
catchError,
finalize
} from 'rxjs/operators';
import { CommentsService } from './comments.service';
import { SearchResult } from '../models/searchresult';
export class SearchDataSource implements DataSource<SearchResult> {
private resultsSubject = new BehaviorSubject<SearchResult[]>([]);
private countSubject = new BehaviorSubject<number>(0);
private loadingSubject = new BehaviorSubject<boolean>(false);
public count$ = this.countSubject.asObservable();
public loading$ = this.loadingSubject.asObservable();
constructor(private commentsService: CommentsService) { }
loadComments(commenter: string,
term: string,
sortColumn: string,
sortDirection: string,
pageIndex: number,
pageSize: number) {
this.loadingSubject.next(true);
this.commentsService.searchComments(commenter, term, sortColumn, sortDirection,
pageIndex, pageSize)
.pipe(
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false))
)
.subscribe((data: any) => {
this.resultsSubject.next(data.results);
this.countSubject.next(data.totalCount);
});
}
connect(collectionViewer: CollectionViewer): Observable<SearchResult[]> {
console.log('Connecting data source');
return this.resultsSubject.asObservable();
}
disconnect(collectionViewer: CollectionViewer): void {
this.resultsSubject.complete();
this.countSubject.complete();
this.loadingSubject.complete();
}
}

@ -2,14 +2,16 @@
<mat-toolbar> <mat-toolbar>
<mat-input-container class="search"> <mat-input-container class="filter">
<input matInput placeholder="Search videos" #input> <input matInput placeholder="Filter videos" #input>
</mat-input-container> </mat-input-container>
<span class="spacer"></span> <span class="spacer"></span>
<button class="search-button" color="primary" mat-raised-button [routerLink]="['/search']">Search</button>
</mat-toolbar> </mat-toolbar>
<div class="spinner-container" *ngIf="dataSource.loading$ | async"> <div class="spinner-container" *ngIf="dataSource.loading$ | async">

Loading…
Cancel
Save