Initial commit that merge both the front end and the API in the same repository

This commit is contained in:
Eliot Berriot 2017-06-23 23:00:42 +02:00
commit 76f98b74dd
285 changed files with 51318 additions and 0 deletions

14
front/.babelrc Normal file
View 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
View file

@ -0,0 +1,2 @@
build/*.js
config/*.js

27
front/.eslintrc.js Normal file
View 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
View 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
View 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
View 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'
))
})
})

View 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)
}
}

View 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
View 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
View 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
}

View 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
})
}

View 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]')
}
}
]
}
}

View 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()
]
})

View 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

View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
module.exports = {
NODE_ENV: '"production"',
BACKEND_URL: '"' + (process.env.BACKEND_URL || '/') + '"'
}

6
front/config/test.env.js Normal file
View file

@ -0,0 +1,6 @@
var merge = require('webpack-merge')
var devEnv = require('./dev.env')
module.exports = merge(devEnv, {
NODE_ENV: '"testing"'
})

View 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
View 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
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View 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
View 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
View 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
View 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
View 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
View 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()
}
}

View 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>

View 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>

View 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 &nbsp;
<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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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()

View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

0
front/static/.gitkeep Normal file
View file

View 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)
})
}
}

View 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
View 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
})
})

View 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()
}
}

View file

@ -0,0 +1,9 @@
{
"env": {
"mocha": true
},
"globals": {
"expect": true,
"sinon": true
}
}

13
front/test/unit/index.js Normal file
View 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)

View 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' }
]
}
})
}

View 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')
})
})