Merge branch 'master' into webseed-merged

This commit is contained in:
Chocobozzz 2016-10-02 15:39:09 +02:00
commit a6375e6966
186 changed files with 5711 additions and 2542 deletions

7
.gitignore vendored
View File

@ -14,4 +14,11 @@ uploads
thumbnails
config/production.yaml
ffmpeg
<<<<<<< HEAD
torrents
=======
.tags
*.sublime-project
*.sublime-workspace
torrents/
>>>>>>> master

View File

@ -1,8 +1,8 @@
language: node_js
node_js:
- "4.4"
- "6.2"
- "4.5"
- "6.6"
env:
- CXX=g++-4.8
@ -19,8 +19,10 @@ sudo: false
services:
- mongodb
before_install: if [[ `npm -v` != 3* ]]; then npm i -g npm@3; fi
before_script:
- npm install electron-prebuilt -g
- npm install electron -g
- npm run build
- wget --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-3.0.2-64bit-static.tar.xz"
- tar xf ffmpeg-release-3.0.2-64bit-static.tar.xz

View File

@ -60,6 +60,7 @@ Want to see in action?
* You can directly test in your browser with this [demo server](http://peertube.cpy.re). Don't forget to use the latest version of Firefox/Chromium/(Opera?) and check your firewall configuration (for WebRTC)
* You can find [a video](https://vimeo.com/164881662 "Yes Vimeo, please don't judge me") to see how the "decentralization feature" looks like
* Experimental demo servers that share videos (they are in the same network): [peertube2](http://peertube2.cpy.re), [peertube3](http://peertube3.cpy.re). Since I do experiments with them, sometimes they might not work correctly.
## Why
@ -95,10 +96,12 @@ Thanks to [WebTorrent](https://github.com/feross/webtorrent), we can make P2P (t
- [ ] Validate the prototype (test PeerTube in a real world with many pods and videos)
- [ ] Manage API breaks
- [ ] Add "DDOS" security (check if a pod don't send too many requests for example)
- [ ] Admin panel
- [ ] Stats about the network (how many friends, how many requests per hour...)
- [ ] Stats about videos
- [ ] Manage users (create/remove)
- [X] Admin panel
- [X] Stats
- [X] Friends list
- [X] Manage users (create/remove)
- [ ] User playlists
- [ ] User subscriptions (by tags, author...)
## Installation
@ -111,6 +114,7 @@ Thanks to [WebTorrent](https://github.com/feross/webtorrent), we can make P2P (t
### Dependencies
* **NodeJS >= 4.2**
* **npm >= 3.0**
* OpenSSL (cli)
* MongoDB
* ffmpeg xvfb-run libgtk2.0-0 libgconf-2-4 libnss3 libasound2 libxtst6 libxss1 libnotify-bin (for electron)
@ -123,7 +127,8 @@ Thanks to [WebTorrent](https://github.com/feross/webtorrent), we can make P2P (t
# apt-get update
# apt-get install ffmpeg mongodb openssl xvfb curl sudo git build-essential libgtk2.0-0 libgconf-2-4 libnss3 libasound2 libxtst6 libxss1 libnotify-bin
# npm install -g electron-prebuilt
# npm install -g npm@3
# npm install -g electron
#### Other distribution... (PR welcome)
@ -160,6 +165,10 @@ Finally, run the server with the `production` `NODE_ENV` variable set.
$ NODE_ENV=production npm start
**Nginx template** (reverse proxy): https://github.com/Chocobozzz/PeerTube/tree/master/support/nginx
**Systemd template**: https://github.com/Chocobozzz/PeerTube/tree/master/support/systemd
### Other commands
To print all available command run:

View File

@ -8,10 +8,15 @@ function hasProcessFlag (flag) {
return process.argv.join('').indexOf(flag) > -1
}
function isWebpackDevServer () {
return process.argv[1] && !!(/webpack-dev-server$/.exec(process.argv[1]))
}
function root (args) {
args = Array.prototype.slice.call(arguments, 0)
return path.join.apply(path, [ROOT].concat(args))
}
exports.hasProcessFlag = hasProcessFlag
exports.isWebpackDevServer = isWebpackDevServer
exports.root = root

View File

@ -5,9 +5,11 @@ const helpers = require('./helpers')
* Webpack Plugins
*/
var CopyWebpackPlugin = (CopyWebpackPlugin = require('copy-webpack-plugin'), CopyWebpackPlugin.default || CopyWebpackPlugin)
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ForkCheckerPlugin = require('awesome-typescript-loader').ForkCheckerPlugin
const AssetsPlugin = require('assets-webpack-plugin')
const ContextReplacementPlugin = require('webpack/lib/ContextReplacementPlugin')
const WebpackNotifierPlugin = require('webpack-notifier')
/*
@ -15,7 +17,8 @@ const WebpackNotifierPlugin = require('webpack-notifier')
*/
const METADATA = {
title: 'PeerTube',
baseUrl: '/'
baseUrl: '/',
isDevServer: helpers.isWebpackDevServer()
}
/*
@ -23,7 +26,10 @@ const METADATA = {
*
* See: http://webpack.github.io/docs/configuration.html#cli
*/
module.exports = {
module.exports = function (options) {
var isProd = options.env === 'production'
return {
/*
* Static metadata for index.html
*
@ -69,10 +75,7 @@ module.exports = {
root: helpers.root('src'),
// remove other default values
modulesDirectories: [ 'node_modules' ],
packageAlias: 'browser'
modulesDirectories: [ 'node_modules' ]
},
output: {
@ -91,30 +94,16 @@ module.exports = {
* See: http://webpack.github.io/docs/configuration.html#module-preloaders-module-postloaders
*/
preLoaders: [
/*
* Tslint loader support for *.ts files
*
* See: https://github.com/wbuchwalter/tslint-loader
*/
// { test: /\.ts$/, loader: 'tslint-loader', exclude: [ helpers.root('node_modules') ] },
/*
* Source map loader support for *.js files
* Extracts SourceMaps for source files that as added as sourceMappingURL comment.
*
* See: https://github.com/webpack/source-map-loader
*/
{
test: /\.js$/,
loader: 'source-map-loader',
exclude: [
// these packages have problems with their sourcemaps
helpers.root('node_modules/rxjs'),
helpers.root('node_modules/@angular')
]
test: /\.ts$/,
loader: 'string-replace-loader',
query: {
search: '(System|SystemJS)(.*[\\n\\r]\\s*\\.|\\.)import\\((.+)\\)',
replace: '$1.import($3).then(mod => (mod.__esModule && mod.default) ? mod.default : mod)',
flags: 'g'
},
include: [helpers.root('src')]
}
],
/*
@ -134,7 +123,11 @@ module.exports = {
*/
{
test: /\.ts$/,
loader: 'awesome-typescript-loader',
loaders: [
'@angularclass/hmr-loader?pretty=' + !isProd + '&prod=' + isProd,
'awesome-typescript-loader',
'angular2-template-loader'
],
exclude: [/\.(spec|e2e)\.ts$/]
},
@ -149,15 +142,11 @@ module.exports = {
},
{
test: /\.scss$/,
exclude: /node_modules/,
loaders: [ 'raw-loader', 'sass-loader' ]
},
{
test: /\.(woff2?|ttf|eot|svg)$/,
loader: 'url?limit=10000&name=assets/fonts/[hash].[ext]'
test: /\.(sass|scss)$/,
loaders: ['css-to-string-loader', 'css-loader?sourceMap', 'resolve-url', 'sass-loader?sourceMap']
},
{ test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: 'url?limit=10000&minetype=application/font-woff' },
{ test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: 'file' },
/* Raw loader support for *.html
* Returns file content as string
@ -184,6 +173,11 @@ module.exports = {
* See: http://webpack.github.io/docs/configuration.html#plugins
*/
plugins: [
new AssetsPlugin({
path: helpers.root('dist'),
filename: 'webpack-assets.json',
prettyPrint: true
}),
/*
* Plugin: ForkCheckerPlugin
@ -193,16 +187,6 @@ module.exports = {
*/
new ForkCheckerPlugin(),
/*
* Plugin: OccurenceOrderPlugin
* Description: Varies the distribution of the ids to get the smallest id length
* for often used ids.
*
* See: https://webpack.github.io/docs/list-of-plugins.html#occurrenceorderplugin
* See: https://github.com/webpack/docs/wiki/optimization#minimize
*/
new webpack.optimize.OccurenceOrderPlugin(true),
/*
* Plugin: CommonsChunkPlugin
* Description: Shares common code between the pages.
@ -215,6 +199,19 @@ module.exports = {
name: [ 'polyfills', 'vendor' ].reverse()
}),
/**
* Plugin: ContextReplacementPlugin
* Description: Provides context to Angular's use of System.import
*
* See: https://webpack.github.io/docs/list-of-plugins.html#contextreplacementplugin
* See: https://github.com/angular/angular/issues/11580
*/
new ContextReplacementPlugin(
// The (\\|\/) piece accounts for path separators in *nix and Windows
/angular(\\|\/)core(\\|\/)(esm(\\|\/)src|src)(\\|\/)linker/,
helpers.root('src') // location of your src
),
/*
* Plugin: CopyWebpackPlugin
* Description: Copy files and directories in webpack.
@ -265,5 +262,5 @@ module.exports = {
clearImmediate: false,
setImmediate: false
}
}
}

View File

@ -6,15 +6,18 @@ const commonConfig = require('./webpack.common.js') // the settings that are com
* Webpack Plugins
*/
const DefinePlugin = require('webpack/lib/DefinePlugin')
const NamedModulesPlugin = require('webpack/lib/NamedModulesPlugin')
/**
* Webpack Constants
*/
const ENV = process.env.ENV = process.env.NODE_ENV = 'development'
const HOST = process.env.HOST || 'localhost'
const PORT = process.env.PORT || 3000
const HMR = helpers.hasProcessFlag('hot')
const METADATA = webpackMerge(commonConfig.metadata, {
host: 'localhost',
port: 3000,
const METADATA = webpackMerge(commonConfig({env: ENV}).metadata, {
host: HOST,
port: PORT,
ENV: ENV,
HMR: HMR
})
@ -24,7 +27,8 @@ const METADATA = webpackMerge(commonConfig.metadata, {
*
* See: http://webpack.github.io/docs/configuration.html#cli
*/
module.exports = webpackMerge(commonConfig, {
module.exports = function (env) {
return webpackMerge(commonConfig({env: ENV}), {
/**
* Merged metadata from webpack.common.js for index.html
*
@ -81,7 +85,10 @@ module.exports = webpackMerge(commonConfig, {
*
* See: http://webpack.github.io/docs/configuration.html#output-chunkfilename
*/
chunkFilename: '[id].chunk.js'
chunkFilename: '[id].chunk.js',
library: 'ac_[name]',
libraryTarget: 'var'
},
@ -109,7 +116,9 @@ module.exports = webpackMerge(commonConfig, {
'NODE_ENV': JSON.stringify(METADATA.ENV),
'HMR': METADATA.HMR
}
})
}),
new NamedModulesPlugin()
],
/**
@ -124,6 +133,17 @@ module.exports = webpackMerge(commonConfig, {
resourcePath: 'src'
},
devServer: {
port: METADATA.port,
host: METADATA.host,
historyApiFallback: true,
watchOptions: {
aggregateTimeout: 300,
poll: 1000
},
outputPath: helpers.root('dist')
},
/*
* Include polyfills or mocks for various node stuff
* Description: Node configuration
@ -138,5 +158,5 @@ module.exports = webpackMerge(commonConfig, {
clearImmediate: false,
setImmediate: false
}
})
}

View File

@ -9,10 +9,12 @@ const commonConfig = require('./webpack.common.js') // the settings that are com
/**
* Webpack Plugins
*/
// const ProvidePlugin = require('webpack/lib/ProvidePlugin')
const DefinePlugin = require('webpack/lib/DefinePlugin')
const DedupePlugin = require('webpack/lib/optimize/DedupePlugin')
const NormalModuleReplacementPlugin = require('webpack/lib/NormalModuleReplacementPlugin')
// const IgnorePlugin = require('webpack/lib/IgnorePlugin')
// const DedupePlugin = require('webpack/lib/optimize/DedupePlugin')
const UglifyJsPlugin = require('webpack/lib/optimize/UglifyJsPlugin')
const CompressionPlugin = require('compression-webpack-plugin')
const WebpackMd5Hash = require('webpack-md5-hash')
/**
@ -21,14 +23,15 @@ const WebpackMd5Hash = require('webpack-md5-hash')
const ENV = process.env.NODE_ENV = process.env.ENV = 'production'
const HOST = process.env.HOST || 'localhost'
const PORT = process.env.PORT || 8080
const METADATA = webpackMerge(commonConfig.metadata, {
const METADATA = webpackMerge(commonConfig({env: ENV}).metadata, {
host: HOST,
port: PORT,
ENV: ENV,
HMR: false
})
module.exports = webpackMerge(commonConfig, {
module.exports = function (env) {
return webpackMerge(commonConfig({env: ENV}), {
/**
* Switch loaders to debug mode.
*
@ -110,7 +113,7 @@ module.exports = webpackMerge(commonConfig, {
* See: https://webpack.github.io/docs/list-of-plugins.html#defineplugin
* See: https://github.com/webpack/docs/wiki/optimization#deduplication
*/
new DedupePlugin(),
// new DedupePlugin(),
/**
* Plugin: DefinePlugin
@ -156,19 +159,16 @@ module.exports = webpackMerge(commonConfig, {
// comments: true, //debug
beautify: false, // prod
mangle: {
screw_ie8: true,
keep_fnames: true
}, // prod
compress: {
screw_ie8: true
}, // prod
mangle: { screw_ie8: true, keep_fnames: true }, // prod
compress: { screw_ie8: true }, // prod
comments: false // prod
}),
new NormalModuleReplacementPlugin(
/angular2-hmr/,
helpers.root('config/modules/angular2-hmr-prod.js')
)
/**
* Plugin: CompressionPlugin
* Description: Prepares compressed versions of assets to serve
@ -176,10 +176,10 @@ module.exports = webpackMerge(commonConfig, {
*
* See: https://github.com/webpack/compression-webpack-plugin
*/
new CompressionPlugin({
regExp: /\.css$|\.html$|\.js$|\.map$/,
threshold: 2 * 1024
})
// new CompressionPlugin({
// regExp: /\.css$|\.html$|\.js$|\.map$/,
// threshold: 2 * 1024
// })
],
@ -229,3 +229,4 @@ module.exports = webpackMerge(commonConfig, {
}
})
}

View File

@ -13,61 +13,72 @@
"url": "git://github.com/Chocobozzz/PeerTube.git"
},
"scripts": {
"postinstall": "typings install",
"test": "standard && tslint -c ./tslint.json src/**/*.ts",
"webpack": "webpack"
},
"license": "GPLv3",
"dependencies": {
"@angular/common": "2.0.0-rc.4",
"@angular/compiler": "2.0.0-rc.4",
"@angular/core": "2.0.0-rc.4",
"@angular/http": "2.0.0-rc.4",
"@angular/platform-browser": "2.0.0-rc.4",
"@angular/platform-browser-dynamic": "2.0.0-rc.4",
"@angular/router": "3.0.0-beta.2",
"angular-pipes": "^2.0.0",
"awesome-typescript-loader": "^0.17.0",
"bootstrap-loader": "^1.0.8",
"@angular/common": "^2.0.0",
"@angular/compiler": "^2.0.0",
"@angular/core": "^2.0.0",
"@angular/forms": "^2.0.0",
"@angular/http": "^2.0.0",
"@angular/platform-browser": "^2.0.0",
"@angular/platform-browser-dynamic": "^2.0.0",
"@angular/router": "^3.0.0",
"@angularclass/hmr": "^1.2.0",
"@angularclass/hmr-loader": "^3.0.2",
"@types/core-js": "^0.9.28",
"@types/node": "^6.0.38",
"@types/source-map": "^0.1.26",
"@types/uglify-js": "^2.0.27",
"@types/webpack": "^1.12.29",
"angular-pipes": "^3.0.0",
"angular2-template-loader": "^0.5.0",
"assets-webpack-plugin": "^3.4.0",
"awesome-typescript-loader": "^2.2.1",
"bootstrap-loader": "^2.0.0-beta.11",
"bootstrap-sass": "^3.3.6",
"compression-webpack-plugin": "^0.3.1",
"copy-webpack-plugin": "^3.0.1",
"core-js": "^2.4.0",
"css-loader": "^0.23.1",
"core-js": "^2.4.1",
"css-loader": "^0.25.0",
"css-to-string-loader": "https://github.com/Chocobozzz/css-to-string-loader#patch-1",
"es6-promise": "^3.0.2",
"es6-promise-loader": "^1.0.1",
"es6-shim": "^0.35.0",
"file-loader": "^0.8.5",
"extract-text-webpack-plugin": "^2.0.0-beta.4",
"file-loader": "^0.9.0",
"html-webpack-plugin": "^2.19.0",
"ie-shim": "^0.1.0",
"intl": "^1.2.4",
"json-loader": "^0.5.4",
"ng2-bootstrap": "1.0.16",
"ng2-bootstrap": "^1.1.5",
"ng2-file-upload": "^1.0.3",
"node-sass": "^3.7.0",
"node-sass": "^3.10.0",
"normalize.css": "^4.1.1",
"raw-loader": "^0.5.1",
"reflect-metadata": "0.1.3",
"resolve-url-loader": "^1.4.3",
"rxjs": "5.0.0-beta.6",
"sass-loader": "^3.2.0",
"resolve-url-loader": "^1.6.0",
"rxjs": "5.0.0-beta.12",
"sass-loader": "^4.0.2",
"source-map-loader": "^0.1.5",
"string-replace-loader": "^1.0.3",
"style-loader": "^0.13.1",
"ts-helpers": "^1.1.1",
"tslint": "^3.7.4",
"tslint": "3.15.1",
"tslint-loader": "^2.1.4",
"typescript": "^1.8.10",
"typings": "^1.0.4",
"typescript": "^2.0.0",
"url-loader": "^0.5.7",
"webpack": "^1.13.1",
"webpack": "2.1.0-beta.22",
"webpack-md5-hash": "0.0.5",
"webpack-merge": "^0.13.0",
"webpack-merge": "^0.14.1",
"webpack-notifier": "^1.3.0",
"webtorrent": "^0.95.2",
"zone.js": "0.6.12"
"webtorrent": "^0.96.0",
"zone.js": "0.6.23"
},
"devDependencies": {
"codelyzer": "0.0.19",
"standard": "^7.0.1"
"codelyzer": "0.0.28",
"standard": "^8.0.0"
}
}

View File

@ -0,0 +1,27 @@
<h3>Account</h3>
<div *ngIf="information" class="alert alert-success">{{ information }}</div>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<form role="form" (ngSubmit)="changePassword()" [formGroup]="form">
<div class="form-group">
<label for="new-password">New password</label>
<input
type="password" class="form-control" id="new-password"
formControlName="new-password"
>
<div *ngIf="formErrors['new-password']" class="alert alert-danger">
{{ formErrors['new-password'] }}
</div>
</div>
<div class="form-group">
<label for="name">Confirm new password</label>
<input
type="password" class="form-control" id="new-confirmed-password"
formControlName="new-confirmed-password"
>
</div>
<input type="submit" value="Change password" class="btn btn-default" [disabled]="!form.valid">
</form>

View File

@ -0,0 +1,67 @@
import { } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { AccountService } from './account.service';
import { FormReactive, USER_PASSWORD } from '../shared';
@Component({
selector: 'my-account',
templateUrl: './account.component.html'
})
export class AccountComponent extends FormReactive implements OnInit {
information: string = null;
error: string = null;
form: FormGroup;
formErrors = {
'new-password': '',
'new-confirmed-password': ''
};
validationMessages = {
'new-password': USER_PASSWORD.MESSAGES,
'new-confirmed-password': USER_PASSWORD.MESSAGES
};
constructor(
private accountService: AccountService,
private formBuilder: FormBuilder,
private router: Router
) {
super();
}
buildForm() {
this.form = this.formBuilder.group({
'new-password': [ '', USER_PASSWORD.VALIDATORS ],
'new-confirmed-password': [ '', USER_PASSWORD.VALIDATORS ],
});
this.form.valueChanges.subscribe(data => this.onValueChanged(data));
}
ngOnInit() {
this.buildForm();
}
changePassword() {
const newPassword = this.form.value['new-password'];
const newConfirmedPassword = this.form.value['new-confirmed-password'];
this.information = null;
this.error = null;
if (newPassword !== newConfirmedPassword) {
this.error = 'The new password and the confirmed password do not correspond.';
return;
}
this.accountService.changePassword(newPassword).subscribe(
ok => this.information = 'Password updated.',
err => this.error = err
);
}
}

View File

@ -0,0 +1,5 @@
import { AccountComponent } from './account.component';
export const AccountRoutes = [
{ path: 'account', component: AccountComponent }
];

View File

@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { AuthHttp, AuthService, RestExtractor } from '../shared';
@Injectable()
export class AccountService {
private static BASE_USERS_URL = '/api/v1/users/';
constructor(
private authHttp: AuthHttp,
private authService: AuthService,
private restExtractor: RestExtractor
) {}
changePassword(newPassword: string) {
const url = AccountService.BASE_USERS_URL + this.authService.getUser().id;
const body = {
password: newPassword
};
return this.authHttp.put(url, body)
.map(this.restExtractor.extractDataBool)
.catch((res) => this.restExtractor.handleError(res));
}
}

View File

@ -0,0 +1,3 @@
export * from './account.component';
export * from './account.routes';
export * from './account.service';

View File

@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
template: '<router-outlet></router-outlet>'
})
export class AdminComponent {
}

View File

@ -0,0 +1,23 @@
import { Routes } from '@angular/router';
import { AdminComponent } from './admin.component';
import { FriendsRoutes } from './friends';
import { RequestsRoutes } from './requests';
import { UsersRoutes } from './users';
export const AdminRoutes: Routes = [
{
path: 'admin',
component: AdminComponent,
children: [
{
path: '',
redirectTo: 'users',
pathMatch: 'full'
},
...FriendsRoutes,
...RequestsRoutes,
...UsersRoutes
]
}
];

View File

@ -0,0 +1,26 @@
<h3>Make friends</h3>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<form (ngSubmit)="makeFriends()" [formGroup]="form">
<div class="form-group" *ngFor="let url of urls; let id = index; trackBy:customTrackBy">
<label for="username">Url</label>
<div class="input-group">
<input
type="text" class="form-control" placeholder="http://domain.com"
[id]="'url-' + id" [formControlName]="'url-' + id"
/>
<span class="input-group-btn">
<button *ngIf="displayAddField(id)" (click)="addField()" class="btn btn-default" type="button">+</button>
<button *ngIf="displayRemoveField(id)" (click)="removeField(id)" class="btn btn-default" type="button">-</button>
</span>
</div>
<div [hidden]="form.controls['url-' + id].valid || form.controls['url-' + id].pristine" class="alert alert-warning">
It should be a valid url.
</div>
</div>
<input type="submit" value="Make friends" class="btn btn-default" [disabled]="!isFormValid()">
</form>

View File

@ -0,0 +1,7 @@
table {
margin-bottom: 40px;
}
.input-group-btn button {
width: 35px;
}

View File

@ -0,0 +1,108 @@
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { validateUrl } from '../../../shared';
import { FriendService } from '../shared';
@Component({
selector: 'my-friend-add',
templateUrl: './friend-add.component.html',
styleUrls: [ './friend-add.component.scss' ]
})
export class FriendAddComponent implements OnInit {
form: FormGroup;
urls = [ ];
error: string = null;
constructor(private router: Router, private friendService: FriendService) {}
ngOnInit() {
this.form = new FormGroup({});
this.addField();
}
addField() {
this.form.addControl(`url-${this.urls.length}`, new FormControl('', [ validateUrl ]));
this.urls.push('');
}
customTrackBy(index: number, obj: any): any {
return index;
}
displayAddField(index: number) {
return index === (this.urls.length - 1);
}
displayRemoveField(index: number) {
return (index !== 0 || this.urls.length > 1) && index !== (this.urls.length - 1);
}
isFormValid() {
// Do not check the last input
for (let i = 0; i < this.urls.length - 1; i++) {
if (!this.form.controls[`url-${i}`].valid) return false;
}
const lastIndex = this.urls.length - 1;
// If the last input (which is not the first) is empty, it's ok
if (this.urls[lastIndex] === '' && lastIndex !== 0) {
return true;
} else {
return this.form.controls[`url-${lastIndex}`].valid;
}
}
removeField(index: number) {
// Remove the last control
this.form.removeControl(`url-${this.urls.length - 1}`);
this.urls.splice(index, 1);
}
makeFriends() {
this.error = '';
const notEmptyUrls = this.getNotEmptyUrls();
if (notEmptyUrls.length === 0) {
this.error = 'You need to specify at less 1 url.';
return;
}
if (!this.isUrlsUnique(notEmptyUrls)) {
this.error = 'Urls need to be unique.';
return;
}
const confirmMessage = 'Are you sure to make friends with:\n - ' + notEmptyUrls.join('\n - ');
if (!confirm(confirmMessage)) return;
this.friendService.makeFriends(notEmptyUrls).subscribe(
status => {
// TODO: extractdatastatus
// if (status === 409) {
// alert('Already made friends!');
// } else {
alert('Make friends request sent!');
this.router.navigate([ '/admin/friends/list' ]);
// }
},
error => alert(error.text)
);
}
private getNotEmptyUrls() {
const notEmptyUrls = [];
Object.keys(this.form.value).forEach((urlKey) => {
const url = this.form.value[urlKey];
if (url !== '') notEmptyUrls.push(url);
});
return notEmptyUrls;
}
private isUrlsUnique(urls: string[]) {
return urls.every(url => urls.indexOf(url) === urls.lastIndexOf(url));
}
}

View File

@ -0,0 +1 @@
export * from './friend-add.component';

View File

@ -0,0 +1,29 @@
<h3>Friends list</h3>
<table class="table table-hover">
<thead>
<tr>
<th class="table-column-id">ID</th>
<th>Url</th>
<th>Score</th>
<th>Created Date</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let friend of friends">
<td>{{ friend.id }}</td>
<td>{{ friend.url }}</td>
<td>{{ friend.score }}</td>
<td>{{ friend.createdDate | date: 'medium' }}</td>
</tr>
</tbody>
</table>
<a *ngIf="friends?.length !== 0" class="add-user btn btn-danger pull-left" (click)="quitFriends()">
Quit friends
</a>
<a *ngIf="friends?.length === 0" class="add-user btn btn-success pull-right" [routerLink]="['/admin/friends/add']">
Make friends
</a>

View File

@ -0,0 +1,3 @@
table {
margin-bottom: 40px;
}

View File

@ -0,0 +1,38 @@
import { Component, OnInit } from '@angular/core';
import { Friend, FriendService } from '../shared';
@Component({
selector: 'my-friend-list',
templateUrl: './friend-list.component.html',
styleUrls: [ './friend-list.component.scss' ]
})
export class FriendListComponent implements OnInit {
friends: Friend[];
constructor(private friendService: FriendService) { }
ngOnInit() {
this.getFriends();
}
quitFriends() {
if (!confirm('Are you sure?')) return;
this.friendService.quitFriends().subscribe(
status => {
alert('Quit friends!');
this.getFriends();
},
error => alert(error.text)
);
}
private getFriends() {
this.friendService.getFriends().subscribe(
friends => this.friends = friends,
err => alert(err.text)
);
}
}

View File

@ -0,0 +1 @@
export * from './friend-list.component';

View File

@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
template: '<router-outlet></router-outlet>'
})
export class FriendsComponent {
}

View File

@ -0,0 +1,27 @@
import { Routes } from '@angular/router';
import { FriendsComponent } from './friends.component';
import { FriendAddComponent } from './friend-add';
import { FriendListComponent } from './friend-list';
export const FriendsRoutes: Routes = [
{
path: 'friends',
component: FriendsComponent,
children: [
{
path: '',
redirectTo: 'list',
pathMatch: 'full'
},
{
path: 'list',
component: FriendListComponent
},
{
path: 'add',
component: FriendAddComponent
}
]
}
];

View File

@ -0,0 +1,5 @@
export * from './friend-add';
export * from './friend-list';
export * from './shared';
export * from './friends.component';
export * from './friends.routes';

View File

@ -0,0 +1,6 @@
export interface Friend {
id: string;
url: string;
score: number;
createdDate: Date;
}

View File

@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Friend } from './friend.model';
import { AuthHttp, RestExtractor } from '../../../shared';
@Injectable()
export class FriendService {
private static BASE_FRIEND_URL: string = '/api/v1/pods/';
constructor (
private authHttp: AuthHttp,
private restExtractor: RestExtractor
) {}
getFriends(): Observable<Friend[]> {
return this.authHttp.get(FriendService.BASE_FRIEND_URL)
// Not implemented as a data list by the server yet
// .map(this.restExtractor.extractDataList)
.map((res) => res.json())
.catch((res) => this.restExtractor.handleError(res));
}
makeFriends(notEmptyUrls) {
const body = {
urls: notEmptyUrls
};
return this.authHttp.post(FriendService.BASE_FRIEND_URL + 'makefriends', body)
.map(this.restExtractor.extractDataBool)
.catch((res) => this.restExtractor.handleError(res));
}
quitFriends() {
return this.authHttp.get(FriendService.BASE_FRIEND_URL + 'quitfriends')
.map(res => res.status)
.catch((res) => this.restExtractor.handleError(res));
}
}

View File

@ -1 +1,2 @@
export * from './friend.model';
export * from './friend.service';

View File

@ -0,0 +1,6 @@
export * from './friends';
export * from './requests';
export * from './users';
export * from './admin.component';
export * from './admin.routes';
export * from './menu-admin.component';

View File

@ -0,0 +1,26 @@
<menu class="col-md-2 col-sm-3 col-xs-3">
<div class="panel-block">
<div id="panel-users" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-user"></span>
<a [routerLink]="['/admin/users/list']">List users</a>
</div>
<div id="panel-friends" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-cloud"></span>
<a [routerLink]="['/admin/friends/list']">List friends</a>
</div>
<div id="panel-request-stats" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-stats"></span>
<a [routerLink]="['/admin/requests/stats']">Request stats</a>
</div>
</div>
<div class="panel-block">
<div id="panel-quit-administration" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-cog"></span>
<a [routerLink]="['/videos/list']">Quit admin.</a>
</div>
</div>
</menu>

View File

@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
selector: 'my-menu-admin',
templateUrl: './menu-admin.component.html'
})
export class MenuAdminComponent { }

View File

@ -0,0 +1,4 @@
export * from './request-stats';
export * from './shared';
export * from './requests.component';
export * from './requests.routes';

View File

@ -0,0 +1 @@
export * from './request-stats.component';

View File

@ -0,0 +1,23 @@
<h3>Requests stats</h3>
<div *ngIf="stats !== null">
<div>
<span class="label-description">Interval seconds between requests:</span>
{{ stats.secondsInterval }}
</div>
<div>
<span class="label-description">Remaining time before the scheduled request:</span>
{{ stats.remainingSeconds }}
</div>
<div>
<span class="label-description">Maximum number of requests per interval:</span>
{{ stats.maxRequestsInParallel }}
</div>
<div>
<span class="label-description">Remaining requests:</span>
{{ stats.requests.length }}
</div>
</div>

View File

@ -0,0 +1,6 @@
.label-description {
display: inline-block;
width: 350px;
font-weight: bold;
color: black;
}

View File

@ -0,0 +1,51 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { RequestService, RequestStats } from '../shared';
@Component({
selector: 'my-request-stats',
templateUrl: './request-stats.component.html',
styleUrls: [ './request-stats.component.scss' ]
})
export class RequestStatsComponent implements OnInit, OnDestroy {
stats: RequestStats = null;
private interval: NodeJS.Timer = null;
constructor(private requestService: RequestService) { }
ngOnInit() {
this.getStats();
}
ngOnDestroy() {
if (this.stats.secondsInterval !== null) {
clearInterval(this.interval);
}
}
getStats() {
this.requestService.getStats().subscribe(
stats => {
console.log(stats);
this.stats = stats;
this.runInterval();
},
err => alert(err.text)
);
}
private runInterval() {
this.interval = setInterval(() => {
this.stats.remainingMilliSeconds -= 1000;
if (this.stats.remainingMilliSeconds <= 0) {
setTimeout(() => this.getStats(), this.stats.remainingMilliSeconds + 100);
clearInterval(this.interval);
}
}, 1000);
}
}

View File

@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
template: '<router-outlet></router-outlet>'
})
export class RequestsComponent {
}

View File

@ -0,0 +1,22 @@
import { Routes } from '@angular/router';
import { RequestsComponent } from './requests.component';
import { RequestStatsComponent } from './request-stats';
export const RequestsRoutes: Routes = [
{
path: 'requests',
component: RequestsComponent,
children: [
{
path: '',
redirectTo: 'stats',
pathMatch: 'full'
},
{
path: 'stats',
component: RequestStatsComponent
}
]
}
];

View File

@ -0,0 +1,2 @@
export * from './request-stats.model';
export * from './request.service';

View File

@ -0,0 +1,32 @@
export interface Request {
request: any;
to: any;
}
export class RequestStats {
maxRequestsInParallel: number;
milliSecondsInterval: number;
remainingMilliSeconds: number;
requests: Request[];
constructor(hash: {
maxRequestsInParallel: number,
milliSecondsInterval: number,
remainingMilliSeconds: number,
requests: Request[];
}) {
this.maxRequestsInParallel = hash.maxRequestsInParallel;
this.milliSecondsInterval = hash.milliSecondsInterval;
this.remainingMilliSeconds = hash.remainingMilliSeconds;
this.requests = hash.requests;
}
get remainingSeconds() {
return Math.floor(this.remainingMilliSeconds / 1000);
}
get secondsInterval() {
return Math.floor(this.milliSecondsInterval / 1000);
}
}

View File

@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { RequestStats } from './request-stats.model';
import { AuthHttp, RestExtractor } from '../../../shared';
@Injectable()
export class RequestService {
private static BASE_REQUEST_URL: string = '/api/v1/requests/';
constructor (
private authHttp: AuthHttp,
private restExtractor: RestExtractor
) {}
getStats(): Observable<RequestStats> {
return this.authHttp.get(RequestService.BASE_REQUEST_URL + 'stats')
.map(this.restExtractor.extractDataGet)
.map((data) => new RequestStats(data))
.catch((res) => this.restExtractor.handleError(res));
}
}

View File

@ -0,0 +1,5 @@
export * from './shared';
export * from './user-add';
export * from './user-list';
export * from './users.component';
export * from './users.routes';

View File

@ -0,0 +1 @@
export * from './user.service';

View File

@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { AuthHttp, RestExtractor, ResultList, User } from '../../../shared';
@Injectable()
export class UserService {
// TODO: merge this constant with account
private static BASE_USERS_URL = '/api/v1/users/';
constructor(
private authHttp: AuthHttp,
private restExtractor: RestExtractor
) {}
addUser(username: string, password: string) {
const body = {
username,
password
};
return this.authHttp.post(UserService.BASE_USERS_URL, body)
.map(this.restExtractor.extractDataBool)
.catch(this.restExtractor.handleError);
}
getUsers() {
return this.authHttp.get(UserService.BASE_USERS_URL)
.map(this.restExtractor.extractDataList)
.map(this.extractUsers)
.catch((res) => this.restExtractor.handleError(res));
}
removeUser(user: User) {
return this.authHttp.delete(UserService.BASE_USERS_URL + user.id);
}
private extractUsers(result: ResultList) {
const usersJson = result.data;
const totalUsers = result.total;
const users = [];
for (const userJson of usersJson) {
users.push(new User(userJson));
}
return { users, totalUsers };
}
}

View File

@ -0,0 +1 @@
export * from './user-add.component';

View File

@ -0,0 +1,29 @@
<h3>Add user</h3>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<form role="form" (ngSubmit)="addUser()" [formGroup]="form">
<div class="form-group">
<label for="username">Username</label>
<input
type="text" class="form-control" id="username" placeholder="Username"
formControlName="username"
>
<div *ngIf="formErrors.username" class="alert alert-danger">
{{ formErrors.username }}
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password" class="form-control" id="password" placeholder="Password"
formControlName="password"
>
<div *ngIf="formErrors.password" class="alert alert-danger">
{{ formErrors.password }}
</div>
</div>
<input type="submit" value="Add user" class="btn btn-default" [disabled]="!form.valid">
</form>

View File

@ -0,0 +1,57 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { UserService } from '../shared';
import { FormReactive, USER_USERNAME, USER_PASSWORD } from '../../../shared';
@Component({
selector: 'my-user-add',
templateUrl: './user-add.component.html'
})
export class UserAddComponent extends FormReactive implements OnInit {
error: string = null;
form: FormGroup;
formErrors = {
'username': '',
'password': ''
};
validationMessages = {
'username': USER_USERNAME.MESSAGES,
'password': USER_PASSWORD.MESSAGES,
};
constructor(
private formBuilder: FormBuilder,
private router: Router,
private userService: UserService
) {
super();
}
buildForm() {
this.form = this.formBuilder.group({
username: [ '', USER_USERNAME.VALIDATORS ],
password: [ '', USER_PASSWORD.VALIDATORS ],
});
this.form.valueChanges.subscribe(data => this.onValueChanged(data));
}
ngOnInit() {
this.buildForm();
}
addUser() {
this.error = null;
const { username, password } = this.form.value;
this.userService.addUser(username, password).subscribe(
ok => this.router.navigate([ '/admin/users/list' ]),
err => this.error = err.text
);
}
}

View File

@ -0,0 +1 @@
export * from './user-list.component';

View File

@ -0,0 +1,28 @@
<h3>Users list</h3>
<table class="table table-hover">
<thead>
<tr>
<th class="table-column-id">ID</th>
<th>Username</th>
<th>Created Date</th>
<th class="text-right">Remove</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let user of users">
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.createdDate | date: 'medium' }}</td>
<td class="text-right">
<span class="glyphicon glyphicon-remove" *ngIf="!user.isAdmin()" (click)="removeUser(user)"></span>
</td>
</tr>
</tbody>
</table>
<a class="add-user btn btn-success pull-right" [routerLink]="['/admin/users/add']">
<span class="glyphicon glyphicon-plus"></span>
Add user
</a>

