Initial commit that merge both the front end and the API in the same repository
14
front/.babelrc
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"presets": [
|
||||
["env", { "modules": false }],
|
||||
"stage-2"
|
||||
],
|
||||
"plugins": ["transform-runtime"],
|
||||
"comments": false,
|
||||
"env": {
|
||||
"test": {
|
||||
"presets": ["env", "stage-2"],
|
||||
"plugins": [ "istanbul" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
2
front/.eslintignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
build/*.js
|
||||
config/*.js
|
||||
27
front/.eslintrc.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// http://eslint.org/docs/user-guide/configuring
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: 'babel-eslint',
|
||||
parserOptions: {
|
||||
sourceType: 'module'
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
// https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style
|
||||
extends: 'standard',
|
||||
// required to lint *.vue files
|
||||
plugins: [
|
||||
'html'
|
||||
],
|
||||
// add your custom rules here
|
||||
'rules': {
|
||||
// allow paren-less arrow functions
|
||||
'arrow-parens': 0,
|
||||
// allow async-await
|
||||
'generator-star-spacing': 0,
|
||||
// allow debugger during development
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0
|
||||
}
|
||||
}
|
||||
8
front/.postcssrc.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// https://github.com/michael-ciniawsky/postcss-load-config
|
||||
|
||||
module.exports = {
|
||||
"plugins": {
|
||||
// to edit target browsers: use "browserlist" field in package.json
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
||||
13
front/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:6-alpine
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
ADD package.json .
|
||||
|
||||
RUN npm install
|
||||
|
||||
ADD . .
|
||||
|
||||
RUN npm run build
|
||||
35
front/build/build.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
require('./check-versions')()
|
||||
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
var ora = require('ora')
|
||||
var rm = require('rimraf')
|
||||
var path = require('path')
|
||||
var chalk = require('chalk')
|
||||
var webpack = require('webpack')
|
||||
var config = require('../config')
|
||||
var webpackConfig = require('./webpack.prod.conf')
|
||||
|
||||
var spinner = ora('building for production...')
|
||||
spinner.start()
|
||||
|
||||
rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {
|
||||
if (err) throw err
|
||||
webpack(webpackConfig, function (err, stats) {
|
||||
spinner.stop()
|
||||
if (err) throw err
|
||||
process.stdout.write(stats.toString({
|
||||
colors: true,
|
||||
modules: false,
|
||||
children: false,
|
||||
chunks: false,
|
||||
chunkModules: false
|
||||
}) + '\n\n')
|
||||
|
||||
console.log(chalk.cyan(' Build complete.\n'))
|
||||
console.log(chalk.yellow(
|
||||
' Tip: built files are meant to be served over an HTTP server.\n' +
|
||||
' Opening index.html over file:// won\'t work.\n'
|
||||
))
|
||||
})
|
||||
})
|
||||
48
front/build/check-versions.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
var chalk = require('chalk')
|
||||
var semver = require('semver')
|
||||
var packageConfig = require('../package.json')
|
||||
var shell = require('shelljs')
|
||||
function exec (cmd) {
|
||||
return require('child_process').execSync(cmd).toString().trim()
|
||||
}
|
||||
|
||||
var versionRequirements = [
|
||||
{
|
||||
name: 'node',
|
||||
currentVersion: semver.clean(process.version),
|
||||
versionRequirement: packageConfig.engines.node
|
||||
},
|
||||
]
|
||||
|
||||
if (shell.which('npm')) {
|
||||
versionRequirements.push({
|
||||
name: 'npm',
|
||||
currentVersion: exec('npm --version'),
|
||||
versionRequirement: packageConfig.engines.npm
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = function () {
|
||||
var warnings = []
|
||||
for (var i = 0; i < versionRequirements.length; i++) {
|
||||
var mod = versionRequirements[i]
|
||||
if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
|
||||
warnings.push(mod.name + ': ' +
|
||||
chalk.red(mod.currentVersion) + ' should be ' +
|
||||
chalk.green(mod.versionRequirement)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length) {
|
||||
console.log('')
|
||||
console.log(chalk.yellow('To use this template, you must update following to modules:'))
|
||||
console.log()
|
||||
for (var i = 0; i < warnings.length; i++) {
|
||||
var warning = warnings[i]
|
||||
console.log(' ' + warning)
|
||||
}
|
||||
console.log()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
9
front/build/dev-client.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/* eslint-disable */
|
||||
require('eventsource-polyfill')
|
||||
var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true')
|
||||
|
||||
hotClient.subscribe(function (event) {
|
||||
if (event.action === 'reload') {
|
||||
window.location.reload()
|
||||
}
|
||||
})
|
||||
92
front/build/dev-server.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
require('./check-versions')()
|
||||
|
||||
var config = require('../config')
|
||||
if (!process.env.NODE_ENV) {
|
||||
process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV)
|
||||
}
|
||||
|
||||
var opn = require('opn')
|
||||
var path = require('path')
|
||||
var express = require('express')
|
||||
var webpack = require('webpack')
|
||||
var proxyMiddleware = require('http-proxy-middleware')
|
||||
var webpackConfig = process.env.NODE_ENV === 'testing'
|
||||
? require('./webpack.prod.conf')
|
||||
: require('./webpack.dev.conf')
|
||||
|
||||
// default port where dev server listens for incoming traffic
|
||||
var port = process.env.PORT || config.dev.port
|
||||
var host = process.env.HOST || config.dev.host
|
||||
// automatically open browser, if not set will be false
|
||||
var autoOpenBrowser = !!config.dev.autoOpenBrowser
|
||||
// Define HTTP proxies to your custom API backend
|
||||
// https://github.com/chimurai/http-proxy-middleware
|
||||
var proxyTable = config.dev.proxyTable
|
||||
|
||||
var app = express()
|
||||
var compiler = webpack(webpackConfig)
|
||||
|
||||
var devMiddleware = require('webpack-dev-middleware')(compiler, {
|
||||
publicPath: webpackConfig.output.publicPath,
|
||||
quiet: true
|
||||
})
|
||||
|
||||
var hotMiddleware = require('webpack-hot-middleware')(compiler, {
|
||||
log: () => {}
|
||||
})
|
||||
// force page reload when html-webpack-plugin template changes
|
||||
compiler.plugin('compilation', function (compilation) {
|
||||
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
|
||||
hotMiddleware.publish({ action: 'reload' })
|
||||
cb()
|
||||
})
|
||||
})
|
||||
|
||||
// proxy api requests
|
||||
Object.keys(proxyTable).forEach(function (context) {
|
||||
var options = proxyTable[context]
|
||||
if (typeof options === 'string') {
|
||||
options = { target: options }
|
||||
}
|
||||
app.use(proxyMiddleware(options.filter || context, options))
|
||||
})
|
||||
|
||||
// handle fallback for HTML5 history API
|
||||
app.use(require('connect-history-api-fallback')())
|
||||
|
||||
// serve webpack bundle output
|
||||
app.use(devMiddleware)
|
||||
|
||||
// enable hot-reload and state-preserving
|
||||
// compilation error display
|
||||
app.use(hotMiddleware)
|
||||
|
||||
// serve pure static assets
|
||||
var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory)
|
||||
app.use(staticPath, express.static('./static'))
|
||||
|
||||
var uri = 'http://' + host + ':' + port
|
||||
|
||||
var _resolve
|
||||
var readyPromise = new Promise(resolve => {
|
||||
_resolve = resolve
|
||||
})
|
||||
|
||||
console.log('> Starting dev server...')
|
||||
devMiddleware.waitUntilValid(() => {
|
||||
console.log('> Listening at ' + uri + '\n')
|
||||
// when env is testing, don't need open it
|
||||
if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') {
|
||||
opn(uri)
|
||||
}
|
||||
_resolve()
|
||||
})
|
||||
|
||||
var server = app.listen(port, host)
|
||||
|
||||
module.exports = {
|
||||
ready: readyPromise,
|
||||
close: () => {
|
||||
server.close()
|
||||
}
|
||||
}
|
||||
71
front/build/utils.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
var path = require('path')
|
||||
var config = require('../config')
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin')
|
||||
|
||||
exports.assetsPath = function (_path) {
|
||||
var assetsSubDirectory = process.env.NODE_ENV === 'production'
|
||||
? config.build.assetsSubDirectory
|
||||
: config.dev.assetsSubDirectory
|
||||
return path.posix.join(assetsSubDirectory, _path)
|
||||
}
|
||||
|
||||
exports.cssLoaders = function (options) {
|
||||
options = options || {}
|
||||
|
||||
var cssLoader = {
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
minimize: process.env.NODE_ENV === 'production',
|
||||
sourceMap: options.sourceMap
|
||||
}
|
||||
}
|
||||
|
||||
// generate loader string to be used with extract text plugin
|
||||
function generateLoaders (loader, loaderOptions) {
|
||||
var loaders = [cssLoader]
|
||||
if (loader) {
|
||||
loaders.push({
|
||||
loader: loader + '-loader',
|
||||
options: Object.assign({}, loaderOptions, {
|
||||
sourceMap: options.sourceMap
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Extract CSS when that option is specified
|
||||
// (which is the case during production build)
|
||||
if (options.extract) {
|
||||
return ExtractTextPlugin.extract({
|
||||
use: loaders,
|
||||
fallback: 'vue-style-loader'
|
||||
})
|
||||
} else {
|
||||
return ['vue-style-loader'].concat(loaders)
|
||||
}
|
||||
}
|
||||
|
||||
// https://vue-loader.vuejs.org/en/configurations/extract-css.html
|
||||
return {
|
||||
css: generateLoaders(),
|
||||
postcss: generateLoaders(),
|
||||
less: generateLoaders('less'),
|
||||
sass: generateLoaders('sass', { indentedSyntax: true }),
|
||||
scss: generateLoaders('sass'),
|
||||
stylus: generateLoaders('stylus'),
|
||||
styl: generateLoaders('stylus')
|
||||
}
|
||||
}
|
||||
|
||||
// Generate loaders for standalone style files (outside of .vue)
|
||||
exports.styleLoaders = function (options) {
|
||||
var output = []
|
||||
var loaders = exports.cssLoaders(options)
|
||||
for (var extension in loaders) {
|
||||
var loader = loaders[extension]
|
||||
output.push({
|
||||
test: new RegExp('\\.' + extension + '$'),
|
||||
use: loader
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
12
front/build/vue-loader.conf.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
var utils = require('./utils')
|
||||
var config = require('../config')
|
||||
var isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
module.exports = {
|
||||
loaders: utils.cssLoaders({
|
||||
sourceMap: isProduction
|
||||
? config.build.productionSourceMap
|
||||
: config.dev.cssSourceMap,
|
||||
extract: isProduction
|
||||
})
|
||||
}
|
||||
67
front/build/webpack.base.conf.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
var path = require('path')
|
||||
var utils = require('./utils')
|
||||
var config = require('../config')
|
||||
var vueLoaderConfig = require('./vue-loader.conf')
|
||||
|
||||
function resolve (dir) {
|
||||
return path.join(__dirname, '..', dir)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
app: './src/main.js'
|
||||
},
|
||||
output: {
|
||||
path: config.build.assetsRoot,
|
||||
filename: '[name].js',
|
||||
publicPath: process.env.NODE_ENV === 'production'
|
||||
? config.build.assetsPublicPath
|
||||
: config.dev.assetsPublicPath
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.vue', '.json'],
|
||||
alias: {
|
||||
'vue$': 'vue/dist/vue.esm.js',
|
||||
'@': resolve('src')
|
||||
}
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|vue)$/,
|
||||
loader: 'eslint-loader',
|
||||
enforce: 'pre',
|
||||
include: [resolve('src'), resolve('test')],
|
||||
options: {
|
||||
formatter: require('eslint-friendly-formatter')
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: 'vue-loader',
|
||||
options: vueLoaderConfig
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
include: [resolve('src'), resolve('test')]
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: utils.assetsPath('img/[name].[hash:7].[ext]')
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
35
front/build/webpack.dev.conf.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
var utils = require('./utils')
|
||||
var webpack = require('webpack')
|
||||
var config = require('../config')
|
||||
var merge = require('webpack-merge')
|
||||
var baseWebpackConfig = require('./webpack.base.conf')
|
||||
var HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
|
||||
|
||||
// add hot-reload related code to entry chunks
|
||||
Object.keys(baseWebpackConfig.entry).forEach(function (name) {
|
||||
baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name])
|
||||
})
|
||||
|
||||
module.exports = merge(baseWebpackConfig, {
|
||||
module: {
|
||||
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })
|
||||
},
|
||||
// cheap-module-eval-source-map is faster for development
|
||||
devtool: '#cheap-module-eval-source-map',
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': config.dev.env
|
||||
}),
|
||||
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
// https://github.com/ampedandwired/html-webpack-plugin
|
||||
new HtmlWebpackPlugin({
|
||||
filename: 'index.html',
|
||||
template: 'index.html',
|
||||
inject: true
|
||||
}),
|
||||
new FriendlyErrorsPlugin()
|
||||
]
|
||||
})
|
||||
124
front/build/webpack.prod.conf.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
var path = require('path')
|
||||
var utils = require('./utils')
|
||||
var webpack = require('webpack')
|
||||
var config = require('../config')
|
||||
var merge = require('webpack-merge')
|
||||
var baseWebpackConfig = require('./webpack.base.conf')
|
||||
var CopyWebpackPlugin = require('copy-webpack-plugin')
|
||||
var HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin')
|
||||
var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')
|
||||
|
||||
var env = process.env.NODE_ENV === 'testing'
|
||||
? require('../config/test.env')
|
||||
: config.build.env
|
||||
|
||||
var webpackConfig = merge(baseWebpackConfig, {
|
||||
module: {
|
||||
rules: utils.styleLoaders({
|
||||
sourceMap: config.build.productionSourceMap,
|
||||
extract: true
|
||||
})
|
||||
},
|
||||
devtool: config.build.productionSourceMap ? '#source-map' : false,
|
||||
output: {
|
||||
path: config.build.assetsRoot,
|
||||
filename: utils.assetsPath('js/[name].[chunkhash].js'),
|
||||
chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
|
||||
},
|
||||
plugins: [
|
||||
// http://vuejs.github.io/vue-loader/en/workflow/production.html
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': env
|
||||
}),
|
||||
new webpack.optimize.UglifyJsPlugin({
|
||||
compress: {
|
||||
warnings: false
|
||||
},
|
||||
sourceMap: true
|
||||
}),
|
||||
// extract css into its own file
|
||||
new ExtractTextPlugin({
|
||||
filename: utils.assetsPath('css/[name].[contenthash].css')
|
||||
}),
|
||||
// Compress extracted CSS. We are using this plugin so that possible
|
||||
// duplicated CSS from different components can be deduped.
|
||||
new OptimizeCSSPlugin({
|
||||
cssProcessorOptions: {
|
||||
safe: true
|
||||
}
|
||||
}),
|
||||
// generate dist index.html with correct asset hash for caching.
|
||||
// you can customize output by editing /index.html
|
||||
// see https://github.com/ampedandwired/html-webpack-plugin
|
||||
new HtmlWebpackPlugin({
|
||||
filename: process.env.NODE_ENV === 'testing'
|
||||
? 'index.html'
|
||||
: config.build.index,
|
||||
template: 'index.html',
|
||||
inject: true,
|
||||
minify: {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
removeAttributeQuotes: true
|
||||
// more options:
|
||||
// https://github.com/kangax/html-minifier#options-quick-reference
|
||||
},
|
||||
// necessary to consistently work with multiple chunks via CommonsChunkPlugin
|
||||
chunksSortMode: 'dependency'
|
||||
}),
|
||||
// split vendor js into its own file
|
||||
new webpack.optimize.CommonsChunkPlugin({
|
||||
name: 'vendor',
|
||||
minChunks: function (module, count) {
|
||||
// any required modules inside node_modules are extracted to vendor
|
||||
return (
|
||||
module.resource &&
|
||||
/\.js$/.test(module.resource) &&
|
||||
module.resource.indexOf(
|
||||
path.join(__dirname, '../node_modules')
|
||||
) === 0
|
||||
)
|
||||
}
|
||||
}),
|
||||
// extract webpack runtime and module manifest to its own file in order to
|
||||
// prevent vendor hash from being updated whenever app bundle is updated
|
||||
new webpack.optimize.CommonsChunkPlugin({
|
||||
name: 'manifest',
|
||||
chunks: ['vendor']
|
||||
}),
|
||||
// copy custom static assets
|
||||
new CopyWebpackPlugin([
|
||||
{
|
||||
from: path.resolve(__dirname, '../static'),
|
||||
to: config.build.assetsSubDirectory,
|
||||
ignore: ['.*']
|
||||
}
|
||||
])
|
||||
]
|
||||
})
|
||||
|
||||
if (config.build.productionGzip) {
|
||||
var CompressionWebpackPlugin = require('compression-webpack-plugin')
|
||||
|
||||
webpackConfig.plugins.push(
|
||||
new CompressionWebpackPlugin({
|
||||
asset: '[path].gz[query]',
|
||||
algorithm: 'gzip',
|
||||
test: new RegExp(
|
||||
'\\.(' +
|
||||
config.build.productionGzipExtensions.join('|') +
|
||||
')$'
|
||||
),
|
||||
threshold: 10240,
|
||||
minRatio: 0.8
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (config.build.bundleAnalyzerReport) {
|
||||
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
|
||||
webpackConfig.plugins.push(new BundleAnalyzerPlugin())
|
||||
}
|
||||
|
||||
module.exports = webpackConfig
|
||||
31
front/build/webpack.test.conf.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// This is the webpack config used for unit tests.
|
||||
|
||||
var utils = require('./utils')
|
||||
var webpack = require('webpack')
|
||||
var merge = require('webpack-merge')
|
||||
var baseConfig = require('./webpack.base.conf')
|
||||
|
||||
var webpackConfig = merge(baseConfig, {
|
||||
// use inline sourcemap for karma-sourcemap-loader
|
||||
module: {
|
||||
rules: utils.styleLoaders()
|
||||
},
|
||||
devtool: '#inline-source-map',
|
||||
resolveLoader: {
|
||||
alias: {
|
||||
// necessary to to make lang="scss" work in test when using vue-loader's ?inject option
|
||||
// see discussion at https://github.com/vuejs/vue-loader/issues/724
|
||||
'scss-loader': 'sass-loader'
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': require('../config/test.env')
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
// no need for app entry during tests
|
||||
delete webpackConfig.entry
|
||||
|
||||
module.exports = webpackConfig
|
||||
6
front/config/dev.env.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
var merge = require('webpack-merge')
|
||||
var prodEnv = require('./prod.env')
|
||||
|
||||
module.exports = merge(prodEnv, {
|
||||
NODE_ENV: '"development"'
|
||||
})
|
||||
39
front/config/index.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// see http://vuejs-templates.github.io/webpack for documentation.
|
||||
var path = require('path')
|
||||
|
||||
module.exports = {
|
||||
build: {
|
||||
env: require('./prod.env'),
|
||||
index: path.resolve(__dirname, '../dist/index.html'),
|
||||
assetsRoot: path.resolve(__dirname, '../dist'),
|
||||
assetsSubDirectory: 'static',
|
||||
assetsPublicPath: '/',
|
||||
productionSourceMap: true,
|
||||
// Gzip off by default as many popular static hosts such as
|
||||
// Surge or Netlify already gzip all static assets for you.
|
||||
// Before setting to `true`, make sure to:
|
||||
// npm install --save-dev compression-webpack-plugin
|
||||
productionGzip: false,
|
||||
productionGzipExtensions: ['js', 'css'],
|
||||
// Run the build command with an extra argument to
|
||||
// View the bundle analyzer report after build finishes:
|
||||
// `npm run build --report`
|
||||
// Set to `true` or `false` to always turn it on or off
|
||||
bundleAnalyzerReport: process.env.npm_config_report
|
||||
},
|
||||
dev: {
|
||||
env: require('./dev.env'),
|
||||
port: 8080,
|
||||
host: '127.0.0.1',
|
||||
autoOpenBrowser: true,
|
||||
assetsSubDirectory: 'static',
|
||||
assetsPublicPath: '/',
|
||||
proxyTable: {},
|
||||
// CSS Sourcemaps off by default because relative paths are "buggy"
|
||||
// with this option, according to the CSS-Loader README
|
||||
// (https://github.com/webpack/css-loader#sourcemaps)
|
||||
// In our experience, they generally work as expected,
|
||||
// just be aware of this issue when enabling this option.
|
||||
cssSourceMap: false
|
||||
}
|
||||
}
|
||||
4
front/config/prod.env.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
NODE_ENV: '"production"',
|
||||
BACKEND_URL: '"' + (process.env.BACKEND_URL || '/') + '"'
|
||||
}
|
||||
6
front/config/test.env.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
var merge = require('webpack-merge')
|
||||
var devEnv = require('./dev.env')
|
||||
|
||||
module.exports = merge(devEnv, {
|
||||
NODE_ENV: '"testing"'
|
||||
})
|
||||
13
front/docker/Dockerfile.dev
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:6-alpine
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
ADD package.json .
|
||||
|
||||
RUN npm install
|
||||
|
||||
VOLUME ["/app/node_modules"]
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
11
front/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Funkwhale</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
100
front/package.json
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
{
|
||||
"name": "front",
|
||||
"version": "1.0.0",
|
||||
"description": "Funkwhale front-end",
|
||||
"author": "Eliot Berriot <contact@eliotberriot.com>",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node build/dev-server.js",
|
||||
"start": "node build/dev-server.js",
|
||||
"build": "node build/build.js",
|
||||
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
|
||||
"e2e": "node test/e2e/runner.js",
|
||||
"test": "npm run unit && npm run e2e",
|
||||
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs"
|
||||
},
|
||||
"dependencies": {
|
||||
"dateformat": "^2.0.0",
|
||||
"js-logger": "^1.3.0",
|
||||
"semantic-ui-css": "^2.2.10",
|
||||
"vue": "^2.3.3",
|
||||
"vue-resource": "^1.3.4",
|
||||
"vue-router": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^6.7.2",
|
||||
"babel-core": "^6.22.1",
|
||||
"babel-eslint": "^7.1.1",
|
||||
"babel-loader": "^6.2.10",
|
||||
"babel-plugin-istanbul": "^4.1.1",
|
||||
"babel-plugin-transform-runtime": "^6.22.0",
|
||||
"babel-preset-env": "^1.3.2",
|
||||
"babel-preset-stage-2": "^6.22.0",
|
||||
"babel-register": "^6.22.0",
|
||||
"chai": "^3.5.0",
|
||||
"chalk": "^1.1.3",
|
||||
"chromedriver": "^2.27.2",
|
||||
"connect-history-api-fallback": "^1.3.0",
|
||||
"copy-webpack-plugin": "^4.0.1",
|
||||
"cross-env": "^4.0.0",
|
||||
"cross-spawn": "^5.0.1",
|
||||
"css-loader": "^0.28.0",
|
||||
"eslint": "^3.19.0",
|
||||
"eslint-config-standard": "^6.2.1",
|
||||
"eslint-friendly-formatter": "^2.0.7",
|
||||
"eslint-loader": "^1.7.1",
|
||||
"eslint-plugin-html": "^2.0.0",
|
||||
"eslint-plugin-promise": "^3.4.0",
|
||||
"eslint-plugin-standard": "^2.0.1",
|
||||
"eventsource-polyfill": "^0.9.6",
|
||||
"express": "^4.14.1",
|
||||
"extract-text-webpack-plugin": "^2.0.0",
|
||||
"file-loader": "^0.11.1",
|
||||
"friendly-errors-webpack-plugin": "^1.1.3",
|
||||
"html-webpack-plugin": "^2.28.0",
|
||||
"http-proxy-middleware": "^0.17.3",
|
||||
"inject-loader": "^3.0.0",
|
||||
"karma": "^1.4.1",
|
||||
"karma-coverage": "^1.1.1",
|
||||
"karma-mocha": "^1.3.0",
|
||||
"karma-phantomjs-launcher": "^1.0.2",
|
||||
"karma-phantomjs-shim": "^1.4.0",
|
||||
"karma-sinon-chai": "^1.3.1",
|
||||
"karma-sourcemap-loader": "^0.3.7",
|
||||
"karma-spec-reporter": "0.0.30",
|
||||
"karma-webpack": "^2.0.2",
|
||||
"lolex": "^1.5.2",
|
||||
"mocha": "^3.2.0",
|
||||
"nightwatch": "^0.9.12",
|
||||
"node-sass": "^4.5.3",
|
||||
"opn": "^4.0.2",
|
||||
"optimize-css-assets-webpack-plugin": "^1.3.0",
|
||||
"ora": "^1.2.0",
|
||||
"phantomjs-prebuilt": "^2.1.14",
|
||||
"rimraf": "^2.6.0",
|
||||
"sass-loader": "^6.0.5",
|
||||
"selenium-server": "^3.0.1",
|
||||
"semver": "^5.3.0",
|
||||
"shelljs": "^0.7.6",
|
||||
"sinon": "^2.1.0",
|
||||
"sinon-chai": "^2.8.0",
|
||||
"url-loader": "^0.5.8",
|
||||
"vue-loader": "^12.1.0",
|
||||
"vue-style-loader": "^3.0.1",
|
||||
"vue-template-compiler": "^2.3.3",
|
||||
"webpack": "^2.6.1",
|
||||
"webpack-bundle-analyzer": "^2.2.1",
|
||||
"webpack-dev-middleware": "^1.10.0",
|
||||
"webpack-hot-middleware": "^2.18.0",
|
||||
"webpack-merge": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not ie <= 8"
|
||||
]
|
||||
}
|
||||
63
front/src/App.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<sidebar></sidebar>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: { Sidebar }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// we do the import here instead in main.js
|
||||
// as resolve order is not deterministric in webpack
|
||||
// and we end up with CSS rules not applied,
|
||||
// see https://github.com/webpack/webpack/issues/215
|
||||
@import 'semantic/semantic.css';
|
||||
|
||||
|
||||
#app {
|
||||
font-family: 'Avenir', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.main.pusher {
|
||||
margin-left: 350px !important;
|
||||
transform: none !important;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.ui.stripe.segment {
|
||||
padding: 4em;
|
||||
}
|
||||
|
||||
.ui.small.text.container {
|
||||
max-width: 500px !important;
|
||||
}
|
||||
|
||||
.button.icon.tiny {
|
||||
padding: 0.5em !important;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
.logo {
|
||||
path {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discrete.link {
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
.floated.buttons .button ~ .dropdown {
|
||||
border-left: none;
|
||||
}
|
||||
</style>
|
||||
BIN
front/src/assets/audio/default-cover.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
front/src/assets/logo/favicon.ico
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
front/src/assets/logo/logo-full.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
34
front/src/assets/logo/logo-with-text.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<svg version="1.1" id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 283.5 44.7" enable-background="new 0 0 283.5 44.7" xml:space="preserve">
|
||||
<g>
|
||||
<path fill="#222222" d="M3.9,16.7c0-9,3.5-12.5,14-12.5c2.2,0,5,0.2,6.5,0.5c0.8,0.2,1.5,0.8,1.5,1.5v2.7c0,0.8-0.6,1.5-1.5,1.5
|
||||
h-0.9c-1.1,0-2-0.4-3.4-0.4c-6.5,0-7.8,1.3-7.8,6.7v0.4h8.9c0.8,0,1.5,0.6,1.5,1.5v2.9c0,0.9-0.6,1.5-1.5,1.5h-8.9v15.2
|
||||
c0,0.8-0.6,1.5-1.5,1.5H5.4c-0.8,0-1.5-0.7-1.5-1.5V16.7z"/>
|
||||
<path fill="#222222" d="M36.4,28.4c0,4.1,1.9,5.8,4.7,5.8c2.4,0,4.7-1.7,6.5-3.5V14.4c0-0.8,0.7-1.5,1.5-1.5h5.5
|
||||
c0.8,0,1.5,0.7,1.5,1.5v23.8c0,0.8-0.6,1.5-1.5,1.5h-5.5c-0.8,0-1.5-0.7-1.5-1.5v-1.6c-2.3,2-4.8,3.6-8.5,3.6
|
||||
c-6.5,0-11.1-3.4-11.1-11.7v-14c0-0.8,0.6-1.5,1.5-1.5h5.5c0.8,0,1.5,0.7,1.5,1.5V28.4z"/>
|
||||
<path fill="#222222" d="M81.7,24.2c0-4.1-1.9-5.8-4.7-5.8c-2.4,0-4.8,1.7-6.6,3.5v16.4c0,0.8-0.6,1.5-1.5,1.5h-5.5
|
||||
c-0.9,0-1.5-0.7-1.5-1.5V14.4c0-0.8,0.6-1.5,1.5-1.5H69c0.8,0,1.5,0.7,1.5,1.5V16c2.3-2,4.8-3.6,8.6-3.6c6.5,0,11.1,3.4,11.1,11.7
|
||||
v14c0,0.8-0.6,1.5-1.5,1.5h-5.5c-0.8,0-1.5-0.7-1.5-1.5V24.2z"/>
|
||||
<path fill="#222222" d="M104.5,23.5c2.9,0,4.8-1.2,5.8-3.3l2.4-5c0.6-1.4,2.1-2.3,3.5-2.3h4.6c1.3,0,1.5,1,0.8,2.3l-3.2,6.7
|
||||
c-1,2.2-3,3.9-5.2,4.4c2,0.6,3.7,2,5.2,4.4l4.2,6.7c0.8,1.3,0.4,2.3-0.8,2.3h-4.6c-1.6,0-2.9-1-3.7-2.3l-3.1-5
|
||||
c-1.3-2.2-3.6-3.2-5.8-3.2v9.1c0,0.8-0.6,1.5-1.5,1.5h-5.5c-0.8,0-1.5-0.7-1.5-1.5v-32c0-0.8,0.6-1.5,1.5-1.5h5.5
|
||||
c0.8,0,1.5,0.7,1.5,1.5V23.5z"/>
|
||||
<path fill="#222222" d="M148.2,12.9c1.4,0,2.4,0.8,2.8,2.1l4,13.2l4-13.2c0.4-1.4,1.8-2.1,3.3-2.1h4.5c1.2,0,1.4,0.9,1,2.1
|
||||
l-7.4,22.5c-0.4,1.3-1.8,2.1-3.1,2.1h-3.5c-1.2,0-2.7-0.8-3.1-2.1L146,23l-4.7,14.6c-0.4,1.3-1.9,2.1-3.1,2.1h-3.5
|
||||
c-1.3,0-2.6-0.8-3.1-2.1l-7.4-22.5c-0.4-1.2-0.1-2.1,1-2.1h4.5c1.5,0,2.8,0.8,3.3,2.1l4,13.2l4-13.2c0.4-1.3,1.4-2.1,2.8-2.1H148.2
|
||||
z"/>
|
||||
<path fill="#222222" d="M191.1,24.2c0-4.1-1.9-5.8-4.6-5.8c-2.4,0-4.8,1.7-6.6,3.5v16.4c0,0.8-0.6,1.5-1.5,1.5h-5.5
|
||||
c-0.8,0-1.5-0.7-1.5-1.5v-32c0-0.8,0.7-1.5,1.5-1.5h5.5c0.8,0,1.5,0.7,1.5,1.5V16c2.3-2,4.8-3.6,8.6-3.6c6.5,0,11.1,3.4,11.1,11.7
|
||||
v14c0,0.8-0.7,1.5-1.5,1.5h-5.5c-0.8,0-1.5-0.7-1.5-1.5V24.2z"/>
|
||||
<path fill="#222222" d="M213.9,19.6c-0.6,0.8-1.6,1.3-2.8,1.3h-3.6c-0.8,0-1.5-0.6-1.5-1.5c0-5.2,5.2-7,13.3-7
|
||||
c7.2,0,12.9,3,12.9,10.6v15.1c0,0.8-0.7,1.5-1.5,1.5h-4.7c-0.8,0-1.5-0.7-1.5-1.5v-0.8c-2.3,1.6-5,2.8-8.9,2.8
|
||||
c-6.5,0-11.5-2.9-11.5-8.6s5-8.5,11.5-8.5h8.1c0-3.9-1.6-5.1-5-5.1C216.6,18,214.7,18.6,213.9,19.6z M223.7,32.4v-3.8h-7.5
|
||||
c-2.4,0-3.7,1.3-3.7,3c0,1.7,1.3,3,4,3C219.4,34.6,221.9,33.5,223.7,32.4z"/>
|
||||
<path fill="#222222" d="M239.6,39.7c-0.8,0-1.5-0.7-1.5-1.5v-32c0-0.8,0.7-1.5,1.5-1.5h5.5c0.8,0,1.5,0.7,1.5,1.5v32
|
||||
c0,0.8-0.6,1.5-1.5,1.5H239.6z"/>
|
||||
<path fill="#222222" d="M259.6,28.9c0.3,4,2.1,5.7,6.2,5.7c2.1,0,4-0.6,4.8-1.6c0.7-0.8,1.6-1.3,2.8-1.3h3.6c0.8,0,1.5,0.7,1.5,1.5
|
||||
c0,5.2-5.3,7-13.3,7c-8.9,0-14.3-4.8-14.3-13.8c0-9,5.4-13.9,14.3-13.9c8.9,0,14.2,4.8,14.2,13.6v1.4c0,0.8-0.6,1.5-1.5,1.5H259.6z
|
||||
M259.6,23.7h11.4c-0.2-3.7-2-5.7-5.7-5.7C261.7,18,259.8,20,259.6,23.7z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3 KiB |
BIN
front/src/assets/logo/logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
19
front/src/assets/logo/logo.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<svg version="1.1" id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#4082B4" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
|
||||
c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z"/>
|
||||
<path fill="#4082B4" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
|
||||
c0,17.1-13.9,31-31,31c-17.1,0-31-13.9-31-31c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C29.6,87.6,48.1,106.1,70.9,106.1z"
|
||||
/>
|
||||
<path fill="#4082B4" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
|
||||
c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,33.8,27.5,61.3,61.3,61.3c33.8,0,61.3-27.5,61.3-61.3
|
||||
C132.2,64.3,131.7,63.8,131.1,63.8z"/>
|
||||
</g>
|
||||
<path fill="#222222" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
|
||||
c0,0,2,5.2,6.4,5.2c4.7,0,6.4-5.2,6.4-5.2l0.3-5.2c0,0-0.1-4.7,2.4-8.5c1.6-2.5,3.2-4.3,5.8-5.7c4-2.3,8.4-2.7,12.5-4.8
|
||||
c4.1-2.1,8.1-4.8,10.8-8.6c2.7-3.8,4-8.8,2.5-13.2c-7.8-0.4-16.8,0.5-23.7,4.2c-9.6,5.1-15.4,3.3-17.1,10.9h-0.1
|
||||
c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
front/src/assets/logo/logos.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
37
front/src/audio/backend.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import config from '@/config'
|
||||
|
||||
var Album = {
|
||||
clean (album) {
|
||||
// we manually rebind the album and artist to each child track
|
||||
album.tracks = album.tracks.map((track) => {
|
||||
track.artist = album.artist
|
||||
track.album = album
|
||||
return track
|
||||
})
|
||||
return album
|
||||
}
|
||||
}
|
||||
var Artist = {
|
||||
clean (artist) {
|
||||
// clean data as given by the API
|
||||
artist.albums = artist.albums.map((album) => {
|
||||
return Album.clean(album)
|
||||
})
|
||||
return artist
|
||||
}
|
||||
}
|
||||
export default {
|
||||
absoluteUrl (url) {
|
||||
if (url.startsWith('http')) {
|
||||
return url
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return config.BACKEND_URL + url.substr(1)
|
||||
} else {
|
||||
return config.BACKEND_URL + url
|
||||
}
|
||||
},
|
||||
Artist: Artist,
|
||||
Album: Album
|
||||
|
||||
}
|
||||
194
front/src/audio/index.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import logger from '@/logging'
|
||||
|
||||
const pad = (val) => {
|
||||
val = Math.floor(val)
|
||||
if (val < 10) {
|
||||
return '0' + val
|
||||
}
|
||||
return val + ''
|
||||
}
|
||||
|
||||
const Cov = {
|
||||
on (el, type, func) {
|
||||
el.addEventListener(type, func)
|
||||
},
|
||||
off (el, type, func) {
|
||||
el.removeEventListener(type, func)
|
||||
}
|
||||
}
|
||||
|
||||
class Audio {
|
||||
constructor (src, options = {}) {
|
||||
let preload = true
|
||||
if (options.preload !== undefined && options.preload === false) {
|
||||
preload = false
|
||||
}
|
||||
this.tmp = {
|
||||
src: src,
|
||||
options: options
|
||||
}
|
||||
this.onEnded = function (e) {
|
||||
logger.default.info('track ended')
|
||||
}
|
||||
if (options.onEnded) {
|
||||
this.onEnded = options.onEnded
|
||||
}
|
||||
|
||||
this.state = {
|
||||
preload: preload,
|
||||
startLoad: false,
|
||||
failed: false,
|
||||
try: 3,
|
||||
tried: 0,
|
||||
playing: false,
|
||||
paused: false,
|
||||
playbackRate: 1.0,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
volume: 0.5,
|
||||
duration: 0,
|
||||
loaded: '0',
|
||||
durationTimerFormat: '00:00',
|
||||
currentTimeFormat: '00:00',
|
||||
lastTimeFormat: '00:00'
|
||||
}
|
||||
if (options.volume !== undefined) {
|
||||
this.state.volume = options.volume
|
||||
}
|
||||
this.hook = {
|
||||
playState: [],
|
||||
loadState: []
|
||||
}
|
||||
if (preload) {
|
||||
this.init(src, options)
|
||||
}
|
||||
}
|
||||
|
||||
init (src, options = {}) {
|
||||
if (!src) throw Error('src must be required')
|
||||
this.state.startLoad = true
|
||||
if (this.state.tried === this.state.try) {
|
||||
this.state.failed = true
|
||||
return
|
||||
}
|
||||
this.$Audio = new window.Audio(src)
|
||||
Cov.on(this.$Audio, 'error', () => {
|
||||
this.state.tried++
|
||||
this.init(src, options)
|
||||
})
|
||||
if (options.autoplay) {
|
||||
this.play()
|
||||
}
|
||||
if (options.rate) {
|
||||
this.$Audio.playbackRate = options.rate
|
||||
}
|
||||
if (options.loop) {
|
||||
this.$Audio.loop = true
|
||||
}
|
||||
if (options.volume) {
|
||||
this.setVolume(options.volume)
|
||||
}
|
||||
this.loadState()
|
||||
}
|
||||
|
||||
loadState () {
|
||||
if (this.$Audio.readyState >= 2) {
|
||||
Cov.on(this.$Audio, 'progress', this.updateLoadState.bind(this))
|
||||
} else {
|
||||
Cov.on(this.$Audio, 'loadeddata', () => {
|
||||
this.loadState()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updateLoadState (e) {
|
||||
if (!this.$Audio) return
|
||||
this.hook.loadState.forEach(func => {
|
||||
func(this.state)
|
||||
})
|
||||
this.state.duration = Math.round(this.$Audio.duration * 100) / 100
|
||||
this.state.loaded = Math.round(10000 * this.$Audio.buffered.end(0) / this.$Audio.duration) / 100
|
||||
this.state.durationTimerFormat = this.timeParse(this.state.duration)
|
||||
}
|
||||
|
||||
updatePlayState (e) {
|
||||
this.state.currentTime = Math.round(this.$Audio.currentTime * 100) / 100
|
||||
this.state.duration = Math.round(this.$Audio.duration * 100) / 100
|
||||
this.state.progress = Math.round(10000 * this.state.currentTime / this.state.duration) / 100
|
||||
|
||||
this.state.durationTimerFormat = this.timeParse(this.state.duration)
|
||||
this.state.currentTimeFormat = this.timeParse(this.state.currentTime)
|
||||
this.state.lastTimeFormat = this.timeParse(this.state.duration - this.state.currentTime)
|
||||
|
||||
this.hook.playState.forEach(func => {
|
||||
func(this.state)
|
||||
})
|
||||
}
|
||||
|
||||
updateHook (type, func) {
|
||||
if (!(type in this.hook)) throw Error('updateHook: type should be playState or loadState')
|
||||
this.hook[type].push(func)
|
||||
}
|
||||
|
||||
play () {
|
||||
logger.default.info('Playing track')
|
||||
if (this.state.startLoad) {
|
||||
if (!this.state.playing && this.$Audio.readyState >= 2) {
|
||||
this.$Audio.play()
|
||||
this.state.paused = false
|
||||
this.state.playing = true
|
||||
Cov.on(this.$Audio, 'timeupdate', this.updatePlayState.bind(this))
|
||||
Cov.on(this.$Audio, 'ended', this.onEnded)
|
||||
} else {
|
||||
Cov.on(this.$Audio, 'loadeddata', () => {
|
||||
this.play()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
this.init(this.tmp.src, this.tmp.options)
|
||||
Cov.on(this.$Audio, 'loadeddata', () => {
|
||||
this.play()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
destroyed () {
|
||||
this.$Audio.pause()
|
||||
Cov.off(this.$Audio, 'timeupdate', this.updatePlayState)
|
||||
Cov.off(this.$Audio, 'progress', this.updateLoadState)
|
||||
Cov.off(this.$Audio, 'ended', this.onEnded)
|
||||
this.$Audio.remove()
|
||||
}
|
||||
|
||||
pause () {
|
||||
logger.default.info('Pausing track')
|
||||
this.$Audio.pause()
|
||||
this.state.paused = true
|
||||
this.state.playing = false
|
||||
this.$Audio.removeEventListener('timeupdate', this.updatePlayState)
|
||||
}
|
||||
|
||||
setVolume (number) {
|
||||
if (number > -0.01 && number <= 1) {
|
||||
this.state.volume = Math.round(number * 100) / 100
|
||||
this.$Audio.volume = this.state.volume
|
||||
}
|
||||
}
|
||||
|
||||
setTime (time) {
|
||||
if (time < 0 && time > this.state.duration) {
|
||||
return false
|
||||
}
|
||||
this.$Audio.currentTime = time
|
||||
}
|
||||
|
||||
timeParse (sec) {
|
||||
let min = 0
|
||||
min = Math.floor(sec / 60)
|
||||
sec = sec - min * 60
|
||||
return pad(min) + ':' + pad(sec)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Audio
|
||||
215
front/src/audio/queue.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import logger from '@/logging'
|
||||
import cache from '@/cache'
|
||||
import config from '@/config'
|
||||
import Audio from '@/audio'
|
||||
import backend from '@/audio/backend'
|
||||
import radios from '@/radios'
|
||||
import Vue from 'vue'
|
||||
|
||||
class Queue {
|
||||
constructor (options = {}) {
|
||||
logger.default.info('Instanciating queue')
|
||||
this.previousQueue = cache.get('queue')
|
||||
this.tracks = []
|
||||
this.currentIndex = -1
|
||||
this.currentTrack = null
|
||||
this.ended = true
|
||||
this.state = {
|
||||
volume: cache.get('volume', 0.5)
|
||||
}
|
||||
this.audio = {
|
||||
state: {
|
||||
startLoad: false,
|
||||
failed: false,
|
||||
try: 3,
|
||||
tried: 0,
|
||||
playing: false,
|
||||
paused: false,
|
||||
playbackRate: 1.0,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: this.state.volume,
|
||||
loaded: '0',
|
||||
durationTimerFormat: '00:00',
|
||||
currentTimeFormat: '00:00',
|
||||
lastTimeFormat: '00:00'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache () {
|
||||
let cached = {
|
||||
tracks: this.tracks.map(track => {
|
||||
// we keep only valuable fields to make the cache lighter and avoid
|
||||
// cyclic value serialization errors
|
||||
let artist = {
|
||||
id: track.artist.id,
|
||||
mbid: track.artist.mbid,
|
||||
name: track.artist.name
|
||||
}
|
||||
return {
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
mbid: track.mbid,
|
||||
album: {
|
||||
id: track.album.id,
|
||||
title: track.album.title,
|
||||
mbid: track.album.mbid,
|
||||
cover: track.album.cover,
|
||||
artist: artist
|
||||
},
|
||||
artist: artist,
|
||||
files: track.files
|
||||
}
|
||||
}),
|
||||
currentIndex: this.currentIndex
|
||||
}
|
||||
cache.set('queue', cached)
|
||||
}
|
||||
|
||||
restore () {
|
||||
let cached = cache.get('queue')
|
||||
if (!cached) {
|
||||
return false
|
||||
}
|
||||
logger.default.info('Restoring previous queue...')
|
||||
this.tracks = cached.tracks
|
||||
this.play(cached.currentIndex)
|
||||
this.previousQueue = null
|
||||
return true
|
||||
}
|
||||
removePrevious () {
|
||||
this.previousQueue = undefined
|
||||
cache.remove('queue')
|
||||
}
|
||||
setVolume (newValue) {
|
||||
this.state.volume = newValue
|
||||
if (this.audio.setVolume) {
|
||||
this.audio.setVolume(newValue)
|
||||
} else {
|
||||
this.audio.state.volume = newValue
|
||||
}
|
||||
cache.set('volume', newValue)
|
||||
}
|
||||
append (track, index) {
|
||||
this.previousQueue = null
|
||||
index = index || this.tracks.length
|
||||
if (index > this.tracks.length - 1) {
|
||||
// we simply push to the end
|
||||
this.tracks.push(track)
|
||||
} else {
|
||||
// we insert the track at given position
|
||||
this.tracks.splice(index, 0, track)
|
||||
}
|
||||
if (this.ended) {
|
||||
this.play(this.currentIndex + 1)
|
||||
}
|
||||
this.cache()
|
||||
}
|
||||
|
||||
appendMany (tracks, index) {
|
||||
let self = this
|
||||
index = index || this.tracks.length - 1
|
||||
tracks.forEach((t) => {
|
||||
self.append(t, index)
|
||||
index += 1
|
||||
})
|
||||
}
|
||||
|
||||
populateFromRadio () {
|
||||
if (!radios.running) {
|
||||
return
|
||||
}
|
||||
var self = this
|
||||
radios.fetch().then((response) => {
|
||||
logger.default.info('Adding track to queue from radio')
|
||||
self.append(response.data.track)
|
||||
}, (response) => {
|
||||
logger.default.error('Error while adding track to queue from radio')
|
||||
})
|
||||
}
|
||||
|
||||
clean () {
|
||||
this.stop()
|
||||
this.tracks = []
|
||||
this.currentIndex = -1
|
||||
this.currentTrack = null
|
||||
}
|
||||
|
||||
cleanTrack (index) {
|
||||
if (index === this.currentIndex) {
|
||||
this.stop()
|
||||
}
|
||||
if (index < this.currentIndex) {
|
||||
this.currentIndex -= 1
|
||||
}
|
||||
this.tracks.splice(index, 1)
|
||||
}
|
||||
|
||||
stop () {
|
||||
this.audio.pause()
|
||||
this.audio.destroyed()
|
||||
}
|
||||
play (index) {
|
||||
if (this.audio.destroyed) {
|
||||
logger.default.debug('Destroying previous audio...')
|
||||
this.audio.destroyed()
|
||||
}
|
||||
this.currentIndex = index
|
||||
this.currentTrack = this.tracks[index]
|
||||
this.ended = false
|
||||
let file = this.currentTrack.files[0]
|
||||
if (!file) {
|
||||
return this.next()
|
||||
}
|
||||
this.audio = new Audio(backend.absoluteUrl(file.path), {
|
||||
preload: true,
|
||||
autoplay: true,
|
||||
rate: 1,
|
||||
loop: false,
|
||||
volume: this.state.volume,
|
||||
onEnded: this.handleAudioEnded.bind(this)
|
||||
})
|
||||
if (this.currentIndex === this.tracks.length - 1) {
|
||||
this.populateFromRadio()
|
||||
}
|
||||
this.cache()
|
||||
}
|
||||
|
||||
handleAudioEnded (e) {
|
||||
this.recordListen(this.currentTrack)
|
||||
if (this.currentIndex < this.tracks.length - 1) {
|
||||
logger.default.info('Audio track ended, playing next one')
|
||||
this.next()
|
||||
} else {
|
||||
logger.default.info('We reached the end of the queue')
|
||||
this.ended = true
|
||||
}
|
||||
}
|
||||
|
||||
recordListen (track) {
|
||||
let url = config.API_URL + 'history/listenings/'
|
||||
let resource = Vue.resource(url)
|
||||
resource.save({}, {'track': track.id}).then((response) => {}, (response) => {
|
||||
logger.default.error('Could not record track in history')
|
||||
})
|
||||
}
|
||||
|
||||
previous () {
|
||||
if (this.currentIndex > 0) {
|
||||
this.play(this.currentIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
next () {
|
||||
if (this.currentIndex < this.tracks.length - 1) {
|
||||
this.play(this.currentIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let queue = new Queue()
|
||||
|
||||
export default queue
|
||||
7
front/src/audio/track.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import backend from './backend'
|
||||
|
||||
export default {
|
||||
getCover (track) {
|
||||
return backend.absoluteUrl(track.album.cover)
|
||||
}
|
||||
}
|
||||
89
front/src/auth/index.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import logger from '@/logging'
|
||||
import config from '@/config'
|
||||
import cache from '@/cache'
|
||||
import Vue from 'vue'
|
||||
|
||||
import favoriteTracks from '@/favorites/tracks'
|
||||
|
||||
// URL and endpoint constants
|
||||
const LOGIN_URL = config.API_URL + 'token/'
|
||||
const USER_PROFILE_URL = config.API_URL + 'users/users/me/'
|
||||
// const SIGNUP_URL = API_URL + 'users/'
|
||||
|
||||
export default {
|
||||
|
||||
// User object will let us check authentication status
|
||||
user: {
|
||||
authenticated: false,
|
||||
username: '',
|
||||
profile: null
|
||||
},
|
||||
|
||||
// Send a request to the login URL and save the returned JWT
|
||||
login (context, creds, redirect, onError) {
|
||||
return context.$http.post(LOGIN_URL, creds).then(response => {
|
||||
logger.default.info('Successfully logged in as', creds.username)
|
||||
cache.set('token', response.data.token)
|
||||
cache.set('username', creds.username)
|
||||
|
||||
this.user.authenticated = true
|
||||
this.user.username = creds.username
|
||||
this.connect()
|
||||
// Redirect to a specified route
|
||||
if (redirect) {
|
||||
context.$router.push(redirect)
|
||||
}
|
||||
}, response => {
|
||||
logger.default.error('Error while logging in', response.data)
|
||||
if (onError) {
|
||||
onError(response)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// To log out, we just need to remove the token
|
||||
logout () {
|
||||
cache.clear()
|
||||
this.user.authenticated = false
|
||||
logger.default.info('Log out, goodbye!')
|
||||
},
|
||||
|
||||
checkAuth () {
|
||||
logger.default.info('Checking authentication...')
|
||||
var jwt = cache.get('token')
|
||||
var username = cache.get('username')
|
||||
if (jwt) {
|
||||
this.user.authenticated = true
|
||||
this.user.username = username
|
||||
logger.default.info('Logged back in as ' + username)
|
||||
this.connect()
|
||||
} else {
|
||||
logger.default.info('Anonymous user')
|
||||
this.user.authenticated = false
|
||||
}
|
||||
},
|
||||
|
||||
// The object to be passed as a header for authenticated requests
|
||||
getAuthHeader () {
|
||||
return 'JWT ' + cache.get('token')
|
||||
},
|
||||
|
||||
fetchProfile () {
|
||||
let resource = Vue.resource(USER_PROFILE_URL)
|
||||
return resource.get({}).then((response) => {
|
||||
logger.default.info('Successfully fetched user profile')
|
||||
return response.data
|
||||
}, (response) => {
|
||||
logger.default.info('Error while fetching user profile')
|
||||
})
|
||||
},
|
||||
connect () {
|
||||
// called once user has logged in successfully / reauthenticated
|
||||
// e.g. after a page refresh
|
||||
let self = this
|
||||
this.fetchProfile().then(data => {
|
||||
Vue.set(self.user, 'profile', data)
|
||||
})
|
||||
favoriteTracks.fetch()
|
||||
}
|
||||
}
|
||||
29
front/src/cache/index.js
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import logger from '@/logging'
|
||||
export default {
|
||||
get (key, d) {
|
||||
let v = localStorage.getItem(key)
|
||||
if (v === null) {
|
||||
return d
|
||||
} else {
|
||||
try {
|
||||
return JSON.parse(v).value
|
||||
} catch (e) {
|
||||
logger.default.error('Removing unparsable cached value for key ' + key)
|
||||
this.remove(key)
|
||||
return d
|
||||
}
|
||||
}
|
||||
},
|
||||
set (key, value) {
|
||||
return localStorage.setItem(key, JSON.stringify({value: value}))
|
||||
},
|
||||
|
||||
remove (key) {
|
||||
return localStorage.removeItem(key)
|
||||
},
|
||||
|
||||
clear () {
|
||||
localStorage.clear()
|
||||
}
|
||||
|
||||
}
|
||||
157
front/src/components/Home.vue
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical center aligned stripe segment">
|
||||
<div class="ui text container">
|
||||
<h1 class="ui huge header">
|
||||
Welcome on funkwhale
|
||||
</h1>
|
||||
<p>We think listening music should be simple.</p>
|
||||
<router-link class="ui icon teal button" to="/browse">
|
||||
Get me to the library
|
||||
<i class="right arrow icon"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui grid">
|
||||
<div class="row">
|
||||
<div class="eight wide left floated column">
|
||||
<h2 class="ui header">
|
||||
Why funkwhale?
|
||||
</h2>
|
||||
<p>That's simple: we loved Grooveshark and we want to build something even better.</p>
|
||||
</div>
|
||||
<div class="four wide left floated column">
|
||||
<img class="ui medium image" src="../assets/logo/logo.png" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
Unlimited music
|
||||
</h2>
|
||||
<p>Funkwhale is designed to make it easy to listen to music you like, or to discover new artists.</p>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="sound icon"></i>
|
||||
<div class="content">
|
||||
Click once, listen for hours using built-in radios
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="heart icon"></i>
|
||||
<div class="content">
|
||||
Keep a track of your favorite songs
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="list icon"></i>
|
||||
<div class="content">
|
||||
Playlists? We got them
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
Clean library
|
||||
</h2>
|
||||
<p>Funkwhale takes care of fealing your music.</p>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="download icon"></i>
|
||||
<div class="content">
|
||||
Import music from various platforms, such as YouTube or SoundCloud
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="tag icon"></i>
|
||||
<div class="content">
|
||||
Get quality metadata about your music thanks to <a href="https://musicbrainz.org" target="_blank">MusicBrainz</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="plus icon"></i>
|
||||
<div class="content">
|
||||
Covers, lyrics, our goal is to have them all ;)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
Easy to use
|
||||
</h2>
|
||||
<p>Funkwhale is dead simple to use.</p>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="browser icon"></i>
|
||||
<div class="content">
|
||||
No add-ons, no plugins : you only need a web browser
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="wizard icon"></i>
|
||||
<div class="content">
|
||||
Access your music from a clean interface that focus on what really matters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui middle aligned stackable text container">
|
||||
<div class="ui hidden divider"></div>
|
||||
<h2 class="ui header">
|
||||
Your music, your way
|
||||
</h2>
|
||||
<p>Funkwhale is free and gives you control on your music.</p>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="smile icon"></i>
|
||||
<div class="content">
|
||||
The plaform is free and open-source, you can install it and modify it without worries
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="protect icon"></i>
|
||||
<div class="content">
|
||||
We do not track you or bother you with ads
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="users icon"></i>
|
||||
<div class="content">
|
||||
You can invite friends and family to your instance so they can enjoy your music
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'home',
|
||||
data () {
|
||||
return {
|
||||
msg: 'Welcome to Your Vue.js App'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
.stripe p {
|
||||
font-size: 120%;
|
||||
}
|
||||
.list.icon {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
33
front/src/components/Logo.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<svg version="1.1" id="layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#4082B4" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
|
||||
c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z"/>
|
||||
<path fill="#4082B4" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
|
||||
c0,17.1-13.9,31-31,31c-17.1,0-31-13.9-31-31c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C29.6,87.6,48.1,106.1,70.9,106.1z"
|
||||
/>
|
||||
<path fill="#4082B4" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
|
||||
c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,33.8,27.5,61.3,61.3,61.3c33.8,0,61.3-27.5,61.3-61.3
|
||||
C132.2,64.3,131.7,63.8,131.1,63.8z"/>
|
||||
</g>
|
||||
<path fill="#222222" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
|
||||
c0,0,2,5.2,6.4,5.2c4.7,0,6.4-5.2,6.4-5.2l0.3-5.2c0,0-0.1-4.7,2.4-8.5c1.6-2.5,3.2-4.3,5.8-5.7c4-2.3,8.4-2.7,12.5-4.8
|
||||
c4.1-2.1,8.1-4.8,10.8-8.6c2.7-3.8,4-8.8,2.5-13.2c-7.8-0.4-16.8,0.5-23.7,4.2c-9.6,5.1-15.4,3.3-17.1,10.9h-0.1
|
||||
c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
190
front/src/components/Sidebar.vue
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<template>
|
||||
<div class="ui vertical left visible wide sidebar">
|
||||
<div class="ui inverted segment header-wrapper">
|
||||
<search-bar>
|
||||
<router-link :title="'Funkwhale'" :to="{name: 'index'}">
|
||||
<i class="logo bordered inverted orange big icon">
|
||||
<logo class="logo"></logo>
|
||||
</i>
|
||||
</router-link>
|
||||
|
||||
</search-bar>
|
||||
</div>
|
||||
|
||||
<div class="menu-area">
|
||||
<div class="ui compact fluid two item inverted menu">
|
||||
<a class="active item" data-tab="browse">Browse</a>
|
||||
<a class="item" data-tab="queue">
|
||||
Queue
|
||||
<template v-if="queue.tracks.length === 0">
|
||||
(empty)
|
||||
</template>
|
||||
<template v-else>
|
||||
({{ queue.currentIndex + 1}} of {{ queue.tracks.length }})
|
||||
</template>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<div class="ui bottom attached active tab" data-tab="browse">
|
||||
<div class="ui inverted vertical fluid menu">
|
||||
<router-link class="item" v-if="auth.user.authenticated" :to="{name: 'profile', params: {username: auth.user.username}}"><i class="user icon"></i> Logged in as {{ auth.user.username }}</router-link>
|
||||
<router-link class="item" v-if="auth.user.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link>
|
||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
||||
<router-link class="item" :to="{path: '/browse'}"><i class="sound icon"> </i>Browse library</router-link>
|
||||
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="queue.previousQueue " class="ui black icon message">
|
||||
<i class="history icon"></i>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
Do you want to restore your previous queue?
|
||||
</div>
|
||||
<p>{{ queue.previousQueue.tracks.length }} tracks</p>
|
||||
<div class="ui two buttons">
|
||||
<div @click="queue.restore()" class="ui basic inverted green button">Yes</div>
|
||||
<div @click="queue.removePrevious()" class="ui basic inverted red button">No</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui bottom attached tab" data-tab="queue">
|
||||
<table class="ui compact inverted very basic fixed single line table">
|
||||
<tbody>
|
||||
<tr @click="queue.play(index)" v-for="(track, index) in queue.tracks" :class="[{'active': index === queue.currentIndex}]">
|
||||
<td class="right aligned">{{ index + 1}}</td>
|
||||
<td class="center aligned">
|
||||
<img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
|
||||
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="4">
|
||||
<strong>{{ track.title }}</strong><br />
|
||||
{{ track.artist.name }}
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="favoriteTracks.objects[track.id]">
|
||||
<i @click.stop="queue.cleanTrack(index)" class="pink heart icon"></i>
|
||||
</template
|
||||
</td>
|
||||
<td>
|
||||
<i @click.stop="queue.cleanTrack(index)" class="circular trash icon"></i>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="radios.running" class="ui black message">
|
||||
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<i class="feed icon"></i> You have a radio playing
|
||||
</div>
|
||||
<p>New tracks will be appended here automatically.</p>
|
||||
<div @click="radios.stop()" class="ui basic inverted red button">Stop radio</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui inverted segment player-wrapper">
|
||||
<player></player>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Player from '@/components/audio/Player'
|
||||
import favoriteTracks from '@/favorites/tracks'
|
||||
import Logo from '@/components/Logo'
|
||||
import SearchBar from '@/components/audio/SearchBar'
|
||||
import auth from '@/auth'
|
||||
import queue from '@/audio/queue'
|
||||
import backend from '@/audio/backend'
|
||||
import radios from '@/radios'
|
||||
|
||||
import $ from 'jquery'
|
||||
|
||||
export default {
|
||||
name: 'sidebar',
|
||||
components: {
|
||||
Player,
|
||||
SearchBar,
|
||||
Logo
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
auth: auth,
|
||||
backend: backend,
|
||||
queue: queue,
|
||||
radios,
|
||||
favoriteTracks
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
$(this.$el).find('.menu .item').tab()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
$sidebar-color: #1B1C1D;
|
||||
|
||||
.sidebar {
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content: space-between;
|
||||
|
||||
> div {
|
||||
margin: 0;
|
||||
background-color: $sidebar-color;
|
||||
}
|
||||
.menu {
|
||||
}
|
||||
}
|
||||
|
||||
.menu-area {
|
||||
padding: 0.5rem;
|
||||
.menu .item:not(.active):not(:hover) {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
}
|
||||
.tabs {
|
||||
overflow-y: auto;
|
||||
height: 0px;
|
||||
}
|
||||
.tab[data-tab="queue"] {
|
||||
tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.sidebar .segment {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ui.inverted.segment.header-wrapper {
|
||||
padding: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
background-color: rgb(46, 46, 46) !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ui.search {
|
||||
display: inline-block;
|
||||
> a {
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
front/src/components/audio/PlayButton.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<div :class="['ui', {'tiny': discrete}, 'buttons']">
|
||||
<button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, 'button']">
|
||||
<i class="ui play icon"></i>
|
||||
<template v-if="!discrete"><slot>Play</slot></template>
|
||||
</button>
|
||||
<div v-if="!discrete" class="ui floating dropdown icon button">
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="menu">
|
||||
<div class="item"@click="add"><i class="plus icon"></i> Add to queue</div>
|
||||
<div class="item"@click="addNext()"><i class="step forward icon"></i> Play next</div>
|
||||
<div class="item"@click="addNext(true)"><i class="arrow down icon"></i> Play now</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import logger from '@/logging'
|
||||
import queue from '@/audio/queue'
|
||||
import jQuery from 'jquery'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
// we can either have a single or multiple tracks to play when clicked
|
||||
tracks: {type: Array, required: false},
|
||||
track: {type: Object, required: false},
|
||||
discrete: {type: Boolean, default: false}
|
||||
},
|
||||
created () {
|
||||
if (!this.track & !this.tracks) {
|
||||
logger.default.error('You have to provide either a track or tracks property')
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (!this.discrete) {
|
||||
jQuery(this.$el).find('.ui.dropdown').dropdown()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
add () {
|
||||
if (this.track) {
|
||||
queue.append(this.track)
|
||||
} else {
|
||||
queue.appendMany(this.tracks)
|
||||
}
|
||||
},
|
||||
addNext (next) {
|
||||
if (this.track) {
|
||||
queue.append(this.track, queue.currentIndex + 1)
|
||||
} else {
|
||||
queue.appendMany(this.tracks, queue.currentIndex + 1)
|
||||
}
|
||||
if (next) {
|
||||
queue.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
i {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
189
front/src/components/audio/Player.vue
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<template>
|
||||
<div class="player">
|
||||
<div v-if="queue.currentTrack" class="track-area ui items">
|
||||
<div class="ui inverted item">
|
||||
<div class="ui tiny image">
|
||||
<img v-if="queue.currentTrack.album.cover" :src="Track.getCover(queue.currentTrack)">
|
||||
<img v-else src="../../assets/audio/default-cover.png">
|
||||
</div>
|
||||
<div class="middle aligned content">
|
||||
<router-link class="small header discrete link track" :to="{name: 'browse.track', params: {id: queue.currentTrack.id }}">
|
||||
{{ queue.currentTrack.title }}
|
||||
</router-link>
|
||||
<div class="meta">
|
||||
<router-link class="artist" :to="{name: 'browse.artist', params: {id: queue.currentTrack.artist.id }}">
|
||||
{{ queue.currentTrack.artist.name }}
|
||||
</router-link> /
|
||||
<router-link class="album" :to="{name: 'browse.album', params: {id: queue.currentTrack.album.id }}">
|
||||
{{ queue.currentTrack.album.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="description">
|
||||
<track-favorite-icon :track="queue.currentTrack"></track-favorite-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-area">
|
||||
<div class="ui grid">
|
||||
<div class="left floated four wide column">
|
||||
<p class="timer start" @click="queue.audio.setTime(0)">{{queue.audio.state.currentTimeFormat}}</p>
|
||||
</div>
|
||||
|
||||
<div class="right floated four wide column">
|
||||
<p class="timer total">{{queue.audio.state.durationTimerFormat}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="progress" class="ui small orange inverted progress" @click="touchProgress">
|
||||
<div class="bar" :data-percent="queue.audio.state.progress" :style="{ 'width': queue.audio.state.progress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls ui grid">
|
||||
<div class="volume-control four wide center aligned column">
|
||||
<input type="range" step="0.05" min="0" max="1" v-model="sliderVolume" />
|
||||
<i title="Unmute" @click="queue.setVolume(1)" v-if="currentVolume === 0" class="volume off secondary icon"></i>
|
||||
<i title="Mute" @click="queue.setVolume(0)" v-else-if="currentVolume < 0.5" class="volume down secondary icon"></i>
|
||||
<i title="Mute" @click="queue.setVolume(0)" v-else class="volume up secondary icon"></i>
|
||||
</div>
|
||||
<div class="eight wide center aligned column">
|
||||
<i title="Previous track" @click="queue.previous()" :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" :disabled="!hasPrevious"></i>
|
||||
<i title="Play track" v-if="!queue.audio.state.playing" :class="['ui', 'play', {'disabled': !queue.currentTrack}, 'big', 'icon']" @click="pauseOrPlay"></i>
|
||||
<i title="Pause track" v-else :class="['ui', 'pause', {'disabled': !queue.currentTrack}, 'big', 'icon']" @click="pauseOrPlay"></i>
|
||||
<i title="Next track" @click="queue.next()" :class="['ui', 'step', 'forward', {'disabled': !hasNext}, 'big', 'icon']" :disabled="!hasNext"></i>
|
||||
</div>
|
||||
<div class="four wide center aligned column">
|
||||
<i title="Clear your queue" @click="queue.clean()" :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" :disabled="queue.tracks.length === 0"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import queue from '@/audio/queue'
|
||||
import Track from '@/audio/track'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import radios from '@/radios'
|
||||
|
||||
export default {
|
||||
name: 'player',
|
||||
components: {
|
||||
TrackFavoriteIcon
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
sliderVolume: this.currentVolume,
|
||||
queue: queue,
|
||||
Track: Track,
|
||||
radios
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// we trigger the watcher explicitely it does not work otherwise
|
||||
this.sliderVolume = this.currentVolume
|
||||
},
|
||||
methods: {
|
||||
pauseOrPlay () {
|
||||
if (this.queue.audio.state.playing) {
|
||||
this.queue.audio.pause()
|
||||
} else {
|
||||
this.queue.audio.play()
|
||||
}
|
||||
},
|
||||
touchProgress (e) {
|
||||
let time
|
||||
let target = this.$refs.progress
|
||||
time = e.layerX / target.offsetWidth * this.queue.audio.state.duration
|
||||
this.queue.audio.setTime(time)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasPrevious () {
|
||||
return this.queue.currentIndex > 0
|
||||
},
|
||||
hasNext () {
|
||||
return this.queue.currentIndex < this.queue.tracks.length - 1
|
||||
},
|
||||
currentVolume () {
|
||||
return this.queue.audio.state.volume
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentVolume (newValue) {
|
||||
this.sliderVolume = newValue
|
||||
},
|
||||
sliderVolume (newValue) {
|
||||
this.queue.setVolume(parseFloat(newValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
.ui.progress {
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.progress {
|
||||
cursor: pointer;
|
||||
.bar {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ui.inverted.item > .content > .description {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.ui.item {
|
||||
.meta {
|
||||
font-size: 90%;
|
||||
line-height: 1.2
|
||||
}
|
||||
}
|
||||
.timer.total {
|
||||
text-align: right;
|
||||
}
|
||||
.timer.start {
|
||||
cursor: pointer
|
||||
}
|
||||
.track-area {
|
||||
.header, .meta, .artist, .album {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.controls .icon.big {
|
||||
cursor: pointer;
|
||||
font-size: 2em !important;
|
||||
}
|
||||
|
||||
.controls .icon {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.secondary.icon {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.progress-area .actions {
|
||||
text-align: center;
|
||||
}
|
||||
.volume-control {
|
||||
position: relative;
|
||||
.icon {
|
||||
margin: 0;
|
||||
}
|
||||
[type="range"] {
|
||||
max-width: 75%;
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 10%;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.ui.feed.icon {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
116
front/src/components/audio/Search.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2>Search for some music</h2>
|
||||
<div :class="['ui', {'loading': isLoading }, 'search']">
|
||||
<div class="ui icon big input">
|
||||
<i class="search icon"></i>
|
||||
<input ref="search" class="prompt" placeholder="Artist, album, track..." v-model.trim="query" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="query.length > 0">
|
||||
<h3 class="ui title">Artists</h3>
|
||||
<div v-if="results.artists.length > 0" class="ui stackable three column grid">
|
||||
<div class="column" :key="artist.id" v-for="artist in results.artists">
|
||||
<artist-card class="fluid" :artist="artist" ></artist-card>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>Sorry, we did not found any artist matching your query</p>
|
||||
</template>
|
||||
<template v-if="query.length > 0">
|
||||
<h3 class="ui title">Albums</h3>
|
||||
<div v-if="results.albums.length > 0" class="ui stackable three column grid">
|
||||
<div class="column" :key="album.id" v-for="album in results.albums">
|
||||
<album-card class="fluid" :album="album" ></album-card>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>Sorry, we did not found any album matching your query</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import logger from '@/logging'
|
||||
import queue from '@/audio/queue'
|
||||
import backend from '@/audio/backend'
|
||||
import AlbumCard from '@/components/audio/album/Card'
|
||||
import ArtistCard from '@/components/audio/artist/Card'
|
||||
import config from '@/config'
|
||||
|
||||
const SEARCH_URL = config.API_URL + 'search'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AlbumCard,
|
||||
ArtistCard
|
||||
},
|
||||
props: {
|
||||
autofocus: {type: Boolean, default: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
query: '',
|
||||
results: {
|
||||
albums: [],
|
||||
artists: []
|
||||
},
|
||||
backend: backend,
|
||||
isLoading: false,
|
||||
queue: queue
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.autofocus) {
|
||||
this.$refs.search.focus()
|
||||
}
|
||||
this.search()
|
||||
},
|
||||
methods: {
|
||||
search () {
|
||||
if (this.query.length < 1) {
|
||||
return
|
||||
}
|
||||
var self = this
|
||||
self.isLoading = true
|
||||
logger.default.debug('Searching track matching "' + this.query + '"')
|
||||
let params = {
|
||||
query: this.query
|
||||
}
|
||||
this.$http.get(SEARCH_URL, {
|
||||
params: params,
|
||||
before (request) {
|
||||
// abort previous request, if exists
|
||||
if (this.previousRequest) {
|
||||
this.previousRequest.abort()
|
||||
}
|
||||
|
||||
// set previous request on Vue instance
|
||||
this.previousRequest = request
|
||||
}
|
||||
}).then((response) => {
|
||||
self.results = self.castResults(response.data)
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
castResults (results) {
|
||||
return {
|
||||
albums: results.albums.map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
}),
|
||||
artists: results.artists.map((artist) => {
|
||||
return backend.Artist.clean(artist)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
query () {
|
||||
this.search()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
101
front/src/components/audio/SearchBar.vue
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<div class="ui fluid category search">
|
||||
<slot></slot>
|
||||
<div class="ui icon input">
|
||||
<input class="prompt" placeholder="Search for artists, albums, tracks..." type="text">
|
||||
<i class="search icon"></i>
|
||||
</div>
|
||||
<div class="results"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import jQuery from 'jquery'
|
||||
import config from '@/config'
|
||||
import auth from '@/auth'
|
||||
import router from '@/router'
|
||||
|
||||
const SEARCH_URL = config.API_URL + 'search?query={query}'
|
||||
|
||||
export default {
|
||||
mounted () {
|
||||
jQuery(this.$el).search({
|
||||
type: 'category',
|
||||
minCharacters: 3,
|
||||
onSelect (result, response) {
|
||||
router.push(result.routerUrl)
|
||||
},
|
||||
apiSettings: {
|
||||
beforeXHR: function (xhrObject) {
|
||||
xhrObject.setRequestHeader('Authorization', auth.getAuthHeader())
|
||||
return xhrObject
|
||||
},
|
||||
onResponse: function (initialResponse) {
|
||||
var results = {}
|
||||
let categories = [
|
||||
{
|
||||
code: 'artists',
|
||||
route: 'browse.artist',
|
||||
name: 'Artist',
|
||||
getTitle (r) {
|
||||
return r.name
|
||||
},
|
||||
getDescription (r) {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 'albums',
|
||||
route: 'browse.album',
|
||||
name: 'Album',
|
||||
getTitle (r) {
|
||||
return r.title
|
||||
},
|
||||
getDescription (r) {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 'tracks',
|
||||
route: 'browse.track',
|
||||
name: 'Track',
|
||||
getTitle (r) {
|
||||
return r.title
|
||||
},
|
||||
getDescription (r) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
]
|
||||
categories.forEach(category => {
|
||||
results[category.code] = {
|
||||
name: category.name,
|
||||
results: []
|
||||
}
|
||||
initialResponse[category.code].forEach(result => {
|
||||
results[category.code].results.push({
|
||||
title: category.getTitle(result),
|
||||
id: result.id,
|
||||
routerUrl: {
|
||||
name: category.route,
|
||||
params: {
|
||||
id: result.id
|
||||
}
|
||||
},
|
||||
description: category.getDescription(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
return {results: results}
|
||||
},
|
||||
url: SEARCH_URL
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
98
front/src/components/audio/album/Card.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="right floated tiny ui image">
|
||||
<img v-if="album.cover" :src="backend.absoluteUrl(album.cover)">
|
||||
<img v-else src="../../../assets/audio/default-cover.png">
|
||||
</div>
|
||||
<div class="header">
|
||||
<router-link class="discrete link" :to="{name: 'browse.album', params: {id: album.id }}">{{ album.title }}</router-link>
|
||||
</div>
|
||||
<div class="meta">
|
||||
By <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
|
||||
{{ album.artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="description" v-if="mode === 'rich'">
|
||||
<table class="ui very basic fixed single line compact table">
|
||||
<tbody>
|
||||
<tr v-for="track in tracks">
|
||||
<td>
|
||||
<play-button class="basic icon" :track="track" :discrete="true"></play-button>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="track discrete link" :to="{name: 'browse.track', params: {id: track.id }}">
|
||||
{{ track.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<track-favorite-icon :track="track"></track-favorite-icon>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="center aligned segment" v-if="album.tracks.length > initialTracks">
|
||||
<em v-if="!showAllTracks" @click="showAllTracks = true" class="expand">Show {{ album.tracks.length - initialTracks }} more tracks</em>
|
||||
<em v-else @click="showAllTracks = false" class="expand">Collapse</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<play-button class="mini basic orange right floated" :tracks="album.tracks">Play all</play-button>
|
||||
<span>
|
||||
<i class="music icon"></i>
|
||||
{{ album.tracks.length }} tracks
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import queue from '@/audio/queue'
|
||||
import backend from '@/audio/backend'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
album: {type: Object},
|
||||
mode: {type: String, default: 'rich'}
|
||||
},
|
||||
components: {
|
||||
TrackFavoriteIcon,
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
backend: backend,
|
||||
queue: queue,
|
||||
initialTracks: 4,
|
||||
showAllTracks: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
tracks () {
|
||||
if (this.showAllTracks) {
|
||||
return this.album.tracks
|
||||
}
|
||||
return this.album.tracks.slice(0, this.initialTracks)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
tr {
|
||||
.favorite-icon:not(.favorited) {
|
||||
display: none;
|
||||
}
|
||||
&:hover .favorite-icon {
|
||||
display: inherit;
|
||||
}
|
||||
}
|
||||
.expand {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
84
front/src/components/audio/artist/Card.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<router-link class="discrete link" :to="{name: 'browse.artist', params: {id: artist.id }}">
|
||||
{{ artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="description">
|
||||
<table class="ui compact very basic fixed single line table">
|
||||
<tbody>
|
||||
<tr v-for="album in albums">
|
||||
<td>
|
||||
<img class="ui mini image" v-if="album.cover" :src="backend.absoluteUrl(album.cover)">
|
||||
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="4">
|
||||
<router-link class="discrete link":to="{name: 'browse.album', params: {id: album.id }}">
|
||||
<strong>{{ album.title }}</strong>
|
||||
</router-link><br />
|
||||
{{ album.tracks.length }} tracks
|
||||
</td>
|
||||
<td>
|
||||
<play-button class="right floated basic icon" :discrete="true" :tracks="album.tracks"></play-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="center aligned segment" v-if="artist.albums.length > initialAlbums">
|
||||
<em v-if="!showAllAlbums" @click="showAllAlbums = true" class="expand">Show {{ artist.albums.length - initialAlbums }} more albums</em>
|
||||
<em v-else @click="showAllAlbums = false" class="expand">Collapse</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<span>
|
||||
<i class="sound icon"></i>
|
||||
{{ artist.albums.length }} albums
|
||||
</span>
|
||||
<play-button class="mini basic orange right floated" :tracks="allTracks">Play all</play-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import backend from '@/audio/backend'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: ['artist'],
|
||||
components: {
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
backend: backend,
|
||||
initialAlbums: 3,
|
||||
showAllAlbums: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
albums () {
|
||||
if (this.showAllAlbums) {
|
||||
return this.artist.albums
|
||||
}
|
||||
return this.artist.albums.slice(0, this.initialAlbums)
|
||||
},
|
||||
allTracks () {
|
||||
let tracks = []
|
||||
this.artist.albums.forEach(album => {
|
||||
album.tracks.forEach(track => {
|
||||
tracks.push(track)
|
||||
})
|
||||
})
|
||||
return tracks
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
68
front/src/components/audio/track/Table.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<table class="ui compact very basic fixed single line table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th colspan="6">Title</th>
|
||||
<th colspan="6">Artist</th>
|
||||
<th colspan="6">Album</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="track in tracks">
|
||||
<td>
|
||||
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
|
||||
</td>
|
||||
<td>
|
||||
<img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
|
||||
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="track" :to="{name: 'browse.track', params: {id: track.id }}">
|
||||
{{ track.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="artist discrete link" :to="{name: 'browse.artist', params: {id: track.artist.id }}">
|
||||
{{ track.artist.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td colspan="6">
|
||||
<router-link class="album discrete link" :to="{name: 'browse.album', params: {id: track.album.id }}">
|
||||
{{ track.album.title }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td><track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import backend from '@/audio/backend'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
|
||||
export default {
|
||||
props: ['tracks'],
|
||||
components: {
|
||||
TrackFavoriteIcon,
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
backend: backend
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
tr:not(:hover) .favorite-icon:not(.favorited) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
90
front/src/components/auth/Login.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<h2>Log in to your Funkwhale account</h2>
|
||||
<form class="ui form" @submit.prevent="submit()">
|
||||
<div v-if="error" class="ui negative message">
|
||||
<div class="header">We cannot log you in</div>
|
||||
<ul class="list">
|
||||
<li v-if="error == 'invalid_credentials'">Please double-check your username/password couple is correct</li>
|
||||
<li v-else>An unknown error happend, this can mean the server is down or cannot be reached</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Username</label>
|
||||
<input
|
||||
ref="username"
|
||||
required
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="Enter your username"
|
||||
v-model="credentials.username"
|
||||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Password</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
v-model="credentials.password"
|
||||
>
|
||||
</div>
|
||||
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import auth from '@/auth'
|
||||
|
||||
export default {
|
||||
name: 'login',
|
||||
data () {
|
||||
return {
|
||||
// We need to initialize the component with any
|
||||
// properties that will be used in it
|
||||
credentials: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
error: '',
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$refs.username.focus()
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
var self = this
|
||||
self.isLoading = true
|
||||
this.error = ''
|
||||
var credentials = {
|
||||
username: this.credentials.username,
|
||||
password: this.credentials.password
|
||||
}
|
||||
// We need to pass the component's this context
|
||||
// to properly make use of http in the auth service
|
||||
auth.login(this, credentials, {path: '/browse'}, function (response) {
|
||||
// error callback
|
||||
if (response.status === 400) {
|
||||
self.error = 'invalid_credentials'
|
||||
} else {
|
||||
self.error = 'unknown_error'
|
||||
}
|
||||
}).then((response) => {
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
37
front/src/components/auth/Logout.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<h2>Are you sure you want to log out?</h2>
|
||||
<p>You are currently logged in as {{ auth.user.username }}</p>
|
||||
<button class="ui button" @click="logout">Yes, log me out!</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import auth from '@/auth'
|
||||
|
||||
export default {
|
||||
name: 'logout',
|
||||
data () {
|
||||
return {
|
||||
// We need to initialize the component with any
|
||||
// properties that will be used in it
|
||||
auth: auth
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logout () {
|
||||
auth.logout()
|
||||
this.$router.push({name: 'index'})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
62
front/src/components/auth/Profile.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="profile">
|
||||
<div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted user green icon"></i>
|
||||
<div class="content">
|
||||
{{ profile.username }}
|
||||
<div class="sub header">Registered since {{ signupDate }}</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="ui basic green label">this is you!</div>
|
||||
<div v-if="profile.is_staff" class="ui yellow label">
|
||||
<i class="star icon"></i>
|
||||
Staff member
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import auth from '@/auth'
|
||||
var dateFormat = require('dateformat')
|
||||
|
||||
export default {
|
||||
name: 'login',
|
||||
props: ['username'],
|
||||
data () {
|
||||
return {
|
||||
profile: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchProfile()
|
||||
},
|
||||
methods: {
|
||||
fetchProfile () {
|
||||
let self = this
|
||||
auth.fetchProfile().then(data => {
|
||||
self.profile = data
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
signupDate () {
|
||||
let d = new Date(this.profile.date_joined)
|
||||
return dateFormat(d, 'longDate')
|
||||
},
|
||||
isLoading () {
|
||||
return !this.profile
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
105
front/src/components/browse/Album.vue
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="album">
|
||||
<div :class="['ui', 'head', {'with-background': album.cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
|
||||
<div class="segment-content">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted sound yellow icon"></i>
|
||||
<div class="content">
|
||||
{{ album.title }}
|
||||
<div class="sub header">
|
||||
Album containing {{ album.tracks.length }} tracks,
|
||||
by <router-link :to="{name: 'browse.artist', params: {id: album.artist.id }}">
|
||||
{{ album.artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
</button>
|
||||
<play-button class="orange" :tracks="album.tracks">Play all</play-button>
|
||||
|
||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||
<i class="wikipedia icon"></i>
|
||||
Search on wikipedia
|
||||
</a>
|
||||
<a :href="musicbrainzUrl" target="_blank" class="ui button">
|
||||
<i class="external icon"></i>
|
||||
View on MusicBrainz
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2>Tracks</h2>
|
||||
<track-table v-if="album" :tracks="album.tracks"></track-table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import logger from '@/logging'
|
||||
import backend from '@/audio/backend'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
import TrackTable from '@/components/audio/track/Table'
|
||||
import config from '@/config'
|
||||
|
||||
const FETCH_URL = config.API_URL + 'albums/'
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: {
|
||||
PlayButton,
|
||||
TrackTable
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
album: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = FETCH_URL + this.id + '/'
|
||||
logger.default.debug('Fetching album "' + this.id + '"')
|
||||
this.$http.get(url).then((response) => {
|
||||
self.album = backend.Album.clean(response.data)
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
wikipediaUrl () {
|
||||
return 'https://en.wikipedia.org/w/index.php?search=' + this.album.title + ' ' + this.album.artist.name
|
||||
},
|
||||
musicbrainzUrl () {
|
||||
return 'https://musicbrainz.org/release/' + this.album.mbid
|
||||
},
|
||||
headerStyle () {
|
||||
if (!this.album.cover) {
|
||||
return ''
|
||||
}
|
||||
return 'background-image: url(' + backend.absoluteUrl(this.album.cover) + ')'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
133
front/src/components/browse/Artist.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="artist">
|
||||
<div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
|
||||
<div class="segment-content">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted users violet icon"></i>
|
||||
<div class="content">
|
||||
{{ artist.name }}
|
||||
<div class="sub header">{{ totalTracks }} tracks in {{ albums.length }} albums</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
<radio-button type="artist" :object-id="artist.id"></radio-button>
|
||||
</button>
|
||||
<play-button class="orange" :tracks="allTracks">Play all albums</play-button>
|
||||
|
||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||
<i class="wikipedia icon"></i>
|
||||
Search on wikipedia
|
||||
</a>
|
||||
<a :href="musicbrainzUrl" target="_blank" class="ui button">
|
||||
<i class="external icon"></i>
|
||||
View on MusicBrainz
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2>Albums by this artist</h2>
|
||||
<div class="ui stackable three column grid">
|
||||
<div class="column" :key="album.id" v-for="album in albums">
|
||||
<album-card :mode="'rich'" class="fluid" :album="album"></album-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import logger from '@/logging'
|
||||
import backend from '@/audio/backend'
|
||||
import AlbumCard from '@/components/audio/album/Card'
|
||||
import RadioButton from '@/components/radios/Button'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
import config from '@/config'
|
||||
|
||||
const FETCH_URL = config.API_URL + 'artists/'
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: {
|
||||
AlbumCard,
|
||||
RadioButton,
|
||||
PlayButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
artist: null,
|
||||
albums: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = FETCH_URL + this.id + '/'
|
||||
logger.default.debug('Fetching artist "' + this.id + '"')
|
||||
this.$http.get(url).then((response) => {
|
||||
self.artist = response.data
|
||||
self.albums = JSON.parse(JSON.stringify(self.artist.albums)).map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
})
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
totalTracks () {
|
||||
return this.albums.map((album) => {
|
||||
return album.tracks.length
|
||||
}).reduce((a, b) => {
|
||||
return a + b
|
||||
})
|
||||
},
|
||||
wikipediaUrl () {
|
||||
return 'https://en.wikipedia.org/w/index.php?search=' + this.artist.name
|
||||
},
|
||||
musicbrainzUrl () {
|
||||
return 'https://musicbrainz.org/artist/' + this.artist.mbid
|
||||
},
|
||||
allTracks () {
|
||||
let tracks = []
|
||||
this.albums.forEach(album => {
|
||||
album.tracks.forEach(track => {
|
||||
tracks.push(track)
|
||||
})
|
||||
})
|
||||
return tracks
|
||||
},
|
||||
cover () {
|
||||
return this.artist.albums.filter(album => {
|
||||
return album.cover
|
||||
}).map(album => {
|
||||
return album.cover
|
||||
})[0]
|
||||
},
|
||||
headerStyle () {
|
||||
if (!this.cover) {
|
||||
return ''
|
||||
}
|
||||
return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
48
front/src/components/browse/Browse.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div class="main browse pusher">
|
||||
<div class="ui secondary pointing menu">
|
||||
<router-link class="ui item" to="/browse">Browse</router-link>
|
||||
</div>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'browse'
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style lang="scss">
|
||||
.browse.pusher > .ui.secondary.menu {
|
||||
margin: 0 2.5rem;
|
||||
}
|
||||
|
||||
.browse {
|
||||
.ui.segment.head {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
padding: 0;
|
||||
.segment-content {
|
||||
margin: 0 auto;
|
||||
padding: 4em;
|
||||
}
|
||||
&.with-background {
|
||||
.header {
|
||||
&, .sub, a {
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
.segment-content {
|
||||
background-color: rgba(0, 0, 0, 0.5)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
80
front/src/components/browse/Home.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<search :autofocus="true"></search>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui stackable two column grid">
|
||||
<div class="column">
|
||||
<h2 class="ui header">Latest artists</h2>
|
||||
<div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div>
|
||||
<div v-if="artists.length > 0" v-for="artist in artists.slice(0, 3)" :key="artist" class="ui cards">
|
||||
<artist-card :artist="artist"></artist-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h2 class="ui header">Radios</h2>
|
||||
<radio-card :type="'favorites'"></radio-card>
|
||||
<radio-card :type="'random'"></radio-card>
|
||||
<radio-card :type="'less-listened'"></radio-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Search from '@/components/audio/Search'
|
||||
import backend from '@/audio/backend'
|
||||
import logger from '@/logging'
|
||||
import ArtistCard from '@/components/audio/artist/Card'
|
||||
import config from '@/config'
|
||||
import RadioCard from '@/components/radios/Card'
|
||||
|
||||
const ARTISTS_URL = config.API_URL + 'artists/'
|
||||
|
||||
export default {
|
||||
name: 'browse',
|
||||
components: {
|
||||
Search,
|
||||
ArtistCard,
|
||||
RadioCard
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
artists: [],
|
||||
isLoadingArtists: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchArtists()
|
||||
},
|
||||
methods: {
|
||||
fetchArtists () {
|
||||
var self = this
|
||||
this.isLoadingArtists = true
|
||||
let params = {
|
||||
ordering: '-creation_date'
|
||||
}
|
||||
let url = ARTISTS_URL
|
||||
logger.default.time('Loading latest artists')
|
||||
this.$http.get(url, {params: params}).then((response) => {
|
||||
self.artists = response.data.results
|
||||
self.artists.map((artist) => {
|
||||
var albums = JSON.parse(JSON.stringify(artist.albums)).map((album) => {
|
||||
return backend.Album.clean(album)
|
||||
})
|
||||
artist.albums = albums
|
||||
return artist
|
||||
})
|
||||
logger.default.timeEnd('Loading latest artists')
|
||||
self.isLoadingArtists = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
153
front/src/components/browse/Track.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="isLoadingTrack" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="track">
|
||||
<div :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
|
||||
<div class="segment-content">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted music orange icon"></i>
|
||||
<div class="content">
|
||||
{{ track.title }}
|
||||
<div class="sub header">
|
||||
From album
|
||||
<router-link :to="{name: 'browse.album', params: {id: track.album.id }}">
|
||||
{{ track.album.title }}
|
||||
</router-link>
|
||||
by <router-link :to="{name: 'browse.artist', params: {id: track.artist.id }}">
|
||||
{{ track.artist.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<play-button class="orange" :track="track">Play</play-button>
|
||||
<track-favorite-icon :track="track" :button="true"></track-favorite-icon>
|
||||
<a :href="wikipediaUrl" target="_blank" class="ui button">
|
||||
<i class="wikipedia icon"></i>
|
||||
Search on wikipedia
|
||||
</a>
|
||||
<a :href="musicbrainzUrl" target="_blank" class="ui button">
|
||||
<i class="external icon"></i>
|
||||
View on MusicBrainz
|
||||
</a>
|
||||
<a v-if="downloadUrl" :href="downloadUrl" target="_blank" class="ui button">
|
||||
<i class="download icon"></i>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe center aligned segment">
|
||||
<h2>Lyrics</h2>
|
||||
<div v-if="isLoadingLyrics" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<div v-if="lyrics" v-html="lyrics.content_rendered">
|
||||
</div>
|
||||
<template v-if="!isLoadingLyrics & !lyrics">
|
||||
<p>
|
||||
No lyrics available for this track.
|
||||
</p>
|
||||
<a class="ui button" target="_blank" :href="lyricsSearchUrl">
|
||||
<i class="search icon"></i>
|
||||
Search on lyrics.wikia.com
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import logger from '@/logging'
|
||||
import backend from '@/audio/backend'
|
||||
import PlayButton from '@/components/audio/PlayButton'
|
||||
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
|
||||
import config from '@/config'
|
||||
|
||||
const FETCH_URL = config.API_URL + 'tracks/'
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: {
|
||||
PlayButton,
|
||||
TrackFavoriteIcon
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoadingTrack: true,
|
||||
isLoadingLyrics: true,
|
||||
track: null,
|
||||
lyrics: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
this.fetchLyrics()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoadingTrack = true
|
||||
let url = FETCH_URL + this.id + '/'
|
||||
logger.default.debug('Fetching track "' + this.id + '"')
|
||||
this.$http.get(url).then((response) => {
|
||||
self.track = response.data
|
||||
self.isLoadingTrack = false
|
||||
})
|
||||
},
|
||||
fetchLyrics () {
|
||||
var self = this
|
||||
this.isLoadingLyrics = true
|
||||
let url = FETCH_URL + this.id + '/lyrics/'
|
||||
logger.default.debug('Fetching lyrics for track "' + this.id + '"')
|
||||
this.$http.get(url).then((response) => {
|
||||
self.lyrics = response.data
|
||||
self.isLoadingLyrics = false
|
||||
}, (response) => {
|
||||
console.error('No lyrics available')
|
||||
self.isLoadingLyrics = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
wikipediaUrl () {
|
||||
return 'https://en.wikipedia.org/w/index.php?search=' + this.track.title + ' ' + this.track.artist.name
|
||||
},
|
||||
musicbrainzUrl () {
|
||||
return 'https://musicbrainz.org/recording/' + this.track.mbid
|
||||
},
|
||||
downloadUrl () {
|
||||
if (this.track.files.length > 0) {
|
||||
return backend.absoluteUrl(this.track.files[0].path)
|
||||
}
|
||||
},
|
||||
lyricsSearchUrl () {
|
||||
let base = 'http://lyrics.wikia.com/wiki/Special:Search?query='
|
||||
let query = this.track.artist.name + ' ' + this.track.title
|
||||
return base + query
|
||||
},
|
||||
cover () {
|
||||
return null
|
||||
},
|
||||
headerStyle () {
|
||||
if (!this.cover) {
|
||||
return ''
|
||||
}
|
||||
return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
78
front/src/components/favorites/List.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical center aligned stripe segment">
|
||||
<div :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
|
||||
<div class="ui text loader">Loading your favorites...</div>
|
||||
</div>
|
||||
<h2 v-if="results" class="ui center aligned icon header">
|
||||
<i class="circular inverted heart pink icon"></i>
|
||||
{{ favoriteTracks.count }} favorites
|
||||
</h2>
|
||||
<radio-button type="favorites"></radio-button>
|
||||
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<button class="ui left floated labeled icon button" @click="fetchFavorites(previousLink)" :disabled="!previousLink"><i class="left arrow icon"></i> Previous</button>
|
||||
<button class="ui right floated right labeled icon button" @click="fetchFavorites(nextLink)" :disabled="!nextLink">Next <i class="right arrow icon"></i></button>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<div class="ui hidden clearing divider"></div>
|
||||
<track-table v-if="results" :tracks="results.results"></track-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import logger from '@/logging'
|
||||
import config from '@/config'
|
||||
import favoriteTracks from '@/favorites/tracks'
|
||||
import TrackTable from '@/components/audio/track/Table'
|
||||
import RadioButton from '@/components/radios/Button'
|
||||
|
||||
const FAVORITES_URL = config.API_URL + 'tracks/'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TrackTable,
|
||||
RadioButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
results: null,
|
||||
isLoading: false,
|
||||
nextLink: null,
|
||||
previousLink: null,
|
||||
favoriteTracks
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchFavorites(FAVORITES_URL)
|
||||
},
|
||||
methods: {
|
||||
fetchFavorites (url) {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let params = {
|
||||
favorites: 'true'
|
||||
}
|
||||
logger.default.time('Loading user favorites')
|
||||
this.$http.get(url, {params: params}).then((response) => {
|
||||
self.results = response.data
|
||||
self.nextLink = response.data.next
|
||||
self.previousLink = response.data.previous
|
||||
Vue.set(favoriteTracks, 'count', response.data.count)
|
||||
favoriteTracks.count = response.data.count
|
||||
self.results.results.forEach((track) => {
|
||||
Vue.set(favoriteTracks.objects, track.id, true)
|
||||
})
|
||||
logger.default.timeEnd('Loading user favorites')
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
50
front/src/components/favorites/TrackFavoriteIcon.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<button @click="favoriteTracks.set(track.id, !isFavorite)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'button']">
|
||||
<i class="heart icon"></i>
|
||||
<template v-if="isFavorite">
|
||||
In favorites
|
||||
</template>
|
||||
<template v-else>
|
||||
Add to favorites
|
||||
</template>
|
||||
</button>
|
||||
<i v-else @click="favoriteTracks.set(track.id, !isFavorite)" :class="['favorite-icon', 'heart', {'pink': isFavorite}, {'favorited': isFavorite}, 'link', 'icon']" :title="title"></i>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import favoriteTracks from '@/favorites/tracks'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
track: {type: Object},
|
||||
button: {type: Boolean, default: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
favoriteTracks
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFavorite () {
|
||||
this.isFavorite = !this.isFavorite
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title () {
|
||||
if (this.isFavorite) {
|
||||
return 'Remove from favorites'
|
||||
} else {
|
||||
return 'Add to favorites'
|
||||
}
|
||||
},
|
||||
isFavorite () {
|
||||
return favoriteTracks.objects[this.track.id]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
||||
50
front/src/components/radios/Button.vue
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<button @click="toggleRadio" :class="['ui', 'blue', {'inverted': running}, 'button']">
|
||||
<i class="ui feed icon"></i>
|
||||
<template v-if="running">Stop</template>
|
||||
<template v-else>Start</template>
|
||||
radio
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import radios from '@/radios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
type: {type: String, required: true},
|
||||
objectId: {type: Number, default: null}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
radios
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleRadio () {
|
||||
if (this.running) {
|
||||
radios.stop()
|
||||
} else {
|
||||
radios.start(this.type, this.objectId)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
running () {
|
||||
if (!radios.running) {
|
||||
return false
|
||||
} else {
|
||||
return radios.current.type === this.type & radios.current.objectId === this.objectId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
i {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
37
front/src/components/radios/Card.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="header">Radio : {{ radio.name }}</div>
|
||||
<div class="description">
|
||||
{{ radio.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<radio-button class="right floated button" :type="type"></radio-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import radios from '@/radios'
|
||||
import RadioButton from './Button'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
type: {type: String, required: true}
|
||||
},
|
||||
components: {
|
||||
RadioButton
|
||||
},
|
||||
computed: {
|
||||
radio () {
|
||||
return radios.types[this.type]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
11
front/src/config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class Config {
|
||||
constructor () {
|
||||
this.BACKEND_URL = process.env.BACKEND_URL
|
||||
if (!this.BACKEND_URL.endsWith('/')) {
|
||||
this.BACKEND_URL += '/'
|
||||
}
|
||||
this.API_URL = this.BACKEND_URL + 'api/'
|
||||
}
|
||||
}
|
||||
|
||||
export default new Config()
|
||||
53
front/src/favorites/tracks.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import config from '@/config'
|
||||
import logger from '@/logging'
|
||||
import Vue from 'vue'
|
||||
|
||||
const REMOVE_URL = config.API_URL + 'favorites/tracks/remove/'
|
||||
const FAVORITES_URL = config.API_URL + 'favorites/tracks/'
|
||||
|
||||
export default {
|
||||
objects: {},
|
||||
count: 0,
|
||||
set (id, newValue) {
|
||||
let self = this
|
||||
Vue.set(self.objects, id, newValue)
|
||||
if (newValue) {
|
||||
Vue.set(self, 'count', self.count + 1)
|
||||
let resource = Vue.resource(FAVORITES_URL)
|
||||
resource.save({}, {'track': id}).then((response) => {
|
||||
logger.default.info('Successfully added track to favorites')
|
||||
}, (response) => {
|
||||
logger.default.info('Error while adding track to favorites')
|
||||
Vue.set(self.objects, id, !newValue)
|
||||
Vue.set(self, 'count', self.count - 1)
|
||||
})
|
||||
} else {
|
||||
Vue.set(self, 'count', self.count - 1)
|
||||
let resource = Vue.resource(REMOVE_URL)
|
||||
resource.delete({}, {'track': id}).then((response) => {
|
||||
logger.default.info('Successfully removed track from favorites')
|
||||
}, (response) => {
|
||||
logger.default.info('Error while removing track from favorites')
|
||||
Vue.set(self.objects, id, !newValue)
|
||||
Vue.set(self, 'count', self.count + 1)
|
||||
})
|
||||
}
|
||||
},
|
||||
fetch (url) {
|
||||
// will fetch favorites by batches from API to have them locally
|
||||
var self = this
|
||||
url = url || FAVORITES_URL
|
||||
let resource = Vue.resource(url)
|
||||
resource.get().then((response) => {
|
||||
logger.default.info('Fetched a batch of ' + response.data.results.length + ' favorites')
|
||||
Vue.set(self, 'count', response.data.count)
|
||||
response.data.results.forEach(result => {
|
||||
Vue.set(self.objects, result.track, true)
|
||||
})
|
||||
if (response.data.next) {
|
||||
self.fetch(response.data.next)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
8
front/src/logging.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import jsLogger from 'js-logger'
|
||||
|
||||
jsLogger.useDefaults()
|
||||
|
||||
export default {
|
||||
get: jsLogger.get,
|
||||
default: jsLogger.get('default')
|
||||
}
|
||||
47
front/src/main.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// The Vue build version to load with the `import` command
|
||||
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
|
||||
import logger from '@/logging'
|
||||
|
||||
logger.default.info('Loading environment:', process.env.NODE_ENV)
|
||||
logger.default.debug('Environment variables:', process.env)
|
||||
|
||||
import Vue from 'vue'
|
||||
import App from './App'
|
||||
import router from './router'
|
||||
import VueResource from 'vue-resource'
|
||||
import auth from './auth'
|
||||
|
||||
window.$ = window.jQuery = require('jquery')
|
||||
|
||||
// this is absolutely dirty but at the moment, semantic UI does not
|
||||
// play really nice with webpack and I want to get rid of Google Fonts
|
||||
// require('./semantic/semantic.css')
|
||||
require('semantic-ui-css/semantic.js')
|
||||
|
||||
Vue.use(VueResource)
|
||||
Vue.config.productionTip = false
|
||||
|
||||
Vue.http.interceptors.push(function (request, next) {
|
||||
// modify headers
|
||||
if (auth.user.authenticated) {
|
||||
request.headers.set('Authorization', auth.getAuthHeader())
|
||||
}
|
||||
next(function (response) {
|
||||
// redirect to login form when we get unauthorized response from server
|
||||
if (response.status === 401) {
|
||||
logger.default.warn('Received 401 response from API, redirecting to login form')
|
||||
router.push({name: 'login'})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
auth.checkAuth()
|
||||
/* eslint-disable no-new */
|
||||
new Vue({
|
||||
el: '#app',
|
||||
router,
|
||||
template: '<App/>',
|
||||
components: { App }
|
||||
})
|
||||
|
||||
logger.default.info('Everything loaded!')
|
||||
64
front/src/radios/index.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import Vue from 'vue'
|
||||
import config from '@/config'
|
||||
import logger from '@/logging'
|
||||
import queue from '@/audio/queue'
|
||||
|
||||
const CREATE_RADIO_URL = config.API_URL + 'radios/sessions/'
|
||||
const GET_TRACK_URL = config.API_URL + 'radios/tracks/'
|
||||
|
||||
var radios = {
|
||||
types: {
|
||||
random: {
|
||||
name: 'Random',
|
||||
description: "Totally random picks, maybe you'll discover new things?"
|
||||
},
|
||||
favorites: {
|
||||
name: 'Favorites',
|
||||
description: 'Play your favorites tunes in a never-ending happiness loop.'
|
||||
},
|
||||
'less-listened': {
|
||||
name: 'Less listened',
|
||||
description: "Listen to tracks you usually don't. It's time to restore some balance."
|
||||
}
|
||||
},
|
||||
start (type, objectId) {
|
||||
this.current.type = type
|
||||
this.current.objectId = objectId
|
||||
this.running = true
|
||||
let resource = Vue.resource(CREATE_RADIO_URL)
|
||||
var self = this
|
||||
var params = {
|
||||
radio_type: type,
|
||||
related_object_id: objectId
|
||||
}
|
||||
resource.save({}, params).then((response) => {
|
||||
logger.default.info('Successfully started radio ', type)
|
||||
self.current.session = response.data.id
|
||||
queue.populateFromRadio()
|
||||
}, (response) => {
|
||||
logger.default.error('Error while starting radio', type)
|
||||
})
|
||||
},
|
||||
stop () {
|
||||
this.current.type = null
|
||||
this.current.objectId = null
|
||||
this.running = false
|
||||
this.session = null
|
||||
},
|
||||
fetch () {
|
||||
let resource = Vue.resource(GET_TRACK_URL)
|
||||
var self = this
|
||||
var params = {
|
||||
session: self.current.session
|
||||
}
|
||||
return resource.save({}, params)
|
||||
}
|
||||
}
|
||||
|
||||
Vue.set(radios, 'running', false)
|
||||
Vue.set(radios, 'current', {})
|
||||
Vue.set(radios.current, 'objectId', null)
|
||||
Vue.set(radios.current, 'type', null)
|
||||
Vue.set(radios.current, 'session', null)
|
||||
|
||||
export default radios
|
||||
57
front/src/router/index.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import Home from '@/components/Home'
|
||||
import Login from '@/components/auth/Login'
|
||||
import Profile from '@/components/auth/Profile'
|
||||
import Logout from '@/components/auth/Logout'
|
||||
import Browse from '@/components/browse/Browse'
|
||||
import BrowseHome from '@/components/browse/Home'
|
||||
import BrowseArtist from '@/components/browse/Artist'
|
||||
import BrowseAlbum from '@/components/browse/Album'
|
||||
import BrowseTrack from '@/components/browse/Track'
|
||||
import Favorites from '@/components/favorites/List'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
export default new Router({
|
||||
mode: 'history',
|
||||
linkActiveClass: 'active',
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'index',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login
|
||||
},
|
||||
{
|
||||
path: '/logout',
|
||||
name: 'logout',
|
||||
component: Logout
|
||||
},
|
||||
{
|
||||
path: '/@:username',
|
||||
name: 'profile',
|
||||
component: Profile,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/favorites',
|
||||
component: Favorites
|
||||
},
|
||||
{
|
||||
path: '/browse',
|
||||
component: Browse,
|
||||
children: [
|
||||
{ path: '', component: BrowseHome },
|
||||
{ path: 'artist/:id', name: 'browse.artist', component: BrowseArtist, props: true },
|
||||
{ path: 'album/:id', name: 'browse.album', component: BrowseAlbum, props: true },
|
||||
{ path: 'track/:id', name: 'browse.track', component: BrowseTrack, props: true }
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
})
|
||||
36998
front/src/semantic/semantic.css
Executable file
BIN
front/src/semantic/themes/default/assets/fonts/icons.eot
Normal file
BIN
front/src/semantic/themes/default/assets/fonts/icons.otf
Normal file
2671
front/src/semantic/themes/default/assets/fonts/icons.svg
Normal file
|
After Width: | Height: | Size: 434 KiB |
BIN
front/src/semantic/themes/default/assets/fonts/icons.ttf
Normal file
BIN
front/src/semantic/themes/default/assets/fonts/icons.woff
Normal file
BIN
front/src/semantic/themes/default/assets/fonts/icons.woff2
Normal file
BIN
front/src/semantic/themes/default/assets/images/flags.png
Executable file
|
After Width: | Height: | Size: 28 KiB |
0
front/static/.gitkeep
Normal file
26
front/test/e2e/custom-assertions/elementCount.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// A custom Nightwatch assertion.
|
||||
// the name of the method is the filename.
|
||||
// can be used in tests like this:
|
||||
//
|
||||
// browser.assert.elementCount(selector, count)
|
||||
//
|
||||
// for how to write custom assertions see
|
||||
// http://nightwatchjs.org/guide#writing-custom-assertions
|
||||
exports.assertion = function (selector, count) {
|
||||
this.message = 'Testing if element <' + selector + '> has count: ' + count
|
||||
this.expected = count
|
||||
this.pass = function (val) {
|
||||
return val === this.expected
|
||||
}
|
||||
this.value = function (res) {
|
||||
return res.value
|
||||
}
|
||||
this.command = function (cb) {
|
||||
var self = this
|
||||
return this.api.execute(function (selector) {
|
||||
return document.querySelectorAll(selector).length
|
||||
}, [selector], function (res) {
|
||||
cb.call(self, res)
|
||||
})
|
||||
}
|
||||
}
|
||||
46
front/test/e2e/nightwatch.conf.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
require('babel-register')
|
||||
var config = require('../../config')
|
||||
|
||||
// http://nightwatchjs.org/gettingstarted#settings-file
|
||||
module.exports = {
|
||||
src_folders: ['test/e2e/specs'],
|
||||
output_folder: 'test/e2e/reports',
|
||||
custom_assertions_path: ['test/e2e/custom-assertions'],
|
||||
|
||||
selenium: {
|
||||
start_process: true,
|
||||
server_path: require('selenium-server').path,
|
||||
host: '127.0.0.1',
|
||||
port: 4444,
|
||||
cli_args: {
|
||||
'webdriver.chrome.driver': require('chromedriver').path
|
||||
}
|
||||
},
|
||||
|
||||
test_settings: {
|
||||
default: {
|
||||
selenium_port: 4444,
|
||||
selenium_host: 'localhost',
|
||||
silent: true,
|
||||
globals: {
|
||||
devServerURL: 'http://localhost:' + (process.env.PORT || config.dev.port)
|
||||
}
|
||||
},
|
||||
|
||||
chrome: {
|
||||
desiredCapabilities: {
|
||||
browserName: 'chrome',
|
||||
javascriptEnabled: true,
|
||||
acceptSslCerts: true
|
||||
}
|
||||
},
|
||||
|
||||
firefox: {
|
||||
desiredCapabilities: {
|
||||
browserName: 'firefox',
|
||||
javascriptEnabled: true,
|
||||
acceptSslCerts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
front/test/e2e/runner.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// 1. start the dev server using production config
|
||||
process.env.NODE_ENV = 'testing'
|
||||
var server = require('../../build/dev-server.js')
|
||||
|
||||
server.ready.then(() => {
|
||||
// 2. run the nightwatch test suite against it
|
||||
// to run in additional browsers:
|
||||
// 1. add an entry in test/e2e/nightwatch.conf.json under "test_settings"
|
||||
// 2. add it to the --env flag below
|
||||
// or override the environment flag, for example: `npm run e2e -- --env chrome,firefox`
|
||||
// For more information on Nightwatch's config file, see
|
||||
// http://nightwatchjs.org/guide#settings-file
|
||||
var opts = process.argv.slice(2)
|
||||
if (opts.indexOf('--config') === -1) {
|
||||
opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js'])
|
||||
}
|
||||
if (opts.indexOf('--env') === -1) {
|
||||
opts = opts.concat(['--env', 'chrome'])
|
||||
}
|
||||
|
||||
var spawn = require('cross-spawn')
|
||||
var runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' })
|
||||
|
||||
runner.on('exit', function (code) {
|
||||
server.close()
|
||||
process.exit(code)
|
||||
})
|
||||
|
||||
runner.on('error', function (err) {
|
||||
server.close()
|
||||
throw err
|
||||
})
|
||||
})
|
||||
19
front/test/e2e/specs/test.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// For authoring Nightwatch tests, see
|
||||
// http://nightwatchjs.org/guide#usage
|
||||
|
||||
module.exports = {
|
||||
'default e2e tests': function (browser) {
|
||||
// automatically uses dev Server port from /config.index.js
|
||||
// default: http://localhost:8080
|
||||
// see nightwatch.conf.js
|
||||
const devServer = browser.globals.devServerURL
|
||||
|
||||
browser
|
||||
.url(devServer)
|
||||
.waitForElementVisible('#app', 5000)
|
||||
.assert.elementPresent('.hello')
|
||||
.assert.containsText('h1', 'Welcome to Your Vue.js App')
|
||||
.assert.elementCount('img', 1)
|
||||
.end()
|
||||
}
|
||||
}
|
||||
9
front/test/unit/.eslintrc
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"env": {
|
||||
"mocha": true
|
||||
},
|
||||
"globals": {
|
||||
"expect": true,
|
||||
"sinon": true
|
||||
}
|
||||
}
|
||||
13
front/test/unit/index.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// require all test files (files that ends with .spec.js)
|
||||
const testsContext = require.context('./specs', true, /\.spec$/)
|
||||
testsContext.keys().forEach(testsContext)
|
||||
|
||||
// require all src files except main.js for coverage.
|
||||
// you can also change this to match only the subset of files that
|
||||
// you want coverage for.
|
||||
const srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/)
|
||||
srcContext.keys().forEach(srcContext)
|
||||
33
front/test/unit/karma.conf.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// This is a karma config file. For more details see
|
||||
// http://karma-runner.github.io/0.13/config/configuration-file.html
|
||||
// we are also using it with karma-webpack
|
||||
// https://github.com/webpack/karma-webpack
|
||||
|
||||
var webpackConfig = require('../../build/webpack.test.conf')
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
// to run in additional browsers:
|
||||
// 1. install corresponding karma launcher
|
||||
// http://karma-runner.github.io/0.13/config/browsers.html
|
||||
// 2. add it to the `browsers` array below.
|
||||
browsers: ['PhantomJS'],
|
||||
frameworks: ['mocha', 'sinon-chai', 'phantomjs-shim'],
|
||||
reporters: ['spec', 'coverage'],
|
||||
files: ['./index.js'],
|
||||
preprocessors: {
|
||||
'./index.js': ['webpack', 'sourcemap']
|
||||
},
|
||||
webpack: webpackConfig,
|
||||
webpackMiddleware: {
|
||||
noInfo: true
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: './coverage',
|
||||
reporters: [
|
||||
{ type: 'lcov', subdir: '.' },
|
||||
{ type: 'text-summary' }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
11
front/test/unit/specs/Hello.spec.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import Vue from 'vue'
|
||||
import Hello from '@/components/Hello'
|
||||
|
||||
describe('Hello.vue', () => {
|
||||
it('should render correct contents', () => {
|
||||
const Constructor = Vue.extend(Hello)
|
||||
const vm = new Constructor().$mount()
|
||||
expect(vm.$el.querySelector('.hello h1').textContent)
|
||||
.to.equal('Welcome to Your Vue.js App')
|
||||
})
|
||||
})
|
||||