diff options
Diffstat (limited to 'client/src/app')
89 files changed, 1746 insertions, 383 deletions
diff --git a/client/src/app/account/account.component.html b/client/src/app/account/account.component.html new file mode 100644 index 000000000..5a8847acd --- /dev/null +++ b/client/src/app/account/account.component.html | |||
@@ -0,0 +1,27 @@ | |||
1 | <h3>Account</h3> | ||
2 | |||
3 | <div *ngIf="information" class="alert alert-success">{{ information }}</div> | ||
4 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | ||
5 | |||
6 | <form role="form" (ngSubmit)="changePassword()" [formGroup]="form"> | ||
7 | <div class="form-group"> | ||
8 | <label for="new-password">New password</label> | ||
9 | <input | ||
10 | type="password" class="form-control" id="new-password" | ||
11 | formControlName="new-password" | ||
12 | > | ||
13 | <div *ngIf="formErrors['new-password']" class="alert alert-danger"> | ||
14 | {{ formErrors['new-password'] }} | ||
15 | </div> | ||
16 | </div> | ||
17 | |||
18 | <div class="form-group"> | ||
19 | <label for="name">Confirm new password</label> | ||
20 | <input | ||
21 | type="password" class="form-control" id="new-confirmed-password" | ||
22 | formControlName="new-confirmed-password" | ||
23 | > | ||
24 | </div> | ||
25 | |||
26 | <input type="submit" value="Change password" class="btn btn-default" [disabled]="!form.valid"> | ||
27 | </form> | ||
diff --git a/client/src/app/account/account.component.ts b/client/src/app/account/account.component.ts new file mode 100644 index 000000000..851eaf198 --- /dev/null +++ b/client/src/app/account/account.component.ts | |||
@@ -0,0 +1,67 @@ | |||
1 | import { } from '@angular/common'; | ||
2 | import { Component, OnInit } from '@angular/core'; | ||
3 | import { FormBuilder, FormGroup } from '@angular/forms'; | ||
4 | import { Router } from '@angular/router'; | ||
5 | |||
6 | import { AccountService } from './account.service'; | ||
7 | import { FormReactive, USER_PASSWORD } from '../shared'; | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-account', | ||
11 | templateUrl: './account.component.html' | ||
12 | }) | ||
13 | |||
14 | export class AccountComponent extends FormReactive implements OnInit { | ||
15 | information: string = null; | ||
16 | error: string = null; | ||
17 | |||
18 | form: FormGroup; | ||
19 | formErrors = { | ||
20 | 'new-password': '', | ||
21 | 'new-confirmed-password': '' | ||
22 | }; | ||
23 | validationMessages = { | ||
24 | 'new-password': USER_PASSWORD.MESSAGES, | ||
25 | 'new-confirmed-password': USER_PASSWORD.MESSAGES | ||
26 | }; | ||
27 | |||
28 | constructor( | ||
29 | private accountService: AccountService, | ||
30 | private formBuilder: FormBuilder, | ||
31 | private router: Router | ||
32 | ) { | ||
33 | super(); | ||
34 | } | ||
35 | |||
36 | buildForm() { | ||
37 | this.form = this.formBuilder.group({ | ||
38 | 'new-password': [ '', USER_PASSWORD.VALIDATORS ], | ||
39 | 'new-confirmed-password': [ '', USER_PASSWORD.VALIDATORS ], | ||
40 | }); | ||
41 | |||
42 | this.form.valueChanges.subscribe(data => this.onValueChanged(data)); | ||
43 | } | ||
44 | |||
45 | ngOnInit() { | ||
46 | this.buildForm(); | ||
47 | } | ||
48 | |||
49 | changePassword() { | ||
50 | const newPassword = this.form.value['new-password']; | ||
51 | const newConfirmedPassword = this.form.value['new-confirmed-password']; | ||
52 | |||
53 | this.information = null; | ||
54 | this.error = null; | ||
55 | |||
56 | if (newPassword !== newConfirmedPassword) { | ||
57 | this.error = 'The new password and the confirmed password do not correspond.'; | ||
58 | return; | ||
59 | } | ||
60 | |||
61 | this.accountService.changePassword(newPassword).subscribe( | ||
62 | ok => this.information = 'Password updated.', | ||
63 | |||
64 | err => this.error = err | ||
65 | ); | ||
66 | } | ||
67 | } | ||
diff --git a/client/src/app/account/account.routes.ts b/client/src/app/account/account.routes.ts new file mode 100644 index 000000000..e348c6ebe --- /dev/null +++ b/client/src/app/account/account.routes.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | import { AccountComponent } from './account.component'; | ||
2 | |||
3 | export const AccountRoutes = [ | ||
4 | { path: 'account', component: AccountComponent } | ||
5 | ]; | ||
diff --git a/client/src/app/account/account.service.ts b/client/src/app/account/account.service.ts new file mode 100644 index 000000000..355bcef74 --- /dev/null +++ b/client/src/app/account/account.service.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | |||
3 | import { AuthHttp, AuthService, RestExtractor } from '../shared'; | ||
4 | |||
5 | @Injectable() | ||
6 | export class AccountService { | ||
7 | private static BASE_USERS_URL = '/api/v1/users/'; | ||
8 | |||
9 | constructor( | ||
10 | private authHttp: AuthHttp, | ||
11 | private authService: AuthService, | ||
12 | private restExtractor: RestExtractor | ||
13 | ) {} | ||
14 | |||
15 | changePassword(newPassword: string) { | ||
16 | const url = AccountService.BASE_USERS_URL + this.authService.getUser().id; | ||
17 | const body = { | ||
18 | password: newPassword | ||
19 | }; | ||
20 | |||
21 | return this.authHttp.put(url, body) | ||
22 | .map(this.restExtractor.extractDataBool) | ||
23 | .catch((res) => this.restExtractor.handleError(res)); | ||
24 | } | ||
25 | } | ||
diff --git a/client/src/app/account/index.ts b/client/src/app/account/index.ts new file mode 100644 index 000000000..823d9fe5f --- /dev/null +++ b/client/src/app/account/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './account.component'; | ||
2 | export * from './account.routes'; | ||
3 | export * from './account.service'; | ||
diff --git a/client/src/app/admin/admin.component.ts b/client/src/app/admin/admin.component.ts new file mode 100644 index 000000000..64a7400e7 --- /dev/null +++ b/client/src/app/admin/admin.component.ts | |||
@@ -0,0 +1,8 @@ | |||
1 | import { Component } from '@angular/core'; | ||
2 | |||
3 | @Component({ | ||
4 | template: '<router-outlet></router-outlet>' | ||
5 | }) | ||
6 | |||
7 | export class AdminComponent { | ||
8 | } | ||
diff --git a/client/src/app/admin/admin.routes.ts b/client/src/app/admin/admin.routes.ts new file mode 100644 index 000000000..edb8ba49f --- /dev/null +++ b/client/src/app/admin/admin.routes.ts | |||
@@ -0,0 +1,23 @@ | |||
1 | import { Routes } from '@angular/router'; | ||
2 | |||
3 | import { AdminComponent } from './admin.component'; | ||
4 | import { FriendsRoutes } from './friends'; | ||
5 | import { RequestsRoutes } from './requests'; | ||
6 | import { UsersRoutes } from './users'; | ||
7 | |||
8 | export const AdminRoutes: Routes = [ | ||
9 | { | ||
10 | path: 'admin', | ||
11 | component: AdminComponent, | ||
12 | children: [ | ||
13 | { | ||
14 | path: '', | ||
15 | redirectTo: 'users', | ||
16 | pathMatch: 'full' | ||
17 | }, | ||
18 | ...FriendsRoutes, | ||
19 | ...RequestsRoutes, | ||
20 | ...UsersRoutes | ||
21 | ] | ||
22 | } | ||
23 | ]; | ||
diff --git a/client/src/app/admin/friends/friend-add/friend-add.component.html b/client/src/app/admin/friends/friend-add/friend-add.component.html new file mode 100644 index 000000000..788f3b44d --- /dev/null +++ b/client/src/app/admin/friends/friend-add/friend-add.component.html | |||
@@ -0,0 +1,26 @@ | |||
1 | <h3>Make friends</h3> | ||
2 | |||
3 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | ||
4 | |||
5 | <form (ngSubmit)="makeFriends()" [formGroup]="form"> | ||
6 | <div class="form-group" *ngFor="let url of urls; let id = index; trackBy:customTrackBy"> | ||
7 | <label for="username">Url</label> | ||
8 | |||
9 | <div class="input-group"> | ||
10 | <input | ||
11 | type="text" class="form-control" placeholder="http://domain.com" | ||
12 | [id]="'url-' + id" [formControlName]="'url-' + id" | ||
13 | /> | ||
14 | <span class="input-group-btn"> | ||
15 | <button *ngIf="displayAddField(id)" (click)="addField()" class="btn btn-default" type="button">+</button> | ||
16 | <button *ngIf="displayRemoveField(id)" (click)="removeField(id)" class="btn btn-default" type="button">-</button> | ||
17 | </span> | ||
18 | </div> | ||
19 | |||
20 | <div [hidden]="form.controls['url-' + id].valid || form.controls['url-' + id].pristine" class="alert alert-warning"> | ||
21 | It should be a valid url. | ||
22 | </div> | ||
23 | </div> | ||
24 | |||
25 | <input type="submit" value="Make friends" class="btn btn-default" [disabled]="!isFormValid()"> | ||
26 | </form> | ||
diff --git a/client/src/app/admin/friends/friend-add/friend-add.component.scss b/client/src/app/admin/friends/friend-add/friend-add.component.scss new file mode 100644 index 000000000..5fde51636 --- /dev/null +++ b/client/src/app/admin/friends/friend-add/friend-add.component.scss | |||
@@ -0,0 +1,7 @@ | |||
1 | table { | ||
2 | margin-bottom: 40px; | ||
3 | } | ||
4 | |||
5 | .input-group-btn button { | ||
6 | width: 35px; | ||
7 | } | ||
diff --git a/client/src/app/admin/friends/friend-add/friend-add.component.ts b/client/src/app/admin/friends/friend-add/friend-add.component.ts new file mode 100644 index 000000000..64165a9a5 --- /dev/null +++ b/client/src/app/admin/friends/friend-add/friend-add.component.ts | |||
@@ -0,0 +1,108 @@ | |||
1 | import { Component, OnInit } from '@angular/core'; | ||
2 | import { FormControl, FormGroup } from '@angular/forms'; | ||
3 | import { Router } from '@angular/router'; | ||
4 | |||
5 | import { validateUrl } from '../../../shared'; | ||
6 | import { FriendService } from '../shared'; | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-friend-add', | ||
10 | templateUrl: './friend-add.component.html', | ||
11 | styleUrls: [ './friend-add.component.scss' ] | ||
12 | }) | ||
13 | export class FriendAddComponent implements OnInit { | ||
14 | form: FormGroup; | ||
15 | urls = [ ]; | ||
16 | error: string = null; | ||
17 | |||
18 | constructor(private router: Router, private friendService: FriendService) {} | ||
19 | |||
20 | ngOnInit() { | ||
21 | this.form = new FormGroup({}); | ||
22 | this.addField(); | ||
23 | } | ||
24 | |||
25 | addField() { | ||
26 | this.form.addControl(`url-${this.urls.length}`, new FormControl('', [ validateUrl ])); | ||
27 | this.urls.push(''); | ||
28 | } | ||
29 | |||
30 | customTrackBy(index: number, obj: any): any { | ||
31 | return index; | ||
32 | } | ||
33 | |||
34 | displayAddField(index: number) { | ||
35 | return index === (this.urls.length - 1); | ||
36 | } | ||
37 | |||
38 | displayRemoveField(index: number) { | ||
39 | return (index !== 0 || this.urls.length > 1) && index !== (this.urls.length - 1); | ||
40 | } | ||
41 | |||
42 | isFormValid() { | ||
43 | // Do not check the last input | ||
44 | for (let i = 0; i < this.urls.length - 1; i++) { | ||
45 | if (!this.form.controls[`url-${i}`].valid) return false; | ||
46 | } | ||
47 | |||
48 | const lastIndex = this.urls.length - 1; | ||
49 | // If the last input (which is not the first) is empty, it's ok | ||
50 | if (this.urls[lastIndex] === '' && lastIndex !== 0) { | ||
51 | return true; | ||
52 | } else { | ||
53 | return this.form.controls[`url-${lastIndex}`].valid; | ||
54 | } | ||
55 | } | ||
56 | |||
57 | removeField(index: number) { | ||
58 | // Remove the last control | ||
59 | this.form.removeControl(`url-${this.urls.length - 1}`); | ||
60 | this.urls.splice(index, 1); | ||
61 | } | ||
62 | |||
63 | makeFriends() { | ||
64 | this.error = ''; | ||
65 | |||
66 | const notEmptyUrls = this.getNotEmptyUrls(); | ||
67 | if (notEmptyUrls.length === 0) { | ||
68 | this.error = 'You need to specify at less 1 url.'; | ||
69 | return; | ||
70 | } | ||
71 | |||
72 | if (!this.isUrlsUnique(notEmptyUrls)) { | ||
73 | this.error = 'Urls need to be unique.'; | ||
74 | return; | ||
75 | } | ||
76 | |||
77 | const confirmMessage = 'Are you sure to make friends with:\n - ' + notEmptyUrls.join('\n - '); | ||
78 | if (!confirm(confirmMessage)) return; | ||
79 | |||
80 | this.friendService.makeFriends(notEmptyUrls).subscribe( | ||
81 | status => { | ||
82 | // TODO: extractdatastatus | ||
83 | // if (status === 409) { | ||
84 | // alert('Already made friends!'); | ||
85 | // } else { | ||
86 | alert('Make friends request sent!'); | ||
87 | this.router.navigate([ '/admin/friends/list' ]); | ||
88 | // } | ||
89 | }, | ||
90 | error => alert(error.text) | ||
91 | ); | ||
92 | } | ||
93 | |||
94 | private getNotEmptyUrls() { | ||
95 | const notEmptyUrls = []; | ||
96 | |||
97 | Object.keys(this.form.value).forEach((urlKey) => { | ||
98 | const url = this.form.value[urlKey]; | ||
99 | if (url !== '') notEmptyUrls.push(url); | ||
100 | }); | ||
101 | |||
102 | return notEmptyUrls; | ||
103 | } | ||
104 | |||
105 | private isUrlsUnique(urls: string[]) { | ||
106 | return urls.every(url => urls.indexOf(url) === urls.lastIndexOf(url)); | ||
107 | } | ||
108 | } | ||
diff --git a/client/src/app/admin/friends/friend-add/index.ts b/client/src/app/admin/friends/friend-add/index.ts new file mode 100644 index 000000000..a101b3be5 --- /dev/null +++ b/client/src/app/admin/friends/friend-add/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './friend-add.component'; | |||
diff --git a/client/src/app/admin/friends/friend-list/friend-list.component.html b/client/src/app/admin/friends/friend-list/friend-list.component.html new file mode 100644 index 000000000..d786a7846 --- /dev/null +++ b/client/src/app/admin/friends/friend-list/friend-list.component.html | |||
@@ -0,0 +1,29 @@ | |||
1 | <h3>Friends list</h3> | ||
2 | |||
3 | <table class="table table-hover"> | ||
4 | <thead> | ||
5 | <tr> | ||
6 | <th class="table-column-id">ID</th> | ||
7 | <th>Url</th> | ||
8 | <th>Score</th> | ||
9 | <th>Created Date</th> | ||
10 | </tr> | ||
11 | </thead> | ||
12 | |||
13 | <tbody> | ||
14 | <tr *ngFor="let friend of friends"> | ||
15 | <td>{{ friend.id }}</td> | ||
16 | <td>{{ friend.url }}</td> | ||
17 | <td>{{ friend.score }}</td> | ||
18 | <td>{{ friend.createdDate | date: 'medium' }}</td> | ||
19 | </tr> | ||
20 | </tbody> | ||
21 | </table> | ||
22 | |||
23 | <a *ngIf="friends?.length !== 0" class="add-user btn btn-danger pull-left" (click)="quitFriends()"> | ||
24 | Quit friends | ||
25 | </a> | ||
26 | |||
27 | <a *ngIf="friends?.length === 0" class="add-user btn btn-success pull-right" [routerLink]="['/admin/friends/add']"> | ||
28 | Make friends | ||
29 | </a> | ||
diff --git a/client/src/app/admin/friends/friend-list/friend-list.component.scss b/client/src/app/admin/friends/friend-list/friend-list.component.scss new file mode 100644 index 000000000..cb597e12b --- /dev/null +++ b/client/src/app/admin/friends/friend-list/friend-list.component.scss | |||
@@ -0,0 +1,3 @@ | |||
1 | table { | ||
2 | margin-bottom: 40px; | ||
3 | } | ||
diff --git a/client/src/app/admin/friends/friend-list/friend-list.component.ts b/client/src/app/admin/friends/friend-list/friend-list.component.ts new file mode 100644 index 000000000..88c4800ee --- /dev/null +++ b/client/src/app/admin/friends/friend-list/friend-list.component.ts | |||
@@ -0,0 +1,38 @@ | |||
1 | import { Component, OnInit } from '@angular/core'; | ||
2 | |||
3 | import { Friend, FriendService } from '../shared'; | ||
4 | |||
5 | @Component({ | ||
6 | selector: 'my-friend-list', | ||
7 | templateUrl: './friend-list.component.html', | ||
8 | styleUrls: [ './friend-list.component.scss' ] | ||
9 | }) | ||
10 | export class FriendListComponent implements OnInit { | ||
11 | friends: Friend[]; | ||
12 | |||
13 | constructor(private friendService: FriendService) { } | ||
14 | |||
15 | ngOnInit() { | ||
16 | this.getFriends(); | ||
17 | } | ||
18 | |||
19 | quitFriends() { | ||
20 | if (!confirm('Are you sure?')) return; | ||
21 | |||
22 | this.friendService.quitFriends().subscribe( | ||
23 | status => { | ||
24 | alert('Quit friends!'); | ||
25 | this.getFriends(); | ||
26 | }, | ||
27 | error => alert(error.text) | ||
28 | ); | ||
29 | } | ||
30 | |||
31 | private getFriends() { | ||
32 | this.friendService.getFriends().subscribe( | ||
33 | friends => this.friends = friends, | ||
34 | |||
35 | err => alert(err.text) | ||
36 | ); | ||
37 | } | ||
38 | } | ||
diff --git a/client/src/app/admin/friends/friend-list/index.ts b/client/src/app/admin/friends/friend-list/index.ts new file mode 100644 index 000000000..354c978a4 --- /dev/null +++ b/client/src/app/admin/friends/friend-list/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './friend-list.component'; | |||
diff --git a/client/src/app/admin/friends/friends.component.ts b/client/src/app/admin/friends/friends.component.ts new file mode 100644 index 000000000..bc3f54158 --- /dev/null +++ b/client/src/app/admin/friends/friends.component.ts | |||
@@ -0,0 +1,8 @@ | |||
1 | import { Component } from '@angular/core'; | ||
2 | |||
3 | @Component({ | ||
4 | template: '<router-outlet></router-outlet>' | ||
5 | }) | ||
6 | |||
7 | export class FriendsComponent { | ||
8 | } | ||
diff --git a/client/src/app/admin/friends/friends.routes.ts b/client/src/app/admin/friends/friends.routes.ts new file mode 100644 index 000000000..7fdef68f9 --- /dev/null +++ b/client/src/app/admin/friends/friends.routes.ts | |||
@@ -0,0 +1,27 @@ | |||
1 | import { Routes } from '@angular/router'; | ||
2 | |||
3 | import { FriendsComponent } from './friends.component'; | ||
4 | import { FriendAddComponent } from './friend-add'; | ||
5 | import { FriendListComponent } from './friend-list'; | ||
6 | |||
7 | export const FriendsRoutes: Routes = [ | ||
8 | { | ||
9 | path: 'friends', | ||
10 | component: FriendsComponent, | ||
11 | children: [ | ||
12 | { | ||
13 | path: '', | ||
14 | redirectTo: 'list', | ||
15 | pathMatch: 'full' | ||
16 | }, | ||
17 | { | ||
18 | path: 'list', | ||
19 | component: FriendListComponent | ||
20 | }, | ||
21 | { | ||
22 | path: 'add', | ||
23 | component: FriendAddComponent | ||
24 | } | ||
25 | ] | ||
26 | } | ||
27 | ]; | ||
diff --git a/client/src/app/admin/friends/index.ts b/client/src/app/admin/friends/index.ts new file mode 100644 index 000000000..dd4df2538 --- /dev/null +++ b/client/src/app/admin/friends/index.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export * from './friend-add'; | ||
2 | export * from './friend-list'; | ||
3 | export * from './shared'; | ||
4 | export * from './friends.component'; | ||
5 | export * from './friends.routes'; | ||
diff --git a/client/src/app/admin/friends/shared/friend.model.ts b/client/src/app/admin/friends/shared/friend.model.ts new file mode 100644 index 000000000..7cb28f440 --- /dev/null +++ b/client/src/app/admin/friends/shared/friend.model.ts | |||
@@ -0,0 +1,6 @@ | |||
1 | export interface Friend { | ||
2 | id: string; | ||
3 | url: string; | ||
4 | score: number; | ||
5 | createdDate: Date; | ||
6 | } | ||
diff --git a/client/src/app/admin/friends/shared/friend.service.ts b/client/src/app/admin/friends/shared/friend.service.ts new file mode 100644 index 000000000..75826fc17 --- /dev/null +++ b/client/src/app/admin/friends/shared/friend.service.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | import { Observable } from 'rxjs/Observable'; | ||
3 | |||
4 | import { Friend } from './friend.model'; | ||
5 | import { AuthHttp, RestExtractor } from '../../../shared'; | ||
6 | |||
7 | @Injectable() | ||
8 | export class FriendService { | ||
9 | private static BASE_FRIEND_URL: string = '/api/v1/pods/'; | ||
10 | |||
11 | constructor ( | ||
12 | private authHttp: AuthHttp, | ||
13 | private restExtractor: RestExtractor | ||
14 | ) {} | ||
15 | |||
16 | getFriends(): Observable<Friend[]> { | ||
17 | return this.authHttp.get(FriendService.BASE_FRIEND_URL) | ||
18 | // Not implemented as a data list by the server yet | ||
19 | // .map(this.restExtractor.extractDataList) | ||
20 | .map((res) => res.json()) | ||
21 | .catch((res) => this.restExtractor.handleError(res)); | ||
22 | } | ||
23 | |||
24 | makeFriends(notEmptyUrls) { | ||
25 | const body = { | ||
26 | urls: notEmptyUrls | ||
27 | }; | ||
28 | |||
29 | return this.authHttp.post(FriendService.BASE_FRIEND_URL + 'makefriends', body) | ||
30 | .map(this.restExtractor.extractDataBool) | ||
31 | .catch((res) => this.restExtractor.handleError(res)); | ||
32 | } | ||
33 | |||
34 | quitFriends() { | ||
35 | return this.authHttp.get(FriendService.BASE_FRIEND_URL + 'quitfriends') | ||
36 | .map(res => res.status) | ||
37 | .catch((res) => this.restExtractor.handleError(res)); | ||
38 | } | ||
39 | } | ||
diff --git a/client/src/app/friends/index.ts b/client/src/app/admin/friends/shared/index.ts index 0adc256c4..0d671637d 100644 --- a/client/src/app/friends/index.ts +++ b/client/src/app/admin/friends/shared/index.ts | |||
@@ -1 +1,2 @@ | |||
1 | export * from './friend.model'; | ||
1 | export * from './friend.service'; | 2 | export * from './friend.service'; |
diff --git a/client/src/app/admin/index.ts b/client/src/app/admin/index.ts new file mode 100644 index 000000000..493caed15 --- /dev/null +++ b/client/src/app/admin/index.ts | |||
@@ -0,0 +1,6 @@ | |||
1 | export * from './friends'; | ||
2 | export * from './requests'; | ||
3 | export * from './users'; | ||
4 | export * from './admin.component'; | ||
5 | export * from './admin.routes'; | ||
6 | export * from './menu-admin.component'; | ||
diff --git a/client/src/app/admin/menu-admin.component.html b/client/src/app/admin/menu-admin.component.html new file mode 100644 index 000000000..e250615aa --- /dev/null +++ b/client/src/app/admin/menu-admin.component.html | |||
@@ -0,0 +1,26 @@ | |||
1 | <menu class="col-md-2 col-sm-3 col-xs-3"> | ||
2 | |||
3 | <div class="panel-block"> | ||
4 | <div id="panel-users" class="panel-button"> | ||
5 | <span class="hidden-xs glyphicon glyphicon-user"></span> | ||
6 | <a [routerLink]="['/admin/users/list']">List users</a> | ||
7 | </div> | ||
8 | |||
9 | <div id="panel-friends" class="panel-button"> | ||
10 | <span class="hidden-xs glyphicon glyphicon-cloud"></span> | ||
11 | <a [routerLink]="['/admin/friends/list']">List friends</a> | ||
12 | </div> | ||
13 | |||
14 | <div id="panel-request-stats" class="panel-button"> | ||
15 | <span class="hidden-xs glyphicon glyphicon-stats"></span> | ||
16 | <a [routerLink]="['/admin/requests/stats']">Request stats</a> | ||
17 | </div> | ||
18 | </div> | ||
19 | |||
20 | <div class="panel-block"> | ||
21 | <div id="panel-quit-administration" class="panel-button"> | ||
22 | <span class="hidden-xs glyphicon glyphicon-cog"></span> | ||
23 | <a [routerLink]="['/videos/list']">Quit admin.</a> | ||
24 | </div> | ||
25 | </div> | ||
26 | </menu> | ||
diff --git a/client/src/app/admin/menu-admin.component.ts b/client/src/app/admin/menu-admin.component.ts new file mode 100644 index 000000000..59ffccf9f --- /dev/null +++ b/client/src/app/admin/menu-admin.component.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | import { Component } from '@angular/core'; | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-menu-admin', | ||
5 | templateUrl: './menu-admin.component.html' | ||
6 | }) | ||
7 | export class MenuAdminComponent { } | ||
diff --git a/client/src/app/admin/requests/index.ts b/client/src/app/admin/requests/index.ts new file mode 100644 index 000000000..236a9ee8f --- /dev/null +++ b/client/src/app/admin/requests/index.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export * from './request-stats'; | ||
2 | export * from './shared'; | ||
3 | export * from './requests.component'; | ||
4 | export * from './requests.routes'; | ||
diff --git a/client/src/app/admin/requests/request-stats/index.ts b/client/src/app/admin/requests/request-stats/index.ts new file mode 100644 index 000000000..be3a66f77 --- /dev/null +++ b/client/src/app/admin/requests/request-stats/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './request-stats.component'; | |||
diff --git a/client/src/app/admin/requests/request-stats/request-stats.component.html b/client/src/app/admin/requests/request-stats/request-stats.component.html new file mode 100644 index 000000000..b5ac59a9a --- /dev/null +++ b/client/src/app/admin/requests/request-stats/request-stats.component.html | |||
@@ -0,0 +1,23 @@ | |||
1 | <h3>Requests stats</h3> | ||
2 | |||
3 | <div *ngIf="stats !== null"> | ||
4 | <div> | ||
5 | <span class="label-description">Interval seconds between requests:</span> | ||
6 | {{ stats.secondsInterval }} | ||
7 | </div> | ||
8 | |||
9 | <div> | ||
10 | <span class="label-description">Remaining time before the scheduled request:</span> | ||
11 | {{ stats.remainingSeconds }} | ||
12 | </div> | ||
13 | |||
14 | <div> | ||
15 | <span class="label-description">Maximum number of requests per interval:</span> | ||
16 | {{ stats.maxRequestsInParallel }} | ||
17 | </div> | ||
18 | |||
19 | <div> | ||
20 | <span class="label-description">Remaining requests:</span> | ||
21 | {{ stats.requests.length }} | ||
22 | </div> | ||
23 | </div> | ||
diff --git a/client/src/app/admin/requests/request-stats/request-stats.component.scss b/client/src/app/admin/requests/request-stats/request-stats.component.scss new file mode 100644 index 000000000..92c28dc99 --- /dev/null +++ b/client/src/app/admin/requests/request-stats/request-stats.component.scss | |||
@@ -0,0 +1,6 @@ | |||
1 | .label-description { | ||
2 | display: inline-block; | ||
3 | width: 350px; | ||
4 | font-weight: bold; | ||
5 | color: black; | ||
6 | } | ||
diff --git a/client/src/app/admin/requests/request-stats/request-stats.component.ts b/client/src/app/admin/requests/request-stats/request-stats.component.ts new file mode 100644 index 000000000..4b0844574 --- /dev/null +++ b/client/src/app/admin/requests/request-stats/request-stats.component.ts | |||
@@ -0,0 +1,51 @@ | |||
1 | import { Component, OnInit, OnDestroy } from '@angular/core'; | ||
2 | |||
3 | import { RequestService, RequestStats } from '../shared'; | ||
4 | |||
5 | @Component({ | ||
6 | selector: 'my-request-stats', | ||
7 | templateUrl: './request-stats.component.html', | ||
8 | styleUrls: [ './request-stats.component.scss' ] | ||
9 | }) | ||
10 | export class RequestStatsComponent implements OnInit, OnDestroy { | ||
11 | stats: RequestStats = null; | ||
12 | |||
13 | private interval: NodeJS.Timer = null; | ||
14 | |||
15 | constructor(private requestService: RequestService) { } | ||
16 | |||
17 | ngOnInit() { | ||
18 | this.getStats(); | ||
19 | } | ||
20 | |||
21 | ngOnDestroy() { | ||
22 | if (this.stats.secondsInterval !== null) { | ||
23 | clearInterval(this.interval); | ||
24 | } | ||
25 | } | ||
26 | |||
27 | getStats() { | ||
28 | this.requestService.getStats().subscribe( | ||
29 | stats => { | ||
30 | console.log(stats); | ||
31 | this.stats = stats; | ||
32 | this.runInterval(); | ||
33 | }, | ||
34 | |||
35 | err => alert(err.text) | ||
36 | ); | ||
37 | } | ||
38 | |||
39 | private runInterval() { | ||
40 | this.interval = setInterval(() => { | ||
41 | this.stats.remainingMilliSeconds -= 1000; | ||
42 | |||
43 | if (this.stats.remainingMilliSeconds <= 0) { | ||
44 | setTimeout(() => this.getStats(), this.stats.remainingMilliSeconds + 100); | ||
45 | clearInterval(this.interval); | ||
46 | } | ||
47 | }, 1000); | ||
48 | } | ||
49 | |||
50 | |||
51 | } | ||
diff --git a/client/src/app/admin/requests/requests.component.ts b/client/src/app/admin/requests/requests.component.ts new file mode 100644 index 000000000..471112b45 --- /dev/null +++ b/client/src/app/admin/requests/requests.component.ts | |||
@@ -0,0 +1,8 @@ | |||
1 | import { Component } from '@angular/core'; | ||
2 | |||
3 | @Component({ | ||
4 | template: '<router-outlet></router-outlet>' | ||
5 | }) | ||
6 | |||
7 | export class RequestsComponent { | ||
8 | } | ||
diff --git a/client/src/app/admin/requests/requests.routes.ts b/client/src/app/admin/requests/requests.routes.ts new file mode 100644 index 000000000..78221a9ff --- /dev/null +++ b/client/src/app/admin/requests/requests.routes.ts | |||
@@ -0,0 +1,22 @@ | |||
1 | import { Routes } from '@angular/router'; | ||
2 | |||
3 | import { RequestsComponent } from './requests.component'; | ||
4 | import { RequestStatsComponent } from './request-stats'; | ||
5 | |||
6 | export const RequestsRoutes: Routes = [ | ||
7 | { | ||
8 | path: 'requests', | ||
9 | component: RequestsComponent, | ||
10 | children: [ | ||
11 | { | ||
12 | path: '', | ||
13 | redirectTo: 'stats', | ||
14 | pathMatch: 'full' | ||
15 | }, | ||
16 | { | ||
17 | path: 'stats', | ||
18 | component: RequestStatsComponent | ||
19 | } | ||
20 | ] | ||
21 | } | ||
22 | ]; | ||
diff --git a/client/src/app/admin/requests/shared/index.ts b/client/src/app/admin/requests/shared/index.ts new file mode 100644 index 000000000..32ab5767b --- /dev/null +++ b/client/src/app/admin/requests/shared/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './request-stats.model'; | ||
2 | export * from './request.service'; | ||
diff --git a/client/src/app/admin/requests/shared/request-stats.model.ts b/client/src/app/admin/requests/shared/request-stats.model.ts new file mode 100644 index 000000000..766e80836 --- /dev/null +++ b/client/src/app/admin/requests/shared/request-stats.model.ts | |||
@@ -0,0 +1,32 @@ | |||
1 | export interface Request { | ||
2 | request: any; | ||
3 | to: any; | ||
4 | } | ||
5 | |||
6 | export class RequestStats { | ||
7 | maxRequestsInParallel: number; | ||
8 | milliSecondsInterval: number; | ||
9 | remainingMilliSeconds: number; | ||
10 | requests: Request[]; | ||
11 | |||
12 | constructor(hash: { | ||
13 | maxRequestsInParallel: number, | ||
14 | milliSecondsInterval: number, | ||
15 | remainingMilliSeconds: number, | ||
16 | requests: Request[]; | ||
17 | }) { | ||
18 | this.maxRequestsInParallel = hash.maxRequestsInParallel; | ||
19 | this.milliSecondsInterval = hash.milliSecondsInterval; | ||
20 | this.remainingMilliSeconds = hash.remainingMilliSeconds; | ||
21 | this.requests = hash.requests; | ||
22 | } | ||
23 | |||
24 | get remainingSeconds() { | ||
25 | return Math.floor(this.remainingMilliSeconds / 1000); | ||
26 | } | ||
27 | |||
28 | get secondsInterval() { | ||
29 | return Math.floor(this.milliSecondsInterval / 1000); | ||
30 | } | ||
31 | |||
32 | } | ||
diff --git a/client/src/app/admin/requests/shared/request.service.ts b/client/src/app/admin/requests/shared/request.service.ts new file mode 100644 index 000000000..aeec37448 --- /dev/null +++ b/client/src/app/admin/requests/shared/request.service.ts | |||
@@ -0,0 +1,22 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | import { Observable } from 'rxjs/Observable'; | ||
3 | |||
4 | import { RequestStats } from './request-stats.model'; | ||
5 | import { AuthHttp, RestExtractor } from '../../../shared'; | ||
6 | |||
7 | @Injectable() | ||
8 | export class RequestService { | ||
9 | private static BASE_REQUEST_URL: string = '/api/v1/requests/'; | ||
10 | |||
11 | constructor ( | ||
12 | private authHttp: AuthHttp, | ||
13 | private restExtractor: RestExtractor | ||
14 | ) {} | ||
15 | |||
16 | getStats(): Observable<RequestStats> { | ||
17 | return this.authHttp.get(RequestService.BASE_REQUEST_URL + 'stats') | ||
18 | .map(this.restExtractor.extractDataGet) | ||
19 | .map((data) => new RequestStats(data)) | ||
20 | .catch((res) => this.restExtractor.handleError(res)); | ||
21 | } | ||
22 | } | ||
diff --git a/client/src/app/admin/users/index.ts b/client/src/app/admin/users/index.ts new file mode 100644 index 000000000..e98a81f62 --- /dev/null +++ b/client/src/app/admin/users/index.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export * from './shared'; | ||
2 | export * from './user-add'; | ||
3 | export * from './user-list'; | ||
4 | export * from './users.component'; | ||
5 | export * from './users.routes'; | ||
diff --git a/client/src/app/admin/users/shared/index.ts b/client/src/app/admin/users/shared/index.ts new file mode 100644 index 000000000..e17ee5c7a --- /dev/null +++ b/client/src/app/admin/users/shared/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './user.service'; | |||
diff --git a/client/src/app/admin/users/shared/user.service.ts b/client/src/app/admin/users/shared/user.service.ts new file mode 100644 index 000000000..13be553c0 --- /dev/null +++ b/client/src/app/admin/users/shared/user.service.ts | |||
@@ -0,0 +1,47 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | |||
3 | import { AuthHttp, RestExtractor, ResultList, User } from '../../../shared'; | ||
4 | |||
5 | @Injectable() | ||
6 | export class UserService { | ||
7 | // TODO: merge this constant with account | ||
8 | private static BASE_USERS_URL = '/api/v1/users/'; | ||
9 | |||
10 | constructor( | ||
11 | private authHttp: AuthHttp, | ||
12 | private restExtractor: RestExtractor | ||
13 | ) {} | ||
14 | |||
15 | addUser(username: string, password: string) { | ||
16 | const body = { | ||
17 | username, | ||
18 | password | ||
19 | }; | ||
20 | |||
21 | return this.authHttp.post(UserService.BASE_USERS_URL, body) | ||
22 | .map(this.restExtractor.extractDataBool) | ||
23 | .catch(this.restExtractor.handleError); | ||
24 | } | ||
25 | |||
26 | getUsers() { | ||
27 | return this.authHttp.get(UserService.BASE_USERS_URL) | ||
28 | .map(this.restExtractor.extractDataList) | ||
29 | .map(this.extractUsers) | ||
30 | .catch((res) => this.restExtractor.handleError(res)); | ||
31 | } | ||
32 | |||
33 | removeUser(user: User) { | ||
34 | return this.authHttp.delete(UserService.BASE_USERS_URL + user.id); | ||
35 | } | ||
36 | |||
37 | private extractUsers(result: ResultList) { | ||
38 | const usersJson = result.data; | ||
39 | const totalUsers = result.total; | ||
40 | const users = []; | ||
41 | for (const userJson of usersJson) { | ||
42 | users.push(new User(userJson)); | ||
43 | } | ||
44 | |||
45 | return { users, totalUsers }; | ||
46 | } | ||
47 | } | ||
diff --git a/client/src/app/admin/users/user-add/index.ts b/client/src/app/admin/users/user-add/index.ts new file mode 100644 index 000000000..66d5ca04f --- /dev/null +++ b/client/src/app/admin/users/user-add/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './user-add.component'; | |||
diff --git a/client/src/app/admin/users/user-add/user-add.component.html b/client/src/app/admin/users/user-add/user-add.component.html new file mode 100644 index 000000000..9b76c7c1b --- /dev/null +++ b/client/src/app/admin/users/user-add/user-add.component.html | |||
@@ -0,0 +1,29 @@ | |||
1 | <h3>Add user</h3> | ||
2 | |||
3 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | ||
4 | |||
5 | <form role="form" (ngSubmit)="addUser()" [formGroup]="form"> | ||
6 | <div class="form-group"> | ||
7 | <label for="username">Username</label> | ||
8 | <input | ||
9 | type="text" class="form-control" id="username" placeholder="Username" | ||
10 | formControlName="username" | ||
11 | > | ||
12 | <div *ngIf="formErrors.username" class="alert alert-danger"> | ||
13 | {{ formErrors.username }} | ||
14 | </div> | ||
15 | </div> | ||
16 | |||
17 | <div class="form-group"> | ||
18 | <label for="password">Password</label> | ||
19 | <input | ||
20 | type="password" class="form-control" id="password" placeholder="Password" | ||
21 | formControlName="password" | ||
22 | > | ||
23 | <div *ngIf="formErrors.password" class="alert alert-danger"> | ||
24 | {{ formErrors.password }} | ||
25 | </div> | ||
26 | </div> | ||
27 | |||
28 | <input type="submit" value="Add user" class="btn btn-default" [disabled]="!form.valid"> | ||
29 | </form> | ||
diff --git a/client/src/app/admin/users/user-add/user-add.component.ts b/client/src/app/admin/users/user-add/user-add.component.ts new file mode 100644 index 000000000..ab96fb01d --- /dev/null +++ b/client/src/app/admin/users/user-add/user-add.component.ts | |||
@@ -0,0 +1,57 @@ | |||
1 | import { Component, OnInit } from '@angular/core'; | ||
2 | import { FormBuilder, FormGroup } from '@angular/forms'; | ||
3 | import { Router } from '@angular/router'; | ||
4 | |||
5 | import { UserService } from '../shared'; | ||
6 | import { FormReactive, USER_USERNAME, USER_PASSWORD } from '../../../shared'; | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-user-add', | ||
10 | templateUrl: './user-add.component.html' | ||
11 | }) | ||
12 | export class UserAddComponent extends FormReactive implements OnInit { | ||
13 | error: string = null; | ||
14 | |||
15 | form: FormGroup; | ||
16 | formErrors = { | ||
17 | 'username': '', | ||
18 | 'password': '' | ||
19 | }; | ||
20 | validationMessages = { | ||
21 | 'username': USER_USERNAME.MESSAGES, | ||
22 | 'password': USER_PASSWORD.MESSAGES, | ||
23 | }; | ||
24 | |||
25 | constructor( | ||
26 | private formBuilder: FormBuilder, | ||
27 | private router: Router, | ||
28 | private userService: UserService | ||
29 | ) { | ||
30 | super(); | ||
31 | } | ||
32 | |||
33 | buildForm() { | ||
34 | this.form = this.formBuilder.group({ | ||
35 | username: [ '', USER_USERNAME.VALIDATORS ], | ||
36 | password: [ '', USER_PASSWORD.VALIDATORS ], | ||
37 | }); | ||
38 | |||
39 | this.form.valueChanges.subscribe(data => this.onValueChanged(data)); | ||
40 | } | ||
41 | |||
42 | ngOnInit() { | ||
43 | this.buildForm(); | ||
44 | } | ||
45 | |||
46 | addUser() { | ||
47 | this.error = null; | ||
48 | |||
49 | const { username, password } = this.form.value; | ||
50 | |||
51 | this.userService.addUser(username, password).subscribe( | ||
52 | ok => this.router.navigate([ '/admin/users/list' ]), | ||
53 | |||
54 | err => this.error = err.text | ||
55 | ); | ||
56 | } | ||
57 | } | ||
diff --git a/client/src/app/admin/users/user-list/index.ts b/client/src/app/admin/users/user-list/index.ts new file mode 100644 index 000000000..51fbefa80 --- /dev/null +++ b/client/src/app/admin/users/user-list/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './user-list.component'; | |||
diff --git a/client/src/app/admin/users/user-list/user-list.component.html b/client/src/app/admin/users/user-list/user-list.component.html new file mode 100644 index 000000000..328b1be77 --- /dev/null +++ b/client/src/app/admin/users/user-list/user-list.component.html | |||
@@ -0,0 +1,28 @@ | |||
1 | <h3>Users list</h3> | ||
2 | |||
3 | <table class="table table-hover"> | ||
4 | <thead> | ||
5 | <tr> | ||
6 | <th class="table-column-id">ID</th> | ||
7 | <th>Username</th> | ||
8 | <th>Created Date</th> | ||
9 | <th class="text-right">Remove</th> | ||
10 | </tr> | ||
11 | </thead> | ||
12 | |||
13 | <tbody> | ||
14 | <tr *ngFor="let user of users"> | ||
15 | <td>{{ user.id }}</td> | ||
16 | <td>{{ user.username }}</td> | ||
17 | <td>{{ user.createdDate | date: 'medium' }}</td> | ||
18 | <td class="text-right"> | ||
19 | <span class="glyphicon glyphicon-remove" *ngIf="!user.isAdmin()" (click)="removeUser(user)"></span> | ||
20 | </td> | ||
21 | </tr> | ||
22 | </tbody> | ||
23 | </table> | ||
24 | |||
25 | <a class="add-user btn btn-success pull-right" [routerLink]="['/admin/users/add']"> | ||
26 | <span class="glyphicon glyphicon-plus"></span> | ||
27 | Add user | ||
28 | </a> | ||
diff --git a/client/src/app/admin/users/user-list/user-list.component.scss b/client/src/app/admin/users/user-list/user-list.component.scss new file mode 100644 index 000000000..e9f61e900 --- /dev/null +++ b/client/src/app/admin/users/user-list/user-list.component.scss | |||
@@ -0,0 +1,7 @@ | |||
1 | .glyphicon-remove { | ||
2 | cursor: pointer; | ||
3 | } | ||
4 | |||
5 | .add-user { | ||
6 | margin-top: 10px; | ||
7 | } | ||
diff --git a/client/src/app/admin/users/user-list/user-list.component.ts b/client/src/app/admin/users/user-list/user-list.component.ts new file mode 100644 index 000000000..03f4e5c0a --- /dev/null +++ b/client/src/app/admin/users/user-list/user-list.component.ts | |||
@@ -0,0 +1,42 @@ | |||
1 | import { Component, OnInit } from '@angular/core'; | ||
2 | |||
3 | import { User } from '../../../shared'; | ||
4 | import { UserService } from '../shared'; | ||
5 | |||
6 | @Component({ | ||
7 | selector: 'my-user-list', | ||
8 | templateUrl: './user-list.component.html', | ||
9 | styleUrls: [ './user-list.component.scss' ] | ||
10 | }) | ||
11 | export class UserListComponent implements OnInit { | ||
12 | totalUsers: number; | ||
13 | users: User[]; | ||
14 | |||
15 | constructor(private userService: UserService) {} | ||
16 | |||
17 | ngOnInit() { | ||
18 | this.getUsers(); | ||
19 | } | ||
20 | |||
21 | getUsers() { | ||
22 | this.userService.getUsers().subscribe( | ||
23 | ({ users, totalUsers }) => { | ||
24 | this.users = users; | ||
25 | this.totalUsers = totalUsers; | ||
26 | }, | ||
27 | |||
28 | err => alert(err.text) | ||
29 | ); | ||
30 | } | ||
31 | |||
32 | |||
33 | removeUser(user: User) { | ||
34 | if (confirm('Are you sure?')) { | ||
35 | this.userService.removeUser(user).subscribe( | ||
36 | () => this.getUsers(), | ||
37 | |||
38 | err => alert(err.text) | ||
39 | ); | ||
40 | } | ||
41 | } | ||
42 | } | ||
diff --git a/client/src/app/admin/users/users.component.ts b/client/src/app/admin/users/users.component.ts new file mode 100644 index 000000000..37e3b158d --- /dev/null +++ b/client/src/app/admin/users/users.component.ts | |||
@@ -0,0 +1,8 @@ | |||
1 | import { Component } from '@angular/core'; | ||
2 | |||
3 | @Component({ | ||
4 | template: '<router-outlet></router-outlet>' | ||
5 | }) | ||
6 | |||
7 | export class UsersComponent { | ||
8 | } | ||
diff --git a/client/src/app/admin/users/users.routes.ts b/client/src/app/admin/users/users.routes.ts new file mode 100644 index 000000000..eb71bd0ae --- /dev/null +++ b/client/src/app/admin/users/users.routes.ts | |||
@@ -0,0 +1,27 @@ | |||
1 | import { Routes } from '@angular/router'; | ||
2 | |||
3 | import { UsersComponent } from './users.component'; | ||
4 | import { UserAddComponent } from './user-add'; | ||
5 | import { UserListComponent } from './user-list'; | ||
6 | |||
7 | export const UsersRoutes: Routes = [ | ||
8 | { | ||
9 | path: 'users', | ||
10 | component: UsersComponent, | ||
11 | children: [ | ||
12 | { | ||
13 | path: '', | ||
14 | redirectTo: 'list', | ||
15 | pathMatch: 'full' | ||
16 | }, | ||
17 | { | ||
18 | path: 'list', | ||
19 | component: UserListComponent | ||
20 | }, | ||
21 | { | ||
22 | path: 'add', | ||
23 | component: UserAddComponent | ||
24 | } | ||
25 | ] | ||
26 | } | ||
27 | ]; | ||
diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html index f2acffea4..04c32f596 100644 --- a/client/src/app/app.component.html +++ b/client/src/app/app.component.html | |||
@@ -14,48 +14,14 @@ | |||
14 | 14 | ||
15 | 15 | ||
16 | <div class="row"> | 16 | <div class="row"> |
17 | 17 | <my-menu *ngIf="isInAdmin() === false"></my-menu> | |
18 | <menu class="col-md-2 col-sm-3 col-xs-3"> | 18 | <my-menu-admin *ngIf="isInAdmin() === true"></my-menu-admin> |
19 | <div class="panel-block"> | ||
20 | <div id="panel-user-login" class="panel-button"> | ||
21 | <span class="hidden-xs glyphicon glyphicon-user"></span> | ||
22 | <a *ngIf="!isLoggedIn" [routerLink]="['/login']">Login</a> | ||
23 | <a *ngIf="isLoggedIn" (click)="logout()">Logout</a> | ||
24 | </div> | ||
25 | </div> | ||
26 | |||
27 | <div class="panel-block"> | ||
28 | <div id="panel-get-videos" class="panel-button"> | ||
29 | <span class="hidden-xs glyphicon glyphicon-list"></span> | ||
30 | <a [routerLink]="['/videos/list']">Get videos</a> | ||
31 | </div> | ||
32 | |||
33 | <div id="panel-upload-video" class="panel-button" *ngIf="isLoggedIn"> | ||
34 | <span class="hidden-xs glyphicon glyphicon-cloud-upload"></span> | ||
35 | <a [routerLink]="['/videos/add']">Upload a video</a> | ||
36 | </div> | ||
37 | </div> | ||
38 | |||
39 | <div class="panel-block" *ngIf="isLoggedIn"> | ||
40 | <div id="panel-make-friends" class="panel-button"> | ||
41 | <span class="hidden-xs glyphicon glyphicon-cloud"></span> | ||
42 | <a (click)='makeFriends()'>Make friends</a> | ||
43 | </div> | ||
44 | |||
45 | <div id="panel-quit-friends" class="panel-button"> | ||
46 | <span class="hidden-xs glyphicon glyphicon-plane"></span> | ||
47 | <a (click)='quitFriends()'>Quit friends</a> | ||
48 | </div> | ||
49 | </div> | ||
50 | </menu> | ||
51 | 19 | ||
52 | <div class="col-md-9 col-sm-8 col-xs-8 router-outlet-container"> | 20 | <div class="col-md-9 col-sm-8 col-xs-8 router-outlet-container"> |
53 | <router-outlet></router-outlet> | 21 | <router-outlet></router-outlet> |
54 | </div> | 22 | </div> |
55 | |||
56 | </div> | 23 | </div> |
57 | 24 | ||
58 | |||
59 | <footer> | 25 | <footer> |
60 | PeerTube, CopyLeft 2015-2016 | 26 | PeerTube, CopyLeft 2015-2016 |
61 | </footer> | 27 | </footer> |
diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss index 1b02b2f57..95f306d75 100644 --- a/client/src/app/app.component.scss +++ b/client/src/app/app.component.scss | |||
@@ -12,40 +12,6 @@ header div { | |||
12 | margin-bottom: 30px; | 12 | margin-bottom: 30px; |
13 | } | 13 | } |
14 | 14 | ||
15 | menu { | ||
16 | @media screen and (max-width: 600px) { | ||
17 | margin-right: 3px !important; | ||
18 | padding: 3px !important; | ||
19 | min-height: 400px !important; | ||
20 | } | ||
21 | |||
22 | min-height: 600px; | ||
23 | margin-right: 20px; | ||
24 | border-right: 1px solid rgba(0, 0, 0, 0.2); | ||
25 | |||
26 | .panel-button { | ||
27 | margin: 8px; | ||
28 | cursor: pointer; | ||
29 | transition: margin 0.2s; | ||
30 | |||
31 | &:hover { | ||
32 | margin-left: 15px; | ||
33 | } | ||
34 | |||
35 | a { | ||
36 | color: #333333; | ||
37 | } | ||
38 | } | ||
39 | |||
40 | .glyphicon { | ||
41 | margin: 5px; | ||
42 | } | ||
43 | } | ||
44 | |||
45 | .panel-block:not(:last-child) { | ||
46 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); | ||
47 | } | ||
48 | |||
49 | .router-outlet-container { | 15 | .router-outlet-container { |
50 | @media screen and (max-width: 400px) { | 16 | @media screen and (max-width: 400px) { |
51 | padding: 0 3px 0 3px; | 17 | padding: 0 3px 0 3px; |
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index b7a3d7c58..d6b83c684 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts | |||
@@ -1,73 +1,16 @@ | |||
1 | import { Component } from '@angular/core'; | 1 | import { Component } from '@angular/core'; |
2 | import { ActivatedRoute, Router, ROUTER_DIRECTIVES } from '@angular/router'; | 2 | import { Router } from '@angular/router'; |
3 | |||
4 | import { FriendService } from './friends'; | ||
5 | import { | ||
6 | AuthService, | ||
7 | AuthStatus, | ||
8 | SearchComponent, | ||
9 | SearchService | ||
10 | } from './shared'; | ||
11 | import { VideoService } from './videos'; | ||
12 | 3 | ||
13 | @Component({ | 4 | @Component({ |
14 | selector: 'my-app', | 5 | selector: 'my-app', |
15 | template: require('./app.component.html'), | 6 | templateUrl: './app.component.html', |
16 | styles: [ require('./app.component.scss') ], | 7 | styleUrls: [ './app.component.scss' ] |
17 | directives: [ ROUTER_DIRECTIVES, SearchComponent ], | ||
18 | providers: [ FriendService, VideoService, SearchService ] | ||
19 | }) | 8 | }) |
20 | 9 | ||
21 | export class AppComponent { | 10 | export class AppComponent { |
22 | choices = []; | 11 | constructor(private router: Router) {} |
23 | isLoggedIn: boolean; | ||
24 | |||
25 | constructor( | ||
26 | private authService: AuthService, | ||
27 | private friendService: FriendService, | ||
28 | private route: ActivatedRoute, | ||
29 | private router: Router | ||
30 | ) { | ||
31 | this.isLoggedIn = this.authService.isLoggedIn(); | ||
32 | |||
33 | this.authService.loginChangedSource.subscribe( | ||
34 | status => { | ||
35 | if (status === AuthStatus.LoggedIn) { | ||
36 | this.isLoggedIn = true; | ||
37 | console.log('Logged in.'); | ||
38 | } else if (status === AuthStatus.LoggedOut) { | ||
39 | this.isLoggedIn = false; | ||
40 | console.log('Logged out.'); | ||
41 | } else { | ||
42 | console.error('Unknown auth status: ' + status); | ||
43 | } | ||
44 | } | ||
45 | ); | ||
46 | } | ||
47 | |||
48 | logout() { | ||
49 | this.authService.logout(); | ||
50 | } | ||
51 | |||
52 | makeFriends() { | ||
53 | this.friendService.makeFriends().subscribe( | ||
54 | status => { | ||
55 | if (status === 409) { | ||
56 | alert('Already made friends!'); | ||
57 | } else { | ||
58 | alert('Made friends!'); | ||
59 | } | ||
60 | }, | ||
61 | error => alert(error) | ||
62 | ); | ||
63 | } | ||
64 | 12 | ||
65 | quitFriends() { | 13 | isInAdmin() { |
66 | this.friendService.quitFriends().subscribe( | 14 | return this.router.url.indexOf('/admin/') !== -1; |
67 | status => { | ||
68 | alert('Quit friends!'); | ||
69 | }, | ||
70 | error => alert(error) | ||
71 | ); | ||
72 | } | 15 | } |
73 | } | 16 | } |
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts new file mode 100644 index 000000000..980625f13 --- /dev/null +++ b/client/src/app/app.module.ts | |||
@@ -0,0 +1,146 @@ | |||
1 | import { ApplicationRef, NgModule } from '@angular/core'; | ||
2 | import { BrowserModule } from '@angular/platform-browser'; | ||
3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; | ||
4 | import { HttpModule, RequestOptions, XHRBackend } from '@angular/http'; | ||
5 | import { RouterModule } from '@angular/router'; | ||
6 | import { removeNgStyles, createNewHosts } from '@angularclass/hmr'; | ||
7 | |||
8 | import { BytesPipe } from 'angular-pipes/src/math/bytes.pipe'; | ||
9 | import { ProgressbarModule } from 'ng2-bootstrap/components/progressbar'; | ||
10 | import { PaginationModule } from 'ng2-bootstrap/components/pagination'; | ||
11 | import { FileUploadModule } from 'ng2-file-upload/ng2-file-upload'; | ||
12 | |||
13 | /* | ||
14 | * Platform and Environment providers/directives/pipes | ||
15 | */ | ||
16 | import { ENV_PROVIDERS } from './environment'; | ||
17 | import { routes } from './app.routes'; | ||
18 | // App is our top level component | ||
19 | import { AppComponent } from './app.component'; | ||
20 | import { AppState } from './app.service'; | ||
21 | |||
22 | import { | ||
23 | AdminComponent, | ||
24 | FriendsComponent, | ||
25 | FriendAddComponent, | ||
26 | FriendListComponent, | ||
27 | FriendService, | ||
28 | MenuAdminComponent, | ||
29 | RequestsComponent, | ||
30 | RequestStatsComponent, | ||
31 | RequestService, | ||
32 | UsersComponent, | ||
33 | UserAddComponent, | ||
34 | UserListComponent, | ||
35 | UserService | ||
36 | } from './admin'; | ||
37 | import { AccountComponent, AccountService } from './account'; | ||
38 | import { LoginComponent } from './login'; | ||
39 | import { MenuComponent } from './menu.component'; | ||
40 | import { AuthService, AuthHttp, RestExtractor, RestService, SearchComponent, SearchService } from './shared'; | ||
41 | import { | ||
42 | LoaderComponent, | ||
43 | VideosComponent, | ||
44 | VideoAddComponent, | ||
45 | VideoListComponent, | ||
46 | VideoMiniatureComponent, | ||
47 | VideoSortComponent, | ||
48 | VideoWatchComponent, | ||
49 | VideoService, | ||
50 | WebTorrentService | ||
51 | } from './videos'; | ||
52 | |||
53 | // Application wide providers | ||
54 | const APP_PROVIDERS = [ | ||
55 | AppState, | ||
56 | |||
57 | { | ||
58 | provide: AuthHttp, | ||
59 | useFactory: (backend: XHRBackend, defaultOptions: RequestOptions, authService: AuthService) => { | ||
60 | return new AuthHttp(backend, defaultOptions, authService); | ||
61 | }, | ||
62 | deps: [ XHRBackend, RequestOptions, AuthService ] | ||
63 | }, | ||
64 | |||
65 | AuthService, | ||
66 | RestExtractor, | ||
67 | RestService, | ||
68 | |||
69 | VideoService, | ||
70 | SearchService, | ||
71 | FriendService, | ||
72 | RequestService, | ||
73 | UserService, | ||
74 | AccountService, | ||
75 | WebTorrentService | ||
76 | ]; | ||
77 | /** | ||
78 | * `AppModule` is the main entry point into Angular2's bootstraping process | ||
79 | */ | ||
80 | @NgModule({ | ||
81 | bootstrap: [ AppComponent ], | ||
82 | declarations: [ | ||
83 | AccountComponent, | ||
84 | AdminComponent, | ||
85 | AppComponent, | ||
86 | BytesPipe, | ||
87 | FriendAddComponent, | ||
88 | FriendListComponent, | ||
89 | FriendsComponent, | ||
90 | LoaderComponent, | ||
91 | LoginComponent, | ||
92 | MenuAdminComponent, | ||
93 | MenuComponent, | ||
94 | RequestsComponent, | ||
95 | RequestStatsComponent, | ||
96 | SearchComponent, | ||
97 | UserAddComponent, | ||
98 | UserListComponent, | ||
99 | UsersComponent, | ||
100 | VideoAddComponent, | ||
101 | VideoListComponent, | ||
102 | VideoMiniatureComponent, | ||
103 | VideosComponent, | ||
104 | VideoSortComponent, | ||
105 | VideoWatchComponent, | ||
106 | ], | ||
107 | imports: [ // import Angular's modules | ||
108 | BrowserModule, | ||
109 | FormsModule, | ||
110 | ReactiveFormsModule, | ||
111 | HttpModule, | ||
112 | RouterModule.forRoot(routes), | ||
113 | |||
114 | ProgressbarModule, | ||
115 | PaginationModule, | ||
116 | FileUploadModule | ||
117 | ], | ||
118 | providers: [ // expose our Services and Providers into Angular's dependency injection | ||
119 | ENV_PROVIDERS, | ||
120 | APP_PROVIDERS | ||
121 | ] | ||
122 | }) | ||
123 | export class AppModule { | ||
124 | constructor(public appRef: ApplicationRef, public appState: AppState) {} | ||
125 | hmrOnInit(store) { | ||
126 | if (!store || !store.state) return; | ||
127 | console.log('HMR store', store); | ||
128 | this.appState._state = store.state; | ||
129 | this.appRef.tick(); | ||
130 | delete store.state; | ||
131 | } | ||
132 | hmrOnDestroy(store) { | ||
133 | const cmpLocation = this.appRef.components.map(cmp => cmp.location.nativeElement); | ||
134 | // recreate elements | ||
135 | const state = this.appState._state; | ||
136 | store.state = state; | ||
137 | store.disposeOldHosts = createNewHosts(cmpLocation); | ||
138 | // remove styles | ||
139 | removeNgStyles(); | ||
140 | } | ||
141 | hmrAfterDestroy(store) { | ||
142 | // display new elements | ||
143 | store.disposeOldHosts(); | ||
144 | delete store.disposeOldHosts; | ||
145 | } | ||
146 | } | ||
diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index 59ef4ce55..03e2bce51 100644 --- a/client/src/app/app.routes.ts +++ b/client/src/app/app.routes.ts | |||
@@ -1,15 +1,18 @@ | |||
1 | import { RouterConfig } from '@angular/router'; | 1 | import { Routes } from '@angular/router'; |
2 | 2 | ||
3 | import { AccountRoutes } from './account'; | ||
3 | import { LoginRoutes } from './login'; | 4 | import { LoginRoutes } from './login'; |
5 | import { AdminRoutes } from './admin'; | ||
4 | import { VideosRoutes } from './videos'; | 6 | import { VideosRoutes } from './videos'; |
5 | 7 | ||
6 | export const routes: RouterConfig = [ | 8 | export const routes: Routes = [ |
7 | { | 9 | { |
8 | path: '', | 10 | path: '', |
9 | redirectTo: '/videos/list', | 11 | redirectTo: '/videos/list', |
10 | pathMatch: 'full' | 12 | pathMatch: 'full' |
11 | }, | 13 | }, |
12 | 14 | ...AdminRoutes, | |
15 | ...AccountRoutes, | ||
13 | ...LoginRoutes, | 16 | ...LoginRoutes, |
14 | ...VideosRoutes | 17 | ...VideosRoutes |
15 | ]; | 18 | ]; |
diff --git a/client/src/app/app.service.ts b/client/src/app/app.service.ts new file mode 100644 index 000000000..033c21900 --- /dev/null +++ b/client/src/app/app.service.ts | |||
@@ -0,0 +1,36 @@ | |||
1 | |||
2 | import { Injectable } from '@angular/core'; | ||
3 | |||
4 | @Injectable() | ||
5 | export class AppState { | ||
6 | _state = { }; | ||
7 | |||
8 | constructor() { ; } | ||
9 | |||
10 | // already return a clone of the current state | ||
11 | get state() { | ||
12 | return this._state = this._clone(this._state); | ||
13 | } | ||
14 | // never allow mutation | ||
15 | set state(value) { | ||
16 | throw new Error('do not mutate the `.state` directly'); | ||
17 | } | ||
18 | |||
19 | |||
20 | get(prop?: any) { | ||
21 | // use our state getter for the clone | ||
22 | const state = this.state; | ||
23 | return state.hasOwnProperty(prop) ? state[prop] : state; | ||
24 | } | ||
25 | |||
26 | set(prop: string, value: any) { | ||
27 | // internally mutate our state | ||
28 | return this._state[prop] = value; | ||
29 | } | ||
30 | |||
31 | |||
32 | _clone(object) { | ||
33 | // simple object clone | ||
34 | return JSON.parse(JSON.stringify( object )); | ||
35 | } | ||
36 | } | ||
diff --git a/client/src/app/environment.ts b/client/src/app/environment.ts new file mode 100644 index 000000000..8bba89c4e --- /dev/null +++ b/client/src/app/environment.ts | |||
@@ -0,0 +1,50 @@ | |||
1 | |||
2 | // Angular 2 | ||
3 | // rc2 workaround | ||
4 | import { enableDebugTools, disableDebugTools } from '@angular/platform-browser'; | ||
5 | import { enableProdMode, ApplicationRef } from '@angular/core'; | ||
6 | // Environment Providers | ||
7 | let PROVIDERS = [ | ||
8 | // common env directives | ||
9 | ]; | ||
10 | |||
11 | // Angular debug tools in the dev console | ||
12 | // https://github.com/angular/angular/blob/86405345b781a9dc2438c0fbe3e9409245647019/TOOLS_JS.md | ||
13 | let _decorateModuleRef = function identity(value) { return value; }; | ||
14 | |||
15 | if ('production' === ENV) { | ||
16 | // Production | ||
17 | disableDebugTools(); | ||
18 | enableProdMode(); | ||
19 | |||
20 | PROVIDERS = [ | ||
21 | ...PROVIDERS, | ||
22 | // custom providers in production | ||
23 | ]; | ||
24 | |||
25 | } else { | ||
26 | |||
27 | _decorateModuleRef = (modRef: any) => { | ||
28 | const appRef = modRef.injector.get(ApplicationRef); | ||
29 | const cmpRef = appRef.components[0]; | ||
30 | |||
31 | let _ng = (<any>window).ng; | ||
32 | enableDebugTools(cmpRef); | ||
33 | (<any>window).ng.probe = _ng.probe; | ||
34 | (<any>window).ng.coreTokens = _ng.coreTokens; | ||
35 | return modRef; | ||
36 | }; | ||
37 | |||
38 | // Development | ||
39 | PROVIDERS = [ | ||
40 | ...PROVIDERS, | ||
41 | // custom providers in development | ||
42 | ]; | ||
43 | |||
44 | } | ||
45 | |||
46 | export const decorateModuleRef = _decorateModuleRef; | ||
47 | |||
48 | export const ENV_PROVIDERS = [ | ||
49 | ...PROVIDERS | ||
50 | ]; | ||
diff --git a/client/src/app/friends/friend.service.ts b/client/src/app/friends/friend.service.ts deleted file mode 100644 index 771046484..000000000 --- a/client/src/app/friends/friend.service.ts +++ /dev/null | |||
@@ -1,29 +0,0 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | import { Response } from '@angular/http'; | ||
3 | import { Observable } from 'rxjs/Observable'; | ||
4 | |||
5 | import { AuthHttp, AuthService } from '../shared'; | ||
6 | |||
7 | @Injectable() | ||
8 | export class FriendService { | ||
9 | private static BASE_FRIEND_URL: string = '/api/v1/pods/'; | ||
10 | |||
11 | constructor (private authHttp: AuthHttp, private authService: AuthService) {} | ||
12 | |||
13 | makeFriends() { | ||
14 | return this.authHttp.get(FriendService.BASE_FRIEND_URL + 'makefriends') | ||
15 | .map(res => res.status) | ||
16 | .catch(this.handleError); | ||
17 | } | ||
18 | |||
19 | quitFriends() { | ||
20 | return this.authHttp.get(FriendService.BASE_FRIEND_URL + 'quitfriends') | ||
21 | .map(res => res.status) | ||
22 | .catch(this.handleError); | ||
23 | } | ||
24 | |||
25 | private handleError (error: Response): Observable<number> { | ||
26 | console.error(error); | ||
27 | return Observable.throw(error.json().error || 'Server error'); | ||
28 | } | ||
29 | } | ||
diff --git a/client/src/app/index.ts b/client/src/app/index.ts new file mode 100644 index 000000000..da53f6aef --- /dev/null +++ b/client/src/app/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './app.module'; | |||
diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html index 5848fcba3..94a405405 100644 --- a/client/src/app/login/login.component.html +++ b/client/src/app/login/login.component.html | |||
@@ -1,17 +1,16 @@ | |||
1 | <h3>Login</h3> | 1 | <h3>Login</h3> |
2 | 2 | ||
3 | |||
4 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | 3 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> |
5 | 4 | ||
6 | <form role="form" (ngSubmit)="login(username.value, password.value)" #loginForm="ngForm"> | 5 | <form role="form" (ngSubmit)="login()" [formGroup]="form"> |
7 | <div class="form-group"> | 6 | <div class="form-group"> |
8 | <label for="username">Username</label> | 7 | <label for="username">Username</label> |
9 | <input | 8 | <input |
10 | type="text" class="form-control" name="username" id="username" placeholder="Username" required | 9 | type="text" class="form-control" id="username" placeholder="Username" required |
11 | ngControl="username" #username="ngForm" | 10 | formControlName="username" |
12 | > | 11 | > |
13 | <div [hidden]="username.valid || username.pristine" class="alert alert-danger"> | 12 | <div *ngIf="formErrors.username" class="alert alert-danger"> |
14 | Username is required | 13 | {{ formErrors.username }} |
15 | </div> | 14 | </div> |
16 | </div> | 15 | </div> |
17 | 16 | ||
@@ -19,12 +18,12 @@ | |||
19 | <label for="password">Password</label> | 18 | <label for="password">Password</label> |
20 | <input | 19 | <input |
21 | type="password" class="form-control" name="password" id="password" placeholder="Password" required | 20 | type="password" class="form-control" name="password" id="password" placeholder="Password" required |
22 | ngControl="password" #password="ngForm" | 21 | formControlName="password" |
23 | > | 22 | > |
24 | <div [hidden]="password.valid || password.pristine" class="alert alert-danger"> | 23 | <div *ngIf="formErrors.password" class="alert alert-danger"> |
25 | Password is required | 24 | {{ formErrors.password }} |
26 | </div> | 25 | </div> |
27 | </div> | 26 | </div> |
28 | 27 | ||
29 | <input type="submit" value="Login" class="btn btn-default" [disabled]="!loginForm.form.valid"> | 28 | <input type="submit" value="Login" class="btn btn-default" [disabled]="!form.valid"> |
30 | </form> | 29 | </form> |
diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts index ddd62462e..c4ff7050b 100644 --- a/client/src/app/login/login.component.ts +++ b/client/src/app/login/login.component.ts | |||
@@ -1,35 +1,67 @@ | |||
1 | import { Component } from '@angular/core'; | 1 | import { Component, OnInit } from '@angular/core'; |
2 | import { FormBuilder, FormGroup, Validators } from '@angular/forms'; | ||
2 | import { Router } from '@angular/router'; | 3 | import { Router } from '@angular/router'; |
3 | 4 | ||
4 | import { AuthService } from '../shared'; | 5 | import { AuthService, FormReactive } from '../shared'; |
5 | 6 | ||
6 | @Component({ | 7 | @Component({ |
7 | selector: 'my-login', | 8 | selector: 'my-login', |
8 | template: require('./login.component.html') | 9 | templateUrl: './login.component.html' |
9 | }) | 10 | }) |
10 | 11 | ||
11 | export class LoginComponent { | 12 | export class LoginComponent extends FormReactive implements OnInit { |
12 | error: string = null; | 13 | error: string = null; |
13 | 14 | ||
15 | form: FormGroup; | ||
16 | formErrors = { | ||
17 | 'username': '', | ||
18 | 'password': '' | ||
19 | }; | ||
20 | validationMessages = { | ||
21 | 'username': { | ||
22 | 'required': 'Username is required.', | ||
23 | }, | ||
24 | 'password': { | ||
25 | 'required': 'Password is required.' | ||
26 | } | ||
27 | }; | ||
28 | |||
14 | constructor( | 29 | constructor( |
15 | private authService: AuthService, | 30 | private authService: AuthService, |
31 | private formBuilder: FormBuilder, | ||
16 | private router: Router | 32 | private router: Router |
17 | ) {} | 33 | ) { |
34 | super(); | ||
35 | } | ||
36 | |||
37 | buildForm() { | ||
38 | this.form = this.formBuilder.group({ | ||
39 | username: [ '', Validators.required ], | ||
40 | password: [ '', Validators.required ], | ||
41 | }); | ||
42 | |||
43 | this.form.valueChanges.subscribe(data => this.onValueChanged(data)); | ||
44 | } | ||
45 | |||
46 | ngOnInit() { | ||
47 | this.buildForm(); | ||
48 | } | ||
49 | |||
50 | login() { | ||
51 | this.error = null; | ||
52 | |||
53 | const { username, password } = this.form.value; | ||
18 | 54 | ||
19 | login(username: string, password: string) { | ||
20 | this.authService.login(username, password).subscribe( | 55 | this.authService.login(username, password).subscribe( |
21 | result => { | 56 | result => this.router.navigate(['/videos/list']), |
22 | this.error = null; | ||
23 | 57 | ||
24 | this.router.navigate(['/videos/list']); | ||
25 | }, | ||
26 | error => { | 58 | error => { |
27 | console.error(error); | 59 | console.error(error.json); |
28 | 60 | ||
29 | if (error.error === 'invalid_grant') { | 61 | if (error.json.error === 'invalid_grant') { |
30 | this.error = 'Credentials are invalid.'; | 62 | this.error = 'Credentials are invalid.'; |
31 | } else { | 63 | } else { |
32 | this.error = `${error.error}: ${error.error_description}`; | 64 | this.error = `${error.json.error}: ${error.json.error_description}`; |
33 | } | 65 | } |
34 | } | 66 | } |
35 | ); | 67 | ); |
diff --git a/client/src/app/menu.component.html b/client/src/app/menu.component.html new file mode 100644 index 000000000..29ef7f9cf --- /dev/null +++ b/client/src/app/menu.component.html | |||
@@ -0,0 +1,39 @@ | |||
1 | <menu class="col-md-2 col-sm-3 col-xs-3"> | ||
2 | <div class="panel-block"> | ||
3 | <div id="panel-user-login" class="panel-button"> | ||
4 | <span *ngIf="!isLoggedIn" > | ||
5 | <span class="hidden-xs glyphicon glyphicon-log-in"></span> | ||
6 | <a [routerLink]="['/login']">Login</a> | ||
7 | </span> | ||
8 | |||
9 | <span *ngIf="isLoggedIn"> | ||
10 | <span class="hidden-xs glyphicon glyphicon-log-out"></span> | ||
11 | <a *ngIf="isLoggedIn" (click)="logout()">Logout</a> | ||
12 | </span> | ||
13 | </div> | ||
14 | |||
15 | <div *ngIf="isLoggedIn" id="panel-user-account" class="panel-button"> | ||
16 | <span class="hidden-xs glyphicon glyphicon-user"></span> | ||
17 | <a [routerLink]="['/account']">My account</a> | ||
18 | </div> | ||
19 | </div> | ||
20 | |||
21 | <div class="panel-block"> | ||
22 | <div id="panel-get-videos" class="panel-button"> | ||
23 | <span class="hidden-xs glyphicon glyphicon-list"></span> | ||
24 | <a [routerLink]="['/videos/list']">Get videos</a> | ||
25 | </div> | ||
26 | |||
27 | <div id="panel-upload-video" class="panel-button" *ngIf="isLoggedIn"> | ||
28 | <span class="hidden-xs glyphicon glyphicon-cloud-upload"></span> | ||
29 | <a [routerLink]="['/videos/add']">Upload a video</a> | ||
30 | </div> | ||
31 | </div> | ||
32 | |||
33 | <div class="panel-block" *ngIf="isUserAdmin()"> | ||
34 | <div id="panel-get-videos" class="panel-button"> | ||
35 | <span class="hidden-xs glyphicon glyphicon-cog"></span> | ||
36 | <a [routerLink]="['/admin']">Administration</a> | ||
37 | </div> | ||
38 | </div> | ||
39 | </menu> | ||
diff --git a/client/src/app/menu.component.ts b/client/src/app/menu.component.ts new file mode 100644 index 000000000..6cfc854dd --- /dev/null +++ b/client/src/app/menu.component.ts | |||
@@ -0,0 +1,45 @@ | |||
1 | import { Component, OnInit } from '@angular/core'; | ||
2 | import { Router } from '@angular/router'; | ||
3 | |||
4 | import { AuthService, AuthStatus } from './shared'; | ||
5 | |||
6 | @Component({ | ||
7 | selector: 'my-menu', | ||
8 | templateUrl: './menu.component.html' | ||
9 | }) | ||
10 | export class MenuComponent implements OnInit { | ||
11 | isLoggedIn: boolean; | ||
12 | |||
13 | constructor ( | ||
14 | private authService: AuthService, | ||
15 | private router: Router | ||
16 | ) {} | ||
17 | |||
18 | ngOnInit() { | ||
19 | this.isLoggedIn = this.authService.isLoggedIn(); | ||
20 | |||
21 | this.authService.loginChangedSource.subscribe( | ||
22 | status => { | ||
23 | if (status === AuthStatus.LoggedIn) { | ||
24 | this.isLoggedIn = true; | ||
25 | console.log('Logged in.'); | ||
26 | } else if (status === AuthStatus.LoggedOut) { | ||
27 | this.isLoggedIn = false; | ||
28 | console.log('Logged out.'); | ||
29 | } else { | ||
30 | console.error('Unknown auth status: ' + status); | ||
31 | } | ||
32 | } | ||
33 | ); | ||
34 | } | ||
35 | |||
36 | isUserAdmin() { | ||
37 | return this.authService.isAdmin(); | ||
38 | } | ||
39 | |||
40 | logout() { | ||
41 | this.authService.logout(); | ||
42 | // Redirect to home page | ||
43 | this.router.navigate(['/videos/list']); | ||
44 | } | ||
45 | } | ||
diff --git a/client/src/app/shared/auth/auth-http.service.ts b/client/src/app/shared/auth/auth-http.service.ts index 9c7ef4389..2392898ca 100644 --- a/client/src/app/shared/auth/auth-http.service.ts +++ b/client/src/app/shared/auth/auth-http.service.ts | |||
@@ -28,7 +28,7 @@ export class AuthHttp extends Http { | |||
28 | return super.request(url, options) | 28 | return super.request(url, options) |
29 | .catch((err) => { | 29 | .catch((err) => { |
30 | if (err.status === 401) { | 30 | if (err.status === 401) { |
31 | return this.handleTokenExpired(err, url, options); | 31 | return this.handleTokenExpired(url, options); |
32 | } | 32 | } |
33 | 33 | ||
34 | return Observable.throw(err); | 34 | return Observable.throw(err); |
@@ -49,26 +49,29 @@ export class AuthHttp extends Http { | |||
49 | return this.request(url, options); | 49 | return this.request(url, options); |
50 | } | 50 | } |
51 | 51 | ||
52 | post(url: string, options?: RequestOptionsArgs): Observable<Response> { | 52 | post(url: string, body: any, options?: RequestOptionsArgs): Observable<Response> { |
53 | if (!options) options = {}; | 53 | if (!options) options = {}; |
54 | options.method = RequestMethod.Post; | 54 | options.method = RequestMethod.Post; |
55 | options.body = body; | ||
55 | 56 | ||
56 | return this.request(url, options); | 57 | return this.request(url, options); |
57 | } | 58 | } |
58 | 59 | ||
59 | put(url: string, options?: RequestOptionsArgs): Observable<Response> { | 60 | put(url: string, body: any, options?: RequestOptionsArgs): Observable<Response> { |
60 | if (!options) options = {}; | 61 | if (!options) options = {}; |
61 | options.method = RequestMethod.Put; | 62 | options.method = RequestMethod.Put; |
63 | options.body = body; | ||
62 | 64 | ||
63 | return this.request(url, options); | 65 | return this.request(url, options); |
64 | } | 66 | } |
65 | 67 | ||
66 | private handleTokenExpired(err: Response, url: string | Request, options: RequestOptionsArgs) { | 68 | private handleTokenExpired(url: string | Request, options: RequestOptionsArgs) { |
67 | return this.authService.refreshAccessToken().flatMap(() => { | 69 | return this.authService.refreshAccessToken() |
68 | this.setAuthorizationHeader(options.headers); | 70 | .flatMap(() => { |
71 | this.setAuthorizationHeader(options.headers); | ||
69 | 72 | ||
70 | return super.request(url, options); | 73 | return super.request(url, options); |
71 | }); | 74 | }); |
72 | } | 75 | } |
73 | 76 | ||
74 | private setAuthorizationHeader(headers: Headers) { | 77 | private setAuthorizationHeader(headers: Headers) { |
diff --git a/client/src/app/shared/auth/user.model.ts b/client/src/app/shared/auth/auth-user.model.ts index 98852f835..bdd5ea5a9 100644 --- a/client/src/app/shared/auth/user.model.ts +++ b/client/src/app/shared/auth/auth-user.model.ts | |||
@@ -1,15 +1,28 @@ | |||
1 | export class User { | 1 | import { User } from '../users'; |
2 | |||
3 | export class AuthUser extends User { | ||
2 | private static KEYS = { | 4 | private static KEYS = { |
5 | ID: 'id', | ||
6 | ROLE: 'role', | ||
3 | USERNAME: 'username' | 7 | USERNAME: 'username' |
4 | }; | 8 | }; |
5 | 9 | ||
10 | id: string; | ||
11 | role: string; | ||
6 | username: string; | 12 | username: string; |
7 | tokens: Tokens; | 13 | tokens: Tokens; |
8 | 14 | ||
9 | static load() { | 15 | static load() { |
10 | const usernameLocalStorage = localStorage.getItem(this.KEYS.USERNAME); | 16 | const usernameLocalStorage = localStorage.getItem(this.KEYS.USERNAME); |
11 | if (usernameLocalStorage) { | 17 | if (usernameLocalStorage) { |
12 | return new User(localStorage.getItem(this.KEYS.USERNAME), Tokens.load()); | 18 | return new AuthUser( |
19 | { | ||
20 | id: localStorage.getItem(this.KEYS.ID), | ||
21 | username: localStorage.getItem(this.KEYS.USERNAME), | ||
22 | role: localStorage.getItem(this.KEYS.ROLE) | ||
23 | }, | ||
24 | Tokens.load() | ||
25 | ); | ||
13 | } | 26 | } |
14 | 27 | ||
15 | return null; | 28 | return null; |
@@ -17,12 +30,14 @@ export class User { | |||
17 | 30 | ||
18 | static flush() { | 31 | static flush() { |
19 | localStorage.removeItem(this.KEYS.USERNAME); | 32 | localStorage.removeItem(this.KEYS.USERNAME); |
33 | localStorage.removeItem(this.KEYS.ID); | ||
34 | localStorage.removeItem(this.KEYS.ROLE); | ||
20 | Tokens.flush(); | 35 | Tokens.flush(); |
21 | } | 36 | } |
22 | 37 | ||
23 | constructor(username: string, hash_tokens: any) { | 38 | constructor(userHash: { id: string, username: string, role: string }, hashTokens: any) { |
24 | this.username = username; | 39 | super(userHash); |
25 | this.tokens = new Tokens(hash_tokens); | 40 | this.tokens = new Tokens(hashTokens); |
26 | } | 41 | } |
27 | 42 | ||
28 | getAccessToken() { | 43 | getAccessToken() { |
@@ -43,12 +58,14 @@ export class User { | |||
43 | } | 58 | } |
44 | 59 | ||
45 | save() { | 60 | save() { |
46 | localStorage.setItem('username', this.username); | 61 | localStorage.setItem(AuthUser.KEYS.ID, this.id); |
62 | localStorage.setItem(AuthUser.KEYS.USERNAME, this.username); | ||
63 | localStorage.setItem(AuthUser.KEYS.ROLE, this.role); | ||
47 | this.tokens.save(); | 64 | this.tokens.save(); |
48 | } | 65 | } |
49 | } | 66 | } |
50 | 67 | ||
51 | // Private class used only by User | 68 | // Private class only used by User |
52 | class Tokens { | 69 | class Tokens { |
53 | private static KEYS = { | 70 | private static KEYS = { |
54 | ACCESS_TOKEN: 'access_token', | 71 | ACCESS_TOKEN: 'access_token', |
diff --git a/client/src/app/shared/auth/auth.service.ts b/client/src/app/shared/auth/auth.service.ts index 584298fff..a30c79c86 100644 --- a/client/src/app/shared/auth/auth.service.ts +++ b/client/src/app/shared/auth/auth.service.ts | |||
@@ -1,32 +1,39 @@ | |||
1 | import { Injectable } from '@angular/core'; | 1 | import { Injectable } from '@angular/core'; |
2 | import { Headers, Http, Response, URLSearchParams } from '@angular/http'; | 2 | import { Headers, Http, Response, URLSearchParams } from '@angular/http'; |
3 | import { Router } from '@angular/router'; | ||
3 | import { Observable } from 'rxjs/Observable'; | 4 | import { Observable } from 'rxjs/Observable'; |
4 | import { Subject } from 'rxjs/Subject'; | 5 | import { Subject } from 'rxjs/Subject'; |
5 | 6 | ||
6 | import { AuthStatus } from './auth-status.model'; | 7 | import { AuthStatus } from './auth-status.model'; |
7 | import { User } from './user.model'; | 8 | import { AuthUser } from './auth-user.model'; |
9 | import { RestExtractor } from '../rest'; | ||
8 | 10 | ||
9 | @Injectable() | 11 | @Injectable() |
10 | export class AuthService { | 12 | export class AuthService { |
11 | private static BASE_CLIENT_URL = '/api/v1/users/client'; | 13 | private static BASE_CLIENT_URL = '/api/v1/clients/local'; |
12 | private static BASE_TOKEN_URL = '/api/v1/users/token'; | 14 | private static BASE_TOKEN_URL = '/api/v1/users/token'; |
15 | private static BASE_USER_INFORMATIONS_URL = '/api/v1/users/me'; | ||
13 | 16 | ||
14 | loginChangedSource: Observable<AuthStatus>; | 17 | loginChangedSource: Observable<AuthStatus>; |
15 | 18 | ||
16 | private clientId: string; | 19 | private clientId: string; |
17 | private clientSecret: string; | 20 | private clientSecret: string; |
18 | private loginChanged: Subject<AuthStatus>; | 21 | private loginChanged: Subject<AuthStatus>; |
19 | private user: User = null; | 22 | private user: AuthUser = null; |
20 | 23 | ||
21 | constructor(private http: Http) { | 24 | constructor( |
25 | private http: Http, | ||
26 | private restExtractor: RestExtractor, | ||
27 | private router: Router | ||
28 | ) { | ||
22 | this.loginChanged = new Subject<AuthStatus>(); | 29 | this.loginChanged = new Subject<AuthStatus>(); |
23 | this.loginChangedSource = this.loginChanged.asObservable(); | 30 | this.loginChangedSource = this.loginChanged.asObservable(); |
24 | 31 | ||
25 | // Fetch the client_id/client_secret | 32 | // Fetch the client_id/client_secret |
26 | // FIXME: save in local storage? | 33 | // FIXME: save in local storage? |
27 | this.http.get(AuthService.BASE_CLIENT_URL) | 34 | this.http.get(AuthService.BASE_CLIENT_URL) |
28 | .map(res => res.json()) | 35 | .map(this.restExtractor.extractDataGet) |
29 | .catch(this.handleError) | 36 | .catch((res) => this.restExtractor.handleError(res)) |
30 | .subscribe( | 37 | .subscribe( |
31 | result => { | 38 | result => { |
32 | this.clientId = result.client_id; | 39 | this.clientId = result.client_id; |
@@ -34,12 +41,15 @@ export class AuthService { | |||
34 | console.log('Client credentials loaded.'); | 41 | console.log('Client credentials loaded.'); |
35 | }, | 42 | }, |
36 | error => { | 43 | error => { |
37 | alert(error); | 44 | alert( |
45 | `Cannot retrieve OAuth Client credentials: ${error.text}. \n` + | ||
46 | 'Ensure you have correctly configured PeerTube (config/ directory), in particular the "webserver" section.' | ||
47 | ); | ||
38 | } | 48 | } |
39 | ); | 49 | ); |
40 | 50 | ||
41 | // Return null if there is nothing to load | 51 | // Return null if there is nothing to load |
42 | this.user = User.load(); | 52 | this.user = AuthUser.load(); |
43 | } | 53 | } |
44 | 54 | ||
45 | getRefreshToken() { | 55 | getRefreshToken() { |
@@ -64,10 +74,16 @@ export class AuthService { | |||
64 | return this.user.getTokenType(); | 74 | return this.user.getTokenType(); |
65 | } | 75 | } |
66 | 76 | ||
67 | getUser(): User { | 77 | getUser(): AuthUser { |
68 | return this.user; | 78 | return this.user; |
69 | } | 79 | } |
70 | 80 | ||
81 | isAdmin() { | ||
82 | if (this.user === null) return false; | ||
83 | |||
84 | return this.user.isAdmin(); | ||
85 | } | ||
86 | |||
71 | isLoggedIn() { | 87 | isLoggedIn() { |
72 | if (this.getAccessToken()) { | 88 | if (this.getAccessToken()) { |
73 | return true; | 89 | return true; |
@@ -94,21 +110,23 @@ export class AuthService { | |||
94 | }; | 110 | }; |
95 | 111 | ||
96 | return this.http.post(AuthService.BASE_TOKEN_URL, body.toString(), options) | 112 | return this.http.post(AuthService.BASE_TOKEN_URL, body.toString(), options) |
97 | .map(res => res.json()) | 113 | .map(this.restExtractor.extractDataGet) |
98 | .map(res => { | 114 | .map(res => { |
99 | res.username = username; | 115 | res.username = username; |
100 | return res; | 116 | return res; |
101 | }) | 117 | }) |
118 | .flatMap(res => this.fetchUserInformations(res)) | ||
102 | .map(res => this.handleLogin(res)) | 119 | .map(res => this.handleLogin(res)) |
103 | .catch(this.handleError); | 120 | .catch((res) => this.restExtractor.handleError(res)); |
104 | } | 121 | } |
105 | 122 | ||
106 | logout() { | 123 | logout() { |
107 | // TODO: make an HTTP request to revoke the tokens | 124 | // TODO: make an HTTP request to revoke the tokens |
108 | this.user = null; | 125 | this.user = null; |
109 | User.flush(); | ||
110 | 126 | ||
111 | this.setStatus(AuthStatus.LoggedIn); | 127 | AuthUser.flush(); |
128 | |||
129 | this.setStatus(AuthStatus.LoggedOut); | ||
112 | } | 130 | } |
113 | 131 | ||
114 | refreshAccessToken() { | 132 | refreshAccessToken() { |
@@ -131,36 +149,64 @@ export class AuthService { | |||
131 | }; | 149 | }; |
132 | 150 | ||
133 | return this.http.post(AuthService.BASE_TOKEN_URL, body.toString(), options) | 151 | return this.http.post(AuthService.BASE_TOKEN_URL, body.toString(), options) |
134 | .map(res => res.json()) | 152 | .map(this.restExtractor.extractDataGet) |
135 | .map(res => this.handleRefreshToken(res)) | 153 | .map(res => this.handleRefreshToken(res)) |
136 | .catch(this.handleError); | 154 | .catch((res: Response) => { |
155 | // The refresh token is invalid? | ||
156 | if (res.status === 400 && res.json() && res.json().error === 'invalid_grant') { | ||
157 | console.error('Cannot refresh token -> logout...'); | ||
158 | this.logout(); | ||
159 | this.router.navigate(['/login']); | ||
160 | |||
161 | return Observable.throw({ | ||
162 | json: '', | ||
163 | text: 'You need to reconnect.' | ||
164 | }); | ||
165 | } | ||
166 | |||
167 | return this.restExtractor.handleError(res); | ||
168 | }); | ||
137 | } | 169 | } |
138 | 170 | ||
139 | private setStatus(status: AuthStatus) { | 171 | private fetchUserInformations (obj: any) { |
140 | this.loginChanged.next(status); | 172 | // Do not call authHttp here to avoid circular dependencies headaches |
173 | |||
174 | const headers = new Headers(); | ||
175 | headers.set('Authorization', `Bearer ${obj.access_token}`); | ||
176 | |||
177 | return this.http.get(AuthService.BASE_USER_INFORMATIONS_URL, { headers }) | ||
178 | .map(res => res.json()) | ||
179 | .map(res => { | ||
180 | obj.id = res.id; | ||
181 | obj.role = res.role; | ||
182 | return obj; | ||
183 | } | ||
184 | ); | ||
141 | } | 185 | } |
142 | 186 | ||
143 | private handleLogin (obj: any) { | 187 | private handleLogin (obj: any) { |
188 | const id = obj.id; | ||
144 | const username = obj.username; | 189 | const username = obj.username; |
145 | const hash_tokens = { | 190 | const role = obj.role; |
191 | const hashTokens = { | ||
146 | access_token: obj.access_token, | 192 | access_token: obj.access_token, |
147 | token_type: obj.token_type, | 193 | token_type: obj.token_type, |
148 | refresh_token: obj.refresh_token | 194 | refresh_token: obj.refresh_token |
149 | }; | 195 | }; |
150 | 196 | ||
151 | this.user = new User(username, hash_tokens); | 197 | this.user = new AuthUser({ id, username, role }, hashTokens); |
152 | this.user.save(); | 198 | this.user.save(); |
153 | 199 | ||
154 | this.setStatus(AuthStatus.LoggedIn); | 200 | this.setStatus(AuthStatus.LoggedIn); |
155 | } | 201 | } |
156 | 202 | ||
157 | private handleError (error: Response) { | ||
158 | console.error(error); | ||
159 | return Observable.throw(error.json() || { error: 'Server error' }); | ||
160 | } | ||
161 | |||
162 | private handleRefreshToken (obj: any) { | 203 | private handleRefreshToken (obj: any) { |
163 | this.user.refreshTokens(obj.access_token, obj.refresh_token); | 204 | this.user.refreshTokens(obj.access_token, obj.refresh_token); |
164 | this.user.save(); | 205 | this.user.save(); |
165 | } | 206 | } |
207 | |||
208 | private setStatus(status: AuthStatus) { | ||
209 | this.loginChanged.next(status); | ||
210 | } | ||
211 | |||
166 | } | 212 | } |
diff --git a/client/src/app/shared/auth/index.ts b/client/src/app/shared/auth/index.ts index aafaacbf1..ebd9e14cd 100644 --- a/client/src/app/shared/auth/index.ts +++ b/client/src/app/shared/auth/index.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | export * from './auth-http.service'; | 1 | export * from './auth-http.service'; |
2 | export * from './auth-status.model'; | 2 | export * from './auth-status.model'; |
3 | export * from './auth.service'; | 3 | export * from './auth.service'; |
4 | export * from './user.model'; | 4 | export * from './auth-user.model'; |
diff --git a/client/src/app/shared/forms/form-reactive.ts b/client/src/app/shared/forms/form-reactive.ts new file mode 100644 index 000000000..1e8a69771 --- /dev/null +++ b/client/src/app/shared/forms/form-reactive.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import { FormGroup } from '@angular/forms'; | ||
2 | |||
3 | export abstract class FormReactive { | ||
4 | abstract form: FormGroup; | ||
5 | abstract formErrors: Object; | ||
6 | abstract validationMessages: Object; | ||
7 | |||
8 | abstract buildForm(): void; | ||
9 | |||
10 | protected onValueChanged(data?: any) { | ||
11 | for (const field in this.formErrors) { | ||
12 | // clear previous error message (if any) | ||
13 | this.formErrors[field] = ''; | ||
14 | const control = this.form.get(field); | ||
15 | |||
16 | if (control && control.dirty && !control.valid) { | ||
17 | const messages = this.validationMessages[field]; | ||
18 | for (const key in control.errors) { | ||
19 | this.formErrors[field] += messages[key] + ' '; | ||
20 | } | ||
21 | } | ||
22 | } | ||
23 | } | ||
24 | } | ||
diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts new file mode 100644 index 000000000..1d2ae6f68 --- /dev/null +++ b/client/src/app/shared/forms/form-validators/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './url.validator'; | ||
2 | export * from './user'; | ||
3 | export * from './video'; | ||
diff --git a/client/src/app/shared/forms/form-validators/url.validator.ts b/client/src/app/shared/forms/form-validators/url.validator.ts new file mode 100644 index 000000000..67163b4e9 --- /dev/null +++ b/client/src/app/shared/forms/form-validators/url.validator.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | import { FormControl } from '@angular/forms'; | ||
2 | |||
3 | export function validateUrl(c: FormControl) { | ||
4 | let URL_REGEXP = new RegExp('^https?://(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$'); | ||
5 | |||
6 | return URL_REGEXP.test(c.value) ? null : { | ||
7 | validateUrl: { | ||
8 | valid: false | ||
9 | } | ||
10 | }; | ||
11 | } | ||
diff --git a/client/src/app/shared/forms/form-validators/user.ts b/client/src/app/shared/forms/form-validators/user.ts new file mode 100644 index 000000000..5b11ff265 --- /dev/null +++ b/client/src/app/shared/forms/form-validators/user.ts | |||
@@ -0,0 +1,17 @@ | |||
1 | import { Validators } from '@angular/forms'; | ||
2 | |||
3 | export const USER_USERNAME = { | ||
4 | VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(20) ], | ||
5 | MESSAGES: { | ||
6 | 'required': 'Username is required.', | ||
7 | 'minlength': 'Username must be at least 3 characters long.', | ||
8 | 'maxlength': 'Username cannot be more than 20 characters long.' | ||
9 | } | ||
10 | }; | ||
11 | export const USER_PASSWORD = { | ||
12 | VALIDATORS: [ Validators.required, Validators.minLength(6) ], | ||
13 | MESSAGES: { | ||
14 | 'required': 'Password is required.', | ||
15 | 'minlength': 'Password must be at least 6 characters long.', | ||
16 | } | ||
17 | }; | ||
diff --git a/client/src/app/shared/forms/form-validators/video.ts b/client/src/app/shared/forms/form-validators/video.ts new file mode 100644 index 000000000..3766d4018 --- /dev/null +++ b/client/src/app/shared/forms/form-validators/video.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { Validators } from '@angular/forms'; | ||
2 | |||
3 | export const VIDEO_NAME = { | ||
4 | VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(50) ], | ||
5 | MESSAGES: { | ||
6 | 'required': 'Video name is required.', | ||
7 | 'minlength': 'Video name must be at least 3 characters long.', | ||
8 | 'maxlength': 'Video name cannot be more than 50 characters long.' | ||
9 | } | ||
10 | }; | ||
11 | export const VIDEO_DESCRIPTION = { | ||
12 | VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(250) ], | ||
13 | MESSAGES: { | ||
14 | 'required': 'Video description is required.', | ||
15 | 'minlength': 'Video description must be at least 3 characters long.', | ||
16 | 'maxlength': 'Video description cannot be more than 250 characters long.' | ||
17 | } | ||
18 | }; | ||
19 | |||
20 | export const VIDEO_TAGS = { | ||
21 | VALIDATORS: [ Validators.pattern('^[a-zA-Z0-9]{2,10}$') ], | ||
22 | MESSAGES: { | ||
23 | 'pattern': 'A tag should be between 2 and 10 alphanumeric characters long.' | ||
24 | } | ||
25 | }; | ||
diff --git a/client/src/app/shared/forms/index.ts b/client/src/app/shared/forms/index.ts new file mode 100644 index 000000000..588ebb4be --- /dev/null +++ b/client/src/app/shared/forms/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './form-validators'; | ||
2 | export * from './form-reactive'; | ||
diff --git a/client/src/app/shared/index.ts b/client/src/app/shared/index.ts index dfea4c67c..af34b4b64 100644 --- a/client/src/app/shared/index.ts +++ b/client/src/app/shared/index.ts | |||
@@ -1,2 +1,5 @@ | |||
1 | export * from './auth'; | 1 | export * from './auth'; |
2 | export * from './forms'; | ||
3 | export * from './rest'; | ||
2 | export * from './search'; | 4 | export * from './search'; |
5 | export * from './users'; | ||
diff --git a/client/src/app/shared/rest/index.ts b/client/src/app/shared/rest/index.ts new file mode 100644 index 000000000..3c9509dc7 --- /dev/null +++ b/client/src/app/shared/rest/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './rest-extractor.service'; | ||
2 | export * from './rest-pagination'; | ||
3 | export * from './rest.service'; | ||
diff --git a/client/src/app/shared/rest/rest-extractor.service.ts b/client/src/app/shared/rest/rest-extractor.service.ts new file mode 100644 index 000000000..fcb1598f4 --- /dev/null +++ b/client/src/app/shared/rest/rest-extractor.service.ts | |||
@@ -0,0 +1,52 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | import { Response } from '@angular/http'; | ||
3 | import { Observable } from 'rxjs/Observable'; | ||
4 | |||
5 | export interface ResultList { | ||
6 | data: any[]; | ||
7 | total: number; | ||
8 | } | ||
9 | |||
10 | @Injectable() | ||
11 | export class RestExtractor { | ||
12 | |||
13 | constructor () { ; } | ||
14 | |||
15 | extractDataBool(res: Response) { | ||
16 | return true; | ||
17 | } | ||
18 | |||
19 | extractDataList(res: Response) { | ||
20 | const body = res.json(); | ||
21 | |||
22 | const ret: ResultList = { | ||
23 | data: body.data, | ||
24 | total: body.total | ||
25 | }; | ||
26 | |||
27 | return ret; | ||
28 | } | ||
29 | |||
30 | extractDataGet(res: Response) { | ||
31 | return res.json(); | ||
32 | } | ||
33 | |||
34 | handleError(res: Response) { | ||
35 | let text = 'Server error: '; | ||
36 | text += res.text(); | ||
37 | let json = ''; | ||
38 | |||
39 | try { | ||
40 | json = res.json(); | ||
41 | } catch (err) { ; } | ||
42 | |||
43 | const error = { | ||
44 | json, | ||
45 | text | ||
46 | }; | ||
47 | |||
48 | console.error(error); | ||
49 | |||
50 | return Observable.throw(error); | ||
51 | } | ||
52 | } | ||
diff --git a/client/src/app/videos/shared/pagination.model.ts b/client/src/app/shared/rest/rest-pagination.ts index eda44ebfb..0cfa4f468 100644 --- a/client/src/app/videos/shared/pagination.model.ts +++ b/client/src/app/shared/rest/rest-pagination.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | export interface Pagination { | 1 | export interface RestPagination { |
2 | currentPage: number; | 2 | currentPage: number; |
3 | itemsPerPage: number; | 3 | itemsPerPage: number; |
4 | totalItems: number; | 4 | totalItems: number; |
5 | } | 5 | }; |
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts new file mode 100644 index 000000000..16b47e957 --- /dev/null +++ b/client/src/app/shared/rest/rest.service.ts | |||
@@ -0,0 +1,27 @@ | |||
1 | import { Injectable } from '@angular/core'; | ||
2 | import { URLSearchParams } from '@angular/http'; | ||
3 | |||
4 | import { RestPagination } from './rest-pagination'; | ||
5 | |||
6 | @Injectable() | ||
7 | export class RestService { | ||
8 | |||
9 | buildRestGetParams(pagination?: RestPagination, sort?: string) { | ||
10 | const params = new URLSearchParams(); | ||
11 | |||
12 | if (pagination) { | ||
13 | const start: number = (pagination.currentPage - 1) * pagination.itemsPerPage; | ||
14 | const count: number = pagination.itemsPerPage; | ||
15 | |||
16 | params.set('start', start.toString()); | ||
17 | params.set('count', count.toString()); | ||
18 | } | ||
19 | |||
20 | if (sort) { | ||
21 | params.set('sort', sort); | ||
22 | } | ||
23 | |||
24 | return params; | ||
25 | } | ||
26 | |||
27 | } | ||
diff --git a/client/src/app/shared/search/search.component.ts b/client/src/app/shared/search/search.component.ts index e864fbc17..b6237469b 100644 --- a/client/src/app/shared/search/search.component.ts +++ b/client/src/app/shared/search/search.component.ts | |||
@@ -1,15 +1,13 @@ | |||
1 | import { Component, OnInit } from '@angular/core'; | 1 | import { Component, OnInit } from '@angular/core'; |
2 | 2 | import { Router } from '@angular/router'; | |
3 | import { DROPDOWN_DIRECTIVES} from 'ng2-bootstrap/components/dropdown'; | ||
4 | 3 | ||
5 | import { Search } from './search.model'; | 4 | import { Search } from './search.model'; |
6 | import { SearchField } from './search-field.type'; | 5 | import { SearchField } from './search-field.type'; |
7 | import { SearchService } from './search.service'; | 6 | import { SearchService } from './search.service'; |
8 | 7 | ||
9 | @Component({ | 8 | @Component({ |
10 | selector: 'my-search', | 9 | selector: 'my-search', |
11 | template: require('./search.component.html'), | 10 | templateUrl: './search.component.html' |
12 | directives: [ DROPDOWN_DIRECTIVES ] | ||
13 | }) | 11 | }) |
14 | 12 | ||
15 | export class SearchComponent implements OnInit { | 13 | export class SearchComponent implements OnInit { |
@@ -25,10 +23,10 @@ export class SearchComponent implements OnInit { | |||
25 | value: '' | 23 | value: '' |
26 | }; | 24 | }; |
27 | 25 | ||
28 | constructor(private searchService: SearchService) {} | 26 | constructor(private searchService: SearchService, private router: Router) {} |
29 | 27 | ||
30 | ngOnInit() { | 28 | ngOnInit() { |
31 | // Subscribe is the search changed | 29 | // Subscribe if the search changed |
32 | // Usually changed by videos list component | 30 | // Usually changed by videos list component |
33 | this.searchService.updateSearch.subscribe( | 31 | this.searchService.updateSearch.subscribe( |
34 | newSearchCriterias => { | 32 | newSearchCriterias => { |
@@ -58,6 +56,10 @@ export class SearchComponent implements OnInit { | |||
58 | } | 56 | } |
59 | 57 | ||
60 | doSearch() { | 58 | doSearch() { |
59 | if (this.router.url.indexOf('/videos/list') === -1) { | ||
60 | this.router.navigate([ '/videos/list' ]); | ||
61 | } | ||
62 | |||
61 | this.searchService.searchUpdated.next(this.searchCriterias); | 63 | this.searchService.searchUpdated.next(this.searchCriterias); |
62 | } | 64 | } |
63 | 65 | ||
diff --git a/client/src/app/shared/search/search.service.ts b/client/src/app/shared/search/search.service.ts index c7993db3d..717a7fa50 100644 --- a/client/src/app/shared/search/search.service.ts +++ b/client/src/app/shared/search/search.service.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | import { Injectable } from '@angular/core'; | 1 | import { Injectable } from '@angular/core'; |
2 | import { Subject } from 'rxjs/Subject'; | 2 | import { Subject } from 'rxjs/Subject'; |
3 | import { ReplaySubject } from 'rxjs/ReplaySubject'; | ||
3 | 4 | ||
4 | import { Search } from './search.model'; | 5 | import { Search } from './search.model'; |
5 | 6 | ||
@@ -12,6 +13,6 @@ export class SearchService { | |||
12 | 13 | ||
13 | constructor() { | 14 | constructor() { |
14 | this.updateSearch = new Subject<Search>(); | 15 | this.updateSearch = new Subject<Search>(); |
15 | this.searchUpdated = new Subject<Search>(); | 16 | this.searchUpdated = new ReplaySubject<Search>(1); |
16 | } | 17 | } |
17 | } | 18 | } |
diff --git a/client/src/app/shared/users/index.ts b/client/src/app/shared/users/index.ts new file mode 100644 index 000000000..5a670ce8f --- /dev/null +++ b/client/src/app/shared/users/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './user.model'; | |||
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..726495d11 --- /dev/null +++ b/client/src/app/shared/users/user.model.ts | |||
@@ -0,0 +1,20 @@ | |||
1 | export class User { | ||
2 | id: string; | ||
3 | username: string; | ||
4 | role: string; | ||
5 | createdDate: Date; | ||
6 | |||
7 | constructor(hash: { id: string, username: string, role: string, createdDate?: Date }) { | ||
8 | this.id = hash.id; | ||
9 | this.username = hash.username; | ||
10 | this.role = hash.role; | ||
11 | |||
12 | if (hash.createdDate) { | ||
13 | this.createdDate = hash.createdDate; | ||
14 | } | ||
15 | } | ||
16 | |||
17 | isAdmin() { | ||
18 | return this.role === 'admin'; | ||
19 | } | ||
20 | } | ||
diff --git a/client/src/app/videos/shared/index.ts b/client/src/app/videos/shared/index.ts index a54120f5d..67d16ead1 100644 --- a/client/src/app/videos/shared/index.ts +++ b/client/src/app/videos/shared/index.ts | |||
@@ -1,5 +1,4 @@ | |||
1 | export * from './loader'; | 1 | export * from './loader'; |
2 | export * from './pagination.model'; | ||
3 | export * from './sort-field.type'; | 2 | export * from './sort-field.type'; |
4 | export * from './video.model'; | 3 | export * from './video.model'; |
5 | export * from './video.service'; | 4 | export * from './video.service'; |
diff --git a/client/src/app/videos/shared/loader/loader.component.ts b/client/src/app/videos/shared/loader/loader.component.ts index cdd07d1b4..e72d2f3f3 100644 --- a/client/src/app/videos/shared/loader/loader.component.ts +++ b/client/src/app/videos/shared/loader/loader.component.ts | |||
@@ -2,8 +2,8 @@ import { Component, Input } from '@angular/core'; | |||
2 | 2 | ||
3 | @Component({ | 3 | @Component({ |
4 | selector: 'my-loader', | 4 | selector: 'my-loader', |
5 | styles: [ require('./loader.component.scss') ], | 5 | styleUrls: [ './loader.component.scss' ], |
6 | template: require('./loader.component.html') | 6 | templateUrl: './loader.component.html' |
7 | }) | 7 | }) |
8 | 8 | ||
9 | export class LoaderComponent { | 9 | export class LoaderComponent { |
diff --git a/client/src/app/videos/shared/video.service.ts b/client/src/app/videos/shared/video.service.ts index b4396f767..ad8557533 100644 --- a/client/src/app/videos/shared/video.service.ts +++ b/client/src/app/videos/shared/video.service.ts | |||
@@ -1,11 +1,10 @@ | |||
1 | import { Injectable } from '@angular/core'; | 1 | import { Injectable } from '@angular/core'; |
2 | import { Http, Response, URLSearchParams } from '@angular/http'; | 2 | import { Http } from '@angular/http'; |
3 | import { Observable } from 'rxjs/Observable'; | 3 | import { Observable } from 'rxjs/Observable'; |
4 | 4 | ||
5 | import { Pagination } from './pagination.model'; | ||
6 | import { Search } from '../../shared'; | 5 | import { Search } from '../../shared'; |
7 | import { SortField } from './sort-field.type'; | 6 | import { SortField } from './sort-field.type'; |
8 | import { AuthHttp, AuthService } from '../../shared'; | 7 | import { AuthHttp, AuthService, RestExtractor, RestPagination, RestService, ResultList } from '../../shared'; |
9 | import { Video } from './video.model'; | 8 | import { Video } from './video.model'; |
10 | 9 | ||
11 | @Injectable() | 10 | @Injectable() |
@@ -15,68 +14,51 @@ export class VideoService { | |||
15 | constructor( | 14 | constructor( |
16 | private authService: AuthService, | 15 | private authService: AuthService, |
17 | private authHttp: AuthHttp, | 16 | private authHttp: AuthHttp, |
18 | private http: Http | 17 | private http: Http, |
18 | private restExtractor: RestExtractor, | ||
19 | private restService: RestService | ||
19 | ) {} | 20 | ) {} |
20 | 21 | ||
21 | getVideo(id: string) { | 22 | getVideo(id: string): Observable<Video> { |
22 | return this.http.get(VideoService.BASE_VIDEO_URL + id) | 23 | return this.http.get(VideoService.BASE_VIDEO_URL + id) |
23 | .map(res => <Video> res.json()) | 24 | .map(this.restExtractor.extractDataGet) |
24 | .catch(this.handleError); | 25 | .catch((res) => this.restExtractor.handleError(res)); |
25 | } | 26 | } |
26 | 27 | ||
27 | getVideos(pagination: Pagination, sort: SortField) { | 28 | getVideos(pagination: RestPagination, sort: SortField) { |
28 | const params = this.createPaginationParams(pagination); | 29 | const params = this.restService.buildRestGetParams(pagination, sort); |
29 | |||
30 | if (sort) params.set('sort', sort); | ||
31 | 30 | ||
32 | return this.http.get(VideoService.BASE_VIDEO_URL, { search: params }) | 31 | return this.http.get(VideoService.BASE_VIDEO_URL, { search: params }) |
33 | .map(res => res.json()) | 32 | .map(res => res.json()) |
34 | .map(this.extractVideos) | 33 | .map(this.extractVideos) |
35 | .catch(this.handleError); | 34 | .catch((res) => this.restExtractor.handleError(res)); |
36 | } | 35 | } |
37 | 36 | ||
38 | removeVideo(id: string) { | 37 | removeVideo(id: string) { |
39 | return this.authHttp.delete(VideoService.BASE_VIDEO_URL + id) | 38 | return this.authHttp.delete(VideoService.BASE_VIDEO_URL + id) |
40 | .map(res => <number> res.status) | 39 | .map(this.restExtractor.extractDataBool) |
41 | .catch(this.handleError); | 40 | .catch((res) => this.restExtractor.handleError(res)); |
42 | } | 41 | } |
43 | 42 | ||
44 | searchVideos(search: Search, pagination: Pagination, sort: SortField) { | 43 | searchVideos(search: Search, pagination: RestPagination, sort: SortField) { |
45 | const params = this.createPaginationParams(pagination); | 44 | const params = this.restService.buildRestGetParams(pagination, sort); |
46 | 45 | ||
47 | if (search.field) params.set('field', search.field); | 46 | if (search.field) params.set('field', search.field); |
48 | if (sort) params.set('sort', sort); | ||
49 | 47 | ||
50 | return this.http.get(VideoService.BASE_VIDEO_URL + 'search/' + encodeURIComponent(search.value), { search: params }) | 48 | return this.http.get(VideoService.BASE_VIDEO_URL + 'search/' + encodeURIComponent(search.value), { search: params }) |
51 | .map(res => res.json()) | 49 | .map(this.restExtractor.extractDataList) |
52 | .map(this.extractVideos) | 50 | .map(this.extractVideos) |
53 | .catch(this.handleError); | 51 | .catch((res) => this.restExtractor.handleError(res)); |
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 | } | 52 | } |
66 | 53 | ||
67 | private extractVideos(body: any) { | 54 | private extractVideos(result: ResultList) { |
68 | const videos_json = body.data; | 55 | const videosJson = result.data; |
69 | const totalVideos = body.total; | 56 | const totalVideos = result.total; |
70 | const videos = []; | 57 | const videos = []; |
71 | for (const video_json of videos_json) { | 58 | for (const videoJson of videosJson) { |
72 | videos.push(new Video(video_json)); | 59 | videos.push(new Video(videoJson)); |
73 | } | 60 | } |
74 | 61 | ||
75 | return { videos, totalVideos }; | 62 | return { videos, totalVideos }; |
76 | } | 63 | } |
77 | |||
78 | private handleError(error: Response) { | ||
79 | console.error(error); | ||
80 | return Observable.throw(error.json().error || 'Server error'); | ||
81 | } | ||
82 | } | 64 | } |
diff --git a/client/src/app/videos/video-add/video-add.component.html b/client/src/app/videos/video-add/video-add.component.html index bcd78c7cb..64320cae7 100644 --- a/client/src/app/videos/video-add/video-add.component.html +++ b/client/src/app/videos/video-add/video-add.component.html | |||
@@ -2,31 +2,31 @@ | |||
2 | 2 | ||
3 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | 3 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> |
4 | 4 | ||
5 | <form novalidate (ngSubmit)="upload()" [ngFormModel]="videoForm"> | 5 | <form novalidate (ngSubmit)="upload()" [formGroup]="form"> |
6 | <div class="form-group"> | 6 | <div class="form-group"> |
7 | <label for="name">Name</label> | 7 | <label for="name">Name</label> |
8 | <input | 8 | <input |
9 | type="text" class="form-control" name="name" id="name" | 9 | type="text" class="form-control" id="name" |
10 | ngControl="name" #name="ngForm" [(ngModel)]="video.name" | 10 | formControlName="name" |
11 | > | 11 | > |
12 | <div [hidden]="name.valid || name.pristine" class="alert alert-warning"> | 12 | <div *ngIf="formErrors.name" class="alert alert-danger"> |
13 | A name is required and should be between 3 and 50 characters long | 13 | {{ formErrors.name }} |
14 | </div> | 14 | </div> |
15 | </div> | 15 | </div> |
16 | 16 | ||
17 | <div class="form-group"> | 17 | <div class="form-group"> |
18 | <label for="tags">Tags</label> | 18 | <label for="tags">Tags</label> |
19 | <input | 19 | <input |
20 | type="text" class="form-control" name="tags" id="tags" | 20 | type="text" class="form-control" id="currentTag" |
21 | ngControl="tags" #tags="ngForm" [disabled]="isTagsInputDisabled" (keyup)="onTagKeyPress($event)" [(ngModel)]="currentTag" | 21 | formControlName="currentTag" (keyup)="onTagKeyPress($event)" |
22 | > | 22 | > |
23 | <div [hidden]="tags.valid || tags.pristine" class="alert alert-warning"> | 23 | <div *ngIf="formErrors.currentTag" class="alert alert-danger"> |
24 | A tag should be between 2 and 10 characters (alphanumeric) long | 24 | {{ formErrors.currentTag }} |
25 | </div> | 25 | </div> |
26 | </div> | 26 | </div> |
27 | 27 | ||
28 | <div class="tags"> | 28 | <div class="tags"> |
29 | <div class="label label-primary tag" *ngFor="let tag of video.tags"> | 29 | <div class="label label-primary tag" *ngFor="let tag of tags"> |
30 | {{ tag }} | 30 | {{ tag }} |
31 | <span class="remove" (click)="removeTag(tag)">x</span> | 31 | <span class="remove" (click)="removeTag(tag)">x</span> |
32 | </div> | 32 | </div> |
@@ -53,12 +53,12 @@ | |||
53 | <div class="form-group"> | 53 | <div class="form-group"> |
54 | <label for="description">Description</label> | 54 | <label for="description">Description</label> |
55 | <textarea | 55 | <textarea |
56 | name="description" id="description" class="form-control" placeholder="Description..." | 56 | id="description" class="form-control" placeholder="Description..." |
57 | ngControl="description" #description="ngForm" [(ngModel)]="video.description" | 57 | formControlName="description" |
58 | > | 58 | > |
59 | </textarea> | 59 | </textarea> |
60 | <div [hidden]="description.valid || description.pristine" class="alert alert-warning"> | 60 | <div *ngIf="formErrors.description" class="alert alert-danger"> |
61 | A description is required and should be between 3 and 250 characters long | 61 | {{ formErrors.description }} |
62 | </div> | 62 | </div> |
63 | </div> | 63 | </div> |
64 | 64 | ||
@@ -69,7 +69,7 @@ | |||
69 | <div class="form-group"> | 69 | <div class="form-group"> |
70 | <input | 70 | <input |
71 | type="submit" value="Upload" class="btn btn-default form-control" [title]="getInvalidFieldsTitle()" | 71 | type="submit" value="Upload" class="btn btn-default form-control" [title]="getInvalidFieldsTitle()" |
72 | [disabled]="!videoForm.valid || video.tags.length === 0 || filename === null" | 72 | [disabled]="!form.valid || tags.length === 0 || filename === null" |
73 | > | 73 | > |
74 | </div> | 74 | </div> |
75 | </form> | 75 | </form> |
diff --git a/client/src/app/videos/video-add/video-add.component.ts b/client/src/app/videos/video-add/video-add.component.ts index c0f8cb9c4..d12a7d572 100644 --- a/client/src/app/videos/video-add/video-add.component.ts +++ b/client/src/app/videos/video-add/video-add.component.ts | |||
@@ -1,37 +1,42 @@ | |||
1 | import { Control, ControlGroup, Validators } from '@angular/common'; | ||
2 | import { Component, ElementRef, OnInit } from '@angular/core'; | 1 | import { Component, ElementRef, OnInit } from '@angular/core'; |
2 | import { FormBuilder, FormGroup } from '@angular/forms'; | ||
3 | import { Router } from '@angular/router'; | 3 | import { Router } from '@angular/router'; |
4 | 4 | ||
5 | import { BytesPipe } from 'angular-pipes/src/math/bytes.pipe'; | 5 | import { FileUploader } from 'ng2-file-upload/ng2-file-upload'; |
6 | import { PROGRESSBAR_DIRECTIVES } from 'ng2-bootstrap/components/progressbar'; | ||
7 | import { FileSelectDirective, FileUploader } from 'ng2-file-upload/ng2-file-upload'; | ||
8 | 6 | ||
9 | import { AuthService } from '../../shared'; | 7 | import { AuthService, FormReactive, VIDEO_NAME, VIDEO_DESCRIPTION, VIDEO_TAGS } from '../../shared'; |
10 | 8 | ||
11 | @Component({ | 9 | @Component({ |
12 | selector: 'my-videos-add', | 10 | selector: 'my-videos-add', |
13 | styles: [ require('./video-add.component.scss') ], | 11 | styleUrls: [ './video-add.component.scss' ], |
14 | template: require('./video-add.component.html'), | 12 | templateUrl: './video-add.component.html' |
15 | directives: [ FileSelectDirective, PROGRESSBAR_DIRECTIVES ], | ||
16 | pipes: [ BytesPipe ] | ||
17 | }) | 13 | }) |
18 | 14 | ||
19 | export class VideoAddComponent implements OnInit { | 15 | export class VideoAddComponent extends FormReactive implements OnInit { |
20 | currentTag: string; // Tag the user is writing in the input | 16 | tags: string[] = []; |
21 | error: string = null; | ||
22 | videoForm: ControlGroup; | ||
23 | uploader: FileUploader; | 17 | uploader: FileUploader; |
24 | video = { | 18 | |
19 | error: string = null; | ||
20 | form: FormGroup; | ||
21 | formErrors = { | ||
25 | name: '', | 22 | name: '', |
26 | tags: [], | 23 | description: '', |
27 | description: '' | 24 | currentTag: '' |
25 | }; | ||
26 | validationMessages = { | ||
27 | name: VIDEO_NAME.MESSAGES, | ||
28 | description: VIDEO_DESCRIPTION.MESSAGES, | ||
29 | currentTag: VIDEO_TAGS.MESSAGES | ||
28 | }; | 30 | }; |
29 | 31 | ||
30 | constructor( | 32 | constructor( |
31 | private authService: AuthService, | 33 | private authService: AuthService, |
32 | private elementRef: ElementRef, | 34 | private elementRef: ElementRef, |
35 | private formBuilder: FormBuilder, | ||
33 | private router: Router | 36 | private router: Router |
34 | ) {} | 37 | ) { |
38 | super(); | ||
39 | } | ||
35 | 40 | ||
36 | get filename() { | 41 | get filename() { |
37 | if (this.uploader.queue.length === 0) { | 42 | if (this.uploader.queue.length === 0) { |
@@ -41,20 +46,26 @@ export class VideoAddComponent implements OnInit { | |||
41 | return this.uploader.queue[0].file.name; | 46 | return this.uploader.queue[0].file.name; |
42 | } | 47 | } |
43 | 48 | ||
44 | get isTagsInputDisabled () { | 49 | buildForm() { |
45 | return this.video.tags.length >= 3; | 50 | this.form = this.formBuilder.group({ |
51 | name: [ '', VIDEO_NAME.VALIDATORS ], | ||
52 | description: [ '', VIDEO_DESCRIPTION.VALIDATORS ], | ||
53 | currentTag: [ '', VIDEO_TAGS.VALIDATORS ] | ||
54 | }); | ||
55 | |||
56 | this.form.valueChanges.subscribe(data => this.onValueChanged(data)); | ||
46 | } | 57 | } |
47 | 58 | ||
48 | getInvalidFieldsTitle() { | 59 | getInvalidFieldsTitle() { |
49 | let title = ''; | 60 | let title = ''; |
50 | const nameControl = this.videoForm.controls['name']; | 61 | const nameControl = this.form.controls['name']; |
51 | const descriptionControl = this.videoForm.controls['description']; | 62 | const descriptionControl = this.form.controls['description']; |
52 | 63 | ||
53 | if (!nameControl.valid) { | 64 | if (!nameControl.valid) { |
54 | title += 'A name is required\n'; | 65 | title += 'A name is required\n'; |
55 | } | 66 | } |
56 | 67 | ||
57 | if (this.video.tags.length === 0) { | 68 | if (this.tags.length === 0) { |
58 | title += 'At least one tag is required\n'; | 69 | title += 'At least one tag is required\n'; |
59 | } | 70 | } |
60 | 71 | ||
@@ -70,13 +81,6 @@ export class VideoAddComponent implements OnInit { | |||
70 | } | 81 | } |
71 | 82 | ||
72 | ngOnInit() { | 83 | ngOnInit() { |
73 | this.videoForm = new ControlGroup({ | ||
74 | name: new Control('', Validators.compose([ Validators.required, Validators.minLength(3), Validators.maxLength(50) ])), | ||
75 | description: new Control('', Validators.compose([ Validators.required, Validators.minLength(3), Validators.maxLength(250) ])), | ||
76 | tags: new Control('', Validators.pattern('^[a-zA-Z0-9]{2,10}$')) | ||
77 | }); | ||
78 | |||
79 | |||
80 | this.uploader = new FileUploader({ | 84 | this.uploader = new FileUploader({ |
81 | authToken: this.authService.getRequestHeaderValue(), | 85 | authToken: this.authService.getRequestHeaderValue(), |
82 | queueLimit: 1, | 86 | queueLimit: 1, |
@@ -85,26 +89,37 @@ export class VideoAddComponent implements OnInit { | |||
85 | }); | 89 | }); |
86 | 90 | ||
87 | this.uploader.onBuildItemForm = (item, form) => { | 91 | this.uploader.onBuildItemForm = (item, form) => { |
88 | form.append('name', this.video.name); | 92 | const name = this.form.value['name']; |
89 | form.append('description', this.video.description); | 93 | const description = this.form.value['description']; |
94 | |||
95 | form.append('name', name); | ||
96 | form.append('description', description); | ||
90 | 97 | ||
91 | for (let i = 0; i < this.video.tags.length; i++) { | 98 | for (let i = 0; i < this.tags.length; i++) { |
92 | form.append(`tags[${i}]`, this.video.tags[i]); | 99 | form.append(`tags[${i}]`, this.tags[i]); |
93 | } | 100 | } |
94 | }; | 101 | }; |
102 | |||
103 | this.buildForm(); | ||
95 | } | 104 | } |
96 | 105 | ||
97 | onTagKeyPress(event: KeyboardEvent) { | 106 | onTagKeyPress(event: KeyboardEvent) { |
107 | const currentTag = this.form.value['currentTag']; | ||
108 | |||
98 | // Enter press | 109 | // Enter press |
99 | if (event.keyCode === 13) { | 110 | if (event.keyCode === 13) { |
100 | // Check if the tag is valid and does not already exist | 111 | // Check if the tag is valid and does not already exist |
101 | if ( | 112 | if ( |
102 | this.currentTag !== '' && | 113 | currentTag !== '' && |
103 | this.videoForm.controls['tags'].valid && | 114 | this.form.controls['currentTag'].valid && |
104 | this.video.tags.indexOf(this.currentTag) === -1 | 115 | this.tags.indexOf(currentTag) === -1 |
105 | ) { | 116 | ) { |
106 | this.video.tags.push(this.currentTag); | 117 | this.tags.push(currentTag); |
107 | this.currentTag = ''; | 118 | this.form.patchValue({ currentTag: '' }); |
119 | |||
120 | if (this.tags.length >= 3) { | ||
121 | this.form.get('currentTag').disable(); | ||
122 | } | ||
108 | } | 123 | } |
109 | } | 124 | } |
110 | } | 125 | } |
@@ -114,7 +129,7 @@ export class VideoAddComponent implements OnInit { | |||
114 | } | 129 | } |
115 | 130 | ||
116 | removeTag(tag: string) { | 131 | removeTag(tag: string) { |
117 | this.video.tags.splice(this.video.tags.indexOf(tag), 1); | 132 | this.tags.splice(this.tags.indexOf(tag), 1); |
118 | } | 133 | } |
119 | 134 | ||
120 | upload() { | 135 | upload() { |
diff --git a/client/src/app/videos/video-list/video-list.component.ts b/client/src/app/videos/video-list/video-list.component.ts index 5691d684e..6b086e938 100644 --- a/client/src/app/videos/video-list/video-list.component.ts +++ b/client/src/app/videos/video-list/video-list.component.ts | |||
@@ -1,39 +1,30 @@ | |||
1 | import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; | 1 | import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
2 | import { AsyncPipe } from '@angular/common'; | 2 | import { ActivatedRoute, Router } from '@angular/router'; |
3 | import { ActivatedRoute, Router, ROUTER_DIRECTIVES } from '@angular/router'; | ||
4 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; | 3 | import { BehaviorSubject } from 'rxjs/BehaviorSubject'; |
5 | 4 | ||
6 | import { PAGINATION_DIRECTIVES } from 'ng2-bootstrap/components/pagination'; | ||
7 | |||
8 | import { | 5 | import { |
9 | LoaderComponent, | ||
10 | Pagination, | ||
11 | SortField, | 6 | SortField, |
12 | Video, | 7 | Video, |
13 | VideoService | 8 | VideoService |
14 | } from '../shared'; | 9 | } from '../shared'; |
15 | import { AuthService, Search, SearchField, User } from '../../shared'; | 10 | import { AuthService, AuthUser, RestPagination, Search, SearchField } from '../../shared'; |
16 | import { VideoMiniatureComponent } from './video-miniature.component'; | ||
17 | import { VideoSortComponent } from './video-sort.component'; | ||
18 | import { SearchService } from '../../shared'; | 11 | import { SearchService } from '../../shared'; |
19 | 12 | ||
20 | @Component({ | 13 | @Component({ |
21 | selector: 'my-videos-list', | 14 | selector: 'my-videos-list', |
22 | styles: [ require('./video-list.component.scss') ], | 15 | styleUrls: [ './video-list.component.scss' ], |
23 | pipes: [ AsyncPipe ], | 16 | templateUrl: './video-list.component.html' |
24 | template: require('./video-list.component.html'), | ||
25 | directives: [ LoaderComponent, PAGINATION_DIRECTIVES, ROUTER_DIRECTIVES, VideoMiniatureComponent, VideoSortComponent ] | ||
26 | }) | 17 | }) |
27 | 18 | ||
28 | export class VideoListComponent implements OnInit, OnDestroy { | 19 | export class VideoListComponent implements OnInit, OnDestroy { |
29 | loading: BehaviorSubject<boolean> = new BehaviorSubject(false); | 20 | loading: BehaviorSubject<boolean> = new BehaviorSubject(false); |
30 | pagination: Pagination = { | 21 | pagination: RestPagination = { |
31 | currentPage: 1, | 22 | currentPage: 1, |
32 | itemsPerPage: 9, | 23 | itemsPerPage: 9, |
33 | totalItems: null | 24 | totalItems: null |
34 | }; | 25 | }; |
35 | sort: SortField; | 26 | sort: SortField; |
36 | user: User = null; | 27 | user: AuthUser = null; |
37 | videos: Video[] = []; | 28 | videos: Video[] = []; |
38 | 29 | ||
39 | private search: Search; | 30 | private search: Search; |
@@ -51,7 +42,7 @@ export class VideoListComponent implements OnInit, OnDestroy { | |||
51 | 42 | ||
52 | ngOnInit() { | 43 | ngOnInit() { |
53 | if (this.authService.isLoggedIn()) { | 44 | if (this.authService.isLoggedIn()) { |
54 | this.user = User.load(); | 45 | this.user = AuthUser.load(); |
55 | } | 46 | } |
56 | 47 | ||
57 | // Subscribe to route changes | 48 | // Subscribe to route changes |
@@ -66,6 +57,8 @@ export class VideoListComponent implements OnInit, OnDestroy { | |||
66 | // Subscribe to search changes | 57 | // Subscribe to search changes |
67 | this.subSearch = this.searchService.searchUpdated.subscribe(search => { | 58 | this.subSearch = this.searchService.searchUpdated.subscribe(search => { |
68 | this.search = search; | 59 | this.search = search; |
60 | // Reset pagination | ||
61 | this.pagination.currentPage = 1; | ||
69 | 62 | ||
70 | this.navigateToNewParams(); | 63 | this.navigateToNewParams(); |
71 | }); | 64 | }); |
@@ -76,7 +69,7 @@ export class VideoListComponent implements OnInit, OnDestroy { | |||
76 | this.subSearch.unsubscribe(); | 69 | this.subSearch.unsubscribe(); |
77 | } | 70 | } |
78 | 71 | ||
79 | getVideos(detectChanges = true) { | 72 | getVideos() { |
80 | this.loading.next(true); | 73 | this.loading.next(true); |
81 | this.videos = []; | 74 | this.videos = []; |
82 | 75 | ||
@@ -97,7 +90,7 @@ export class VideoListComponent implements OnInit, OnDestroy { | |||
97 | 90 | ||
98 | this.loading.next(false); | 91 | this.loading.next(false); |
99 | }, | 92 | }, |
100 | error => alert(error) | 93 | error => alert(error.text) |
101 | ); | 94 | ); |
102 | } | 95 | } |
103 | 96 | ||
@@ -153,7 +146,11 @@ export class VideoListComponent implements OnInit, OnDestroy { | |||
153 | 146 | ||
154 | this.sort = <SortField>routeParams['sort'] || '-createdDate'; | 147 | this.sort = <SortField>routeParams['sort'] || '-createdDate'; |
155 | 148 | ||
156 | this.pagination.currentPage = parseInt(routeParams['page']) || 1; | 149 | if (routeParams['page'] !== undefined) { |
150 | this.pagination.currentPage = parseInt(routeParams['page']); | ||
151 | } else { | ||
152 | this.pagination.currentPage = 1; | ||
153 | } | ||
157 | 154 | ||
158 | this.changeDetector.detectChanges(); | 155 | this.changeDetector.detectChanges(); |
159 | } | 156 | } |
diff --git a/client/src/app/videos/video-list/video-miniature.component.ts b/client/src/app/videos/video-list/video-miniature.component.ts index 84bab950e..398d2db75 100644 --- a/client/src/app/videos/video-list/video-miniature.component.ts +++ b/client/src/app/videos/video-list/video-miniature.component.ts | |||
@@ -1,16 +1,12 @@ | |||
1 | import { DatePipe } from '@angular/common'; | ||
2 | import { Component, Input, Output, EventEmitter } from '@angular/core'; | 1 | import { Component, Input, Output, EventEmitter } from '@angular/core'; |
3 | import { ROUTER_DIRECTIVES } from '@angular/router'; | ||
4 | 2 | ||
5 | import { SortField, Video, VideoService } from '../shared'; | 3 | import { SortField, Video, VideoService } from '../shared'; |
6 | import { User } from '../../shared'; | 4 | import { User } from '../../shared'; |
7 | 5 | ||
8 | @Component({ | 6 | @Component({ |
9 | selector: 'my-video-miniature', | 7 | selector: 'my-video-miniature', |
10 | styles: [ require('./video-miniature.component.scss') ], | 8 | styleUrls: [ './video-miniature.component.scss' ], |
11 | template: require('./video-miniature.component.html'), | 9 | templateUrl: './video-miniature.component.html' |
12 | directives: [ ROUTER_DIRECTIVES ], | ||
13 | pipes: [ DatePipe ] | ||
14 | }) | 10 | }) |
15 | 11 | ||
16 | export class VideoMiniatureComponent { | 12 | export class VideoMiniatureComponent { |
@@ -40,7 +36,7 @@ export class VideoMiniatureComponent { | |||
40 | if (confirm('Do you really want to remove this video?')) { | 36 | if (confirm('Do you really want to remove this video?')) { |
41 | this.videoService.removeVideo(id).subscribe( | 37 | this.videoService.removeVideo(id).subscribe( |
42 | status => this.removed.emit(true), | 38 | status => this.removed.emit(true), |
43 | error => alert(error) | 39 | error => alert(error.text) |
44 | ); | 40 | ); |
45 | } | 41 | } |
46 | } | 42 | } |
diff --git a/client/src/app/videos/video-list/video-sort.component.ts b/client/src/app/videos/video-list/video-sort.component.ts index 0d76b54b7..ca94b07c2 100644 --- a/client/src/app/videos/video-list/video-sort.component.ts +++ b/client/src/app/videos/video-list/video-sort.component.ts | |||
@@ -4,7 +4,7 @@ import { SortField } from '../shared'; | |||
4 | 4 | ||
5 | @Component({ | 5 | @Component({ |
6 | selector: 'my-video-sort', | 6 | selector: 'my-video-sort', |
7 | template: require('./video-sort.component.html') | 7 | templateUrl: './video-sort.component.html' |
8 | }) | 8 | }) |
9 | 9 | ||
10 | export class VideoSortComponent { | 10 | export class VideoSortComponent { |
diff --git a/client/src/app/videos/video-watch/video-watch.component.ts b/client/src/app/videos/video-watch/video-watch.component.ts index 3aaed0487..239e24c99 100644 --- a/client/src/app/videos/video-watch/video-watch.component.ts +++ b/client/src/app/videos/video-watch/video-watch.component.ts | |||
@@ -1,18 +1,13 @@ | |||
1 | import { Component, ElementRef, OnDestroy, OnInit } from '@angular/core'; | 1 | import { Component, ElementRef, NgZone, OnDestroy, OnInit } from '@angular/core'; |
2 | import { ActivatedRoute } from '@angular/router'; | 2 | import { ActivatedRoute } from '@angular/router'; |
3 | 3 | ||
4 | import { BytesPipe } from 'angular-pipes/src/math/bytes.pipe'; | 4 | import { Video, VideoService } from '../shared'; |
5 | |||
6 | import { LoaderComponent, Video, VideoService } from '../shared'; | ||
7 | import { WebTorrentService } from './webtorrent.service'; | 5 | import { WebTorrentService } from './webtorrent.service'; |
8 | 6 | ||
9 | @Component({ | 7 | @Component({ |
10 | selector: 'my-video-watch', | 8 | selector: 'my-video-watch', |
11 | template: require('./video-watch.component.html'), | 9 | templateUrl: './video-watch.component.html', |
12 | styles: [ require('./video-watch.component.scss') ], | 10 | styleUrls: [ './video-watch.component.scss' ] |
13 | providers: [ WebTorrentService ], | ||
14 | directives: [ LoaderComponent ], | ||
15 | pipes: [ BytesPipe ] | ||
16 | }) | 11 | }) |
17 | 12 | ||
18 | export class VideoWatchComponent implements OnInit, OnDestroy { | 13 | export class VideoWatchComponent implements OnInit, OnDestroy { |
@@ -31,6 +26,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
31 | 26 | ||
32 | constructor( | 27 | constructor( |
33 | private elementRef: ElementRef, | 28 | private elementRef: ElementRef, |
29 | private ngZone: NgZone, | ||
34 | private route: ActivatedRoute, | 30 | private route: ActivatedRoute, |
35 | private videoService: VideoService, | 31 | private videoService: VideoService, |
36 | private webTorrentService: WebTorrentService | 32 | private webTorrentService: WebTorrentService |
@@ -65,12 +61,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
65 | } | 61 | } |
66 | }); | 62 | }); |
67 | 63 | ||
68 | // Refresh each second | 64 | this.runInProgress(torrent); |
69 | this.torrentInfosInterval = setInterval(() => { | ||
70 | this.downloadSpeed = torrent.downloadSpeed; | ||
71 | this.numPeers = torrent.numPeers; | ||
72 | this.uploadSpeed = torrent.uploadSpeed; | ||
73 | }, 1000); | ||
74 | }); | 65 | }); |
75 | } | 66 | } |
76 | 67 | ||
@@ -91,7 +82,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
91 | this.video = video; | 82 | this.video = video; |
92 | this.loadVideo(); | 83 | this.loadVideo(); |
93 | }, | 84 | }, |
94 | error => alert(error) | 85 | error => alert(error.text) |
95 | ); | 86 | ); |
96 | }); | 87 | }); |
97 | } | 88 | } |
@@ -100,4 +91,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
100 | this.error = true; | 91 | this.error = true; |
101 | console.error('The video load seems to be abnormally long.'); | 92 | console.error('The video load seems to be abnormally long.'); |
102 | } | 93 | } |
94 | |||
95 | private runInProgress(torrent: any) { | ||
96 | // Refresh each second | ||
97 | this.torrentInfosInterval = setInterval(() => { | ||
98 | this.ngZone.run(() => { | ||
99 | this.downloadSpeed = torrent.downloadSpeed; | ||
100 | this.numPeers = torrent.numPeers; | ||
101 | this.uploadSpeed = torrent.uploadSpeed; | ||
102 | }); | ||
103 | }, 1000); | ||
104 | } | ||
103 | } | 105 | } |
diff --git a/client/src/app/videos/videos.component.ts b/client/src/app/videos/videos.component.ts index 76252afbb..591e7523d 100644 --- a/client/src/app/videos/videos.component.ts +++ b/client/src/app/videos/videos.component.ts | |||
@@ -1,9 +1,7 @@ | |||
1 | import { Component } from '@angular/core'; | 1 | import { Component } from '@angular/core'; |
2 | import { ROUTER_DIRECTIVES } from '@angular/router'; | ||
3 | 2 | ||
4 | @Component({ | 3 | @Component({ |
5 | template: '<router-outlet></router-outlet>', | 4 | template: '<router-outlet></router-outlet>' |
6 | directives: [ ROUTER_DIRECTIVES ] | ||
7 | }) | 5 | }) |
8 | 6 | ||
9 | export class VideosComponent { | 7 | export class VideosComponent { |
diff --git a/client/src/app/videos/videos.routes.ts b/client/src/app/videos/videos.routes.ts index 1f088b376..074f96596 100644 --- a/client/src/app/videos/videos.routes.ts +++ b/client/src/app/videos/videos.routes.ts | |||
@@ -1,11 +1,11 @@ | |||
1 | import { RouterConfig } from '@angular/router'; | 1 | import { Routes } from '@angular/router'; |
2 | 2 | ||
3 | import { VideoAddComponent } from './video-add'; | 3 | import { VideoAddComponent } from './video-add'; |
4 | import { VideoListComponent } from './video-list'; | 4 | import { VideoListComponent } from './video-list'; |
5 | import { VideosComponent } from './videos.component'; | 5 | import { VideosComponent } from './videos.component'; |
6 | import { VideoWatchComponent } from './video-watch'; | 6 | import { VideoWatchComponent } from './video-watch'; |
7 | 7 | ||
8 | export const VideosRoutes: RouterConfig = [ | 8 | export const VideosRoutes: Routes = [ |
9 | { | 9 | { |
10 | path: 'videos', | 10 | path: 'videos', |
11 | component: VideosComponent, | 11 | component: VideosComponent, |