View File

@ -0,0 +1,7 @@
.glyphicon-remove {
cursor: pointer;
}
.add-user {
margin-top: 10px;
}

View File

@ -0,0 +1,42 @@
import { Component, OnInit } from '@angular/core';
import { User } from '../../../shared';
import { UserService } from '../shared';
@Component({
selector: 'my-user-list',
templateUrl: './user-list.component.html',
styleUrls: [ './user-list.component.scss' ]
})
export class UserListComponent implements OnInit {
totalUsers: number;
users: User[];
constructor(private userService: UserService) {}
ngOnInit() {
this.getUsers();
}
getUsers() {
this.userService.getUsers().subscribe(
({ users, totalUsers }) => {
this.users = users;
this.totalUsers = totalUsers;
},
err => alert(err.text)
);
}
removeUser(user: User) {
if (confirm('Are you sure?')) {
this.userService.removeUser(user).subscribe(
() => this.getUsers(),
err => alert(err.text)
);
}
}
}

View File

@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
template: '<router-outlet></router-outlet>'
})
export class UsersComponent {
}

View File

@ -0,0 +1,27 @@
import { Routes } from '@angular/router';
import { UsersComponent } from './users.component';
import { UserAddComponent } from './user-add';
import { UserListComponent } from './user-list';
export const UsersRoutes: Routes = [
{
path: 'users',
component: UsersComponent,
children: [
{
path: '',
redirectTo: 'list',
pathMatch: 'full'
},
{
path: 'list',
component: UserListComponent
},
{
path: 'add',
component: UserAddComponent
}
]
}
];

