diff options
author | Chocobozzz <florian.bigard@gmail.com> | 2016-06-03 22:08:03 +0200 |
---|---|---|
committer | Chocobozzz <florian.bigard@gmail.com> | 2016-06-03 22:08:03 +0200 |
commit | 4a6995be18b15de1834a39c8921a0e4109671bb6 (patch) | |
tree | b659661cea33687fcc6bd8fc2251cb7a15ab9f9d /client/src/app | |
parent | 468892541175f9662f8b1b977e819dc1a496f282 (diff) | |
download | PeerTube-4a6995be18b15de1834a39c8921a0e4109671bb6.tar.gz PeerTube-4a6995be18b15de1834a39c8921a0e4109671bb6.tar.zst PeerTube-4a6995be18b15de1834a39c8921a0e4109671bb6.zip |
First draft to use webpack instead of systemjs
Diffstat (limited to 'client/src/app')
47 files changed, 1324 insertions, 0 deletions
diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html new file mode 100644 index 000000000..48e97d523 --- /dev/null +++ b/client/src/app/app.component.html | |||
@@ -0,0 +1,60 @@ | |||
1 | <div class="container"> | ||
2 | |||
3 | <header class="row"> | ||
4 | <div class="col-md-2"> | ||
5 | <h4>PeerTube</h4> | ||
6 | </div> | ||
7 | |||
8 | <div class="col-md-9"> | ||
9 | <my-search (search)="onSearch($event)"></my-search> | ||
10 | </div> | ||
11 | </header> | ||
12 | |||
13 | |||
14 | <div class="row"> | ||
15 | |||
16 | <menu class="col-md-2 col-xs-3"> | ||
17 | <div class="panel_block"> | ||
18 | <div id="panel_user_login" class="panel_button"> | ||
19 | <span class="glyphicon glyphicon-user"></span> | ||
20 | <a *ngIf="!isLoggedIn" [routerLink]="['UserLogin']">Login</a> | ||
21 | <a *ngIf="isLoggedIn" (click)="logout()">Logout</a> | ||
22 | </div> | ||
23 | </div> | ||
24 | |||
25 | <div class="panel_block"> | ||
26 | <div id="panel_get_videos" class="panel_button"> | ||
27 | <span class="glyphicon glyphicon-list"></span> | ||
28 | <a [routerLink]="['VideosList']">Get videos</a> | ||
29 | </div> | ||
30 | |||
31 | <div id="panel_upload_video" class="panel_button" *ngIf="isLoggedIn"> | ||
32 | <span class="glyphicon glyphicon-cloud-upload"></span> | ||
33 | <a [routerLink]="['VideosAdd']">Upload a video</a> | ||
34 | </div> | ||
35 | </div> | ||
36 | |||
37 | <div class="panel_block" *ngIf="isLoggedIn"> | ||
38 | <div id="panel_make_friends" class="panel_button"> | ||
39 | <span class="glyphicon glyphicon-cloud"></span> | ||
40 | <a (click)='makeFriends()'>Make friends</a> | ||
41 | </div> | ||
42 | |||
43 | <div id="panel_quit_friends" class="panel_button"> | ||
44 | <span class="glyphicon glyphicon-plane"></span> | ||
45 | <a (click)='quitFriends()'>Quit friends</a> | ||
46 | </div> | ||
47 | </div> | ||
48 | </menu> | ||
49 | |||
50 | <div class="col-md-9 col-xs-8 router_outler_container"> | ||
51 | <router-outlet></router-outlet> | ||
52 | </div> | ||
53 | |||
54 | </div> | ||
55 | |||
56 | |||
57 | <footer> | ||
58 | PeerTube, CopyLeft 2015-2016 | ||
59 | </footer> | ||
60 | </div> | ||
diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss new file mode 100644 index 000000000..e02c2d5b0 --- /dev/null +++ b/client/src/app/app.component.scss | |||
@@ -0,0 +1,32 @@ | |||
1 | header div { | ||
2 | line-height: 25px; | ||
3 | margin-bottom: 30px; | ||
4 | } | ||
5 | |||
6 | menu { | ||
7 | min-height: 600px; | ||
8 | margin-right: 20px; | ||
9 | border-right: 1px solid rgba(0, 0, 0, 0.2); | ||
10 | |||
11 | .panel_button { | ||
12 | margin: 8px; | ||
13 | cursor: pointer; | ||
14 | transition: margin 0.2s; | ||
15 | |||
16 | &:hover { | ||
17 | margin-left: 15px; | ||
18 | } | ||
19 | |||
20 | a { | ||
21 | color: #333333; | ||
22 | } | ||
23 | } | ||
24 | |||
25 | .glyphicon { | ||
26 | margin: 5px; | ||
27 | } | ||
28 | } | ||
29 | |||
30 | .panel_block:not(:last-child) { | ||
31 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); | ||
32 | } | ||
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts new file mode 100644 index 000000000..81b700a21 --- /dev/null +++ b/client/src/app/app.component.ts | |||
@@ -0,0 +1,109 @@ | |||
1 | import { Component } from '@angular/core'; | ||
2 | import { HTTP_PROVIDERS } from '@angular/http'; | ||
3 | import { RouteConfig, Router, ROUTER_DIRECTIVES, ROUTER_PROVIDERS } from '@angular/router-deprecated'; | ||
4 | |||
5 | import { FriendService } from './friends'; | ||
6 | import { LoginComponent } from './login'; | ||
7 | import { | ||
8 | AuthService, | ||
9 | AuthStatus, | ||
10 | Search, | ||
11 | SearchComponent | ||
12 | } from './shared'; | ||
13 | import { | ||
14 | VideoAddComponent, | ||
15 | VideoListComponent, | ||
16 | VideoWatchComponent, | ||
17 | VideoService | ||
18 | } from './videos'; | ||
19 | |||
20 | @RouteConfig([ | ||
21 | { | ||
22 | path: '/users/login', | ||
23 | name: 'UserLogin', | ||
24 | component: LoginComponent | ||
25 | }, | ||
26 | { | ||
27 | path: '/videos/list', | ||
28 | name: 'VideosList', | ||
29 | component: VideoListComponent, | ||
30 | useAsDefault: true | ||
31 | }, | ||
32 | { | ||
33 | path: '/videos/watch/:id', | ||
34 | name: 'VideosWatch', | ||
35 | component: VideoWatchComponent | ||
36 | }, | ||
37 | { | ||
38 | path: '/videos/add', | ||
39 | name: 'VideosAdd', | ||
40 | component: VideoAddComponent | ||
41 | } | ||
42 | ]) | ||
43 | |||
44 | @Component({ | ||
45 | selector: 'my-app', | ||
46 | template: require('./app.component.html'), | ||
47 | styles: [ require('./app.component.scss') ], | ||
48 | directives: [ ROUTER_DIRECTIVES, SearchComponent ], | ||
49 | providers: [ AuthService, FriendService, HTTP_PROVIDERS, ROUTER_PROVIDERS, VideoService ] | ||
50 | }) | ||
51 | |||
52 | export class AppComponent { | ||
53 | choices = []; | ||
54 | isLoggedIn: boolean; | ||
55 | |||
56 | constructor( | ||
57 | private authService: AuthService, | ||
58 | private friendService: FriendService, | ||
59 | private router: Router | ||
60 | ) { | ||
61 | this.isLoggedIn = this.authService.isLoggedIn(); | ||
62 | |||
63 | this.authService.loginChangedSource.subscribe( | ||
64 | status => { | ||
65 | if (status === AuthStatus.LoggedIn) { | ||
66 | this.isLoggedIn = true; | ||
67 | } | ||
68 | } | ||
69 | ); | ||
70 | } | ||
71 | |||
72 | onSearch(search: Search) { | ||
73 | if (search.value !== '') { | ||
74 | const params = { | ||
75 | field: search.field, | ||
76 | search: search.value | ||
77 | }; | ||
78 | this.router.navigate(['VideosList', params]); | ||
79 | } else { | ||
80 | this.router.navigate(['VideosList']); | ||
81 | } | ||
82 | } | ||
83 | |||
84 | logout() { | ||
85 | // this._authService.logout(); | ||
86 | } | ||
87 | |||
88 | makeFriends() { | ||
89 | this.friendService.makeFriends().subscribe( | ||
90 | status => { | ||
91 | if (status === 409) { | ||
92 | alert('Already made friends!'); | ||
93 | } else { | ||
94 | alert('Made friends!'); | ||
95 | } | ||
96 | }, | ||
97 | error => alert(error) | ||
98 | ); | ||
99 | } | ||
100 | |||
101 | quitFriends() { | ||
102 | this.friendService.quitFriends().subscribe( | ||
103 | status => { | ||
104 | alert('Quit friends!'); | ||
105 | }, | ||
106 | error => alert(error) | ||
107 | ); | ||
108 | } | ||
109 | } | ||
diff --git a/client/src/app/friends/friend.service.ts b/client/src/app/friends/friend.service.ts new file mode 100644 index 000000000..a8b1a1bd3 --- /dev/null +++ b/client/src/app/friends/friend.service.ts | |||
@@ -0,0 +1,31 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | import { Http, Response } from '@angular/http'; | ||
3 | import { Observable } from 'rxjs/Rx'; | ||
4 | |||
5 | import { AuthService } from '../shared'; | ||
6 | |||
7 | @Injectable() | ||
8 | export class FriendService { | ||
9 | private static BASE_FRIEND_URL: string = '/api/v1/pods/'; | ||
10 | |||
11 | constructor (private http: Http, private authService: AuthService) {} | ||
12 | |||
13 | makeFriends() { | ||
14 | const headers = this.authService.getRequestHeader(); | ||
15 | return this.http.get(FriendService.BASE_FRIEND_URL + 'makefriends', { headers }) | ||
16 | .map(res => res.status) | ||
17 | .catch(this.handleError); | ||
18 | } | ||
19 | |||
20 | quitFriends() { | ||
21 | const headers = this.authService.getRequestHeader(); | ||
22 | return this.http.get(FriendService.BASE_FRIEND_URL + 'quitfriends', { headers }) | ||
23 | .map(res => res.status) | ||
24 | .catch(this.handleError); | ||
25 | } | ||
26 | |||
27 | private handleError (error: Response): Observable<number> { | ||
28 | console.error(error); | ||
29 | return Observable.throw(error.json().error || 'Server error'); | ||
30 | } | ||
31 | } | ||
diff --git a/client/src/app/friends/index.ts b/client/src/app/friends/index.ts new file mode 100644 index 000000000..0adc256c4 --- /dev/null +++ b/client/src/app/friends/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './friend.service'; | |||
diff --git a/client/src/app/login/index.ts b/client/src/app/login/index.ts new file mode 100644 index 000000000..69c16441f --- /dev/null +++ b/client/src/app/login/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './login.component'; | |||
diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html new file mode 100644 index 000000000..940694515 --- /dev/null +++ b/client/src/app/login/login.component.html | |||
@@ -0,0 +1,14 @@ | |||
1 | <h3>Login</h3> | ||
2 | <form role="form" (submit)="login(username.value, password.value)"> | ||
3 | <div class="form-group"> | ||
4 | <label for="username">Username</label> | ||
5 | <input type="text" #username class="form-control" id="username" placeholder="Username"> | ||
6 | </div> | ||
7 | |||
8 | <div class="form-group"> | ||
9 | <label for="password">Password</label> | ||
10 | <input type="password" #password class="form-control" id="password" placeholder="Password"> | ||
11 | </div> | ||
12 | |||
13 | <input type="submit" value="Login" class="btn btn-default"> | ||
14 | </form> | ||
diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts new file mode 100644 index 000000000..9d88536ca --- /dev/null +++ b/client/src/app/login/login.component.ts | |||
@@ -0,0 +1,36 @@ | |||
1 | import { Component } from '@angular/core'; | ||
2 | import { Router } from '@angular/router-deprecated'; | ||
3 | |||
4 | import { AuthService, AuthStatus, User } from '../shared'; | ||
5 | |||
6 | @Component({ | ||
7 | selector: 'my-login', | ||
8 | template: require('./login.component.html') | ||
9 | }) | ||
10 | |||
11 | export class LoginComponent { | ||
12 | constructor( | ||
13 | private authService: AuthService, | ||
14 | private router: Router | ||
15 | ) {} | ||
16 | |||
17 | login(username: string, password: string) { | ||
18 | this.authService.login(username, password).subscribe( | ||
19 | result => { | ||
20 | const user = new User(username, result); | ||
21 | user.save(); | ||
22 | |||
23 | this.authService.setStatus(AuthStatus.LoggedIn); | ||
24 | |||
25 | this.router.navigate(['VideosList']); | ||
26 | }, | ||
27 | error => { | ||
28 | if (error.error === 'invalid_grant') { | ||
29 | alert('Credentials are invalid.'); | ||
30 | } else { | ||
31 | alert(`${error.error}: ${error.error_description}`); | ||
32 | } | ||
33 | } | ||
34 | ); | ||
35 | } | ||
36 | } | ||
diff --git a/client/src/app/shared/index.ts b/client/src/app/shared/index.ts new file mode 100644 index 000000000..0cab7dad0 --- /dev/null +++ b/client/src/app/shared/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './search'; | ||
2 | export * from './users' | ||
diff --git a/client/src/app/shared/search/index.ts b/client/src/app/shared/search/index.ts new file mode 100644 index 000000000..a49a4f1a9 --- /dev/null +++ b/client/src/app/shared/search/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './search-field.type'; | ||
2 | export * from './search.component'; | ||
3 | export * from './search.model'; | ||
diff --git a/client/src/app/shared/search/search-field.type.ts b/client/src/app/shared/search/search-field.type.ts new file mode 100644 index 000000000..846236290 --- /dev/null +++ b/client/src/app/shared/search/search-field.type.ts | |||
@@ -0,0 +1 @@ | |||
export type SearchField = "name" | "author" | "podUrl" | "magnetUri"; | |||
diff --git a/client/src/app/shared/search/search.component.html b/client/src/app/shared/search/search.component.html new file mode 100644 index 000000000..fb13ac72e --- /dev/null +++ b/client/src/app/shared/search/search.component.html | |||
@@ -0,0 +1,17 @@ | |||
1 | <div class="input-group"> | ||
2 | <div class="input-group-btn" dropdown> | ||
3 | <button id="simple-btn-keyboard-nav" type="button" class="btn btn-default" dropdownToggle> | ||
4 | {{ getStringChoice(searchCriterias.field) }} <span class="caret"></span> | ||
5 | </button> | ||
6 | <ul class="dropdown-menu" role="menu" aria-labelledby="simple-btn-keyboard-nav"> | ||
7 | <li *ngFor="let choice of choiceKeys" class="dropdown-item"> | ||
8 | <a class="dropdown-item" href="#" (click)="choose($event, choice)">{{ getStringChoice(choice) }}</a> | ||
9 | </li> | ||
10 | </ul> | ||
11 | </div> | ||
12 | |||
13 | <input | ||
14 | type="text" id="search-video" name="search-video" class="form-control" placeholder="Search a video..." class="form-control" | ||
15 | [(ngModel)]="searchCriterias.value" (keyup.enter)="doSearch()" | ||
16 | > | ||
17 | </div> | ||
diff --git a/client/src/app/shared/search/search.component.ts b/client/src/app/shared/search/search.component.ts new file mode 100644 index 000000000..31f8b1535 --- /dev/null +++ b/client/src/app/shared/search/search.component.ts | |||
@@ -0,0 +1,46 @@ | |||
1 | import { Component, EventEmitter, Output } from '@angular/core'; | ||
2 | |||
3 | import { DROPDOWN_DIRECTIVES} from 'ng2-bootstrap/components/dropdown'; | ||
4 | |||
5 | import { Search } from './search.model'; | ||
6 | import { SearchField } from './search-field.type'; | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-search', | ||
10 | template: require('./search.component.html'), | ||
11 | directives: [ DROPDOWN_DIRECTIVES ] | ||
12 | }) | ||
13 | |||
14 | export class SearchComponent { | ||
15 | @Output() search = new EventEmitter<Search>(); | ||
16 | |||
17 | fieldChoices = { | ||
18 | name: 'Name', | ||
19 | author: 'Author', | ||
20 | podUrl: 'Pod Url', | ||
21 | magnetUri: 'Magnet Uri' | ||
22 | }; | ||
23 | searchCriterias: Search = { | ||
24 | field: 'name', | ||
25 | value: '' | ||
26 | }; | ||
27 | |||
28 | get choiceKeys() { | ||
29 | return Object.keys(this.fieldChoices); | ||
30 | } | ||
31 | |||
32 | choose($event: MouseEvent, choice: SearchField) { | ||
33 | $event.preventDefault(); | ||
34 | $event.stopPropagation(); | ||
35 | |||
36 | this.searchCriterias.field = choice; | ||
37 | } | ||
38 | |||
39 | doSearch() { | ||
40 | this.search.emit(this.searchCriterias); | ||
41 | } | ||
42 | |||
43 | getStringChoice(choiceKey: SearchField) { | ||
44 | return this.fieldChoices[choiceKey]; | ||
45 | } | ||
46 | } | ||
diff --git a/client/src/app/shared/search/search.model.ts b/client/src/app/shared/search/search.model.ts new file mode 100644 index 000000000..932a6566c --- /dev/null +++ b/client/src/app/shared/search/search.model.ts | |||
@@ -0,0 +1,6 @@ | |||
1 | import { SearchField } from './search-field.type'; | ||
2 | |||
3 | export interface Search { | ||
4 | field: SearchField; | ||
5 | value: string; | ||
6 | } | ||
diff --git a/client/src/app/shared/users/auth-status.model.ts b/client/src/app/shared/users/auth-status.model.ts new file mode 100644 index 000000000..f646bd4cf --- /dev/null +++ b/client/src/app/shared/users/auth-status.model.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export enum AuthStatus { | ||
2 | LoggedIn, | ||
3 | LoggedOut | ||
4 | } | ||
diff --git a/client/src/app/shared/users/auth.service.ts b/client/src/app/shared/users/auth.service.ts new file mode 100644 index 000000000..d63fe38f3 --- /dev/null +++ b/client/src/app/shared/users/auth.service.ts | |||
@@ -0,0 +1,108 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | import { Headers, Http, RequestOptions, Response, URLSearchParams } from '@angular/http'; | ||
3 | import { Observable, Subject } from 'rxjs/Rx'; | ||
4 | |||
5 | import { AuthStatus } from './auth-status.model'; | ||
6 | import { User } from './user.model'; | ||
7 | |||
8 | @Injectable() | ||
9 | export class AuthService { | ||
10 | private static BASE_CLIENT_URL = '/api/v1/users/client'; | ||
11 | private static BASE_LOGIN_URL = '/api/v1/users/token'; | ||
12 | |||
13 | loginChangedSource: Observable<AuthStatus>; | ||
14 | |||
15 | private clientId: string; | ||
16 | private clientSecret: string; | ||
17 | private loginChanged: Subject<AuthStatus>; | ||
18 | |||
19 | constructor(private http: Http) { | ||
20 | this.loginChanged = new Subject<AuthStatus>(); | ||
21 | this.loginChangedSource = this.loginChanged.asObservable(); | ||
22 | |||
23 | // Fetch the client_id/client_secret | ||
24 | // FIXME: save in local storage? | ||
25 | this.http.get(AuthService.BASE_CLIENT_URL) | ||
26 | .map(res => res.json()) | ||
27 | .catch(this.handleError) | ||
28 | .subscribe( | ||
29 | result => { | ||
30 | this.clientId = result.client_id; | ||
31 | this.clientSecret = result.client_secret; | ||
32 | console.log('Client credentials loaded.'); | ||
33 | }, | ||
34 | error => { | ||
35 | alert(error); | ||
36 | } | ||
37 | ); | ||
38 | } | ||
39 | |||
40 | getAuthRequestOptions(): RequestOptions { | ||
41 | return new RequestOptions({ headers: this.getRequestHeader() }); | ||
42 | } | ||
43 | |||
44 | getRequestHeader() { | ||
45 | return new Headers({ 'Authorization': `${this.getTokenType()} ${this.getToken()}` }); | ||
46 | } | ||
47 | |||
48 | getToken() { | ||
49 | return localStorage.getItem('access_token'); | ||
50 | } | ||
51 | |||
52 | getTokenType() { | ||
53 | return localStorage.getItem('token_type'); | ||
54 | } | ||
55 | |||
56 | getUser(): User { | ||
57 | if (this.isLoggedIn() === false) { | ||
58 | return null; | ||
59 | } | ||
60 | |||
61 | const user = User.load(); | ||
62 | |||
63 | return user; | ||
64 | } | ||
65 | |||
66 | isLoggedIn() { | ||
67 | if (this.getToken()) { | ||
68 | return true; | ||
69 | } else { | ||
70 | return false; | ||
71 | } | ||
72 | } | ||
73 | |||
74 | login(username: string, password: string) { | ||
75 | let body = new URLSearchParams(); | ||
76 | body.set('client_id', this.clientId); | ||
77 | body.set('client_secret', this.clientSecret); | ||
78 | body.set('response_type', 'code'); | ||
79 | body.set('grant_type', 'password'); | ||
80 | body.set('scope', 'upload'); | ||
81 | body.set('username', username); | ||
82 | body.set('password', password); | ||
83 | |||
84 | let headers = new Headers(); | ||
85 | headers.append('Content-Type', 'application/x-www-form-urlencoded'); | ||
86 | |||
87 | let options = { | ||
88 | headers: headers | ||
89 | }; | ||
90 | |||
91 | return this.http.post(AuthService.BASE_LOGIN_URL, body.toString(), options) | ||
92 | .map(res => res.json()) | ||
93 | .catch(this.handleError); | ||
94 | } | ||
95 | |||
96 | logout() { | ||
97 | // TODO make HTTP request | ||
98 | } | ||
99 | |||
100 | setStatus(status: AuthStatus) { | ||
101 | this.loginChanged.next(status); | ||
102 | } | ||
103 | |||
104 | private handleError (error: Response) { | ||
105 | console.error(error); | ||
106 | return Observable.throw(error.json() || { error: 'Server error' }); | ||
107 | } | ||
108 | } | ||
diff --git a/client/src/app/shared/users/index.ts b/client/src/app/shared/users/index.ts new file mode 100644 index 000000000..c6816b3c6 --- /dev/null +++ b/client/src/app/shared/users/index.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export * from './auth-status.model'; | ||
2 | export * from './auth.service'; | ||
3 | export * from './token.model'; | ||
4 | export * from './user.model'; | ||
diff --git a/client/src/app/shared/users/token.model.ts b/client/src/app/shared/users/token.model.ts new file mode 100644 index 000000000..021c83fad --- /dev/null +++ b/client/src/app/shared/users/token.model.ts | |||
@@ -0,0 +1,32 @@ | |||
1 | export class Token { | ||
2 | access_token: string; | ||
3 | refresh_token: string; | ||
4 | token_type: string; | ||
5 | |||
6 | static load() { | ||
7 | return new Token({ | ||
8 | access_token: localStorage.getItem('access_token'), | ||
9 | refresh_token: localStorage.getItem('refresh_token'), | ||
10 | token_type: localStorage.getItem('token_type') | ||
11 | }); | ||
12 | } | ||
13 | |||
14 | constructor(hash?: any) { | ||
15 | if (hash) { | ||
16 | this.access_token = hash.access_token; | ||
17 | this.refresh_token = hash.refresh_token; | ||
18 | |||
19 | if (hash.token_type === 'bearer') { | ||
20 | this.token_type = 'Bearer'; | ||
21 | } else { | ||
22 | this.token_type = hash.token_type; | ||
23 | } | ||
24 | } | ||
25 | } | ||
26 | |||
27 | save() { | ||
28 | localStorage.setItem('access_token', this.access_token); | ||
29 | localStorage.setItem('refresh_token', this.refresh_token); | ||
30 | localStorage.setItem('token_type', this.token_type); | ||
31 | } | ||
32 | } | ||
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts new file mode 100644 index 000000000..ca0a5f26c --- /dev/null +++ b/client/src/app/shared/users/user.model.ts | |||
@@ -0,0 +1,20 @@ | |||
1 | import { Token } from './token.model'; | ||
2 | |||
3 | export class User { | ||
4 | username: string; | ||
5 | token: Token; | ||
6 | |||
7 | static load() { | ||
8 | return new User(localStorage.getItem('username'), Token.load()); | ||
9 | } | ||
10 | |||
11 | constructor(username: string, hash_token: any) { | ||
12 | this.username = username; | ||
13 | this.token = new Token(hash_token); | ||
14 | } | ||
15 | |||
16 | save() { | ||
17 | localStorage.setItem('username', this.username); | ||
18 | this.token.save(); | ||
19 | } | ||
20 | } | ||
diff --git a/client/src/app/videos/index.ts b/client/src/app/videos/index.ts new file mode 100644 index 000000000..9a92fa57a --- /dev/null +++ b/client/src/app/videos/index.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export * from './shared'; | ||
2 | export * from './video-add'; | ||
3 | export * from './video-list'; | ||
4 | export * from './video-watch'; | ||
diff --git a/client/src/app/videos/shared/index.ts b/client/src/app/videos/shared/index.ts new file mode 100644 index 000000000..a54120f5d --- /dev/null +++ b/client/src/app/videos/shared/index.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export * from './loader'; | ||
2 | export * from './pagination.model'; | ||
3 | export * from './sort-field.type'; | ||
4 | export * from './video.model'; | ||
5 | export * from './video.service'; | ||
diff --git a/client/src/app/videos/shared/loader/index.ts b/client/src/app/videos/shared/loader/index.ts new file mode 100644 index 000000000..ab22584e4 --- /dev/null +++ b/client/src/app/videos/shared/loader/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './loader.component'; | |||
diff --git a/client/src/app/videos/shared/loader/loader.component.html b/client/src/app/videos/shared/loader/loader.component.html new file mode 100644 index 000000000..d02296a2d --- /dev/null +++ b/client/src/app/videos/shared/loader/loader.component.html | |||
@@ -0,0 +1,3 @@ | |||
1 | <div id="video-loading" class="col-md-12 text-center" *ngIf="loading"> | ||
2 | <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div> | ||
3 | </div> | ||
diff --git a/client/src/app/videos/shared/loader/loader.component.scss b/client/src/app/videos/shared/loader/loader.component.scss new file mode 100644 index 000000000..454195811 --- /dev/null +++ b/client/src/app/videos/shared/loader/loader.component.scss | |||
@@ -0,0 +1,26 @@ | |||
1 | div { | ||
2 | margin-top: 150px; | ||
3 | } | ||
4 | |||
5 | // Thanks https://gist.github.com/alexandrevicenzi/680147013e902a4eaa5d | ||
6 | .glyphicon-refresh-animate { | ||
7 | -animation: spin .7s infinite linear; | ||
8 | -ms-animation: spin .7s infinite linear; | ||
9 | -webkit-animation: spinw .7s infinite linear; | ||
10 | -moz-animation: spinm .7s infinite linear; | ||
11 | } | ||
12 | |||
13 | @keyframes spin { | ||
14 | from { transform: scale(1) rotate(0deg);} | ||
15 | to { transform: scale(1) rotate(360deg);} | ||
16 | } | ||
17 | |||
18 | @-webkit-keyframes spinw { | ||
19 | from { -webkit-transform: rotate(0deg);} | ||
20 | to { -webkit-transform: rotate(360deg);} | ||
21 | } | ||
22 | |||
23 | @-moz-keyframes spinm { | ||
24 | from { -moz-transform: rotate(0deg);} | ||
25 | to { -moz-transform: rotate(360deg);} | ||
26 | } | ||
diff --git a/client/src/app/videos/shared/loader/loader.component.ts b/client/src/app/videos/shared/loader/loader.component.ts new file mode 100644 index 000000000..cdd07d1b4 --- /dev/null +++ b/client/src/app/videos/shared/loader/loader.component.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | import { Component, Input } from '@angular/core'; | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-loader', | ||
5 | styles: [ require('./loader.component.scss') ], | ||
6 | template: require('./loader.component.html') | ||
7 | }) | ||
8 | |||
9 | export class LoaderComponent { | ||
10 | @Input() loading: boolean; | ||
11 | } | ||
diff --git a/client/src/app/videos/shared/pagination.model.ts b/client/src/app/videos/shared/pagination.model.ts new file mode 100644 index 000000000..06f7a7875 --- /dev/null +++ b/client/src/app/videos/shared/pagination.model.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export interface Pagination { | ||
2 | currentPage: number; | ||
3 | itemsPerPage: number; | ||
4 | total: number; | ||
5 | } | ||
diff --git a/client/src/app/videos/shared/sort-field.type.ts b/client/src/app/videos/shared/sort-field.type.ts new file mode 100644 index 000000000..6e8cc7936 --- /dev/null +++ b/client/src/app/videos/shared/sort-field.type.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export type SortField = "name" | "-name" | ||
2 | | "duration" | "-duration" | ||
3 | | "createdDate" | "-createdDate"; | ||
diff --git a/client/src/app/videos/shared/video.model.ts b/client/src/app/videos/shared/video.model.ts new file mode 100644 index 000000000..614403d79 --- /dev/null +++ b/client/src/app/videos/shared/video.model.ts | |||
@@ -0,0 +1,64 @@ | |||
1 | export class Video { | ||
2 | author: string; | ||
3 | by: string; | ||
4 | createdDate: Date; | ||
5 | description: string; | ||
6 | duration: string; | ||
7 | id: string; | ||
8 | isLocal: boolean; | ||
9 | magnetUri: string; | ||
10 | name: string; | ||
11 | podUrl: string; | ||
12 | thumbnailPath: string; | ||
13 | |||
14 | private static createByString(author: string, podUrl: string) { | ||
15 | let [ host, port ] = podUrl.replace(/^https?:\/\//, '').split(':'); | ||
16 | |||
17 | if (port === '80' || port === '443') { | ||
18 | port = ''; | ||
19 | } else { | ||
20 | port = ':' + port; | ||
21 | } | ||
22 | |||
23 | return author + '@' + host + port; | ||
24 | } | ||
25 | |||
26 | private static createDurationString(duration: number) { | ||
27 | const minutes = Math.floor(duration / 60); | ||
28 | const seconds = duration % 60; | ||
29 | const minutes_padding = minutes >= 10 ? '' : '0'; | ||
30 | const seconds_padding = seconds >= 10 ? '' : '0'; | ||
31 | |||
32 | return minutes_padding + minutes.toString() + ':' + seconds_padding + seconds.toString(); | ||
33 | } | ||
34 | |||
35 | constructor(hash: { | ||
36 | author: string, | ||
37 | createdDate: string, | ||
38 | description: string, | ||
39 | duration: number; | ||
40 | id: string, | ||
41 | isLocal: boolean, | ||
42 | magnetUri: string, | ||
43 | name: string, | ||
44 | podUrl: string, | ||
45 | thumbnailPath: string | ||
46 | }) { | ||
47 | this.author = hash.author; | ||
48 | this.createdDate = new Date(hash.createdDate); | ||
49 | this.description = hash.description; | ||
50 | this.duration = Video.createDurationString(hash.duration); | ||
51 | this.id = hash.id; | ||
52 | this.isLocal = hash.isLocal; | ||
53 | this.magnetUri = hash.magnetUri; | ||
54 | this.name = hash.name; | ||
55 | this.podUrl = hash.podUrl; | ||
56 | this.thumbnailPath = hash.thumbnailPath; | ||
57 | |||
58 | this.by = Video.createByString(hash.author, hash.podUrl); | ||
59 | } | ||
60 | |||
61 | isRemovableBy(user) { | ||
62 | return this.isLocal === true && user && this.author === user.username; | ||
63 | } | ||
64 | } | ||
diff --git a/client/src/app/videos/shared/video.service.ts b/client/src/app/videos/shared/video.service.ts new file mode 100644 index 000000000..76d46cbb4 --- /dev/null +++ b/client/src/app/videos/shared/video.service.ts | |||
@@ -0,0 +1,82 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | import { Http, Response, URLSearchParams } from '@angular/http'; | ||
3 | import { Observable } from 'rxjs/Rx'; | ||
4 | |||
5 | import { Pagination } from './pagination.model'; | ||
6 | import { Search } from '../../shared'; | ||
7 | import { SortField } from './sort-field.type'; | ||
8 | import { AuthService } from '../../shared'; | ||
9 | import { Video } from './video.model'; | ||
10 | |||
11 | @Injectable() | ||
12 | export class VideoService { | ||
13 | private static BASE_VIDEO_URL = '/api/v1/videos/'; | ||
14 | |||
15 | constructor( | ||
16 | private authService: AuthService, | ||
17 | private http: Http | ||
18 | ) {} | ||
19 | |||
20 | getVideo(id: string) { | ||
21 | return this.http.get(VideoService.BASE_VIDEO_URL + id) | ||
22 | .map(res => <Video> res.json()) | ||
23 | .catch(this.handleError); | ||
24 | } | ||
25 | |||
26 | getVideos(pagination: Pagination, sort: SortField) { | ||
27 | const params = this.createPaginationParams(pagination); | ||
28 | |||
29 | if (sort) params.set('sort', sort); | ||
30 | |||
31 | return this.http.get(VideoService.BASE_VIDEO_URL, { search: params }) | ||
32 | .map(res => res.json()) | ||
33 | .map(this.extractVideos) | ||
34 | .catch(this.handleError); | ||
35 | } | ||
36 | |||
37 | removeVideo(id: string) { | ||
38 | const options = this.authService.getAuthRequestOptions(); | ||
39 | return this.http.delete(VideoService.BASE_VIDEO_URL + id, options) | ||
40 | .map(res => <number> res.status) | ||
41 | .catch(this.handleError); | ||
42 | } | ||
43 | |||
44 | searchVideos(search: Search, pagination: Pagination, sort: SortField) { | ||
45 | const params = this.createPaginationParams(pagination); | ||
46 | |||
47 | if (search.field) params.set('field', search.field); | ||
48 | if (sort) params.set('sort', sort); | ||
49 | |||
50 | return this.http.get(VideoService.BASE_VIDEO_URL + 'search/' + encodeURIComponent(search.value), { search: params }) | ||
51 | .map(res => res.json()) | ||
52 | .map(this.extractVideos) | ||
53 | .catch(this.handleError); | ||
54 | } | ||
55 | |||
56 | private createPaginationParams(pagination: Pagination) { | ||
57 | const params = new URLSearchParams(); | ||
58 | const start: number = (pagination.currentPage - 1) * pagination.itemsPerPage; | ||
59 | const count: number = pagination.itemsPerPage; | ||
60 | |||
61 | params.set('start', start.toString()); | ||
62 | params.set('count', count.toString()); | ||
63 | |||
64 | return params; | ||
65 | } | ||
66 | |||
67 | private extractVideos(body: any) { | ||
68 | const videos_json = body.data; | ||
69 | const totalVideos = body.total; | ||
70 | const videos = []; | ||
71 | for (const video_json of videos_json) { | ||
72 | videos.push(new Video(video_json)); | ||
73 | } | ||
74 | |||
75 | return { videos, totalVideos }; | ||
76 | } | ||
77 | |||
78 | private handleError(error: Response) { | ||
79 | console.error(error); | ||
80 | return Observable.throw(error.json().error || 'Server error'); | ||
81 | } | ||
82 | } | ||
diff --git a/client/src/app/videos/video-add/index.ts b/client/src/app/videos/video-add/index.ts new file mode 100644 index 000000000..79488e851 --- /dev/null +++ b/client/src/app/videos/video-add/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './video-add.component'; | |||
diff --git a/client/src/app/videos/video-add/video-add.component.html b/client/src/app/videos/video-add/video-add.component.html new file mode 100644 index 000000000..80d229cb8 --- /dev/null +++ b/client/src/app/videos/video-add/video-add.component.html | |||
@@ -0,0 +1,41 @@ | |||
1 | <h3>Upload a video</h3> | ||
2 | |||
3 | <form (ngSubmit)="uploadFile()" #videoForm="ngForm"> | ||
4 | <div class="form-group"> | ||
5 | <label for="name">Video name</label> | ||
6 | <input | ||
7 | type="text" class="form-control" name="name" id="name" required | ||
8 | ngControl="name" #name="ngForm" | ||
9 | > | ||
10 | <div [hidden]="name.valid || name.pristine" class="alert alert-danger"> | ||
11 | Name is required | ||
12 | </div> | ||
13 | </div> | ||
14 | |||
15 | <div class="form-group"> | ||
16 | <div class="btn btn-default btn-file"> | ||
17 | <span>Select the video...</span> | ||
18 | <input type="file" name="videofile" id="videofile"> | ||
19 | </div> | ||
20 | |||
21 | <span *ngIf="fileToUpload">{{ fileToUpload.name }}</span> | ||
22 | </div> | ||
23 | |||
24 | <div class="form-group"> | ||
25 | <label for="description">Description</label> | ||
26 | <textarea | ||
27 | name="description" id="description" class="form-control" placeholder="Description..." required | ||
28 | ngControl="description" #description="ngForm" | ||
29 | > | ||
30 | </textarea> | ||
31 | <div [hidden]="description.valid || description.pristine" class="alert alert-danger"> | ||
32 | A description is required | ||
33 | </div> | ||
34 | </div> | ||
35 | |||
36 | <div id="progress" *ngIf="progressBar.max !== 0"> | ||
37 | <progressbar [value]="progressBar.value" [max]="progressBar.max">{{ progressBar.value | bytes }} / {{ progressBar.max | bytes }}</progressbar> | ||
38 | </div> | ||
39 | |||
40 | <input type="submit" value="Upload" class="btn btn-default" [disabled]="!videoForm.form.valid || !fileToUpload"> | ||
41 | </form> | ||
diff --git a/client/src/app/videos/video-add/video-add.component.scss b/client/src/app/videos/video-add/video-add.component.scss new file mode 100644 index 000000000..01195f017 --- /dev/null +++ b/client/src/app/videos/video-add/video-add.component.scss | |||
@@ -0,0 +1,33 @@ | |||
1 | .btn-file { | ||
2 | position: relative; | ||
3 | overflow: hidden; | ||
4 | } | ||
5 | |||
6 | .btn-file input[type=file] { | ||
7 | position: absolute; | ||
8 | top: 0; | ||
9 | right: 0; | ||
10 | min-width: 100%; | ||
11 | min-height: 100%; | ||
12 | font-size: 100px; | ||
13 | text-align: right; | ||
14 | filter: alpha(opacity=0); | ||
15 | opacity: 0; | ||
16 | outline: none; | ||
17 | background: white; | ||
18 | cursor: inherit; | ||
19 | display: block; | ||
20 | } | ||
21 | |||
22 | .name_file { | ||
23 | display: inline-block; | ||
24 | margin-left: 10px; | ||
25 | } | ||
26 | |||
27 | .form-group { | ||
28 | margin-bottom: 10px; | ||
29 | } | ||
30 | |||
31 | #progress { | ||
32 | margin-bottom: 10px; | ||
33 | } | ||
diff --git a/client/src/app/videos/video-add/video-add.component.ts b/client/src/app/videos/video-add/video-add.component.ts new file mode 100644 index 000000000..8df4f951b --- /dev/null +++ b/client/src/app/videos/video-add/video-add.component.ts | |||
@@ -0,0 +1,69 @@ | |||
1 | /// <reference path="../../../../typings/globals/jquery/index.d.ts" /> | ||
2 | /// <reference path="../../../../typings/globals/jquery.fileupload/index.d.ts" /> | ||
3 | |||
4 | import { Component, ElementRef, OnInit } from '@angular/core'; | ||
5 | import { Router } from '@angular/router-deprecated'; | ||
6 | |||
7 | import { BytesPipe } from 'angular-pipes/src/math/bytes.pipe'; | ||
8 | import { PROGRESSBAR_DIRECTIVES } from 'ng2-bootstrap/components/progressbar'; | ||
9 | |||
10 | import { AuthService, User } from '../../shared'; | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-videos-add', | ||
14 | styles: [ require('./video-add.component.scss') ], | ||
15 | template: require('./video-add.component.html'), | ||
16 | directives: [ PROGRESSBAR_DIRECTIVES ], | ||
17 | pipes: [ BytesPipe ] | ||
18 | }) | ||
19 | |||
20 | export class VideoAddComponent implements OnInit { | ||
21 | fileToUpload: any; | ||
22 | progressBar: { value: number; max: number; } = { value: 0, max: 0 }; | ||
23 | user: User; | ||
24 | |||
25 | private form: any; | ||
26 | |||
27 | constructor( | ||
28 | private authService: AuthService, | ||
29 | private elementRef: ElementRef, | ||
30 | private router: Router | ||
31 | ) {} | ||
32 | |||
33 | ngOnInit() { | ||
34 | this.user = User.load(); | ||
35 | jQuery(this.elementRef.nativeElement).find('#videofile').fileupload({ | ||
36 | url: '/api/v1/videos', | ||
37 | dataType: 'json', | ||
38 | singleFileUploads: true, | ||
39 | multipart: true, | ||
40 | autoUpload: false, | ||
41 | |||
42 | add: (e, data) => { | ||
43 | this.form = data; | ||
44 | this.fileToUpload = data['files'][0]; | ||
45 | }, | ||
46 | |||
47 | progressall: (e, data) => { | ||
48 | this.progressBar.value = data.loaded; | ||
49 | // The server is a little bit slow to answer (has to seed the video) | ||
50 | // So we add more time to the progress bar (+10%) | ||
51 | this.progressBar.max = data.total + (0.1 * data.total); | ||
52 | }, | ||
53 | |||
54 | done: (e, data) => { | ||
55 | this.progressBar.value = this.progressBar.max; | ||
56 | console.log('Video uploaded.'); | ||
57 | |||
58 | // Print all the videos once it's finished | ||
59 | this.router.navigate(['VideosList']); | ||
60 | } | ||
61 | }); | ||
62 | } | ||
63 | |||
64 | uploadFile() { | ||
65 | this.form.formData = jQuery(this.elementRef.nativeElement).find('form').serializeArray(); | ||
66 | this.form.headers = this.authService.getRequestHeader().toJSON(); | ||
67 | this.form.submit(); | ||
68 | } | ||
69 | } | ||
diff --git a/client/src/app/videos/video-list/index.ts b/client/src/app/videos/video-list/index.ts new file mode 100644 index 000000000..1f6d6a4e7 --- /dev/null +++ b/client/src/app/videos/video-list/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './video-list.component'; | ||
2 | export * from './video-miniature.component'; | ||
3 | export * from './video-sort.component'; | ||
diff --git a/client/src/app/videos/video-list/video-list.component.html b/client/src/app/videos/video-list/video-list.component.html new file mode 100644 index 000000000..80b1e7b1b --- /dev/null +++ b/client/src/app/videos/video-list/video-list.component.html | |||
@@ -0,0 +1,18 @@ | |||
1 | <div class="row videos-info"> | ||
2 | <div class="col-md-9 videos-total-results"> {{ pagination.total }} videos</div> | ||
3 | <my-video-sort class="col-md-3" [currentSort]="sort" (sort)="onSort($event)"></my-video-sort> | ||
4 | </div> | ||
5 | |||
6 | <div class="videos-miniatures"> | ||
7 | <my-loader [loading]="loading"></my-loader> | ||
8 | |||
9 | <div class="col-md-12 no-video" *ngIf="noVideo()">There is no video.</div> | ||
10 | |||
11 | <my-video-miniature *ngFor="let video of videos" [video]="video" [user]="user" (removed)="onRemoved(video)"> | ||
12 | </my-video-miniature> | ||
13 | </div> | ||
14 | |||
15 | <pagination | ||
16 | [totalItems]="pagination.total" [itemsPerPage]="pagination.itemsPerPage" [(ngModel)]="pagination.currentPage" | ||
17 | (ngModelChange)="getVideos()" | ||
18 | ></pagination> | ||
diff --git a/client/src/app/videos/video-list/video-list.component.scss b/client/src/app/videos/video-list/video-list.component.scss new file mode 100644 index 000000000..9441d80c3 --- /dev/null +++ b/client/src/app/videos/video-list/video-list.component.scss | |||
@@ -0,0 +1,37 @@ | |||
1 | .videos-info { | ||
2 | |||
3 | padding-bottom: 20px; | ||
4 | margin-bottom: 20px; | ||
5 | border-bottom: 1px solid #f1f1f1; | ||
6 | height: 40px; | ||
7 | line-height: 40px; | ||
8 | width: 765px; | ||
9 | margin-left: 15px; | ||
10 | |||
11 | my-video-sort { | ||
12 | padding-right: 0; | ||
13 | } | ||
14 | |||
15 | .videos-total-results { | ||
16 | font-size: 13px; | ||
17 | padding-left: 0; | ||
18 | } | ||
19 | } | ||
20 | |||
21 | .videos-miniatures { | ||
22 | min-height: 600px; | ||
23 | |||
24 | my-videos-miniature { | ||
25 | display: inline-block; | ||
26 | } | ||
27 | |||
28 | .no-video { | ||
29 | margin-top: 50px; | ||
30 | text-align: center; | ||
31 | } | ||
32 | } | ||
33 | |||
34 | pagination { | ||
35 | display: block; | ||
36 | text-align: center; | ||
37 | } | ||
diff --git a/client/src/app/videos/video-list/video-list.component.ts b/client/src/app/videos/video-list/video-list.component.ts new file mode 100644 index 000000000..b1ce55845 --- /dev/null +++ b/client/src/app/videos/video-list/video-list.component.ts | |||
@@ -0,0 +1,105 @@ | |||
1 | import { Component, OnInit } from '@angular/core'; | ||
2 | import { Router, ROUTER_DIRECTIVES, RouteParams } from '@angular/router-deprecated'; | ||
3 | |||
4 | import { PAGINATION_DIRECTIVES } from 'ng2-bootstrap/components/pagination'; | ||
5 | |||
6 | import { | ||
7 | LoaderComponent, | ||
8 | Pagination, | ||
9 | SortField, | ||
10 | Video, | ||
11 | VideoService | ||
12 | } from '../shared'; | ||
13 | import { AuthService, Search, SearchField, User } from '../../shared'; | ||
14 | import { VideoMiniatureComponent } from './video-miniature.component'; | ||
15 | import { VideoSortComponent } from './video-sort.component'; | ||
16 | |||
17 | @Component({ | ||
18 | selector: 'my-videos-list', | ||
19 | styles: [ require('./video-list.component.scss') ], | ||
20 | template: require('./video-list.component.html'), | ||
21 | directives: [ LoaderComponent, PAGINATION_DIRECTIVES, ROUTER_DIRECTIVES, VideoMiniatureComponent, VideoSortComponent ] | ||
22 | }) | ||
23 | |||
24 | export class VideoListComponent implements OnInit { | ||
25 | loading = false; | ||
26 | pagination: Pagination = { | ||
27 | currentPage: 1, | ||
28 | itemsPerPage: 9, | ||
29 | total: 0 | ||
30 | }; | ||
31 | sort: SortField; | ||
32 | user: User = null; | ||
33 | videos: Video[] = []; | ||
34 | |||
35 | private search: Search; | ||
36 | |||
37 | constructor( | ||
38 | private authService: AuthService, | ||
39 | private router: Router, | ||
40 | private routeParams: RouteParams, | ||
41 | private videoService: VideoService | ||
42 | ) { | ||
43 | this.search = { | ||
44 | value: this.routeParams.get('search'), | ||
45 | field: <SearchField>this.routeParams.get('field') | ||
46 | }; | ||
47 | |||
48 | this.sort = <SortField>this.routeParams.get('sort') || '-createdDate'; | ||
49 | } | ||
50 | |||
51 | ngOnInit() { | ||
52 | if (this.authService.isLoggedIn()) { | ||
53 | this.user = User.load(); | ||
54 | } | ||
55 | |||
56 | this.getVideos(); | ||
57 | } | ||
58 | |||
59 | getVideos() { | ||
60 | this.loading = true; | ||
61 | this.videos = []; | ||
62 | |||
63 | let observable = null; | ||
64 | |||
65 | if (this.search.value !== null) { | ||
66 | observable = this.videoService.searchVideos(this.search, this.pagination, this.sort); | ||
67 | } else { | ||
68 | observable = this.videoService.getVideos(this.pagination, this.sort); | ||
69 | } | ||
70 | |||
71 | observable.subscribe( | ||
72 | ({ videos, totalVideos }) => { | ||
73 | this.videos = videos; | ||
74 | this.pagination.total = totalVideos; | ||
75 | |||
76 | this.loading = false; | ||
77 | }, | ||
78 | error => alert(error) | ||
79 | ); | ||
80 | } | ||
81 | |||
82 | noVideo() { | ||
83 | return !this.loading && this.videos.length === 0; | ||
84 | } | ||
85 | |||
86 | onRemoved(video: Video) { | ||
87 | this.videos.splice(this.videos.indexOf(video), 1); | ||
88 | } | ||
89 | |||
90 | onSort(sort: SortField) { | ||
91 | this.sort = sort; | ||
92 | |||
93 | const params: any = { | ||
94 | sort: this.sort | ||
95 | }; | ||
96 | |||
97 | if (this.search.value) { | ||
98 | params.field = this.search.field; | ||
99 | params.search = this.search.value; | ||
100 | } | ||
101 | |||
102 | this.router.navigate(['VideosList', params]); | ||
103 | this.getVideos(); | ||
104 | } | ||
105 | } | ||
diff --git a/client/src/app/videos/video-list/video-miniature.component.html b/client/src/app/videos/video-list/video-miniature.component.html new file mode 100644 index 000000000..244254b5a --- /dev/null +++ b/client/src/app/videos/video-list/video-miniature.component.html | |||
@@ -0,0 +1,22 @@ | |||
1 | <div class="video-miniature col-md-4" (mouseenter)="onHover()" (mouseleave)="onBlur()"> | ||
2 | <a | ||
3 | [routerLink]="['VideosWatch', { id: video.id }]" [attr.title]="video.description" | ||
4 | class="video-miniature-thumbnail" | ||
5 | > | ||
6 | <img [attr.src]="video.thumbnailPath" alt="video thumbnail" /> | ||
7 | <span class="video-miniature-duration">{{ video.duration }}</span> | ||
8 | </a> | ||
9 | <span | ||
10 | *ngIf="displayRemoveIcon()" (click)="removeVideo(video.id)" | ||
11 | class="video-miniature-remove glyphicon glyphicon-remove" | ||
12 | ></span> | ||
13 | |||
14 | <div class="video-miniature-informations"> | ||
15 | <a [routerLink]="['VideosWatch', { id: video.id }]" class="video-miniature-name"> | ||
16 | <span>{{ video.name }}</span> | ||
17 | </a> | ||
18 | |||
19 | <span class="video-miniature-author">by {{ video.by }}</span> | ||
20 | <span class="video-miniature-created-date">on {{ video.createdDate | date:'short' }}</span> | ||
21 | </div> | ||
22 | </div> | ||
diff --git a/client/src/app/videos/video-list/video-miniature.component.scss b/client/src/app/videos/video-list/video-miniature.component.scss new file mode 100644 index 000000000..4488abe22 --- /dev/null +++ b/client/src/app/videos/video-list/video-miniature.component.scss | |||
@@ -0,0 +1,55 @@ | |||
1 | .video-miniature { | ||
2 | height: 200px; | ||
3 | display: inline-block; | ||
4 | position: relative; | ||
5 | |||
6 | .video-miniature-thumbnail { | ||
7 | display: block; | ||
8 | position: relative; | ||
9 | |||
10 | .video-miniature-duration { | ||
11 | position: absolute; | ||
12 | right: 60px; | ||
13 | bottom: 2px; | ||
14 | display: inline-block; | ||
15 | background-color: rgba(0, 0, 0, 0.8); | ||
16 | color: rgba(255, 255, 255, 0.8); | ||
17 | padding: 2px; | ||
18 | font-size: 11px; | ||
19 | } | ||
20 | } | ||
21 | |||
22 | .video-miniature-remove { | ||
23 | display: inline-block; | ||
24 | position: absolute; | ||
25 | left: 16px; | ||
26 | background-color: rgba(0, 0, 0, 0.8); | ||
27 | color: rgba(255, 255, 255, 0.8); | ||
28 | padding: 2px; | ||
29 | cursor: pointer; | ||
30 | |||
31 | &:hover { | ||
32 | color: rgba(255, 255, 255, 0.9); | ||
33 | } | ||
34 | } | ||
35 | |||
36 | .video-miniature-informations { | ||
37 | margin-left: 3px; | ||
38 | |||
39 | .video-miniature-name { | ||
40 | display: block; | ||
41 | font-weight: bold; | ||
42 | |||
43 | &:hover { | ||
44 | text-decoration: none; | ||
45 | } | ||
46 | } | ||
47 | |||
48 | .video-miniature-author, .video-miniature-created-date { | ||
49 | display: block; | ||
50 | margin-left: 1px; | ||
51 | font-size: 11px; | ||
52 | color: rgba(0, 0, 0, 0.5); | ||
53 | } | ||
54 | } | ||
55 | } | ||
diff --git a/client/src/app/videos/video-list/video-miniature.component.ts b/client/src/app/videos/video-list/video-miniature.component.ts new file mode 100644 index 000000000..639339b44 --- /dev/null +++ b/client/src/app/videos/video-list/video-miniature.component.ts | |||
@@ -0,0 +1,46 @@ | |||
1 | import { DatePipe } from '@angular/common'; | ||
2 | import { Component, Input, Output, EventEmitter } from '@angular/core'; | ||
3 | import { ROUTER_DIRECTIVES } from '@angular/router-deprecated'; | ||
4 | |||
5 | import { Video, VideoService } from '../shared'; | ||
6 | import { User } from '../../shared'; | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-video-miniature', | ||
10 | styles: [ require('./video-miniature.component.scss') ], | ||
11 | template: require('./video-miniature.component.html'), | ||
12 | directives: [ ROUTER_DIRECTIVES ], | ||
13 | pipes: [ DatePipe ] | ||
14 | }) | ||
15 | |||
16 | export class VideoMiniatureComponent { | ||
17 | @Output() removed = new EventEmitter<any>(); | ||
18 | |||
19 | @Input() user: User; | ||
20 | @Input() video: Video; | ||
21 | |||
22 | hovering = false; | ||
23 | |||
24 | constructor(private videoService: VideoService) {} | ||
25 | |||
26 | displayRemoveIcon() { | ||
27 | return this.hovering && this.video.isRemovableBy(this.user); | ||
28 | } | ||
29 | |||
30 | onBlur() { | ||
31 | this.hovering = false; | ||
32 | } | ||
33 | |||
34 | onHover() { | ||
35 | this.hovering = true; | ||
36 | } | ||
37 | |||
38 | removeVideo(id: string) { | ||
39 | if (confirm('Do you really want to remove this video?')) { | ||
40 | this.videoService.removeVideo(id).subscribe( | ||
41 | status => this.removed.emit(true), | ||
42 | error => alert(error) | ||
43 | ); | ||
44 | } | ||
45 | } | ||
46 | } | ||
diff --git a/client/src/app/videos/video-list/video-sort.component.html b/client/src/app/videos/video-list/video-sort.component.html new file mode 100644 index 000000000..3bece0b22 --- /dev/null +++ b/client/src/app/videos/video-list/video-sort.component.html | |||
@@ -0,0 +1,5 @@ | |||
1 | <select class="form-control input-sm" [(ngModel)]="currentSort" (ngModelChange)="onSortChange()"> | ||
2 | <option *ngFor="let choice of choiceKeys" [value]="choice"> | ||
3 | {{ getStringChoice(choice) }} | ||
4 | </option> | ||
5 | </select> | ||
diff --git a/client/src/app/videos/video-list/video-sort.component.ts b/client/src/app/videos/video-list/video-sort.component.ts new file mode 100644 index 000000000..0d76b54b7 --- /dev/null +++ b/client/src/app/videos/video-list/video-sort.component.ts | |||
@@ -0,0 +1,35 @@ | |||
1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; | ||
2 | |||
3 | import { SortField } from '../shared'; | ||
4 | |||
5 | @Component({ | ||
6 | selector: 'my-video-sort', | ||
7 | template: require('./video-sort.component.html') | ||
8 | }) | ||
9 | |||
10 | export class VideoSortComponent { | ||
11 | @Output() sort = new EventEmitter<any>(); | ||
12 | |||
13 | @Input() currentSort: SortField; | ||
14 | |||
15 | sortChoices = { | ||
16 | 'name': 'Name - Asc', | ||
17 | '-name': 'Name - Desc', | ||
18 | 'duration': 'Duration - Asc', | ||
19 | '-duration': 'Duration - Desc', | ||
20 | 'createdDate': 'Created Date - Asc', | ||
21 | '-createdDate': 'Created Date - Desc' | ||
22 | }; | ||
23 | |||
24 | get choiceKeys() { | ||
25 | return Object.keys(this.sortChoices); | ||
26 | } | ||
27 | |||
28 | getStringChoice(choiceKey: SortField) { | ||
29 | return this.sortChoices[choiceKey]; | ||
30 | } | ||
31 | |||
32 | onSortChange() { | ||
33 | this.sort.emit(this.currentSort); | ||
34 | } | ||
35 | } | ||
diff --git a/client/src/app/videos/video-watch/index.ts b/client/src/app/videos/video-watch/index.ts new file mode 100644 index 000000000..b17aaacf2 --- /dev/null +++ b/client/src/app/videos/video-watch/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './video-watch.component'; | ||
2 | export * from './webtorrent.service'; | ||
diff --git a/client/src/app/videos/video-watch/video-watch.component.html b/client/src/app/videos/video-watch/video-watch.component.html new file mode 100644 index 000000000..6c36b27e2 --- /dev/null +++ b/client/src/app/videos/video-watch/video-watch.component.html | |||
@@ -0,0 +1,10 @@ | |||
1 | <my-loader [loading]="loading"></my-loader> | ||
2 | |||
3 | <div class="embed-responsive embed-responsive-19by9"> | ||
4 | </div> | ||
5 | |||
6 | <div id="torrent-info"> | ||
7 | <div id="torrent-info-download">Download: {{ downloadSpeed | bytes }}/s</div> | ||
8 | <div id="torrent-info-upload">Upload: {{ uploadSpeed | bytes }}/s</div> | ||
9 | <div id="torrent-info-peers">Number of peers: {{ numPeers }}</div> | ||
10 | <div> | ||
diff --git a/client/src/app/videos/video-watch/video-watch.component.scss b/client/src/app/videos/video-watch/video-watch.component.scss new file mode 100644 index 000000000..1228d42f4 --- /dev/null +++ b/client/src/app/videos/video-watch/video-watch.component.scss | |||
@@ -0,0 +1,13 @@ | |||
1 | .embed-responsive { | ||
2 | height: 500px; | ||
3 | } | ||
4 | |||
5 | #torrent-info { | ||
6 | font-size: 10px; | ||
7 | |||
8 | div { | ||
9 | display: inline-block; | ||
10 | width: 33%; | ||
11 | text-align: center; | ||
12 | } | ||
13 | } | ||
diff --git a/client/src/app/videos/video-watch/video-watch.component.ts b/client/src/app/videos/video-watch/video-watch.component.ts new file mode 100644 index 000000000..db82283b4 --- /dev/null +++ b/client/src/app/videos/video-watch/video-watch.component.ts | |||
@@ -0,0 +1,72 @@ | |||
1 | import { Component, ElementRef, OnInit } from '@angular/core'; | ||
2 | import { CanDeactivate, ComponentInstruction, RouteParams } from '@angular/router-deprecated'; | ||
3 | |||
4 | import { BytesPipe } from 'angular-pipes/src/math/bytes.pipe'; | ||
5 | |||
6 | import { LoaderComponent, Video, VideoService } from '../shared'; | ||
7 | import { WebTorrentService } from './webtorrent.service'; | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-video-watch', | ||
11 | template: require('./video-watch.component.html'), | ||
12 | styles: [ require('./video-watch.component.scss') ], | ||
13 | providers: [ WebTorrentService ], | ||
14 | directives: [ LoaderComponent ], | ||
15 | pipes: [ BytesPipe ] | ||
16 | }) | ||
17 | |||
18 | export class VideoWatchComponent implements OnInit, CanDeactivate { | ||
19 | downloadSpeed: number; | ||
20 | loading: boolean = false; | ||
21 | numPeers: number; | ||
22 | uploadSpeed: number; | ||
23 | video: Video; | ||
24 | |||
25 | private interval: NodeJS.Timer; | ||
26 | |||
27 | constructor( | ||
28 | private elementRef: ElementRef, | ||
29 | private routeParams: RouteParams, | ||
30 | private videoService: VideoService, | ||
31 | private webTorrentService: WebTorrentService | ||
32 | ) {} | ||
33 | |||
34 | loadVideo(video: Video) { | ||
35 | this.loading = true; | ||
36 | this.video = video; | ||
37 | console.log('Adding ' + this.video.magnetUri + '.'); | ||
38 | |||
39 | this.webTorrentService.add(this.video.magnetUri, (torrent) => { | ||
40 | this.loading = false; | ||
41 | console.log('Added ' + this.video.magnetUri + '.'); | ||
42 | torrent.files[0].appendTo(this.elementRef.nativeElement.querySelector('.embed-responsive'), (err) => { | ||
43 | if (err) { | ||
44 | alert('Cannot append the file.'); | ||
45 | console.error(err); | ||
46 | } | ||
47 | }); | ||
48 | |||
49 | // Refresh each second | ||
50 | this.interval = setInterval(() => { | ||
51 | this.downloadSpeed = torrent.downloadSpeed; | ||
52 | this.numPeers = torrent.numPeers; | ||
53 | this.uploadSpeed = torrent.uploadSpeed; | ||
54 | }, 1000); | ||
55 | }); | ||
56 | } | ||
57 | |||
58 | ngOnInit() { | ||
59 | let id = this.routeParams.get('id'); | ||
60 | this.videoService.getVideo(id).subscribe( | ||
61 | video => this.loadVideo(video), | ||
62 | error => alert(error) | ||
63 | ); | ||
64 | } | ||
65 | |||
66 | routerCanDeactivate(next: ComponentInstruction, prev: ComponentInstruction) { | ||
67 | console.log('Removing video from webtorrent.'); | ||
68 | clearInterval(this.interval); | ||
69 | this.webTorrentService.remove(this.video.magnetUri); | ||
70 | return true; | ||
71 | } | ||
72 | } | ||
diff --git a/client/src/app/videos/video-watch/webtorrent.service.ts b/client/src/app/videos/video-watch/webtorrent.service.ts new file mode 100644 index 000000000..bf38b5aaa --- /dev/null +++ b/client/src/app/videos/video-watch/webtorrent.service.ts | |||
@@ -0,0 +1,26 @@ | |||
1 | // Don't use webtorrent typings for now | ||
2 | // It misses some little things I'll fix later | ||
3 | // <reference path="../../../../typings/globals/webtorrent/index.d.ts" /> | ||
4 | |||
5 | import { Injectable } from '@angular/core'; | ||
6 | |||
7 | // import WebTorrent = require('webtorrent'); | ||
8 | declare var WebTorrent: any; | ||
9 | |||
10 | @Injectable() | ||
11 | export class WebTorrentService { | ||
12 | // private client: WebTorrent.Client; | ||
13 | private client: any; | ||
14 | |||
15 | constructor() { | ||
16 | this.client = new WebTorrent({ dht: false }); | ||
17 | } | ||
18 | |||
19 | add(magnetUri: string, callback: Function) { | ||
20 | return this.client.add(magnetUri, callback); | ||
21 | } | ||
22 | |||
23 | remove(magnetUri: string) { | ||
24 | return this.client.remove(magnetUri); | ||
25 | } | ||
26 | } | ||