diff options
Diffstat (limited to 'client/src/app/shared')
37 files changed, 918 insertions, 237 deletions
diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts new file mode 100644 index 000000000..0b008188a --- /dev/null +++ b/client/src/app/shared/account/account.model.ts | |||
@@ -0,0 +1,20 @@ | |||
1 | import { Account as ServerAccount } from '../../../../../shared/models/accounts/account.model' | ||
2 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | ||
3 | |||
4 | export class Account implements ServerAccount { | ||
5 | id: number | ||
6 | uuid: string | ||
7 | name: string | ||
8 | host: string | ||
9 | followingCount: number | ||
10 | followersCount: number | ||
11 | createdAt: Date | ||
12 | updatedAt: Date | ||
13 | avatar: Avatar | ||
14 | |||
15 | static GET_ACCOUNT_AVATAR_PATH (account: Account) { | ||
16 | if (account && account.avatar) return account.avatar.path | ||
17 | |||
18 | return API_URL + '/client/assets/images/default-avatar.png' | ||
19 | } | ||
20 | } | ||
diff --git a/client/src/app/shared/forms/form-validators/host.validator.ts b/client/src/app/shared/forms/form-validators/host.validator.ts index 03e810fdb..c18a35f9b 100644 --- a/client/src/app/shared/forms/form-validators/host.validator.ts +++ b/client/src/app/shared/forms/form-validators/host.validator.ts | |||
@@ -1,14 +1,8 @@ | |||
1 | import { FormControl } from '@angular/forms' | 1 | export function validateHost (value: string) { |
2 | |||
3 | export function validateHost (c: FormControl) { | ||
4 | // Thanks to http://stackoverflow.com/a/106223 | 2 | // Thanks to http://stackoverflow.com/a/106223 |
5 | const HOST_REGEXP = new RegExp( | 3 | const HOST_REGEXP = new RegExp( |
6 | '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$' | 4 | '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$' |
7 | ) | 5 | ) |
8 | 6 | ||
9 | return HOST_REGEXP.test(c.value) ? null : { | 7 | return HOST_REGEXP.test(value) |
10 | validateHost: { | ||
11 | valid: false | ||
12 | } | ||
13 | } | ||
14 | } | 8 | } |
diff --git a/client/src/app/shared/forms/form-validators/video-abuse.ts b/client/src/app/shared/forms/form-validators/video-abuse.ts index 3c7f26205..4b2a2b789 100644 --- a/client/src/app/shared/forms/form-validators/video-abuse.ts +++ b/client/src/app/shared/forms/form-validators/video-abuse.ts | |||
@@ -3,8 +3,8 @@ import { Validators } from '@angular/forms' | |||
3 | export const VIDEO_ABUSE_REASON = { | 3 | export const VIDEO_ABUSE_REASON = { |
4 | VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(300) ], | 4 | VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(300) ], |
5 | MESSAGES: { | 5 | MESSAGES: { |
6 | 'required': 'Report reason name is required.', | 6 | 'required': 'Report reason is required.', |
7 | 'minlength': 'Report reson must be at least 2 characters long.', | 7 | 'minlength': 'Report reason must be at least 2 characters long.', |
8 | 'maxlength': 'Report reson cannot be more than 300 characters long.' | 8 | 'maxlength': 'Report reason cannot be more than 300 characters long.' |
9 | } | 9 | } |
10 | } | 10 | } |
diff --git a/client/src/app/shared/forms/form-validators/video.ts b/client/src/app/shared/forms/form-validators/video.ts index 65f11f5da..45da7df4a 100644 --- a/client/src/app/shared/forms/form-validators/video.ts +++ b/client/src/app/shared/forms/form-validators/video.ts | |||
@@ -1,5 +1,11 @@ | |||
1 | import { Validators } from '@angular/forms' | 1 | import { Validators } from '@angular/forms' |
2 | 2 | ||
3 | export type ValidatorMessage = { | ||
4 | [ id: string ]: { | ||
5 | [ error: string ]: string | ||
6 | } | ||
7 | } | ||
8 | |||
3 | export const VIDEO_NAME = { | 9 | export const VIDEO_NAME = { |
4 | VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(120) ], | 10 | VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(120) ], |
5 | MESSAGES: { | 11 | MESSAGES: { |
@@ -17,17 +23,13 @@ export const VIDEO_PRIVACY = { | |||
17 | } | 23 | } |
18 | 24 | ||
19 | export const VIDEO_CATEGORY = { | 25 | export const VIDEO_CATEGORY = { |
20 | VALIDATORS: [ Validators.required ], | 26 | VALIDATORS: [ ], |
21 | MESSAGES: { | 27 | MESSAGES: {} |
22 | 'required': 'Video category is required.' | ||
23 | } | ||
24 | } | 28 | } |
25 | 29 | ||
26 | export const VIDEO_LICENCE = { | 30 | export const VIDEO_LICENCE = { |
27 | VALIDATORS: [ Validators.required ], | 31 | VALIDATORS: [ ], |
28 | MESSAGES: { | 32 | MESSAGES: {} |
29 | 'required': 'Video licence is required.' | ||
30 | } | ||
31 | } | 33 | } |
32 | 34 | ||
33 | export const VIDEO_LANGUAGE = { | 35 | export const VIDEO_LANGUAGE = { |
@@ -43,9 +45,8 @@ export const VIDEO_CHANNEL = { | |||
43 | } | 45 | } |
44 | 46 | ||
45 | export const VIDEO_DESCRIPTION = { | 47 | export const VIDEO_DESCRIPTION = { |
46 | VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(3000) ], | 48 | VALIDATORS: [ Validators.minLength(3), Validators.maxLength(3000) ], |
47 | MESSAGES: { | 49 | MESSAGES: { |
48 | 'required': 'Video description is required.', | ||
49 | 'minlength': 'Video description must be at least 3 characters long.', | 50 | 'minlength': 'Video description must be at least 3 characters long.', |
50 | 'maxlength': 'Video description cannot be more than 3000 characters long.' | 51 | 'maxlength': 'Video description cannot be more than 3000 characters long.' |
51 | } | 52 | } |
@@ -58,10 +59,3 @@ export const VIDEO_TAGS = { | |||
58 | 'maxlength': 'A tag should be less than 30 characters long.' | 59 | 'maxlength': 'A tag should be less than 30 characters long.' |
59 | } | 60 | } |
60 | } | 61 | } |
61 | |||
62 | export const VIDEO_FILE = { | ||
63 | VALIDATORS: [ Validators.required ], | ||
64 | MESSAGES: { | ||
65 | 'required': 'Video file is required.' | ||
66 | } | ||
67 | } | ||
diff --git a/client/src/app/shared/index.ts b/client/src/app/shared/index.ts index 79bf5ef43..413dda16a 100644 --- a/client/src/app/shared/index.ts +++ b/client/src/app/shared/index.ts | |||
@@ -1,7 +1,6 @@ | |||
1 | export * from './auth' | 1 | export * from './auth' |
2 | export * from './forms' | 2 | export * from './forms' |
3 | export * from './rest' | 3 | export * from './rest' |
4 | export * from './search' | ||
5 | export * from './users' | 4 | export * from './users' |
6 | export * from './video-abuse' | 5 | export * from './video-abuse' |
7 | export * from './video-blacklist' | 6 | export * from './video-blacklist' |
diff --git a/client/src/app/shared/misc/button.component.scss b/client/src/app/shared/misc/button.component.scss new file mode 100644 index 000000000..5fcae4f10 --- /dev/null +++ b/client/src/app/shared/misc/button.component.scss | |||
@@ -0,0 +1,27 @@ | |||
1 | .action-button { | ||
2 | @include peertube-button-link; | ||
3 | |||
4 | font-size: 15px; | ||
5 | font-weight: $font-semibold; | ||
6 | color: #585858; | ||
7 | background-color: #E5E5E5; | ||
8 | |||
9 | &:hover { | ||
10 | background-color: #EFEFEF; | ||
11 | } | ||
12 | |||
13 | .icon { | ||
14 | @include icon(21px); | ||
15 | |||
16 | position: relative; | ||
17 | top: -2px; | ||
18 | |||
19 | &.icon-edit { | ||
20 | background-image: url('../../../assets/images/global/edit.svg'); | ||
21 | } | ||
22 | |||
23 | &.icon-delete-grey { | ||
24 | background-image: url('../../../assets/images/global/delete-grey.svg'); | ||
25 | } | ||
26 | } | ||
27 | } | ||
diff --git a/client/src/app/shared/misc/delete-button.component.html b/client/src/app/shared/misc/delete-button.component.html new file mode 100644 index 000000000..3db483882 --- /dev/null +++ b/client/src/app/shared/misc/delete-button.component.html | |||
@@ -0,0 +1,4 @@ | |||
1 | <span class="action-button action-button-delete" > | ||
2 | <span class="icon icon-delete-grey"></span> | ||
3 | Delete | ||
4 | </span> | ||
diff --git a/client/src/app/shared/misc/delete-button.component.ts b/client/src/app/shared/misc/delete-button.component.ts new file mode 100644 index 000000000..e04039f69 --- /dev/null +++ b/client/src/app/shared/misc/delete-button.component.ts | |||
@@ -0,0 +1,10 @@ | |||
1 | import { Component } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-delete-button', | ||
5 | styleUrls: [ './button.component.scss' ], | ||
6 | templateUrl: './delete-button.component.html' | ||
7 | }) | ||
8 | |||
9 | export class DeleteButtonComponent { | ||
10 | } | ||
diff --git a/client/src/app/shared/misc/edit-button.component.html b/client/src/app/shared/misc/edit-button.component.html new file mode 100644 index 000000000..6e9564bd7 --- /dev/null +++ b/client/src/app/shared/misc/edit-button.component.html | |||
@@ -0,0 +1,4 @@ | |||
1 | <a class="action-button" [routerLink]="routerLink"> | ||
2 | <span class="icon icon-edit"></span> | ||
3 | Edit | ||
4 | </a> | ||
diff --git a/client/src/app/shared/misc/edit-button.component.ts b/client/src/app/shared/misc/edit-button.component.ts new file mode 100644 index 000000000..201a618ec --- /dev/null +++ b/client/src/app/shared/misc/edit-button.component.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-edit-button', | ||
5 | styleUrls: [ './button.component.scss' ], | ||
6 | templateUrl: './edit-button.component.html' | ||
7 | }) | ||
8 | |||
9 | export class EditButtonComponent { | ||
10 | @Input() routerLink = [] | ||
11 | } | ||
diff --git a/client/src/app/shared/misc/from-now.pipe.ts b/client/src/app/shared/misc/from-now.pipe.ts new file mode 100644 index 000000000..fac02af0b --- /dev/null +++ b/client/src/app/shared/misc/from-now.pipe.ts | |||
@@ -0,0 +1,36 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | ||
2 | |||
3 | // Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site | ||
4 | @Pipe({ name: 'myFromNow' }) | ||
5 | export class FromNowPipe implements PipeTransform { | ||
6 | |||
7 | transform (value: number) { | ||
8 | const seconds = Math.floor((Date.now() - value) / 1000) | ||
9 | |||
10 | let interval = Math.floor(seconds / 31536000) | ||
11 | if (interval > 1) { | ||
12 | return interval + ' years ago' | ||
13 | } | ||
14 | |||
15 | interval = Math.floor(seconds / 2592000) | ||
16 | if (interval > 1) return interval + ' months ago' | ||
17 | if (interval === 1) return interval + ' month ago' | ||
18 | |||
19 | interval = Math.floor(seconds / 604800) | ||
20 | if (interval > 1) return interval + ' weeks ago' | ||
21 | if (interval === 1) return interval + ' week ago' | ||
22 | |||
23 | interval = Math.floor(seconds / 86400) | ||
24 | if (interval > 1) return interval + ' days ago' | ||
25 | if (interval === 1) return interval + ' day ago' | ||
26 | |||
27 | interval = Math.floor(seconds / 3600) | ||
28 | if (interval > 1) return interval + ' hours ago' | ||
29 | if (interval === 1) return interval + ' hour ago' | ||
30 | |||
31 | interval = Math.floor(seconds / 60) | ||
32 | if (interval >= 1) return interval + ' min ago' | ||
33 | |||
34 | return Math.floor(seconds) + ' sec ago' | ||
35 | } | ||
36 | } | ||
diff --git a/client/src/app/shared/misc/number-formatter.pipe.ts b/client/src/app/shared/misc/number-formatter.pipe.ts new file mode 100644 index 000000000..8a0756a36 --- /dev/null +++ b/client/src/app/shared/misc/number-formatter.pipe.ts | |||
@@ -0,0 +1,19 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | ||
2 | |||
3 | // Thanks: https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts | ||
4 | |||
5 | @Pipe({ name: 'myNumberFormatter' }) | ||
6 | export class NumberFormatterPipe implements PipeTransform { | ||
7 | private dictionary: Array<{max: number, type: string}> = [ | ||
8 | { max: 1000, type: '' }, | ||
9 | { max: 1000000, type: 'K' }, | ||
10 | { max: 1000000000, type: 'M' } | ||
11 | ] | ||
12 | |||
13 | transform (value: number) { | ||
14 | const format = this.dictionary.find(d => value < d.max) || this.dictionary[this.dictionary.length - 1] | ||
15 | const calc = Math.floor(value / (format.max / 1000)) | ||
16 | |||
17 | return `${calc}${format.type}` | ||
18 | } | ||
19 | } | ||
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts new file mode 100644 index 000000000..df9e0381a --- /dev/null +++ b/client/src/app/shared/misc/utils.ts | |||
@@ -0,0 +1,23 @@ | |||
1 | // Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript | ||
2 | |||
3 | function getParameterByName (name: string, url: string) { | ||
4 | if (!url) url = window.location.href | ||
5 | name = name.replace(/[\[\]]/g, '\\$&') | ||
6 | |||
7 | const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)') | ||
8 | const results = regex.exec(url) | ||
9 | |||
10 | if (!results) return null | ||
11 | if (!results[2]) return '' | ||
12 | |||
13 | return decodeURIComponent(results[2].replace(/\+/g, ' ')) | ||
14 | } | ||
15 | |||
16 | function viewportHeight () { | ||
17 | return Math.max(document.documentElement.clientHeight, window.innerHeight || 0) | ||
18 | } | ||
19 | |||
20 | export { | ||
21 | viewportHeight, | ||
22 | getParameterByName | ||
23 | } | ||
diff --git a/client/src/app/shared/search/index.ts b/client/src/app/shared/search/index.ts deleted file mode 100644 index d4016cf89..000000000 --- a/client/src/app/shared/search/index.ts +++ /dev/null | |||
@@ -1,4 +0,0 @@ | |||
1 | export * from './search-field.type' | ||
2 | export * from './search.component' | ||
3 | export * from './search.model' | ||
4 | export * from './search.service' | ||
diff --git a/client/src/app/shared/search/search-field.type.ts b/client/src/app/shared/search/search-field.type.ts deleted file mode 100644 index 7323d6cc3..000000000 --- a/client/src/app/shared/search/search-field.type.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | export type SearchField = 'name' | 'account' | 'host' | 'tags' | ||
diff --git a/client/src/app/shared/search/search.component.html b/client/src/app/shared/search/search.component.html deleted file mode 100644 index 75e9dfa59..000000000 --- a/client/src/app/shared/search/search.component.html +++ /dev/null | |||
@@ -1,22 +0,0 @@ | |||
1 | <div class="input-group"> | ||
2 | |||
3 | <span class="hidden-xs input-group-addon icon-addon"> | ||
4 | <span class="glyphicon glyphicon-search"></span> | ||
5 | </span> | ||
6 | |||
7 | <input | ||
8 | type="text" id="search-video" name="search-video" class="form-control" placeholder="Search" class="form-control" | ||
9 | [(ngModel)]="searchCriteria.value" (keyup.enter)="doSearch()" | ||
10 | > | ||
11 | |||
12 | <div class="input-group-btn" dropdown placement="bottom right"> | ||
13 | <button id="simple-btn-keyboard-nav" type="button" class="btn btn-default" dropdownToggle> | ||
14 | {{ getStringChoice(searchCriteria.field) }} <span class="caret"></span> | ||
15 | </button> | ||
16 | <ul class="dropdown-menu dropdown-menu-right" role="menu" aria-labelledby="simple-btn-keyboard-nav" *dropdownMenu> | ||
17 | <li *ngFor="let choice of choiceKeys" class="dropdown-item" role="menu-item"> | ||
18 | <a class="dropdown-item" href="#" (click)="choose($event, choice)">{{ getStringChoice(choice) }}</a> | ||
19 | </li> | ||
20 | </ul> | ||
21 | </div> | ||
22 | </div> | ||
diff --git a/client/src/app/shared/search/search.component.scss b/client/src/app/shared/search/search.component.scss deleted file mode 100644 index 583f9586f..000000000 --- a/client/src/app/shared/search/search.component.scss +++ /dev/null | |||
@@ -1,51 +0,0 @@ | |||
1 | .icon-addon { | ||
2 | background-color: #fff; | ||
3 | border-radius: 0; | ||
4 | border-color: $header-border-color; | ||
5 | border-width: 0 0 1px 0; | ||
6 | text-align: right; | ||
7 | |||
8 | .glyphicon-search { | ||
9 | width: 30px; | ||
10 | font-size: 20px; | ||
11 | } | ||
12 | } | ||
13 | |||
14 | input, button, .input-group { | ||
15 | height: 100%; | ||
16 | } | ||
17 | |||
18 | input, .input-group-btn { | ||
19 | border-radius: 0; | ||
20 | border-top: none; | ||
21 | border-left: none; | ||
22 | } | ||
23 | |||
24 | input { | ||
25 | height: $header-height; | ||
26 | border-right: none; | ||
27 | font-weight: bold; | ||
28 | box-shadow: none; | ||
29 | |||
30 | &, &:focus { | ||
31 | border-bottom: 1px solid $header-border-color !important; | ||
32 | outline: none !important; | ||
33 | box-shadow: none !important; | ||
34 | } | ||
35 | } | ||
36 | |||
37 | button { | ||
38 | |||
39 | &, &:hover, &:focus, &:active, &:visited { | ||
40 | background-color: #fff !important; | ||
41 | border-color: $header-border-color !important; | ||
42 | color: #858585 !important; | ||
43 | outline: none !important; | ||
44 | |||
45 | height: $header-height; | ||
46 | border-width: 0 0 1px 0; | ||
47 | font-weight: bold; | ||
48 | text-decoration: none; | ||
49 | box-shadow: none; | ||
50 | } | ||
51 | } | ||
diff --git a/client/src/app/shared/search/search.component.ts b/client/src/app/shared/search/search.component.ts deleted file mode 100644 index 6ef19c97a..000000000 --- a/client/src/app/shared/search/search.component.ts +++ /dev/null | |||
@@ -1,69 +0,0 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | |||
4 | import { Search } from './search.model' | ||
5 | import { SearchField } from './search-field.type' | ||
6 | import { SearchService } from './search.service' | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-search', | ||
10 | templateUrl: './search.component.html', | ||
11 | styleUrls: [ './search.component.scss' ] | ||
12 | }) | ||
13 | |||
14 | export class SearchComponent implements OnInit { | ||
15 | fieldChoices = { | ||
16 | name: 'Name', | ||
17 | account: 'Account', | ||
18 | host: 'Host', | ||
19 | tags: 'Tags' | ||
20 | } | ||
21 | searchCriteria: Search = { | ||
22 | field: 'name', | ||
23 | value: '' | ||
24 | } | ||
25 | |||
26 | constructor (private searchService: SearchService, private router: Router) {} | ||
27 | |||
28 | ngOnInit () { | ||
29 | // Subscribe if the search changed | ||
30 | // Usually changed by videos list component | ||
31 | this.searchService.updateSearch.subscribe( | ||
32 | newSearchCriteria => { | ||
33 | // Put a field by default | ||
34 | if (!newSearchCriteria.field) { | ||
35 | newSearchCriteria.field = 'name' | ||
36 | } | ||
37 | |||
38 | this.searchCriteria = newSearchCriteria | ||
39 | } | ||
40 | ) | ||
41 | } | ||
42 | |||
43 | get choiceKeys () { | ||
44 | return Object.keys(this.fieldChoices) | ||
45 | } | ||
46 | |||
47 | choose ($event: MouseEvent, choice: SearchField) { | ||
48 | $event.preventDefault() | ||
49 | $event.stopPropagation() | ||
50 | |||
51 | this.searchCriteria.field = choice | ||
52 | |||
53 | if (this.searchCriteria.value) { | ||
54 | this.doSearch() | ||
55 | } | ||
56 | } | ||
57 | |||
58 | doSearch () { | ||
59 | if (this.router.url.indexOf('/videos/list') === -1) { | ||
60 | this.router.navigate([ '/videos/list' ]) | ||
61 | } | ||
62 | |||
63 | this.searchService.searchUpdated.next(this.searchCriteria) | ||
64 | } | ||
65 | |||
66 | getStringChoice (choiceKey: SearchField) { | ||
67 | return this.fieldChoices[choiceKey] | ||
68 | } | ||
69 | } | ||
diff --git a/client/src/app/shared/search/search.model.ts b/client/src/app/shared/search/search.model.ts deleted file mode 100644 index 174adf2c6..000000000 --- a/client/src/app/shared/search/search.model.ts +++ /dev/null | |||
@@ -1,6 +0,0 @@ | |||
1 | import { SearchField } from './search-field.type' | ||
2 | |||
3 | export interface Search { | ||
4 | field: SearchField | ||
5 | value: string | ||
6 | } | ||
diff --git a/client/src/app/shared/search/search.service.ts b/client/src/app/shared/search/search.service.ts deleted file mode 100644 index 0480b46bd..000000000 --- a/client/src/app/shared/search/search.service.ts +++ /dev/null | |||
@@ -1,18 +0,0 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { Subject } from 'rxjs/Subject' | ||
3 | import { ReplaySubject } from 'rxjs/ReplaySubject' | ||
4 | |||
5 | import { Search } from './search.model' | ||
6 | |||
7 | // This class is needed to communicate between videos/ and search component | ||
8 | // Remove it when we'll be able to subscribe to router changes | ||
9 | @Injectable() | ||
10 | export class SearchService { | ||
11 | searchUpdated: Subject<Search> | ||
12 | updateSearch: Subject<Search> | ||
13 | |||
14 | constructor () { | ||
15 | this.updateSearch = new Subject<Search>() | ||
16 | this.searchUpdated = new ReplaySubject<Search>(1) | ||
17 | } | ||
18 | } | ||
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 456ce851e..d0e163f69 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts | |||
@@ -1,25 +1,29 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { HttpClientModule } from '@angular/common/http' | ||
3 | import { CommonModule } from '@angular/common' | 1 | import { CommonModule } from '@angular/common' |
2 | import { HttpClientModule } from '@angular/common/http' | ||
3 | import { NgModule } from '@angular/core' | ||
4 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' | 4 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' |
5 | import { RouterModule } from '@angular/router' | 5 | import { RouterModule } from '@angular/router' |
6 | 6 | ||
7 | import { BytesPipe } from 'angular-pipes/src/math/bytes.pipe' | ||
8 | import { KeysPipe } from 'angular-pipes/src/object/keys.pipe' | ||
9 | import { BsDropdownModule } from 'ngx-bootstrap/dropdown' | 7 | import { BsDropdownModule } from 'ngx-bootstrap/dropdown' |
10 | import { ProgressbarModule } from 'ngx-bootstrap/progressbar' | ||
11 | import { PaginationModule } from 'ngx-bootstrap/pagination' | ||
12 | import { ModalModule } from 'ngx-bootstrap/modal' | 8 | import { ModalModule } from 'ngx-bootstrap/modal' |
13 | import { DataTableModule } from 'primeng/components/datatable/datatable' | 9 | import { InfiniteScrollModule } from 'ngx-infinite-scroll' |
10 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' | ||
14 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' | 11 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' |
12 | import { DataTableModule } from 'primeng/components/datatable/datatable' | ||
15 | 13 | ||
16 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | 14 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' |
15 | import { DeleteButtonComponent } from './misc/delete-button.component' | ||
16 | import { EditButtonComponent } from './misc/edit-button.component' | ||
17 | import { FromNowPipe } from './misc/from-now.pipe' | ||
18 | import { LoaderComponent } from './misc/loader.component' | ||
19 | import { NumberFormatterPipe } from './misc/number-formatter.pipe' | ||
17 | import { RestExtractor, RestService } from './rest' | 20 | import { RestExtractor, RestService } from './rest' |
18 | import { SearchComponent, SearchService } from './search' | ||
19 | import { UserService } from './users' | 21 | import { UserService } from './users' |
20 | import { VideoAbuseService } from './video-abuse' | 22 | import { VideoAbuseService } from './video-abuse' |
21 | import { VideoBlacklistService } from './video-blacklist' | 23 | import { VideoBlacklistService } from './video-blacklist' |
22 | import { LoaderComponent } from './misc/loader.component' | 24 | import { VideoMiniatureComponent } from './video/video-miniature.component' |
25 | import { VideoThumbnailComponent } from './video/video-thumbnail.component' | ||
26 | import { VideoService } from './video/video.service' | ||
23 | 27 | ||
24 | @NgModule({ | 28 | @NgModule({ |
25 | imports: [ | 29 | imports: [ |
@@ -31,18 +35,21 @@ import { LoaderComponent } from './misc/loader.component' | |||
31 | 35 | ||
32 | BsDropdownModule.forRoot(), | 36 | BsDropdownModule.forRoot(), |
33 | ModalModule.forRoot(), | 37 | ModalModule.forRoot(), |
34 | PaginationModule.forRoot(), | ||
35 | ProgressbarModule.forRoot(), | ||
36 | 38 | ||
37 | DataTableModule, | 39 | DataTableModule, |
38 | PrimeSharedModule | 40 | PrimeSharedModule, |
41 | InfiniteScrollModule, | ||
42 | NgPipesModule | ||
39 | ], | 43 | ], |
40 | 44 | ||
41 | declarations: [ | 45 | declarations: [ |
42 | BytesPipe, | 46 | LoaderComponent, |
43 | KeysPipe, | 47 | VideoThumbnailComponent, |
44 | SearchComponent, | 48 | VideoMiniatureComponent, |
45 | LoaderComponent | 49 | DeleteButtonComponent, |
50 | EditButtonComponent, | ||
51 | NumberFormatterPipe, | ||
52 | FromNowPipe | ||
46 | ], | 53 | ], |
47 | 54 | ||
48 | exports: [ | 55 | exports: [ |
@@ -54,25 +61,30 @@ import { LoaderComponent } from './misc/loader.component' | |||
54 | 61 | ||
55 | BsDropdownModule, | 62 | BsDropdownModule, |
56 | ModalModule, | 63 | ModalModule, |
57 | PaginationModule, | ||
58 | ProgressbarModule, | ||
59 | DataTableModule, | 64 | DataTableModule, |
60 | PrimeSharedModule, | 65 | PrimeSharedModule, |
66 | InfiniteScrollModule, | ||
61 | BytesPipe, | 67 | BytesPipe, |
62 | KeysPipe, | 68 | KeysPipe, |
63 | 69 | ||
64 | SearchComponent, | 70 | LoaderComponent, |
65 | LoaderComponent | 71 | VideoThumbnailComponent, |
72 | VideoMiniatureComponent, | ||
73 | DeleteButtonComponent, | ||
74 | EditButtonComponent, | ||
75 | |||
76 | NumberFormatterPipe, | ||
77 | FromNowPipe | ||
66 | ], | 78 | ], |
67 | 79 | ||
68 | providers: [ | 80 | providers: [ |
69 | AUTH_INTERCEPTOR_PROVIDER, | 81 | AUTH_INTERCEPTOR_PROVIDER, |
70 | RestExtractor, | 82 | RestExtractor, |
71 | RestService, | 83 | RestService, |
72 | SearchService, | ||
73 | VideoAbuseService, | 84 | VideoAbuseService, |
74 | VideoBlacklistService, | 85 | VideoBlacklistService, |
75 | UserService | 86 | UserService, |
87 | VideoService | ||
76 | ] | 88 | ] |
77 | }) | 89 | }) |
78 | export class SharedModule { } | 90 | export class SharedModule { } |
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts index b075ab717..b4d13f37c 100644 --- a/client/src/app/shared/users/user.model.ts +++ b/client/src/app/shared/users/user.model.ts | |||
@@ -1,10 +1,5 @@ | |||
1 | import { | 1 | import { hasUserRight, User as UserServerModel, UserRight, UserRole, VideoChannel } from '../../../../../shared' |
2 | User as UserServerModel, | 2 | import { Account } from '../account/account.model' |
3 | UserRole, | ||
4 | VideoChannel, | ||
5 | UserRight, | ||
6 | hasUserRight | ||
7 | } from '../../../../../shared' | ||
8 | 3 | ||
9 | export type UserConstructorHash = { | 4 | export type UserConstructorHash = { |
10 | id: number, | 5 | id: number, |
@@ -14,10 +9,7 @@ export type UserConstructorHash = { | |||
14 | videoQuota?: number, | 9 | videoQuota?: number, |
15 | displayNSFW?: boolean, | 10 | displayNSFW?: boolean, |
16 | createdAt?: Date, | 11 | createdAt?: Date, |
17 | account?: { | 12 | account?: Account, |
18 | id: number | ||
19 | uuid: string | ||
20 | }, | ||
21 | videoChannels?: VideoChannel[] | 13 | videoChannels?: VideoChannel[] |
22 | } | 14 | } |
23 | export class User implements UserServerModel { | 15 | export class User implements UserServerModel { |
@@ -27,10 +19,7 @@ export class User implements UserServerModel { | |||
27 | role: UserRole | 19 | role: UserRole |
28 | displayNSFW: boolean | 20 | displayNSFW: boolean |
29 | videoQuota: number | 21 | videoQuota: number |
30 | account: { | 22 | account: Account |
31 | id: number | ||
32 | uuid: string | ||
33 | } | ||
34 | videoChannels: VideoChannel[] | 23 | videoChannels: VideoChannel[] |
35 | createdAt: Date | 24 | createdAt: Date |
36 | 25 | ||
@@ -61,4 +50,8 @@ export class User implements UserServerModel { | |||
61 | hasRight (right: UserRight) { | 50 | hasRight (right: UserRight) { |
62 | return hasUserRight(this.role, right) | 51 | return hasUserRight(this.role, right) |
63 | } | 52 | } |
53 | |||
54 | getAvatarPath () { | ||
55 | return Account.GET_ACCOUNT_AVATAR_PATH(this.account) | ||
56 | } | ||
64 | } | 57 | } |
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html new file mode 100644 index 000000000..5761f2c81 --- /dev/null +++ b/client/src/app/shared/video/abstract-video-list.html | |||
@@ -0,0 +1,20 @@ | |||
1 | <div class="margin-content"> | ||
2 | <div class="title-page title-page-single"> | ||
3 | {{ titlePage }} | ||
4 | </div> | ||
5 | |||
6 | <div | ||
7 | class="videos" | ||
8 | infiniteScroll | ||
9 | [infiniteScrollUpDistance]="1.5" | ||
10 | [infiniteScrollDistance]="0.5" | ||
11 | (scrolled)="onNearOfBottom()" | ||
12 | (scrolledUp)="onNearOfTop()" | ||
13 | > | ||
14 | <my-video-miniature | ||
15 | class="ng-animate" | ||
16 | *ngFor="let video of videos" [video]="video" [user]="user" | ||
17 | > | ||
18 | </my-video-miniature> | ||
19 | </div> | ||
20 | </div> | ||
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss new file mode 100644 index 000000000..52797bc6c --- /dev/null +++ b/client/src/app/shared/video/abstract-video-list.scss | |||
@@ -0,0 +1,7 @@ | |||
1 | .videos { | ||
2 | text-align: center; | ||
3 | |||
4 | my-video-miniature { | ||
5 | text-align: left; | ||
6 | } | ||
7 | } | ||
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts new file mode 100644 index 000000000..ba1635a18 --- /dev/null +++ b/client/src/app/shared/video/abstract-video-list.ts | |||
@@ -0,0 +1,133 @@ | |||
1 | import { OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { NotificationsService } from 'angular2-notifications' | ||
4 | import { Observable } from 'rxjs/Observable' | ||
5 | import { SortField } from './sort-field.type' | ||
6 | import { VideoPagination } from './video-pagination.model' | ||
7 | import { Video } from './video.model' | ||
8 | |||
9 | export abstract class AbstractVideoList implements OnInit { | ||
10 | pagination: VideoPagination = { | ||
11 | currentPage: 1, | ||
12 | itemsPerPage: 25, | ||
13 | totalItems: null | ||
14 | } | ||
15 | sort: SortField = '-createdAt' | ||
16 | defaultSort: SortField = '-createdAt' | ||
17 | videos: Video[] = [] | ||
18 | loadOnInit = true | ||
19 | |||
20 | protected notificationsService: NotificationsService | ||
21 | protected router: Router | ||
22 | protected route: ActivatedRoute | ||
23 | |||
24 | protected abstract currentRoute: string | ||
25 | |||
26 | abstract titlePage: string | ||
27 | private loadedPages: { [ id: number ]: boolean } = {} | ||
28 | |||
29 | abstract getVideosObservable (): Observable<{ videos: Video[], totalVideos: number}> | ||
30 | |||
31 | ngOnInit () { | ||
32 | // Subscribe to route changes | ||
33 | const routeParams = this.route.snapshot.params | ||
34 | this.loadRouteParams(routeParams) | ||
35 | |||
36 | if (this.loadOnInit === true) this.loadMoreVideos('after') | ||
37 | } | ||
38 | |||
39 | onNearOfTop () { | ||
40 | if (this.pagination.currentPage > 1) { | ||
41 | this.previousPage() | ||
42 | } | ||
43 | } | ||
44 | |||
45 | onNearOfBottom () { | ||
46 | if (this.hasMoreVideos()) { | ||
47 | this.nextPage() | ||
48 | } | ||
49 | } | ||
50 | |||
51 | reloadVideos () { | ||
52 | this.videos = [] | ||
53 | this.loadedPages = {} | ||
54 | this.loadMoreVideos('before') | ||
55 | } | ||
56 | |||
57 | loadMoreVideos (where: 'before' | 'after') { | ||
58 | if (this.loadedPages[this.pagination.currentPage] === true) return | ||
59 | |||
60 | const observable = this.getVideosObservable() | ||
61 | |||
62 | observable.subscribe( | ||
63 | ({ videos, totalVideos }) => { | ||
64 | // Paging is too high, return to the first one | ||
65 | if (this.pagination.currentPage > 1 && totalVideos <= ((this.pagination.currentPage - 1) * this.pagination.itemsPerPage)) { | ||
66 | this.pagination.currentPage = 1 | ||
67 | this.setNewRouteParams() | ||
68 | return this.reloadVideos() | ||
69 | } | ||
70 | |||
71 | this.loadedPages[this.pagination.currentPage] = true | ||
72 | this.pagination.totalItems = totalVideos | ||
73 | |||
74 | if (where === 'before') { | ||
75 | this.videos = videos.concat(this.videos) | ||
76 | } else { | ||
77 | this.videos = this.videos.concat(videos) | ||
78 | } | ||
79 | }, | ||
80 | error => this.notificationsService.error('Error', error.text) | ||
81 | ) | ||
82 | } | ||
83 | |||
84 | protected hasMoreVideos () { | ||
85 | // No results | ||
86 | if (this.pagination.totalItems === 0) return false | ||
87 | |||
88 | // Not loaded yet | ||
89 | if (!this.pagination.totalItems) return true | ||
90 | |||
91 | const maxPage = this.pagination.totalItems / this.pagination.itemsPerPage | ||
92 | return maxPage > this.pagination.currentPage | ||
93 | } | ||
94 | |||
95 | protected previousPage () { | ||
96 | this.pagination.currentPage-- | ||
97 | |||
98 | this.setNewRouteParams() | ||
99 | this.loadMoreVideos('before') | ||
100 | } | ||
101 | |||
102 | protected nextPage () { | ||
103 | this.pagination.currentPage++ | ||
104 | |||
105 | this.setNewRouteParams() | ||
106 | this.loadMoreVideos('after') | ||
107 | } | ||
108 | |||
109 | protected buildRouteParams () { | ||
110 | // There is always a sort and a current page | ||
111 | const params = { | ||
112 | sort: this.sort, | ||
113 | page: this.pagination.currentPage | ||
114 | } | ||
115 | |||
116 | return params | ||
117 | } | ||
118 | |||
119 | protected loadRouteParams (routeParams: { [ key: string ]: any }) { | ||
120 | this.sort = routeParams['sort'] as SortField || this.defaultSort | ||
121 | |||
122 | if (routeParams['page'] !== undefined) { | ||
123 | this.pagination.currentPage = parseInt(routeParams['page'], 10) | ||
124 | } else { | ||
125 | this.pagination.currentPage = 1 | ||
126 | } | ||
127 | } | ||
128 | |||
129 | protected setNewRouteParams () { | ||
130 | const routeParams = this.buildRouteParams() | ||
131 | this.router.navigate([ this.currentRoute, routeParams ]) | ||
132 | } | ||
133 | } | ||
diff --git a/client/src/app/shared/video/sort-field.type.ts b/client/src/app/shared/video/sort-field.type.ts new file mode 100644 index 000000000..776f360f8 --- /dev/null +++ b/client/src/app/shared/video/sort-field.type.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export type SortField = 'name' | '-name' | ||
2 | | 'duration' | '-duration' | ||
3 | | 'createdAt' | '-createdAt' | ||
4 | | 'views' | '-views' | ||
5 | | 'likes' | '-likes' | ||
diff --git a/client/src/app/shared/video/video-details.model.ts b/client/src/app/shared/video/video-details.model.ts new file mode 100644 index 000000000..b96f8f6c8 --- /dev/null +++ b/client/src/app/shared/video/video-details.model.ts | |||
@@ -0,0 +1,92 @@ | |||
1 | import { Account } from '../../../../../shared/models/accounts' | ||
2 | import { Video } from '../../shared/video/video.model' | ||
3 | import { AuthUser } from '../../core' | ||
4 | import { | ||
5 | VideoDetails as VideoDetailsServerModel, | ||
6 | VideoFile, | ||
7 | VideoChannel, | ||
8 | VideoResolution, | ||
9 | UserRight, | ||
10 | VideoPrivacy | ||
11 | } from '../../../../../shared' | ||
12 | |||
13 | export class VideoDetails extends Video implements VideoDetailsServerModel { | ||
14 | accountName: string | ||
15 | by: string | ||
16 | createdAt: Date | ||
17 | updatedAt: Date | ||
18 | categoryLabel: string | ||
19 | category: number | ||
20 | licenceLabel: string | ||
21 | licence: number | ||
22 | languageLabel: string | ||
23 | language: number | ||
24 | description: string | ||
25 | duration: number | ||
26 | durationLabel: string | ||
27 | id: number | ||
28 | uuid: string | ||
29 | isLocal: boolean | ||
30 | name: string | ||
31 | serverHost: string | ||
32 | tags: string[] | ||
33 | thumbnailPath: string | ||
34 | thumbnailUrl: string | ||
35 | previewPath: string | ||
36 | previewUrl: string | ||
37 | embedPath: string | ||
38 | embedUrl: string | ||
39 | views: number | ||
40 | likes: number | ||
41 | dislikes: number | ||
42 | nsfw: boolean | ||
43 | descriptionPath: string | ||
44 | files: VideoFile[] | ||
45 | channel: VideoChannel | ||
46 | privacy: VideoPrivacy | ||
47 | privacyLabel: string | ||
48 | account: Account | ||
49 | likesPercent: number | ||
50 | dislikesPercent: number | ||
51 | |||
52 | constructor (hash: VideoDetailsServerModel) { | ||
53 | super(hash) | ||
54 | |||
55 | this.privacy = hash.privacy | ||
56 | this.privacyLabel = hash.privacyLabel | ||
57 | this.descriptionPath = hash.descriptionPath | ||
58 | this.files = hash.files | ||
59 | this.channel = hash.channel | ||
60 | this.account = hash.account | ||
61 | |||
62 | this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100 | ||
63 | this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100 | ||
64 | } | ||
65 | |||
66 | getAppropriateMagnetUri (actualDownloadSpeed = 0) { | ||
67 | if (this.files === undefined || this.files.length === 0) return '' | ||
68 | if (this.files.length === 1) return this.files[0].magnetUri | ||
69 | |||
70 | // Find first video that is good for our download speed (remember they are sorted) | ||
71 | let betterResolutionFile = this.files.find(f => actualDownloadSpeed > (f.size / this.duration)) | ||
72 | |||
73 | // If the download speed is too bad, return the lowest resolution we have | ||
74 | if (betterResolutionFile === undefined) { | ||
75 | betterResolutionFile = this.files.find(f => f.resolution === VideoResolution.H_240P) | ||
76 | } | ||
77 | |||
78 | return betterResolutionFile.magnetUri | ||
79 | } | ||
80 | |||
81 | isRemovableBy (user: AuthUser) { | ||
82 | return user && this.isLocal === true && (this.accountName === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO)) | ||
83 | } | ||
84 | |||
85 | isBlackistableBy (user: AuthUser) { | ||
86 | return user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true && this.isLocal === false | ||
87 | } | ||
88 | |||
89 | isUpdatableBy (user: AuthUser) { | ||
90 | return user && this.isLocal === true && user.username === this.accountName | ||
91 | } | ||
92 | } | ||
diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts new file mode 100644 index 000000000..955255bfa --- /dev/null +++ b/client/src/app/shared/video/video-edit.model.ts | |||
@@ -0,0 +1,52 @@ | |||
1 | import { VideoDetails } from './video-details.model' | ||
2 | import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum' | ||
3 | |||
4 | export class VideoEdit { | ||
5 | category: number | ||
6 | licence: number | ||
7 | language: number | ||
8 | description: string | ||
9 | name: string | ||
10 | tags: string[] | ||
11 | nsfw: boolean | ||
12 | channel: number | ||
13 | privacy: VideoPrivacy | ||
14 | uuid?: string | ||
15 | id?: number | ||
16 | |||
17 | constructor (videoDetails?: VideoDetails) { | ||
18 | if (videoDetails) { | ||
19 | this.id = videoDetails.id | ||
20 | this.uuid = videoDetails.uuid | ||
21 | this.category = videoDetails.category | ||
22 | this.licence = videoDetails.licence | ||
23 | this.language = videoDetails.language | ||
24 | this.description = videoDetails.description | ||
25 | this.name = videoDetails.name | ||
26 | this.tags = videoDetails.tags | ||
27 | this.nsfw = videoDetails.nsfw | ||
28 | this.channel = videoDetails.channel.id | ||
29 | this.privacy = videoDetails.privacy | ||
30 | } | ||
31 | } | ||
32 | |||
33 | patch (values: Object) { | ||
34 | Object.keys(values).forEach((key) => { | ||
35 | this[key] = values[key] | ||
36 | }) | ||
37 | } | ||
38 | |||
39 | toJSON () { | ||
40 | return { | ||
41 | category: this.category, | ||
42 | licence: this.licence, | ||
43 | language: this.language, | ||
44 | description: this.description, | ||
45 | name: this.name, | ||
46 | tags: this.tags, | ||
47 | nsfw: this.nsfw, | ||
48 | channel: this.channel, | ||
49 | privacy: this.privacy | ||
50 | } | ||
51 | } | ||
52 | } | ||
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html new file mode 100644 index 000000000..7ac017235 --- /dev/null +++ b/client/src/app/shared/video/video-miniature.component.html | |||
@@ -0,0 +1,17 @@ | |||
1 | <div class="video-miniature"> | ||
2 | <my-video-thumbnail [video]="video" [nsfw]="isVideoNSFWForThisUser()"></my-video-thumbnail> | ||
3 | |||
4 | <div class="video-miniature-information"> | ||
5 | <span class="video-miniature-name"> | ||
6 | <a | ||
7 | class="video-miniature-name" | ||
8 | [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoNSFWForThisUser() }" | ||
9 | > | ||
10 | {{ video.name }} | ||
11 | </a> | ||
12 | </span> | ||
13 | |||
14 | <span class="video-miniature-created-at-views">{{ video.createdAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span> | ||
15 | <span class="video-miniature-account">{{ video.by }}</span> | ||
16 | </div> | ||
17 | </div> | ||
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss new file mode 100644 index 000000000..37e84897b --- /dev/null +++ b/client/src/app/shared/video/video-miniature.component.scss | |||
@@ -0,0 +1,44 @@ | |||
1 | .video-miniature { | ||
2 | display: inline-block; | ||
3 | padding-right: 15px; | ||
4 | margin-bottom: 30px; | ||
5 | height: 175px; | ||
6 | vertical-align: top; | ||
7 | |||
8 | .video-miniature-information { | ||
9 | width: 200px; | ||
10 | margin-top: 2px; | ||
11 | line-height: normal; | ||
12 | |||
13 | .video-miniature-name { | ||
14 | display: block; | ||
15 | overflow: hidden; | ||
16 | text-overflow: ellipsis; | ||
17 | white-space: nowrap; | ||
18 | font-weight: bold; | ||
19 | transition: color 0.2s; | ||
20 | font-size: 16px; | ||
21 | font-weight: $font-semibold; | ||
22 | color: #000; | ||
23 | |||
24 | &:hover { | ||
25 | text-decoration: none; | ||
26 | } | ||
27 | |||
28 | &.blur-filter { | ||
29 | filter: blur(3px); | ||
30 | padding-left: 4px; | ||
31 | } | ||
32 | } | ||
33 | |||
34 | .video-miniature-created-at-views { | ||
35 | display: block; | ||
36 | font-size: 13px; | ||
37 | } | ||
38 | |||
39 | .video-miniature-account { | ||
40 | font-size: 13px; | ||
41 | color: #585858; | ||
42 | } | ||
43 | } | ||
44 | } | ||
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts new file mode 100644 index 000000000..4d79a74bb --- /dev/null +++ b/client/src/app/shared/video/video-miniature.component.ts | |||
@@ -0,0 +1,17 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { User } from '../users' | ||
3 | import { Video } from './video.model' | ||
4 | |||
5 | @Component({ | ||
6 | selector: 'my-video-miniature', | ||
7 | styleUrls: [ './video-miniature.component.scss' ], | ||
8 | templateUrl: './video-miniature.component.html' | ||
9 | }) | ||
10 | export class VideoMiniatureComponent { | ||
11 | @Input() user: User | ||
12 | @Input() video: Video | ||
13 | |||
14 | isVideoNSFWForThisUser () { | ||
15 | return this.video.isVideoNSFWForUser(this.user) | ||
16 | } | ||
17 | } | ||
diff --git a/client/src/app/shared/video/video-pagination.model.ts b/client/src/app/shared/video/video-pagination.model.ts new file mode 100644 index 000000000..e9db61596 --- /dev/null +++ b/client/src/app/shared/video/video-pagination.model.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | export interface VideoPagination { | ||
2 | currentPage: number | ||
3 | itemsPerPage: number | ||
4 | totalItems?: number | ||
5 | } | ||
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html new file mode 100644 index 000000000..5c698e8f6 --- /dev/null +++ b/client/src/app/shared/video/video-thumbnail.component.html | |||
@@ -0,0 +1,10 @@ | |||
1 | <a | ||
2 | [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" | ||
3 | class="video-thumbnail" | ||
4 | > | ||
5 | <img [attr.src]="video.thumbnailUrl" alt="video thumbnail" [ngClass]="{ 'blur-filter': nsfw }" /> | ||
6 | |||
7 | <div class="video-thumbnail-overlay"> | ||
8 | {{ video.durationLabel }} | ||
9 | </div> | ||
10 | </a> | ||
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss new file mode 100644 index 000000000..ab4f9bcb1 --- /dev/null +++ b/client/src/app/shared/video/video-thumbnail.component.scss | |||
@@ -0,0 +1,28 @@ | |||
1 | .video-thumbnail { | ||
2 | display: inline-block; | ||
3 | position: relative; | ||
4 | border-radius: 4px; | ||
5 | overflow: hidden; | ||
6 | |||
7 | &:hover { | ||
8 | text-decoration: none !important; | ||
9 | } | ||
10 | |||
11 | img.blur-filter { | ||
12 | filter: blur(5px); | ||
13 | transform : scale(1.03); | ||
14 | } | ||
15 | |||
16 | .video-thumbnail-overlay { | ||
17 | position: absolute; | ||
18 | right: 5px; | ||
19 | bottom: 5px; | ||
20 | display: inline-block; | ||
21 | background-color: rgba(0, 0, 0, 0.7); | ||
22 | color: #fff; | ||
23 | font-size: 12px; | ||
24 | font-weight: $font-bold; | ||
25 | border-radius: 3px; | ||
26 | padding: 0 5px; | ||
27 | } | ||
28 | } | ||
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts new file mode 100644 index 000000000..e543e9903 --- /dev/null +++ b/client/src/app/shared/video/video-thumbnail.component.ts | |||
@@ -0,0 +1,12 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { Video } from './video.model' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-video-thumbnail', | ||
6 | styleUrls: [ './video-thumbnail.component.scss' ], | ||
7 | templateUrl: './video-thumbnail.component.html' | ||
8 | }) | ||
9 | export class VideoThumbnailComponent { | ||
10 | @Input() video: Video | ||
11 | @Input() nsfw = false | ||
12 | } | ||
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts new file mode 100644 index 000000000..d86ef8f92 --- /dev/null +++ b/client/src/app/shared/video/video.model.ts | |||
@@ -0,0 +1,92 @@ | |||
1 | import { Video as VideoServerModel } from '../../../../../shared' | ||
2 | import { User } from '../' | ||
3 | import { Account } from '../../../../../shared/models/accounts' | ||
4 | |||
5 | export class Video implements VideoServerModel { | ||
6 | accountName: string | ||
7 | by: string | ||
8 | createdAt: Date | ||
9 | updatedAt: Date | ||
10 | categoryLabel: string | ||
11 | category: number | ||
12 | licenceLabel: string | ||
13 | licence: number | ||
14 | languageLabel: string | ||
15 | language: number | ||
16 | description: string | ||
17 | duration: number | ||
18 | durationLabel: string | ||
19 | id: number | ||
20 | uuid: string | ||
21 | isLocal: boolean | ||
22 | name: string | ||
23 | serverHost: string | ||
24 | tags: string[] | ||
25 | thumbnailPath: string | ||
26 | thumbnailUrl: string | ||
27 | previewPath: string | ||
28 | previewUrl: string | ||
29 | embedPath: string | ||
30 | embedUrl: string | ||
31 | views: number | ||
32 | likes: number | ||
33 | dislikes: number | ||
34 | nsfw: boolean | ||
35 | account: Account | ||
36 | |||
37 | private static createByString (account: string, serverHost: string) { | ||
38 | return account + '@' + serverHost | ||
39 | } | ||
40 | |||
41 | private static createDurationString (duration: number) { | ||
42 | const minutes = Math.floor(duration / 60) | ||
43 | const seconds = duration % 60 | ||
44 | const minutesPadding = minutes >= 10 ? '' : '0' | ||
45 | const secondsPadding = seconds >= 10 ? '' : '0' | ||
46 | |||
47 | return minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString() | ||
48 | } | ||
49 | |||
50 | constructor (hash: VideoServerModel) { | ||
51 | let absoluteAPIUrl = API_URL | ||
52 | if (!absoluteAPIUrl) { | ||
53 | // The API is on the same domain | ||
54 | absoluteAPIUrl = window.location.origin | ||
55 | } | ||
56 | |||
57 | this.accountName = hash.accountName | ||
58 | this.createdAt = new Date(hash.createdAt.toString()) | ||
59 | this.categoryLabel = hash.categoryLabel | ||
60 | this.category = hash.category | ||
61 | this.licenceLabel = hash.licenceLabel | ||
62 | this.licence = hash.licence | ||
63 | this.languageLabel = hash.languageLabel | ||
64 | this.language = hash.language | ||
65 | this.description = hash.description | ||
66 | this.duration = hash.duration | ||
67 | this.durationLabel = Video.createDurationString(hash.duration) | ||
68 | this.id = hash.id | ||
69 | this.uuid = hash.uuid | ||
70 | this.isLocal = hash.isLocal | ||
71 | this.name = hash.name | ||
72 | this.serverHost = hash.serverHost | ||
73 | this.tags = hash.tags | ||
74 | this.thumbnailPath = hash.thumbnailPath | ||
75 | this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath | ||
76 | this.previewPath = hash.previewPath | ||
77 | this.previewUrl = absoluteAPIUrl + hash.previewPath | ||
78 | this.embedPath = hash.embedPath | ||
79 | this.embedUrl = absoluteAPIUrl + hash.embedPath | ||
80 | this.views = hash.views | ||
81 | this.likes = hash.likes | ||
82 | this.dislikes = hash.dislikes | ||
83 | this.nsfw = hash.nsfw | ||
84 | |||
85 | this.by = Video.createByString(hash.accountName, hash.serverHost) | ||
86 | } | ||
87 | |||
88 | isVideoNSFWForUser (user: User) { | ||
89 | // If the video is NSFW and the user is not logged in, or the user does not want to display NSFW videos... | ||
90 | return (this.nsfw && (!user || user.displayNSFW === false)) | ||
91 | } | ||
92 | } | ||
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts new file mode 100644 index 000000000..1a0644c3d --- /dev/null +++ b/client/src/app/shared/video/video.service.ts | |||
@@ -0,0 +1,172 @@ | |||
1 | import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import 'rxjs/add/operator/catch' | ||
4 | import 'rxjs/add/operator/map' | ||
5 | import { Observable } from 'rxjs/Observable' | ||
6 | import { Video as VideoServerModel, VideoDetails as VideoDetailsServerModel } from '../../../../../shared' | ||
7 | import { ResultList } from '../../../../../shared/models/result-list.model' | ||
8 | import { UserVideoRateUpdate } from '../../../../../shared/models/videos/user-video-rate-update.model' | ||
9 | import { UserVideoRate } from '../../../../../shared/models/videos/user-video-rate.model' | ||
10 | import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type' | ||
11 | import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model' | ||
12 | import { RestExtractor } from '../rest/rest-extractor.service' | ||
13 | import { RestService } from '../rest/rest.service' | ||
14 | import { Search } from '../header/search.model' | ||
15 | import { UserService } from '../users/user.service' | ||
16 | import { SortField } from './sort-field.type' | ||
17 | import { VideoDetails } from './video-details.model' | ||
18 | import { VideoEdit } from './video-edit.model' | ||
19 | import { VideoPagination } from './video-pagination.model' | ||
20 | import { Video } from './video.model' | ||
21 | |||
22 | @Injectable() | ||
23 | export class VideoService { | ||
24 | private static BASE_VIDEO_URL = API_URL + '/api/v1/videos/' | ||
25 | |||
26 | constructor ( | ||
27 | private authHttp: HttpClient, | ||
28 | private restExtractor: RestExtractor, | ||
29 | private restService: RestService | ||
30 | ) {} | ||
31 | |||
32 | getVideo (uuid: string): Observable<VideoDetails> { | ||
33 | return this.authHttp.get<VideoDetailsServerModel>(VideoService.BASE_VIDEO_URL + uuid) | ||
34 | .map(videoHash => new VideoDetails(videoHash)) | ||
35 | .catch((res) => this.restExtractor.handleError(res)) | ||
36 | } | ||
37 | |||
38 | viewVideo (uuid: string): Observable<VideoDetails> { | ||
39 | return this.authHttp.post(VideoService.BASE_VIDEO_URL + uuid + '/views', {}) | ||
40 | .map(this.restExtractor.extractDataBool) | ||
41 | .catch(this.restExtractor.handleError) | ||
42 | } | ||
43 | |||
44 | updateVideo (video: VideoEdit) { | ||
45 | const language = video.language || undefined | ||
46 | const licence = video.licence || undefined | ||
47 | const category = video.category || undefined | ||
48 | const description = video.description || undefined | ||
49 | |||
50 | const body: VideoUpdate = { | ||
51 | name: video.name, | ||
52 | category, | ||
53 | licence, | ||
54 | language, | ||
55 | description, | ||
56 | privacy: video.privacy, | ||
57 | tags: video.tags, | ||
58 | nsfw: video.nsfw | ||
59 | } | ||
60 | |||
61 | return this.authHttp.put(VideoService.BASE_VIDEO_URL + video.id, body) | ||
62 | .map(this.restExtractor.extractDataBool) | ||
63 | .catch(this.restExtractor.handleError) | ||
64 | } | ||
65 | |||
66 | uploadVideo (video: FormData) { | ||
67 | const req = new HttpRequest('POST', VideoService.BASE_VIDEO_URL + 'upload', video, { reportProgress: true }) | ||
68 | |||
69 | return this.authHttp | ||
70 | .request(req) | ||
71 | .catch(this.restExtractor.handleError) | ||
72 | } | ||
73 | |||
74 | getMyVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { | ||
75 | const pagination = this.videoPaginationToRestPagination(videoPagination) | ||
76 | |||
77 | let params = new HttpParams() | ||
78 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
79 | |||
80 | return this.authHttp.get(UserService.BASE_USERS_URL + '/me/videos', { params }) | ||
81 | .map(this.extractVideos) | ||
82 | .catch((res) => this.restExtractor.handleError(res)) | ||
83 | } | ||
84 | |||
85 | getVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { | ||
86 | const pagination = this.videoPaginationToRestPagination(videoPagination) | ||
87 | |||
88 | let params = new HttpParams() | ||
89 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
90 | |||
91 | return this.authHttp | ||
92 | .get(VideoService.BASE_VIDEO_URL, { params }) | ||
93 | .map(this.extractVideos) | ||
94 | .catch((res) => this.restExtractor.handleError(res)) | ||
95 | } | ||
96 | |||
97 | searchVideos (search: string, videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { | ||
98 | const url = VideoService.BASE_VIDEO_URL + 'search' | ||
99 | |||
100 | const pagination = this.videoPaginationToRestPagination(videoPagination) | ||
101 | |||
102 | let params = new HttpParams() | ||
103 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
104 | params = params.append('search', search) | ||
105 | |||
106 | return this.authHttp | ||
107 | .get<ResultList<VideoServerModel>>(url, { params }) | ||
108 | .map(this.extractVideos) | ||
109 | .catch((res) => this.restExtractor.handleError(res)) | ||
110 | } | ||
111 | |||
112 | removeVideo (id: number) { | ||
113 | return this.authHttp | ||
114 | .delete(VideoService.BASE_VIDEO_URL + id) | ||
115 | .map(this.restExtractor.extractDataBool) | ||
116 | .catch((res) => this.restExtractor.handleError(res)) | ||
117 | } | ||
118 | |||
119 | loadCompleteDescription (descriptionPath: string) { | ||
120 | return this.authHttp | ||
121 | .get(API_URL + descriptionPath) | ||
122 | .map(res => res['description']) | ||
123 | .catch((res) => this.restExtractor.handleError(res)) | ||
124 | } | ||
125 | |||
126 | setVideoLike (id: number) { | ||
127 | return this.setVideoRate(id, 'like') | ||
128 | } | ||
129 | |||
130 | setVideoDislike (id: number) { | ||
131 | return this.setVideoRate(id, 'dislike') | ||
132 | } | ||
133 | |||
134 | getUserVideoRating (id: number): Observable<UserVideoRate> { | ||
135 | const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating' | ||
136 | |||
137 | return this.authHttp | ||
138 | .get(url) | ||
139 | .catch(res => this.restExtractor.handleError(res)) | ||
140 | } | ||
141 | |||
142 | private videoPaginationToRestPagination (videoPagination: VideoPagination) { | ||
143 | const start: number = (videoPagination.currentPage - 1) * videoPagination.itemsPerPage | ||
144 | const count: number = videoPagination.itemsPerPage | ||
145 | |||
146 | return { start, count } | ||
147 | } | ||
148 | |||
149 | private setVideoRate (id: number, rateType: VideoRateType) { | ||
150 | const url = VideoService.BASE_VIDEO_URL + id + '/rate' | ||
151 | const body: UserVideoRateUpdate = { | ||
152 | rating: rateType | ||
153 | } | ||
154 | |||
155 | return this.authHttp | ||
156 | .put(url, body) | ||
157 | .map(this.restExtractor.extractDataBool) | ||
158 | .catch(res => this.restExtractor.handleError(res)) | ||
159 | } | ||
160 | |||
161 | private extractVideos (result: ResultList<VideoServerModel>) { | ||
162 | const videosJson = result.data | ||
163 | const totalVideos = result.total | ||
164 | const videos = [] | ||
165 | |||
166 | for (const videoJson of videosJson) { | ||
167 | videos.push(new Video(videoJson)) | ||
168 | } | ||
169 | |||
170 | return { videos, totalVideos } | ||
171 | } | ||
172 | } | ||