View File

@ -14,48 +14,14 @@
<div class="row">
<menu class="col-md-2 col-sm-3 col-xs-3">
<div class="panel-block">
<div id="panel-user-login" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-user"></span>
<a *ngIf="!isLoggedIn" [routerLink]="['/login']">Login</a>
<a *ngIf="isLoggedIn" (click)="logout()">Logout</a>
</div>
</div>
<div class="panel-block">
<div id="panel-get-videos" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-list"></span>
<a [routerLink]="['/videos/list']">Get videos</a>
</div>
<div id="panel-upload-video" class="panel-button" *ngIf="isLoggedIn">
<span class="hidden-xs glyphicon glyphicon-cloud-upload"></span>
<a [routerLink]="['/videos/add']">Upload a video</a>
</div>
</div>
<div class="panel-block" *ngIf="isLoggedIn">
<div id="panel-make-friends" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-cloud"></span>
<a (click)='makeFriends()'>Make friends</a>
</div>
<div id="panel-quit-friends" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-plane"></span>
<a (click)='quitFriends()'>Quit friends</a>
</div>
</div>
</menu>
<my-menu *ngIf="isInAdmin() === false"></my-menu>
<my-menu-admin *ngIf="isInAdmin() === true"></my-menu-admin>
<div class="col-md-9 col-sm-8 col-xs-8 router-outlet-container">
<router-outlet></router-outlet>
</div>
</div>
<footer>
PeerTube, CopyLeft 2015-2016
</footer>

View File

@ -12,40 +12,6 @@ header div {
margin-bottom: 30px;
}
menu {
@media screen and (max-width: 600px) {
margin-right: 3px !important;
padding: 3px !important;
min-height: 400px !important;
}
min-height: 600px;
margin-right: 20px;
border-right: 1px solid rgba(0, 0, 0, 0.2);
.panel-button {
margin: 8px;
cursor: pointer;
transition: margin 0.2s;
&:hover {
margin-left: 15px;
}
a {
color: #333333;
}
}
.glyphicon {
margin: 5px;
}
}
.panel-block:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.router-outlet-container {
@media screen and (max-width: 400px) {
padding: 0 3px 0 3px;

View File

@ -1,73 +1,16 @@
import { Component } from '@angular/core';
import { ActivatedRoute, Router, ROUTER_DIRECTIVES } from '@angular/router';
import { FriendService } from './friends';
import {
AuthService,
AuthStatus,
SearchComponent,
SearchService
} from './shared';
import { VideoService } from './videos';
import { Router } from '@angular/router';
@Component({
selector: 'my-app',
template: require('./app.component.html'),
styles: [ require('./app.component.scss') ],
directives: [ ROUTER_DIRECTIVES, SearchComponent ],
providers: [ FriendService, VideoService, SearchService ]
templateUrl: './app.component.html',
styleUrls: [ './app.component.scss' ]
})
export class AppComponent {
choices = [];
isLoggedIn: boolean;
constructor(private router: Router) {}
constructor(
private authService: AuthService,
private friendService: FriendService,
private route: ActivatedRoute,
private router: Router
) {
this.isLoggedIn = this.authService.isLoggedIn();
this.authService.loginChangedSource.subscribe(
status => {
if (status === AuthStatus.LoggedIn) {
this.isLoggedIn = true;
console.log('Logged in.');
} else if (status === AuthStatus.LoggedOut) {
this.isLoggedIn = false;
console.log('Logged out.');
} else {
console.error('Unknown auth status: ' + status);
}
}
);
}
logout() {
this.authService.logout();
}
makeFriends() {
this.friendService.makeFriends().subscribe(
status => {
if (status === 409) {
alert('Already made friends!');
} else {
alert('Made friends!');
}
},
error => alert(error)
);
}
quitFriends() {
this.friendService.quitFriends().subscribe(
status => {
alert('Quit friends!');
},
error => alert(error)
);
isInAdmin() {
return this.router.url.indexOf('/admin/') !== -1;
}
}

View File

@ -0,0 +1,146 @@
import { ApplicationRef, NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpModule, RequestOptions, XHRBackend } from '@angular/http';
import { RouterModule } from '@angular/router';
import { removeNgStyles, createNewHosts } from '@angularclass/hmr';
import { BytesPipe } from 'angular-pipes/src/math/bytes.pipe';
import { ProgressbarModule } from 'ng2-bootstrap/components/progressbar';
import { PaginationModule } from 'ng2-bootstrap/components/pagination';
import { FileUploadModule } from 'ng2-file-upload/ng2-file-upload';
/*
* Platform and Environment providers/directives/pipes
*/
import { ENV_PROVIDERS } from './environment';
import { routes } from './app.routes';
// App is our top level component
import { AppComponent } from './app.component';
import { AppState } from './app.service';
import {
AdminComponent,
FriendsComponent,
FriendAddComponent,
FriendListComponent,
FriendService,
MenuAdminComponent,
RequestsComponent,
RequestStatsComponent,
RequestService,
UsersComponent,
UserAddComponent,
UserListComponent,
UserService
} from './admin';
import { AccountComponent, AccountService } from './account';
import { LoginComponent } from './login';
import { MenuComponent } from './menu.component';
import { AuthService, AuthHttp, RestExtractor, RestService, SearchComponent, SearchService } from './shared';
import {
LoaderComponent,
VideosComponent,
VideoAddComponent,
VideoListComponent,
VideoMiniatureComponent,
VideoSortComponent,
VideoWatchComponent,
VideoService,
WebTorrentService
} from './videos';
// Application wide providers
const APP_PROVIDERS = [
AppState,
{
provide: AuthHttp,
useFactory: (backend: XHRBackend, defaultOptions: RequestOptions, authService: AuthService) => {
return new AuthHttp(backend, defaultOptions, authService);
},
deps: [ XHRBackend, RequestOptions, AuthService ]
},
AuthService,
RestExtractor,
RestService,
VideoService,
SearchService,
FriendService,
RequestService,
UserService,
AccountService,
WebTorrentService
];
/**
* `AppModule` is the main entry point into Angular2's bootstraping process
*/
@NgModule({
bootstrap: [ AppComponent ],
declarations: [
AccountComponent,
AdminComponent,
AppComponent,
BytesPipe,
FriendAddComponent,
FriendListComponent,
FriendsComponent,
LoaderComponent,
LoginComponent,
MenuAdminComponent,
MenuComponent,
RequestsComponent,
RequestStatsComponent,
SearchComponent,
UserAddComponent,
UserListComponent,
UsersComponent,
VideoAddComponent,
VideoListComponent,
VideoMiniatureComponent,
VideosComponent,
VideoSortComponent,
VideoWatchComponent,
],
imports: [ // import Angular's modules
BrowserModule,
FormsModule,
ReactiveFormsModule,
HttpModule,
RouterModule.forRoot(routes),
ProgressbarModule,
PaginationModule,
FileUploadModule
],
providers: [ // expose our Services and Providers into Angular's dependency injection
ENV_PROVIDERS,
APP_PROVIDERS
]
})
export class AppModule {
constructor(public appRef: ApplicationRef, public appState: AppState) {}
hmrOnInit(store) {
if (!store || !store.state) return;
console.log('HMR store', store);
this.appState._state = store.state;
this.appRef.tick();
delete store.state;
}
hmrOnDestroy(store) {
const cmpLocation = this.appRef.components.map(cmp => cmp.location.nativeElement);
// recreate elements
const state = this.appState._state;
store.state = state;
store.disposeOldHosts = createNewHosts(cmpLocation);
// remove styles
removeNgStyles();
}
hmrAfterDestroy(store) {
// display new elements
store.disposeOldHosts();
delete store.disposeOldHosts;
}
}

View File

@ -1,15 +1,18 @@
import { RouterConfig } from '@angular/router';
import { Routes } from '@angular/router';
import { AccountRoutes } from './account';
import { LoginRoutes } from './login';
import { AdminRoutes } from './admin';
import { VideosRoutes } from './videos';
export const routes: RouterConfig = [
export const routes: Routes = [
{
path: '',
redirectTo: '/videos/list',
pathMatch: 'full'
},
...AdminRoutes,
...AccountRoutes,
...LoginRoutes,
...VideosRoutes
];

View File

@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
@Injectable()
export class AppState {
_state = { };
constructor() { ; }
// already return a clone of the current state
get state() {
return this._state = this._clone(this._state);
}
// never allow mutation
set state(value) {
throw new Error('do not mutate the `.state` directly');
}
get(prop?: any) {
// use our state getter for the clone
const state = this.state;
return state.hasOwnProperty(prop) ? state[prop] : state;
}
set(prop: string, value: any) {
// internally mutate our state
return this._state[prop] = value;
}
_clone(object) {
// simple object clone
return JSON.parse(JSON.stringify( object ));
}
}

View File

@ -0,0 +1,50 @@
// Angular 2
// rc2 workaround
import { enableDebugTools, disableDebugTools } from '@angular/platform-browser';
import { enableProdMode, ApplicationRef } from '@angular/core';
// Environment Providers
let PROVIDERS = [
// common env directives
];
// Angular debug tools in the dev console
// https://github.com/angular/angular/blob/86405345b781a9dc2438c0fbe3e9409245647019/TOOLS_JS.md
let _decorateModuleRef = function identity(value) { return value; };
if ('production' === ENV) {
// Production
disableDebugTools();
enableProdMode();
PROVIDERS = [
...PROVIDERS,
// custom providers in production
];
} else {
_decorateModuleRef = (modRef: any) => {
const appRef = modRef.injector.get(ApplicationRef);
const cmpRef = appRef.components[0];
let _ng = (<any>window).ng;
enableDebugTools(cmpRef);
(<any>window).ng.probe = _ng.probe;
(<any>window).ng.coreTokens = _ng.coreTokens;
return modRef;
};
// Development
PROVIDERS = [
...PROVIDERS,
// custom providers in development
];
}
export const decorateModuleRef = _decorateModuleRef;
export const ENV_PROVIDERS = [
...PROVIDERS
];

View File

@ -1,29 +0,0 @@
import { Injectable } from '@angular/core';
import { Response } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import { AuthHttp, AuthService } from '../shared';
@Injectable()
export class FriendService {
private static BASE_FRIEND_URL: string = '/api/v1/pods/';
constructor (private authHttp: AuthHttp, private authService: AuthService) {}
makeFriends() {
return this.authHttp.get(FriendService.BASE_FRIEND_URL + 'makefriends')
.map(res => res.status)
.catch(this.handleError);
}
quitFriends() {
return this.authHttp.get(FriendService.BASE_FRIEND_URL + 'quitfriends')
.map(res => res.status)
.catch(this.handleError);
}
private handleError (error: Response): Observable<number> {
console.error(error);
return Observable.throw(error.json().error || 'Server error');
}
}

1
client/src/app/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './app.module';

View File

@ -1,17 +1,16 @@
<h3>Login</h3>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<form role="form" (ngSubmit)="login(username.value, password.value)" #loginForm="ngForm">
<form role="form" (ngSubmit)="login()" [formGroup]="form">
<div class="form-group">
<label for="username">Username</label>
<input
type="text" class="form-control" name="username" id="username" placeholder="Username" required
ngControl="username" #username="ngForm"
type="text" class="form-control" id="username" placeholder="Username" required
formControlName="username"
>
<div [hidden]="username.valid || username.pristine" class="alert alert-danger">
Username is required
<div *ngIf="formErrors.username" class="alert alert-danger">
{{ formErrors.username }}
</div>
</div>
@ -19,12 +18,12 @@
<label for="password">Password</label>
<input
type="password" class="form-control" name="password" id="password" placeholder="Password" required
ngControl="password" #password="ngForm"
formControlName="password"
>
<div [hidden]="password.valid || password.pristine" class="alert alert-danger">
Password is required
<div *ngIf="formErrors.password" class="alert alert-danger">
{{ formErrors.password }}
</div>
</div>
<input type="submit" value="Login" class="btn btn-default" [disabled]="!loginForm.form.valid">
<input type="submit" value="Login" class="btn btn-default" [disabled]="!form.valid">
</form>

View File

@ -1,35 +1,67 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../shared';
import { AuthService, FormReactive } from '../shared';
@Component({
selector: 'my-login',
template: require('./login.component.html')
templateUrl: './login.component.html'
})
export class LoginComponent {
export class LoginComponent extends FormReactive implements OnInit {
error: string = null;
form: FormGroup;
formErrors = {
'username': '',
'password': ''
};
validationMessages = {
'username': {
'required': 'Username is required.',
},
'password': {
'required': 'Password is required.'
}
};
constructor(
private authService: AuthService,
private formBuilder: FormBuilder,
private router: Router
) {}
) {
super();
}
login(username: string, password: string) {
this.authService.login(username, password).subscribe(
result => {
buildForm() {
this.form = this.formBuilder.group({
username: [ '', Validators.required ],
password: [ '', Validators.required ],
});
this.form.valueChanges.subscribe(data => this.onValueChanged(data));
}
ngOnInit() {
this.buildForm();
}
login() {
this.error = null;
this.router.navigate(['/videos/list']);
},
error => {
console.error(error);
const { username, password } = this.form.value;
if (error.error === 'invalid_grant') {
this.authService.login(username, password).subscribe(
result => this.router.navigate(['/videos/list']),
error => {
console.error(error.json);
if (error.json.error === 'invalid_grant') {
this.error = 'Credentials are invalid.';
} else {
this.error = `${error.error}: ${error.error_description}`;
this.error = `${error.json.error}: ${error.json.error_description}`;
}
}
);

View File

@ -0,0 +1,39 @@
<menu class="col-md-2 col-sm-3 col-xs-3">
<div class="panel-block">
<div id="panel-user-login" class="panel-button">
<span *ngIf="!isLoggedIn" >
<span class="hidden-xs glyphicon glyphicon-log-in"></span>
<a [routerLink]="['/login']">Login</a>
</span>
<span *ngIf="isLoggedIn">
<span class="hidden-xs glyphicon glyphicon-log-out"></span>
<a *ngIf="isLoggedIn" (click)="logout()">Logout</a>
</span>
</div>
<div *ngIf="isLoggedIn" id="panel-user-account" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-user"></span>
<a [routerLink]="['/account']">My account</a>
</div>
</div>
<div class="panel-block">
<div id="panel-get-videos" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-list"></span>
<a [routerLink]="['/videos/list']">Get videos</a>
</div>
<div id="panel-upload-video" class="panel-button" *ngIf="isLoggedIn">
<span class="hidden-xs glyphicon glyphicon-cloud-upload"></span>
<a [routerLink]="['/videos/add']">Upload a video</a>
</div>
</div>
<div class="panel-block" *ngIf="isUserAdmin()">
<div id="panel-get-videos" class="panel-button">
<span class="hidden-xs glyphicon glyphicon-cog"></span>
<a [routerLink]="['/admin']">Administration</a>
</div>
</div>
</menu>

View File

@ -0,0 +1,45 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService, AuthStatus } from './shared';
@Component({
selector: 'my-menu',
templateUrl: './menu.component.html'
})
export class MenuComponent implements OnInit {
isLoggedIn: boolean;
constructor (
private authService: AuthService,
private router: Router
) {}
ngOnInit() {
this.isLoggedIn = this.authService.isLoggedIn();
this.authService.loginChangedSource.subscribe(
status => {
if (status === AuthStatus.LoggedIn) {
this.isLoggedIn = true;
console.log('Logged in.');
} else if (status === AuthStatus.LoggedOut) {
this.isLoggedIn = false;
console.log('Logged out.');
} else {
console.error('Unknown auth status: ' + status);
}
}
);
}
isUserAdmin() {
return this.authService.isAdmin();
}
logout() {
this.authService.logout();
// Redirect to home page
this.router.navigate(['/videos/list']);
}
}

View File

@ -28,7 +28,7 @@ export class AuthHttp extends Http {
return super.request(url, options)
.catch((err) => {
if (err.status === 401) {
return this.handleTokenExpired(err, url, options);
return this.handleTokenExpired(url, options);
}
return Observable.throw(err);
@ -49,22 +49,25 @@ export class AuthHttp extends Http {
return this.request(url, options);
}
post(url: string, options?: RequestOptionsArgs): Observable<Response> {
post(url: string, body: any, options?: RequestOptionsArgs): Observable<Response> {
if (!options) options = {};
options.method = RequestMethod.Post;
options.body = body;
return this.request(url, options);
}
put(url: string, options?: RequestOptionsArgs): Observable<Response> {
put(url: string, body: any, options?: RequestOptionsArgs): Observable<Response> {
if (!options) options = {};
options.method = RequestMethod.Put;
options.body = body;
return this.request(url, options);
}
private handleTokenExpired(err: Response, url: string | Request, options: RequestOptionsArgs) {
return this.authService.refreshAccessToken().flatMap(() => {
private handleTokenExpired(url: string | Request, options: RequestOptionsArgs) {
return this.authService.refreshAccessToken()
.flatMap(() => {
this.setAuthorizationHeader(options.headers);
return super.request(url, options);

View File

@ -1,15 +1,28 @@
export class User {
import { User } from '../users';
export class AuthUser extends User {
private static KEYS = {
ID: 'id',
ROLE: 'role',
USERNAME: 'username'
};
id: string;
role: string;
username: string;
tokens: Tokens;
static load() {
const usernameLocalStorage = localStorage.getItem(this.KEYS.USERNAME);
if (usernameLocalStorage) {
return new User(localStorage.getItem(this.KEYS.USERNAME), Tokens.load());
return new AuthUser(
{
id: localStorage.getItem(this.KEYS.ID),
username: localStorage.getItem(this.KEYS.USERNAME),
role: localStorage.getItem(this.KEYS.ROLE)
},
Tokens.load()
);
}
return null;
@ -17,12 +30,14 @@ export class User {
static flush() {
localStorage.removeItem(this.KEYS.USERNAME);
localStorage.removeItem(this.KEYS.ID);
localStorage.removeItem(this.KEYS.ROLE);
Tokens.flush();
}
constructor(username: string, hash_tokens: any) {
this.username = username;
this.tokens = new Tokens(hash_tokens);
constructor(userHash: { id: string, username: string, role: string }, hashTokens: any) {
super(userHash);
this.tokens = new Tokens(hashTokens);
}
getAccessToken() {
@ -43,12 +58,14 @@ export class User {
}
save() {
localStorage.setItem('username', this.username);
localStorage.setItem(AuthUser.KEYS.ID, this.id);
localStorage.setItem(AuthUser.KEYS.USERNAME, this.username);
localStorage.setItem(AuthUser.KEYS.ROLE, this.role);
this.tokens.save();
}
}
// Private class used only by User
// Private class only used by User
class Tokens {
private static KEYS = {
ACCESS_TOKEN: 'access_token',

View File

@ -1,32 +1,39 @@
import { Injectable } from '@angular/core';
import { Headers, Http, Response, URLSearchParams } from '@angular/http';
import { Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { AuthStatus } from './auth-status.model';
import { User } from './user.model';
import { AuthUser } from './auth-user.model';
import { RestExtractor } from '../rest';
@Injectable()
export class AuthService {
private static BASE_CLIENT_URL = '/api/v1/users/client';
private static BASE_CLIENT_URL = '/api/v1/clients/local';
private static BASE_TOKEN_URL = '/api/v1/users/token';
private static BASE_USER_INFORMATIONS_URL = '/api/v1/users/me';
loginChangedSource: Observable<AuthStatus>;
private clientId: string;
private clientSecret: string;
private loginChanged: Subject<AuthStatus>;
private user: User = null;
private user: AuthUser = null;
constructor(private http: Http) {
constructor(
private http: Http,
private restExtractor: RestExtractor,
private router: Router
) {
this.loginChanged = new Subject<AuthStatus>();
this.loginChangedSource = this.loginChanged.asObservable();
// Fetch the client_id/client_secret
// FIXME: save in local storage?
this.http.get(AuthService.BASE_CLIENT_URL)
.map(res => res.json())
.catch(this.handleError)
.map(this.restExtractor.extractDataGet)
.catch((res) => this.restExtractor.handleError(res))
.subscribe(
result => {
this.clientId = result.client_id;
@ -34,12 +41,15 @@ export class AuthService {
console.log('Client credentials loaded.');
},
error => {
alert(error);
alert(
`Cannot retrieve OAuth Client credentials: ${error.text}. \n` +
'Ensure you have correctly configured PeerTube (config/ directory), in particular the "webserver" section.'
);
}
);
// Return null if there is nothing to load
this.user = User.load();
this.user = AuthUser.load();
}
getRefreshToken() {
@ -64,10 +74,16 @@ export class AuthService {
return this.user.getTokenType();
}
getUser(): User {
getUser(): AuthUser {
return this.user;
}
isAdmin() {
if (this.user === null) return false;
return this.user.isAdmin();
}
isLoggedIn() {
if (this.getAccessToken()) {
return true;
@ -94,21 +110,23 @@ export class AuthService {
};
return this.http.post(AuthService.BASE_TOKEN_URL, body.toString(), options)
.map(res => res.json())
.map(this.restExtractor.extractDataGet)
.map(res => {
res.username = username;
return res;
})
.flatMap(res => this.fetchUserInformations(res))
.map(res => this.handleLogin(res))
.catch(this.handleError);
.catch((res) => this.restExtractor.handleError(res));
}
logout() {
// TODO: make an HTTP request to revoke the tokens
this.user = null;
User.flush();
this.setStatus(AuthStatus.LoggedIn);
AuthUser.flush();
this.setStatus(AuthStatus.LoggedOut);
}
refreshAccessToken() {
@ -131,36 +149,64 @@ export class AuthService {
};
return this.http.post(AuthService.BASE_TOKEN_URL, body.toString(), options)
.map(res => res.json())
.map(this.restExtractor.extractDataGet)
.map(res => this.handleRefreshToken(res))
.catch(this.handleError);
.catch((res: Response) => {
// The refresh token is invalid?
if (res.status === 400 && res.json() && res.json().error === 'invalid_grant') {
console.error('Cannot refresh token -> logout...');
this.logout();
this.router.navigate(['/login']);
return Observable.throw({
json: '',
text: 'You need to reconnect.'
});
}
private setStatus(status: AuthStatus) {
this.loginChanged.next(status);
return this.restExtractor.handleError(res);
});
}
private fetchUserInformations (obj: any) {
// Do not call authHttp here to avoid circular dependencies headaches
const headers = new Headers();
headers.set('Authorization', `Bearer ${obj.access_token}`);
return this.http.get(AuthService.BASE_USER_INFORMATIONS_URL, { headers })
.map(res => res.json())
.map(res => {
obj.id = res.id;
obj.role = res.role;
return obj;
}
);
}
private handleLogin (obj: any) {
const id = obj.id;
const username = obj.username;
const hash_tokens = {
const role = obj.role;
const hashTokens = {
access_token: obj.access_token,
token_type: obj.token_type,
refresh_token: obj.refresh_token
};
this.user = new User(username, hash_tokens);
this.user = new AuthUser({ id, username, role }, hashTokens);
this.user.save();
this.setStatus(AuthStatus.LoggedIn);
}
private handleError (error: Response) {
console.error(error);
return Observable.throw(error.json() || { error: 'Server error' });
}
private handleRefreshToken (obj: any) {
this.user.refreshTokens(obj.access_token, obj.refresh_token);
this.user.save();
}
private setStatus(status: AuthStatus) {
this.loginChanged.next(status);
}
}

View File

@ -1,4 +1,4 @@
export * from './auth-http.service';
export * from './auth-status.model';
export * from './auth.service';
export * from './user.model';
export * from './auth-user.model';

View File

@ -0,0 +1,24 @@
import { FormGroup } from '@angular/forms';
export abstract class FormReactive {
abstract form: FormGroup;
abstract formErrors: Object;
abstract validationMessages: Object;
abstract buildForm(): void;
protected onValueChanged(data?: any) {
for (const field in this.formErrors) {
// clear previous error message (if any)
this.formErrors[field] = '';
const control = this.form.get(field);
if (control && control.dirty && !control.valid) {
const messages = this.validationMessages[field];
for (const key in control.errors) {
this.formErrors[field] += messages[key] + ' ';
}
}
}
}
}

View File

@ -0,0 +1,3 @@
export * from './url.validator';
export * from './user';
export * from './video';

View File

@ -0,0 +1,11 @@
import { FormControl } from '@angular/forms';
export function validateUrl(c: FormControl) {
let URL_REGEXP = new RegExp('^https?://(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$');
return URL_REGEXP.test(c.value) ? null : {
validateUrl: {
valid: false
}
};
}

View File

@ -0,0 +1,17 @@
import { Validators } from '@angular/forms';
export const USER_USERNAME = {
VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(20) ],
MESSAGES: {
'required': 'Username is required.',
'minlength': 'Username must be at least 3 characters long.',
'maxlength': 'Username cannot be more than 20 characters long.'
}
};
export const USER_PASSWORD = {
VALIDATORS: [ Validators.required, Validators.minLength(6) ],
MESSAGES: {
'required': 'Password is required.',
'minlength': 'Password must be at least 6 characters long.',
}
};

View File

@ -0,0 +1,25 @@
import { Validators } from '@angular/forms';
export const VIDEO_NAME = {
VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(50) ],
MESSAGES: {
'required': 'Video name is required.',
'minlength': 'Video name must be at least 3 characters long.',
'maxlength': 'Video name cannot be more than 50 characters long.'
}
};
export const VIDEO_DESCRIPTION = {
VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(250) ],
MESSAGES: {
'required': 'Video description is required.',
'minlength': 'Video description must be at least 3 characters long.',
'maxlength': 'Video description cannot be more than 250 characters long.'
}
};
export const VIDEO_TAGS = {
VALIDATORS: [ Validators.pattern('^[a-zA-Z0-9]{2,10}$') ],
MESSAGES: {
'pattern': 'A tag should be between 2 and 10 alphanumeric characters long.'
}
};

View File

@ -0,0 +1,2 @@
export * from './form-validators';
export * from './form-reactive';

View File

@ -1,2 +1,5 @@
export * from './auth';
export * from './forms';
export * from './rest';
export * from './search';
export * from './users';

View File

@ -0,0 +1,3 @@
export * from './rest-extractor.service';
export * from './rest-pagination';
export * from './rest.service';

View File

@ -0,0 +1,52 @@
import { Injectable } from '@angular/core';
import { Response } from '@angular/http';
import { Observable } from 'rxjs/Observable';
export interface ResultList {
data: any[];
total: number;
}
@Injectable()
export class RestExtractor {
constructor () { ; }
extractDataBool(res: Response) {
return true;
}
extractDataList(res: Response) {
const body = res.json();
const ret: ResultList = {
data: body.data,
total: body.total
};
return ret;
}
extractDataGet(res: Response) {
return res.json();
}
handleError(res: Response) {
let text = 'Server error: ';
text += res.text();
let json = '';
try {
json = res.json();
} catch (err) { ; }
const error = {
json,
text
};
console.error(error);
return Observable.throw(error);
}
}

View File

@ -1,5 +1,5 @@
export interface Pagination {
export interface RestPagination {
currentPage: number;
itemsPerPage: number;
totalItems: number;
}
};

View File

@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { URLSearchParams } from '@angular/http';
import { RestPagination } from './rest-pagination';
@Injectable()
export class RestService {
buildRestGetParams(pagination?: RestPagination, sort?: string) {
const params = new URLSearchParams();
if (pagination) {
const start: number = (pagination.currentPage - 1) * pagination.itemsPerPage;
const count: number = pagination.itemsPerPage;
params.set('start', start.toString());
params.set('count', count.toString());
}
if (sort) {
params.set('sort', sort);
}
return params;
}
}

View File

@ -1,6 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { DROPDOWN_DIRECTIVES} from 'ng2-bootstrap/components/dropdown';
import { Router } from '@angular/router';
import { Search } from './search.model';
import { SearchField } from './search-field.type';
@ -8,8 +7,7 @@ import { SearchService } from './search.service';
@Component({
selector: 'my-search',
template: require('./search.component.html'),
directives: [ DROPDOWN_DIRECTIVES ]
templateUrl: './search.component.html'
})
export class SearchComponent implements OnInit {
@ -25,10 +23,10 @@ export class SearchComponent implements OnInit {
value: ''
};
constructor(private searchService: SearchService) {}
constructor(private searchService: SearchService, private router: Router) {}
ngOnInit() {
// Subscribe is the search changed
// Subscribe if the search changed
// Usually changed by videos list component
this.searchService.updateSearch.subscribe(
newSearchCriterias => {
@ -58,6 +56,10 @@ export class SearchComponent implements OnInit {
}
doSearch() {
if (this.router.url.indexOf('/videos/list') === -1) {
this.router.navigate([ '/videos/list' ]);
}
this.searchService.searchUpdated.next(this.searchCriterias);
}

View File

@ -1,5 +1,6 @@
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import { Search } from './search.model';
@ -12,6 +13,6 @@ export class SearchService {
constructor() {
this.updateSearch = new Subject<Search>();
this.searchUpdated = new Subject<Search>();
this.searchUpdated = new ReplaySubject<Search>(1);
}
}

View File

@ -0,0 +1 @@
export * from './user.model';

View File

@ -0,0 +1,20 @@
export class User {
id: string;
username: string;
role: string;
createdDate: Date;
constructor(hash: { id: string, username: string, role: string, createdDate?: Date }) {
this.id = hash.id;
this.username = hash.username;
this.role = hash.role;
if (hash.createdDate) {
this.createdDate = hash.createdDate;
}
}
isAdmin() {
return this.role === 'admin';
}
}

View File

@ -1,5 +1,4 @@
export * from './loader';
export * from './pagination.model';
export * from './sort-field.type';
export * from './video.model';
export * from './video.service';

View File

@ -2,8 +2,8 @@ import { Component, Input } from '@angular/core';
@Component({
selector: 'my-loader',
styles: [ require('./loader.component.scss') ],
template: require('./loader.component.html')
styleUrls: [ './loader.component.scss' ],
templateUrl: './loader.component.html'
})
export class LoaderComponent {

View File

@ -1,11 +1,10 @@
import { Injectable } from '@angular/core';
import { Http, Response, URLSearchParams } from '@angular/http';
import { Http } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import { Pagination } from './pagination.model';
import { Search } from '../../shared';
import { SortField } from './sort-field.type';
import { AuthHttp, AuthService } from '../../shared';
import { AuthHttp, AuthService, RestExtractor, RestPagination, RestService, ResultList } from '../../shared';
import { Video } from './video.model';
@Injectable()
@ -15,68 +14,51 @@ export class VideoService {
constructor(
private authService: AuthService,
private authHttp: AuthHttp,
private http: Http
private http: Http,
private restExtractor: RestExtractor,
private restService: RestService
) {}
getVideo(id: string) {
getVideo(id: string): Observable<Video> {
return this.http.get(VideoService.BASE_VIDEO_URL + id)
.map(res => <Video> res.json())
.catch(this.handleError);
.map(this.restExtractor.extractDataGet)
.catch((res) => this.restExtractor.handleError(res));
}
getVideos(pagination: Pagination, sort: SortField) {
const params = this.createPaginationParams(pagination);
if (sort) params.set('sort', sort);
getVideos(pagination: RestPagination, sort: SortField) {
const params = this.restService.buildRestGetParams(pagination, sort);
return this.http.get(VideoService.BASE_VIDEO_URL, { search: params })
.map(res => res.json())
.map(this.extractVideos)
.catch(this.handleError);
.catch((res) => this.restExtractor.handleError(res));
}
removeVideo(id: string) {
return this.authHttp.delete(VideoService.BASE_VIDEO_URL + id)
.map(res => <number> res.status)
.catch(this.handleError);
.map(this.restExtractor.extractDataBool)
.catch((res) => this.restExtractor.handleError(res));
}
searchVideos(search: Search, pagination: Pagination, sort: SortField) {
const params = this.createPaginationParams(pagination);
searchVideos(search: Search, pagination: RestPagination, sort: SortField) {
const params = this.restService.buildRestGetParams(pagination, sort);
if (search.field) params.set('field', search.field);
if (sort) params.set('sort', sort);
return this.http.get(VideoService.BASE_VIDEO_URL + 'search/' + encodeURIComponent(search.value), { search: params })
.map(res => res.json())
.map(this.restExtractor.extractDataList)
.map(this.extractVideos)
.catch(this.handleError);
.catch((res) => this.restExtractor.handleError(res));
}
private createPaginationParams(pagination: Pagination) {
const params = new URLSearchParams();
const start: number = (pagination.currentPage - 1) * pagination.itemsPerPage;
const count: number = pagination.itemsPerPage;
params.set('start', start.toString());
params.set('count', count.toString());
return params;
}
private extractVideos(body: any) {
const videos_json = body.data;
const totalVideos = body.total;
private extractVideos(result: ResultList) {
const videosJson = result.data;
const totalVideos = result.total;
const videos = [];
for (const video_json of videos_json) {
videos.push(new Video(video_json));
for (const videoJson of videosJson) {
videos.push(new Video(videoJson));
}
return { videos, totalVideos };
}
private handleError(error: Response) {
console.error(error);
return Observable.throw(error.json().error || 'Server error');
}
}

View File

@ -2,31 +2,31 @@
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
<form novalidate (ngSubmit)="upload()" [ngFormModel]="videoForm">
<form novalidate (ngSubmit)="upload()" [formGroup]="form">
<div class="form-group">
<label for="name">Name</label>
<input
type="text" class="form-control" name="name" id="name"
ngControl="name" #name="ngForm" [(ngModel)]="video.name"
type="text" class="form-control" id="name"
formControlName="name"
>
<div [hidden]="name.valid || name.pristine" class="alert alert-warning">
A name is required and should be between 3 and 50 characters long
<div *ngIf="formErrors.name" class="alert alert-danger">
{{ formErrors.name }}
</div>
</div>
<div class="form-group">
<label for="tags">Tags</label>
<input
type="text" class="form-control" name="tags" id="tags"
ngControl="tags" #tags="ngForm" [disabled]="isTagsInputDisabled" (keyup)="onTagKeyPress($event)" [(ngModel)]="currentTag"
type="text" class="form-control" id="currentTag"
formControlName="currentTag" (keyup)="onTagKeyPress($event)"
>
<div [hidden]="tags.valid || tags.pristine" class="alert alert-warning">
A tag should be between 2 and 10 characters (alphanumeric) long
<div *ngIf="formErrors.currentTag" class="alert alert-danger">
{{ formErrors.currentTag }}
</div>
</div>
<div class="tags">
<div class="label label-primary tag" *ngFor="let tag of video.tags">
<div class="label label-primary tag" *ngFor="let tag of tags">
{{ tag }}
<span class="remove" (click)="removeTag(tag)">x</span>
</div>
@ -53,12 +53,12 @@
<div class="form-group">
<label for="description">Description</label>
<textarea
name="description" id="description" class="form-control" placeholder="Description..."
ngControl="description" #description="ngForm" [(ngModel)]="video.description"
id="description" class="form-control" placeholder="Description..."
formControlName="description"
>
</textarea>
<div [hidden]="description.valid || description.pristine" class="alert alert-warning">
A description is required and should be between 3 and 250 characters long
<div *ngIf="formErrors.description" class="alert alert-danger">
{{ formErrors.description }}
</div>
</div>
@ -69,7 +69,7 @@
<div class="form-group">
<input
type="submit" value="Upload" class="btn btn-default form-control" [title]="getInvalidFieldsTitle()"
[disabled]="!videoForm.valid || video.tags.length === 0 || filename === null"
[disabled]="!form.valid || tags.length === 0 || filename === null"
>
</div>
</form>

View File

@ -1,37 +1,42 @@
import { Control, ControlGroup, Validators } from '@angular/common';
import { Component, ElementRef, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { BytesPipe } from 'angular-pipes/src/math/bytes.pipe';
import { PROGRESSBAR_DIRECTIVES } from 'ng2-bootstrap/components/progressbar';
import { FileSelectDirective, FileUploader } from 'ng2-file-upload/ng2-file-upload';
import { FileUploader } from 'ng2-file-upload/ng2-file-upload';
import { AuthService } from '../../shared';
import { AuthService, FormReactive, VIDEO_NAME, VIDEO_DESCRIPTION, VIDEO_TAGS } from '../../shared';
@Component({
selector: 'my-videos-add',
styles: [ require('./video-add.component.scss') ],
template: require('./video-add.component.html'),
directives: [ FileSelectDirective, PROGRESSBAR_DIRECTIVES ],
pipes: [ BytesPipe ]
styleUrls: [ './video-add.component.scss' ],
templateUrl: './video-add.component.html'
})
export class VideoAddComponent implements OnInit {
currentTag: string; // Tag the user is writing in the input
error: string = null;
videoForm: ControlGroup;
export class VideoAddComponent extends FormReactive implements OnInit {
tags: string[] = [];
uploader: FileUploader;
video = {
error: string = null;
form: FormGroup;
formErrors = {
name: '',
tags: [],
description: ''
description: '',
currentTag: ''
};
validationMessages = {
name: VIDEO_NAME.MESSAGES,
description: VIDEO_DESCRIPTION.MESSAGES,
currentTag: VIDEO_TAGS.MESSAGES
};
constructor(
private authService: AuthService,
private elementRef: ElementRef,
private formBuilder: FormBuilder,
private router: Router
) {}
) {
super();
}
get filename() {
if (this.uploader.queue.length === 0) {
@ -41,20 +46,26 @@ export class VideoAddComponent implements OnInit {
return this.uploader.queue[0].file.name;
}
get isTagsInputDisabled () {
return this.video.tags.length >= 3;
buildForm() {
this.form = this.formBuilder.group({
name: [ '', VIDEO_NAME.VALIDATORS ],
description: [ '', VIDEO_DESCRIPTION.VALIDATORS ],
currentTag: [ '', VIDEO_TAGS.VALIDATORS ]
});
this.form.valueChanges.subscribe(data => this.onValueChanged(data));
}
getInvalidFieldsTitle() {
let title = '';
const nameControl = this.videoForm.controls['name'];
const descriptionControl = this.videoForm.controls['description'];
const nameControl = this.form.controls['name'];
const descriptionControl = this.form.controls['description'];
if (!nameControl.valid) {
title += 'A name is required\n';
}
if (this.video.tags.length === 0) {
if (this.tags.length === 0) {
title += 'At least one tag is required\n';
}
@ -70,13 +81,6 @@ export class VideoAddComponent implements OnInit {
}
ngOnInit() {
this.videoForm = new ControlGroup({
name: new Control('', Validators.compose([ Validators.required, Validators.minLength(3), Validators.maxLength(50) ])),
description: new Control('', Validators.compose([ Validators.required, Validators.minLength(3), Validators.maxLength(250) ])),
tags: new Control('', Validators.pattern('^[a-zA-Z0-9]{2,10}$'))
});
this.uploader = new FileUploader({
authToken: this.authService.getRequestHeaderValue(),
queueLimit: 1,
@ -85,26 +89,37 @@ export class VideoAddComponent implements OnInit {
});
this.uploader.onBuildItemForm = (item, form) => {
form.append('name', this.video.name);
form.append('description', this.video.description);
const name = this.form.value['name'];
const description = this.form.value['description'];
for (let i = 0; i < this.video.tags.length; i++) {
form.append(`tags[${i}]`, this.video.tags[i]);
form.append('name', name);
form.append('description', description);
for (let i = 0; i < this.tags.length; i++) {
form.append(`tags[${i}]`, this.tags[i]);
}
};
this.buildForm();
}
onTagKeyPress(event: KeyboardEvent) {
const currentTag = this.form.value['currentTag'];
// Enter press
if (event.keyCode === 13) {
// Check if the tag is valid and does not already exist
if (
this.currentTag !== '' &&
this.videoForm.controls['tags'].valid &&
this.video.tags.indexOf(this.currentTag) === -1
currentTag !== '' &&
this.form.controls['currentTag'].valid &&
this.tags.indexOf(currentTag) === -1
) {
this.video.tags.push(this.currentTag);
this.currentTag = '';
this.tags.push(currentTag);
this.form.patchValue({ currentTag: '' });
if (this.tags.length >= 3) {
this.form.get('currentTag').disable();
}
}
}
}
@ -114,7 +129,7 @@ export class VideoAddComponent implements OnInit {
}
removeTag(tag: string) {
this.video.tags.splice(this.video.tags.indexOf(tag), 1);
this.tags.splice(this.tags.indexOf(tag), 1);
}
upload() {

View File

@ -1,39 +1,30 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { ActivatedRoute, Router, ROUTER_DIRECTIVES } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { PAGINATION_DIRECTIVES } from 'ng2-bootstrap/components/pagination';
import {
LoaderComponent,
Pagination,
SortField,
Video,
VideoService
} from '../shared';
import { AuthService, Search, SearchField, User } from '../../shared';
import { VideoMiniatureComponent } from './video-miniature.component';
import { VideoSortComponent } from './video-sort.component';
import { AuthService, AuthUser, RestPagination, Search, SearchField } from '../../shared';
import { SearchService } from '../../shared';
@Component({
selector: 'my-videos-list',
styles: [ require('./video-list.component.scss') ],
pipes: [ AsyncPipe ],
template: require('./video-list.component.html'),
directives: [ LoaderComponent, PAGINATION_DIRECTIVES, ROUTER_DIRECTIVES, VideoMiniatureComponent, VideoSortComponent ]
styleUrls: [ './video-list.component.scss' ],
templateUrl: './video-list.component.html'
})
export class VideoListComponent implements OnInit, OnDestroy {
loading: BehaviorSubject<boolean> = new BehaviorSubject(false);
pagination: Pagination = {
pagination: RestPagination = {
currentPage: 1,
itemsPerPage: 9,
totalItems: null
};
sort: SortField;
user: User = null;
user: AuthUser = null;
videos: Video[] = [];
private search: Search;
@ -51,7 +42,7 @@ export class VideoListComponent implements OnInit, OnDestroy {
ngOnInit() {
if (this.authService.isLoggedIn()) {
this.user = User.load();
this.user = AuthUser.load();
}
// Subscribe to route changes
@ -66,6 +57,8 @@ export class VideoListComponent implements OnInit, OnDestroy {
// Subscribe to search changes
this.subSearch = this.searchService.searchUpdated.subscribe(search => {
this.search = search;
// Reset pagination
this.pagination.currentPage = 1;
this.navigateToNewParams();
});
@ -76,7 +69,7 @@ export class VideoListComponent implements OnInit, OnDestroy {
this.subSearch.unsubscribe();
}
getVideos(detectChanges = true) {
getVideos() {
this.loading.next(true);
this.videos = [];
@ -97,7 +90,7 @@ export class VideoListComponent implements OnInit, OnDestroy {
this.loading.next(false);
},
error => alert(error)
error => alert(error.text)
);
}
@ -153,7 +146,11 @@ export class VideoListComponent implements OnInit, OnDestroy {
this.sort = <SortField>routeParams['sort'] || '-createdDate';
this.pagination.currentPage = parseInt(routeParams['page']) || 1;
if (routeParams['page'] !== undefined) {
this.pagination.currentPage = parseInt(routeParams['page']);
} else {
this.pagination.currentPage = 1;
}
this.changeDetector.detectChanges();
}

View File

@ -1,16 +1,12 @@
import { DatePipe } from '@angular/common';
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { ROUTER_DIRECTIVES } from '@angular/router';
import { SortField, Video, VideoService } from '../shared';
import { User } from '../../shared';
@Component({
selector: 'my-video-miniature',
styles: [ require('./video-miniature.component.scss') ],
template: require('./video-miniature.component.html'),
directives: [ ROUTER_DIRECTIVES ],
pipes: [ DatePipe ]
styleUrls: [ './video-miniature.component.scss' ],
templateUrl: './video-miniature.component.html'
})
export class VideoMiniatureComponent {
@ -40,7 +36,7 @@ export class VideoMiniatureComponent {
if (confirm('Do you really want to remove this video?')) {
this.videoService.removeVideo(id).subscribe(
status => this.removed.emit(true),
error => alert(error)
error => alert(error.text)
);
}
}

View File

@ -4,7 +4,7 @@ import { SortField } from '../shared';
@Component({
selector: 'my-video-sort',
template: require('./video-sort.component.html')
templateUrl: './video-sort.component.html'
})
export class VideoSortComponent {

View File

@ -1,18 +1,13 @@
import { Component, ElementRef, OnDestroy, OnInit } from '@angular/core';
import { Component, ElementRef, NgZone, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { BytesPipe } from 'angular-pipes/src/math/bytes.pipe';
import { LoaderComponent, Video, VideoService } from '../shared';
import { Video, VideoService } from '../shared';
import { WebTorrentService } from './webtorrent.service';
@Component({
selector: 'my-video-watch',
template: require('./video-watch.component.html'),
styles: [ require('./video-watch.component.scss') ],
providers: [ WebTorrentService ],
directives: [ LoaderComponent ],
pipes: [ BytesPipe ]
templateUrl: './video-watch.component.html',
styleUrls: [ './video-watch.component.scss' ]
})
export class VideoWatchComponent implements OnInit, OnDestroy {
@ -31,6 +26,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
constructor(
private elementRef: ElementRef,
private ngZone: NgZone,
private route: ActivatedRoute,
private videoService: VideoService,
private webTorrentService: WebTorrentService
@ -65,12 +61,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
}
});
// Refresh each second
this.torrentInfosInterval = setInterval(() => {
this.downloadSpeed = torrent.downloadSpeed;
this.numPeers = torrent.numPeers;
this.uploadSpeed = torrent.uploadSpeed;
}, 1000);
this.runInProgress(torrent);
});
}
@ -91,7 +82,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.video = video;
this.loadVideo();
},
error => alert(error)
error => alert(error.text)
);
});
}
@ -100,4 +91,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.error = true;
console.error('The video load seems to be abnormally long.');
}
private runInProgress(torrent: any) {
// Refresh each second
this.torrentInfosInterval = setInterval(() => {
this.ngZone.run(() => {
this.downloadSpeed = torrent.downloadSpeed;
this.numPeers = torrent.numPeers;
this.uploadSpeed = torrent.uploadSpeed;
});
}, 1000);
}
}

View File

@ -1,9 +1,7 @@
import { Component } from '@angular/core';
import { ROUTER_DIRECTIVES } from '@angular/router';
@Component({
template: '<router-outlet></router-outlet>',
directives: [ ROUTER_DIRECTIVES ]
template: '<router-outlet></router-outlet>'
})
export class VideosComponent {

View File

@ -1,11 +1,11 @@
import { RouterConfig } from '@angular/router';
import { Routes } from '@angular/router';
import { VideoAddComponent } from './video-add';
import { VideoListComponent } from './video-list';
import { VideosComponent } from './videos.component';
import { VideoWatchComponent } from './video-watch';
export const VideosRoutes: RouterConfig = [
export const VideosRoutes: Routes = [
{
path: 'videos',
component: VideosComponent,

View File

@ -1,49 +1,93 @@
/*
* Custom Type Definitions
* When including 3rd party modules you also need to include the type definition for the module
* if they don't provide one within the module. You can try to install it with typings
* if they don't provide one within the module. You can try to install it with @types
typings install node --save
npm install @types/node
npm install @types/lodash
* If you can't find the type definition in the registry we can make an ambient definition in
* If you can't find the type definition in the registry we can make an ambient/global definition in
* this file for now. For example
declare module "my-module" {
declare module 'my-module' {
export function doesSomething(value: string): string;
}
* If you are using a CommonJS module that is using module.exports then you will have to write your
* types using export = yourObjectOrFunction with a namespace above it
* notice how we have to create a namespace that is equal to the function we're
* assigning the export to
declare module 'jwt-decode' {
function jwtDecode(token: string): any;
namespace jwtDecode {}
export = jwtDecode;
}
*
* If you're prototying and you will fix the types later you can also declare it as type any
*
declare var assert: any;
declare var _: any;
declare var $: any;
*
* If you're importing a module that uses Node.js modules which are CommonJS you need to import as
* in the files such as main.browser.ts or any file within app/
*
import * as _ from 'lodash'
* You can include your type definitions in this file until you create one for the typings registry
* see https://github.com/typings/registry
* You can include your type definitions in this file until you create one for the @types
*
*/
// support NodeJS modules without type definitions
declare module '*';
// Extra variables that live on Global that will be replaced by webpack DefinePlugin
declare var ENV: string;
declare var HMR: boolean;
declare var System: SystemJS;
interface SystemJS {
import: (path?: string) => Promise<any>;
}
interface GlobalEnvironment {
ENV;
HMR;
SystemJS: SystemJS;
System: SystemJS;
}
interface Es6PromiseLoader {
(id: string): (exportName?: string) => Promise<any>;
}
type FactoryEs6PromiseLoader = () => Es6PromiseLoader;
type FactoryPromise = () => Promise<any>;
type AsyncRoutes = {
[component: string]: Es6PromiseLoader |
Function |
FactoryEs6PromiseLoader |
FactoryPromise
};
type IdleCallbacks = Es6PromiseLoader |
Function |
FactoryEs6PromiseLoader |
FactoryPromise ;
interface WebpackModule {
hot: {
data?: any,
idle: any,
accept(dependencies?: string | string[], callback?: (updatedDependencies?: any) => void): void;
decline(dependencies?: string | string[]): void;
decline(deps?: any | string | string[]): void;
dispose(callback?: (data?: any) => void): void;
addDisposeHandler(callback?: (data?: any) => void): void;
removeDisposeHandler(callback?: (data?: any) => void): void;
@ -54,66 +98,26 @@ interface WebpackModule {
};
}
interface WebpackRequire {
context(file: string, flag?: boolean, exp?: RegExp): any;
(id: string): any;
(paths: string[], callback: (...modules: any[]) => void): void;
ensure(ids: string[], callback: (req: WebpackRequire) => void, chunkName?: string): void;
context(directory: string, useSubDirectories?: boolean, regExp?: RegExp): WebpackContext;
}
interface WebpackContext extends WebpackRequire {
keys(): string[];
}
interface ErrorStackTraceLimit {
stackTraceLimit: number;
}
// Extend typings
interface NodeRequire extends WebpackRequire {}
interface ErrorConstructor extends ErrorStackTraceLimit {}
interface NodeRequireFunction extends Es6PromiseLoader {}
interface NodeModule extends WebpackModule {}
interface Global extends GlobalEnvironment {}
declare namespace Reflect {
function decorate(decorators: ClassDecorator[], target: Function): Function;
function decorate(
decorators: (PropertyDecorator | MethodDecorator)[],
target: Object,
targetKey: string | symbol,
descriptor?: PropertyDescriptor): PropertyDescriptor;
function metadata(metadataKey: any, metadataValue: any): {
(target: Function): void;
(target: Object, propertyKey: string | symbol): void;
};
function defineMetadata(metadataKey: any, metadataValue: any, target: Object): void;
function defineMetadata(
metadataKey: any,
metadataValue: any,
target: Object,
targetKey: string | symbol): void;
function hasMetadata(metadataKey: any, target: Object): boolean;
function hasMetadata(metadataKey: any, target: Object, targetKey: string | symbol): boolean;
function hasOwnMetadata(metadataKey: any, target: Object): boolean;
function hasOwnMetadata(metadataKey: any, target: Object, targetKey: string | symbol): boolean;
function getMetadata(metadataKey: any, target: Object): any;
function getMetadata(metadataKey: any, target: Object, targetKey: string | symbol): any;
function getOwnMetadata(metadataKey: any, target: Object): any;
function getOwnMetadata(metadataKey: any, target: Object, targetKey: string | symbol): any;
function getMetadataKeys(target: Object): any[];
function getMetadataKeys(target: Object, targetKey: string | symbol): any[];
function getOwnMetadataKeys(target: Object): any[];
function getOwnMetadataKeys(target: Object, targetKey: string | symbol): any[];
function deleteMetadata(metadataKey: any, target: Object): boolean;
function deleteMetadata(metadataKey: any, target: Object, targetKey: string | symbol): boolean;
}
// We need this here since there is a problem with Zone.js typings
interface Thenable<T> {
then<U>(
onFulfilled?: (value: T) => U | Thenable<U>,
onRejected?: (error: any) => U | Thenable<U>): Thenable<U>;
then<U>(
onFulfilled?: (value: T) => U | Thenable<U>,
onRejected?: (error: any) => void): Thenable<U>;
catch<U>(onRejected?: (error: any) => U | Thenable<U>): Thenable<U>;
}

View File

@ -1,3 +1,4 @@
<!DOCTYPE html>
<html>
<head>
<base href="/">

View File

@ -1,28 +1,20 @@
import { enableProdMode, provide } from '@angular/core';
import {
HTTP_PROVIDERS,
RequestOptions,
XHRBackend
} from '@angular/http';
import { bootstrap } from '@angular/platform-browser-dynamic';
import { provideRouter } from '@angular/router';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { decorateModuleRef } from './app/environment';
import { bootloader } from '@angularclass/hmr';
/*
* App Module
* our top level module that holds all of our components
*/
import { AppModule } from './app';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { AuthHttp, AuthService } from './app/shared';
if (process.env.ENV === 'production') {
enableProdMode();
/*
* Bootstrap our Angular app with a top level NgModule
*/
export function main(): Promise<any> {
return platformBrowserDynamic()
.bootstrapModule(AppModule)
.then(decorateModuleRef)
.catch(err => console.error(err));
}
bootstrap(AppComponent, [
HTTP_PROVIDERS,
provide(AuthHttp, {
useFactory: (backend: XHRBackend, defaultOptions: RequestOptions, authService: AuthService) => {
return new AuthHttp(backend, defaultOptions, authService);
},
deps: [ XHRBackend, RequestOptions, AuthService ]
}),
AuthService,
provideRouter(routes)
]);
bootloader(main);

Some files were not shown because too many files have changed in this diff Show More