Initial commit

This commit is contained in:
Roman Cervantes 2018-10-18 22:15:28 -07:00
commit 21e2c55003
135 changed files with 17433 additions and 0 deletions

20
.babelrc Executable file
View file

@ -0,0 +1,20 @@
{
"presets": [
[
"env", {
"modules": false,
"targets": {
"browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
}
}
],
"stage-2"
],
"plugins": ["transform-vue-jsx", "transform-runtime"],
"env": {
"test": {
"presets": ["env", "stage-2"],
"plugins": ["transform-vue-jsx", "istanbul"]
}
}
}

9
.editorconfig Executable file
View file

@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

5
.eslintignore Executable file
View file

@ -0,0 +1,5 @@
/build/
/config/
/dist/
/*.js
/test/unit/coverage/

76
.eslintrc.js Executable file
View file

@ -0,0 +1,76 @@
// https://eslint.org/docs/user-guide/configuring
module.exports = {
root: true,
parserOptions: {
parser: 'babel-eslint'
},
env: {
browser: true,
},
// https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
// consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
extends: ['plugin:vue/essential', 'airbnb-base'],
// required to lint *.vue files
plugins: [
'vue'
],
// check if imports actually resolve
settings: {
'import/resolver': {
webpack: {
config: 'build/webpack.base.conf.js'
}
}
},
// add your custom rules here
rules: {
"vue/order-in-components": [
"error", {
"order": [
"el",
"name",
"parent",
"functional",
["delimiters", "comments"],
["components", "directives", "filters"],
"extends",
"mixins",
"inheritAttrs",
"model",
["props", "propsData"],
"data",
"computed",
"watch",
"LIFECYCLE_HOOKS",
"methods",
["template", "render"],
"renderError",
],
},
],
// don't require .vue extension when importing
'import/extensions': ['error', 'always', {
js: 'never',
vue: 'never'
}],
"indent": ["error", 4],
// disallow reassignment of function parameters
// disallow parameter object manipulation except for specific exclusions
'no-param-reassign': ['error', {
props: true,
ignorePropertyModificationsFor: [
'state', // for vuex state
'acc', // for reduce accumulators
'e' // for e.returnvalue
]
}],
// allow optionalDependencies
'import/no-extraneous-dependencies': ['error', {
optionalDependencies: ['test/unit/index.js']
}],
// allow debugger during development
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
}
}

5
.firebaserc Executable file
View file

@ -0,0 +1,5 @@
{
"projects": {
"default": "gamebrary-8c736"
}
}

16
.gitignore vendored Executable file
View file

@ -0,0 +1,16 @@
.DS_Store
node_modules/
/dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/test/unit/coverage/
# Editor directories and files
.idea
.firebase
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln

10
.postcssrc.js Executable file
View file

@ -0,0 +1,10 @@
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
"plugins": {
"postcss-import": {},
"postcss-url": {},
// to edit target browsers: use "browserslist" field in package.json
"autoprefixer": {}
}
}

83
README.md Executable file
View file

@ -0,0 +1,83 @@
# SwitchList
![kapture 2018-07-02 at 21 56 24](https://user-images.githubusercontent.com/645310/42199388-21895a4a-7e43-11e8-9959-513a535eea25.gif)
User friendly and intuitive tool to keep track of your switch collection.
## Getting Started
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system.
### Prerequisites
What things you need to install the software and how to install them
```
yarn / node / npm
```
## Build Setup
``` bash
# install dependencies
yarn
# serve with hot reload at localhost:3000 (production backend)
yarn dev
# serve with hot reload at localhost:3000 (local backend)
yarn dev:local
# build for production with minification
yarn build
# build for production and view the bundle analyzer report
yarn build --report
# run unit tests
yarn unit
```
## Running the tests
```
yarn test
```
## Deployment
```
yarn build
```
## Built With
* [VueJS](https://vuejs.org/) - VueJS
* [Vuex](https://github.com/vuejs/vuex) - State management
* [Vuei18n](https://github.com/kazupon/vue-i18n) - i18n
* [Vue Draggable](https://github.com/SortableJS/Vue.Draggable) - Drag n drop component
* [MongoDB](https://www.mongodb.com/) - Database
* [IGDB](https://www.igdb.com/) - Game database API (These guys are awesome! Huge open source supporters!)
## Contributing
Please refer to [Issues](https://github.com/romancmx/switchlist/issues) for details on open issues. Any contribution is appreciated.
## Versioning
We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/romancmx/switchlist/releases).
## Authors
* **Roman** - *Lead Developer* - [Roman](https://twitter.com/romancm)
* **Tristan** - *Contributor* - [3stan](https://github.com/3stan)
See also the list of [contributors](https://github.com/romancmx/switchlist/graphs/contributors) who participated in this project.
## License
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details
## Acknowledgments
* The [/r/NintendoSwitch/](https://www.reddit.com/r/NintendoSwitch/) community for great initial poll feedback

41
build/build.js Executable file
View file

@ -0,0 +1,41 @@
'use strict'
require('./check-versions')()
process.env.NODE_ENV = 'production'
const ora = require('ora')
const rm = require('rimraf')
const path = require('path')
const chalk = require('chalk')
const webpack = require('webpack')
const config = require('../config')
const webpackConfig = require('./webpack.prod.conf')
const spinner = ora('building for production...')
spinner.start()
rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {
if (err) throw err
webpack(webpackConfig, (err, stats) => {
spinner.stop()
if (err) throw err
process.stdout.write(stats.toString({
colors: true,
modules: false,
children: false, // If you are using ts-loader, setting this to true will make TypeScript errors show up during build.
chunks: false,
chunkModules: false
}) + '\n\n')
if (stats.hasErrors()) {
console.log(chalk.red(' Build failed with errors.\n'))
process.exit(1)
}
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'
))
})
})

54
build/check-versions.js Executable file
View file

@ -0,0 +1,54 @@
'use strict'
const chalk = require('chalk')
const semver = require('semver')
const packageConfig = require('../package.json')
const shell = require('shelljs')
function exec (cmd) {
return require('child_process').execSync(cmd).toString().trim()
}
const 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 () {
const warnings = []
for (let i = 0; i < versionRequirements.length; i++) {
const 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 (let i = 0; i < warnings.length; i++) {
const warning = warnings[i]
console.log(' ' + warning)
}
console.log()
process.exit(1)
}
}

11
build/load-minified.js Normal file
View file

@ -0,0 +1,11 @@
'use strict'
const fs = require('fs')
const UglifyJS = require('uglify-es')
module.exports = function(filePath) {
const code = fs.readFileSync(filePath, 'utf-8')
const result = UglifyJS.minify(code)
if (result.error) return ''
return result.code
}

BIN
build/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -0,0 +1,17 @@
// This service worker file is effectively a 'no-op' that will reset any
// previous service worker registered for the same host:port combination.
// In the production build, this file is replaced with an actual service worker
// file that will precache your site's local assets.
// See https://github.com/facebookincubator/create-react-app/issues/2272#issuecomment-302832432
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', () => {
self.clients.matchAll({ type: 'window' }).then(windowClients => {
for (let windowClient of windowClients) {
// Force open pages to refresh, so that they have a chance to load the
// fresh navigation response from the local dev server.
windowClient.navigate(windowClient.url);
}
});
});

View file

@ -0,0 +1,55 @@
(function() {
'use strict';
// Check to make sure service workers are supported in the current browser,
// and that the current page is accessed from a secure origin. Using a
// service worker from an insecure origin will trigger JS console errors.
var isLocalhost = Boolean(window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
window.addEventListener('load', function() {
if ('serviceWorker' in navigator &&
(window.location.protocol === 'https:' || isLocalhost)) {
navigator.serviceWorker.register('service-worker.js')
.then(function(registration) {
// updatefound is fired if service-worker.js changes.
registration.onupdatefound = function() {
// updatefound is also fired the very first time the SW is installed,
// and there's no need to prompt for a reload at that point.
// So check here to see if the page is already controlled,
// i.e. whether there's an existing service worker.
if (navigator.serviceWorker.controller) {
// The updatefound event implies that registration.installing is set
var installingWorker = registration.installing;
installingWorker.onstatechange = function() {
switch (installingWorker.state) {
case 'installed':
// At this point, the old content will have been purged and the
// fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in the page's interface.
break;
case 'redundant':
throw new Error('The installing ' +
'service worker became redundant.');
default:
// Ignore
}
};
}
};
}).catch(function(e) {
console.error('Error during service worker registration:', e);
});
}
});
})();

101
build/utils.js Executable file
View file

@ -0,0 +1,101 @@
'use strict'
const path = require('path')
const config = require('../config')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const packageConfig = require('../package.json')
exports.assetsPath = function (_path) {
const assetsSubDirectory = process.env.NODE_ENV === 'production'
? config.build.assetsSubDirectory
: config.dev.assetsSubDirectory
return path.posix.join(assetsSubDirectory, _path)
}
exports.cssLoaders = function (options) {
options = options || {}
const cssLoader = {
loader: 'css-loader',
options: {
sourceMap: options.sourceMap
}
}
const postcssLoader = {
loader: 'postcss-loader',
options: {
sourceMap: options.sourceMap
}
}
// generate loader string to be used with extract text plugin
function generateLoaders (loader, loaderOptions) {
const loaders = options.usePostCSS ? [cssLoader, postcssLoader] : [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) {
const output = []
const loaders = exports.cssLoaders(options)
for (const extension in loaders) {
const loader = loaders[extension]
output.push({
test: new RegExp('\\.' + extension + '$'),
use: loader
})
}
return output
}
exports.createNotifierCallback = () => {
const notifier = require('node-notifier')
return (severity, errors) => {
if (severity !== 'error') return
const error = errors[0]
const filename = error.file && error.file.split('!').pop()
notifier.notify({
title: packageConfig.name,
message: severity + ': ' + error.name,
subtitle: filename || '',
icon: path.join(__dirname, 'logo.png')
})
}
}

22
build/vue-loader.conf.js Executable file
View file

@ -0,0 +1,22 @@
'use strict'
const utils = require('./utils')
const config = require('../config')
const isProduction = process.env.NODE_ENV === 'production'
const sourceMapEnabled = isProduction
? config.build.productionSourceMap
: config.dev.cssSourceMap
module.exports = {
loaders: utils.cssLoaders({
sourceMap: sourceMapEnabled,
extract: isProduction
}),
cssSourceMap: sourceMapEnabled,
cacheBusting: config.dev.cacheBusting,
transformToRequire: {
video: ['src', 'poster'],
source: 'src',
img: 'src',
image: 'xlink:href'
}
}

93
build/webpack.base.conf.js Executable file
View file

@ -0,0 +1,93 @@
'use strict'
const path = require('path')
const utils = require('./utils')
const config = require('../config')
const vueLoaderConfig = require('./vue-loader.conf')
function resolve (dir) {
return path.join(__dirname, '..', dir)
}
const createLintingRule = () => ({
test: /\.(js|vue)$/,
loader: 'eslint-loader',
enforce: 'pre',
include: [resolve('src'), resolve('test')],
options: {
formatter: require('eslint-friendly-formatter'),
emitWarning: !config.dev.showEslintErrorsInOverlay
}
})
module.exports = {
context: path.resolve(__dirname, '../'),
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',
'styles': path.resolve('./src/styles'),
'@': resolve('src'),
}
},
module: {
rules: [
...(config.dev.useEslint ? [createLintingRule()] : []),
{
test: /\.vue$/,
loader: 'vue-loader',
options: vueLoaderConfig
},
{
test: /\.js$/,
loader: 'babel-loader',
include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')]
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
},
{
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('media/[name].[hash:7].[ext]')
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
}
}
]
},
node: {
// prevent webpack from injecting useless setImmediate polyfill because Vue
// source contains it (although only uses it if it's native).
setImmediate: false,
// prevent webpack from injecting mocks to Node native modules
// that does not make sense for the client
dgram: 'empty',
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty'
}
}

98
build/webpack.dev.conf.js Executable file
View file

@ -0,0 +1,98 @@
'use strict'
const fs = require('fs')
const utils = require('./utils')
const webpack = require('webpack')
const config = require('../config')
const merge = require('webpack-merge')
const path = require('path')
const baseWebpackConfig = require('./webpack.base.conf')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const portfinder = require('portfinder')
const HOST = process.env.HOST
const PORT = process.env.PORT && Number(process.env.PORT)
const devWebpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, usePostCSS: true })
},
// cheap-module-eval-source-map is faster for development
devtool: config.dev.devtool,
// these devServer options should be customized in /config/index.js
devServer: {
clientLogLevel: 'warning',
historyApiFallback: {
rewrites: [
{ from: /.*/, to: path.posix.join(config.dev.assetsPublicPath, 'index.html') },
],
},
hot: true,
contentBase: false, // since we use CopyWebpackPlugin.
compress: true,
host: HOST || config.dev.host,
port: PORT || config.dev.port,
open: config.dev.autoOpenBrowser,
overlay: config.dev.errorOverlay
? { warnings: false, errors: true }
: false,
publicPath: config.dev.assetsPublicPath,
proxy: config.dev.proxyTable,
quiet: true, // necessary for FriendlyErrorsPlugin
watchOptions: {
poll: config.dev.poll,
}
},
plugins: [
new webpack.DefinePlugin({
'process.env': require('../config/dev.env')
}),
new webpack.HotModuleReplacementPlugin(),
new webpack.NamedModulesPlugin(), // HMR shows correct file names in console on update.
new webpack.NoEmitOnErrorsPlugin(),
// https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'index.html',
inject: true,
serviceWorkerLoader: `<script>${fs.readFileSync(path.join(__dirname,
'./service-worker-dev.js'), 'utf-8')}</script>`
}),
// copy custom static assets
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../static'),
to: config.dev.assetsSubDirectory,
ignore: ['.*']
}
])
]
})
module.exports = new Promise((resolve, reject) => {
portfinder.basePort = process.env.PORT || config.dev.port
portfinder.getPort((err, port) => {
if (err) {
reject(err)
} else {
// publish the new Port, necessary for e2e tests
process.env.PORT = port
// add port to devServer config
devWebpackConfig.devServer.port = port
// Add FriendlyErrorsPlugin
devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({
compilationSuccessInfo: {
messages: [`Your application is running here: http://${devWebpackConfig.devServer.host}:${port}`],
},
onErrors: config.dev.notifyOnErrors
? utils.createNotifierCallback()
: undefined
}))
resolve(devWebpackConfig)
}
})
})

View file

@ -0,0 +1,98 @@
'use strict'
const fs = require('fs')
const utils = require('./utils')
const webpack = require('webpack')
const config = require('../config')
const merge = require('webpack-merge')
const path = require('path')
const baseWebpackConfig = require('./webpack.base.conf')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const portfinder = require('portfinder')
const HOST = process.env.HOST
const PORT = process.env.PORT && Number(process.env.PORT)
const devWebpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, usePostCSS: true })
},
// cheap-module-eval-source-map is faster for development
devtool: config.dev.devtool,
// these devServer options should be customized in /config/index.js
devServer: {
clientLogLevel: 'warning',
historyApiFallback: {
rewrites: [
{ from: /.*/, to: path.posix.join(config.dev.assetsPublicPath, 'index.html') },
],
},
hot: true,
contentBase: false, // since we use CopyWebpackPlugin.
compress: true,
host: HOST || config.dev.host,
port: PORT || config.dev.port,
open: config.dev.autoOpenBrowser,
overlay: config.dev.errorOverlay
? { warnings: false, errors: true }
: false,
publicPath: config.dev.assetsPublicPath,
proxy: config.dev.proxyTable,
quiet: true, // necessary for FriendlyErrorsPlugin
watchOptions: {
poll: config.dev.poll,
}
},
plugins: [
new webpack.DefinePlugin({
'process.env': require('../config/dev.local.env')
}),
new webpack.HotModuleReplacementPlugin(),
new webpack.NamedModulesPlugin(), // HMR shows correct file names in console on update.
new webpack.NoEmitOnErrorsPlugin(),
// https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'index.html',
inject: true,
serviceWorkerLoader: `<script>${fs.readFileSync(path.join(__dirname,
'./service-worker-dev.js'), 'utf-8')}</script>`
}),
// copy custom static assets
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../static'),
to: config.dev.assetsSubDirectory,
ignore: ['.*']
}
])
]
})
module.exports = new Promise((resolve, reject) => {
portfinder.basePort = process.env.PORT || config.dev.port
portfinder.getPort((err, port) => {
if (err) {
reject(err)
} else {
// publish the new Port, necessary for e2e tests
process.env.PORT = port
// add port to devServer config
devWebpackConfig.devServer.port = port
// Add FriendlyErrorsPlugin
devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({
compilationSuccessInfo: {
messages: [`Your application is running here: http://${devWebpackConfig.devServer.host}:${port}`],
},
onErrors: config.dev.notifyOnErrors
? utils.createNotifierCallback()
: undefined
}))
resolve(devWebpackConfig)
}
})
})

161
build/webpack.prod.conf.js Executable file
View file

@ -0,0 +1,161 @@
'use strict'
const path = require('path')
const utils = require('./utils')
const webpack = require('webpack')
const config = require('../config')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
const loadMinified = require('./load-minified')
const env = process.env.NODE_ENV === 'testing'
? require('../config/test.env')
: require('../config/prod.env')
const webpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({
sourceMap: config.build.productionSourceMap,
extract: true,
usePostCSS: true
})
},
devtool: config.build.productionSourceMap ? config.build.devtool : 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
}),
// service worker caching
new SWPrecacheWebpackPlugin({
cacheId: 'SwitchList',
filename: 'service-worker.js',
staticFileGlobs: ['dist/**/*.{js,html,css}'],
minify: true,
stripPrefix: 'dist/'
}),
new UglifyJsPlugin({
uglifyOptions: {
compress: {
warnings: false
}
},
sourceMap: config.build.productionSourceMap,
parallel: true
}),
// extract css into its own file
new ExtractTextPlugin({
filename: utils.assetsPath('css/[name].[contenthash].css'),
// Setting the following option to `false` will not extract CSS from codesplit chunks.
// Their CSS will instead be inserted dynamically with style-loader when the codesplit chunk has been loaded by webpack.
// It's currently set to `true` because we are seeing that sourcemaps are included in the codesplit bundle as well when it's `false`,
// increasing file size: https://github.com/vuejs-templates/webpack/issues/1110
allChunks: true,
}),
// Compress extracted CSS. We are using this plugin so that possible
// duplicated CSS from different components can be deduped.
new OptimizeCSSPlugin({
cssProcessorOptions: config.build.productionSourceMap
? { safe: true, map: { inline: false } }
: { 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',
serviceWorkerLoader: `<script>${loadMinified(path.join(__dirname,
'./service-worker-prod.js'))}</script>`
}),
// keep module.id stable when vendor modules does not change
new webpack.HashedModuleIdsPlugin(),
// enable scope hoisting
new webpack.optimize.ModuleConcatenationPlugin(),
// split vendor js into its own file
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks (module) {
// 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',
minChunks: Infinity
}),
// This instance extracts shared chunks from code splitted chunks and bundles them
// in a separate chunk, similar to the vendor chunk
// see: https://webpack.js.org/plugins/commons-chunk-plugin/#extra-async-commons-chunk
new webpack.optimize.CommonsChunkPlugin({
name: 'app',
async: 'vendor-async',
children: true,
minChunks: 3
}),
// copy custom static assets
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../static'),
to: config.build.assetsSubDirectory,
ignore: ['.*']
}
])
]
})
if (config.build.productionGzip) {
const 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) {
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
webpackConfig.plugins.push(new BundleAnalyzerPlugin())
}
module.exports = webpackConfig

32
build/webpack.test.conf.js Executable file
View file

@ -0,0 +1,32 @@
'use strict'
// This is the webpack config used for unit tests.
const utils = require('./utils')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
const webpackConfig = merge(baseWebpackConfig, {
// 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

8
config/dev.env.js Executable file
View file

@ -0,0 +1,8 @@
'use strict'
const merge = require('webpack-merge')
const prodEnv = require('./prod.env')
module.exports = merge(prodEnv, {
NODE_ENV: '"development"',
API_URL: '"https://switchlist-api.herokuapp.com"'
})

8
config/dev.local.env.js Normal file
View file

@ -0,0 +1,8 @@
'use strict'
const merge = require('webpack-merge')
const prodEnv = require('./prod.env')
module.exports = merge(prodEnv, {
NODE_ENV: '"development"',
API_URL: '"http://localhost:3333"'
})

View file

@ -0,0 +1,7 @@
{
/* Visit https://firebase.google.com/docs/database/security to learn more about security rules. */
"rules": {
".read": true,
".write": true
}
}

View file

@ -0,0 +1,3 @@
{
"indexes": []
}

View file

@ -0,0 +1,7 @@
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.auth.uid != null;
}
}
}

7
config/firebase/storage.rules Executable file
View file

@ -0,0 +1,7 @@
service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read, write: if request.auth!=null;
}
}
}

76
config/index.js Executable file
View file

@ -0,0 +1,76 @@
'use strict'
// Template version: 1.3.1
// see http://vuejs-templates.github.io/webpack for documentation.
const path = require('path')
module.exports = {
dev: {
// Paths
assetsSubDirectory: 'static',
assetsPublicPath: '',
proxyTable: {},
// Various Dev Server settings
host: 'localhost', // can be overwritten by process.env.HOST
port: 3000, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
autoOpenBrowser: false,
errorOverlay: true,
notifyOnErrors: true,
poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions-
// Use Eslint Loader?
// If true, your code will be linted during bundling and
// linting errors and warnings will be shown in the console.
useEslint: true,
// If true, eslint errors and warnings will also be shown in the error overlay
// in the browser.
showEslintErrorsInOverlay: false,
/**
* Source Maps
*/
// https://webpack.js.org/configuration/devtool/#development
devtool: 'cheap-module-eval-source-map',
// If you have problems debugging vue-files in devtools,
// set this to false - it *may* help
// https://vue-loader.vuejs.org/en/options.html#cachebusting
cacheBusting: true,
cssSourceMap: true
},
build: {
// Template for index.html
index: path.resolve(__dirname, '../dist/index.html'),
// Paths
assetsRoot: path.resolve(__dirname, '../dist'),
assetsSubDirectory: 'static',
assetsPublicPath: '',
/**
* Source Maps
*/
productionSourceMap: true,
// https://webpack.js.org/configuration/devtool/#production
devtool: '#source-map',
// 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
}
}

5
config/prod.env.js Executable file
View file

@ -0,0 +1,5 @@
'use strict'
module.exports = {
NODE_ENV: '"production"',
API_URL: '"https://switchlist-api.herokuapp.com"'
}

7
config/test.env.js Executable file
View file

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

26
firebase.json Executable file
View file

@ -0,0 +1,26 @@
{
"database": {
"rules": "config/firebase/database.rules.json"
},
"firestore": {
"rules": "config/firebase/firestore.rules",
"indexes": "config/firebase/firestore.indexes.json"
},
"hosting": {
"public": "dist",
"ignore": [
"config/firebase/firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
},
"storage": {
"rules": "config/firebase/storage.rules"
}
}

23
functions/index.js Executable file
View file

@ -0,0 +1,23 @@
const functions = require('firebase-functions');
const axios = require('axios');
const circular = require('circular-json');
axios.defaults.headers.common['user-key'] = functions.config().igdb.key;
const igdbUrl = 'https://api-endpoint.igdb.com';
exports.search = functions.https.onRequest((req, res) => {
const { searchText, platformId } = req.query;
axios.get(`${igdbUrl}/games/?search=${searchText}&fields=*&filter[platforms][eq]=${platformId}&limit=20&order=popularity:desc`)
.then(({ data }) => { res.status(200).send(data) })
.catch(() => { res.send(400) });
});
exports.games = functions.https.onRequest((req, res) => {
const { games, platformId } = req.query;
axios.get(`${igdbUrl}/games/${games}/?fields=id,name,slug,created_at,updated_at,summary,rating,category,player_perspectives,release_dates,name,cover,platforms,screenshots,videos,websites,esrb,pegi,themes.name,game.name&expand=game,themes,developers,publishers,game_engines,game_modes,genres,platforms,player_perspectives`)
.then(({ data }) => { res.status(200).send(data) })
.catch(() => { res.send(400) });
});

19
functions/package.json Executable file
View file

@ -0,0 +1,19 @@
{
"name": "functions",
"description": "Cloud Functions for Firebase",
"scripts": {
"serve": "firebase serve --only functions",
"shell": "firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"dependencies": {
"axios": "^0.18.0",
"circular-json": "^0.5.7",
"express": "^4.16.4",
"firebase-admin": "~6.0.0",
"firebase-functions": "^2.0.3"
},
"private": true
}

2661
functions/yarn.lock Normal file

File diff suppressed because it is too large Load diff

41
index.html Executable file
View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-120053966-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-120053966-1');
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.2.0/css/all.css" integrity="sha384-hWVjflwFxL6sNzntih27bfxkr27PmbbK/iSvJ+a4+0owXq79v+lsFkW54bOGbiDQ" crossorigin="anonymous">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:400,500,700,400italic|Material+Icons">
<link rel="apple-touch-icon" sizes="57x57" href="static/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="static/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="static/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="static/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="static/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="static/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="static/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="static/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="static/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="static/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="static/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="static/favicon-16x16.png">
<link rel="manifest" href="static/manifest.json">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="static/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<title>GAMEBRARY</title>
</head>
<body>
<div id="app" />
</body>
</html>

113
package.json Executable file
View file

@ -0,0 +1,113 @@
{
"name": "switch-list",
"version": "1.0.0",
"description": "Keep track of your switch collection",
"author": "Roman Cervantes",
"private": true,
"scripts": {
"dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js",
"dev:local": "webpack-dev-server --inline --progress --config build/webpack.dev.local.conf.js",
"start": "npm run dev",
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
"test": "npm run unit",
"lint": "eslint --ext .js,.vue src test/unit",
"build": "node build/build.js",
"deploy": "node ./node_modules/vue-gh-pages/index.js"
},
"dependencies": {
"EventEmitter": "^1.0.0",
"axios": "^0.18.0",
"eventemitter3": "^3.1.0",
"firebase-admin": "^6.0.0",
"firebase-functions": "^2.0.5",
"lodash": "^4.17.10",
"moment": "^2.22.1",
"node-sass": "^4.8.3",
"sass-loader": "^7.0.1",
"sw-precache-webpack-plugin": "^0.11.5",
"v-tooltip": "^2.0.0-rc.33",
"vue": "^2.5.2",
"vue-axios": "^2.1.1",
"vue-content-placeholders": "^0.2.1",
"vue-dragscroll": "^1.5.0",
"vue-gravatar": "^1.2.1",
"vue-i18n": "^8.0.0",
"vue-router": "^3.0.1",
"vue-sweetalert2": "^1.5.3",
"vuedraggable": "^2.16.0",
"vuex": "^3.0.1",
"vuex-persist": "^1.2.2"
},
"devDependencies": {
"autoprefixer": "^7.1.2",
"babel-core": "^6.22.1",
"babel-eslint": "^8.2.1",
"babel-helper-vue-jsx-merge-props": "^2.0.3",
"babel-loader": "^7.1.1",
"babel-plugin-istanbul": "^4.1.1",
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-runtime": "^6.22.0",
"babel-plugin-transform-vue-jsx": "^3.5.0",
"babel-preset-env": "^1.3.2",
"babel-preset-stage-2": "^6.22.0",
"chai": "^4.1.2",
"chalk": "^2.0.1",
"copy-webpack-plugin": "^4.0.1",
"cross-env": "^5.0.1",
"css-loader": "^0.28.0",
"eslint": "^4.15.0",
"eslint-config-airbnb-base": "^11.3.0",
"eslint-friendly-formatter": "^3.0.0",
"eslint-import-resolver-webpack": "^0.8.3",
"eslint-loader": "^1.7.1",
"eslint-plugin-import": "^2.7.0",
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-vue": "^4.0.0",
"extract-text-webpack-plugin": "^3.0.0",
"file-loader": "^1.1.4",
"friendly-errors-webpack-plugin": "^1.6.1",
"html-webpack-plugin": "^2.30.1",
"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.31",
"karma-webpack": "^2.0.2",
"mocha": "^3.2.0",
"node-notifier": "^5.1.2",
"optimize-css-assets-webpack-plugin": "^3.2.0",
"ora": "^1.2.0",
"phantomjs-prebuilt": "^2.1.14",
"portfinder": "^1.0.13",
"postcss-import": "^11.0.0",
"postcss-loader": "^2.0.8",
"postcss-url": "^7.2.1",
"rimraf": "^2.6.0",
"semver": "^5.3.0",
"shelljs": "^0.7.6",
"sinon": "^4.0.0",
"sinon-chai": "^2.8.0",
"uglifyjs-webpack-plugin": "^1.1.1",
"url-loader": "^0.5.8",
"vue-loader": "^13.3.0",
"vue-style-loader": "^3.0.1",
"vue-template-compiler": "^2.5.2",
"webpack": "^3.6.0",
"webpack-bundle-analyzer": "^2.9.0",
"webpack-dev-server": "^2.9.1",
"webpack-merge": "^4.1.0"
},
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
}

50
src/App.vue Executable file
View file

@ -0,0 +1,50 @@
<template>
<div id="app">
<nav-header />
<main>
<router-view />
</main>
</div>
</template>
<script>
import NavHeader from '@/components/NavHeader/NavHeader';
export default {
name: 'App',
components: {
NavHeader,
},
};
</script>
<style lang="scss" rel="stylesheet/scss">
@import "~styles/styles.scss";
body {
margin: 0;
font-size: 14px;
}
#app {
height: 100%;
width: 100%;
overflow: hidden;
> main {
height: calc(100vh - #{$navHeight});
overflow: auto;
background: $color-gray;
}
}
@import url('https://fonts.googleapis.com/css?family=Roboto:400,700');
body {
font-family: 'Roboto', sans-serif;
// background: url('/static/background-pattern.png');
}
</style>

BIN
src/assets/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,202 @@
<template lang="html">
<div class="list">
<div class="list-header">
<list-name-edit
:list-name="name"
:list-index="listIndex"
:game-count="games.length"
@update="updateLists"
/>
<div :class="['list-actions', { show: listOptionsActive }]">
<div class="more-actions" v-show="listOptionsActive">
<button
v-if="hasGames"
class="small"
@click="sortList"
>
<i class="fas fa-sort-alpha-down" />
</button>
<button @click="addGame" class="small">
<i class="fas fa-plus" />
</button>
<button @click="remove" class="small">
<i class="far fa-trash-alt" />
</button>
</div>
<button
v-if="listOptionsActive"
class="small accent hollow"
@click="cancelListEdit"
>
<i class="fas fa-times" />
</button>
<button @click="editList" class="small accent hollow" v-else>
<i class="fas fa-pencil-alt" />
</button>
</div>
</div>
<game-search v-if="showSearch" :list-id="listIndex" />
<draggable
v-else
class="games"
:list="games"
:id="listIndex"
:move="validateMove"
:options="gameDraggableOptions"
@end="end"
@start="start"
>
<game-card
v-for="game in games"
:key="game"
:id="game"
:game-id="game"
:list-id="listIndex"
/>
<div class="empty-list" v-if="!games.length">
<img src="/static/img/empty-collection.png" />
<h3>This list is empty</h3>
<button class="primary" @click="addGame">
<i class="fas fa-plus" />
Add game
</button>
</div>
</draggable>
</div>
</template>
<script>
import draggable from 'vuedraggable';
import ListNameEdit from '@/components/GameBoard/ListNameEdit';
import GameCard from '@/components/GameCard/GameCard';
import GameSearch from '@/components/GameSearch/GameSearch';
import { mapState } from 'vuex';
export default {
components: {
GameCard,
GameSearch,
ListNameEdit,
draggable,
},
props: {
name: String,
games: [Object, Array],
listIndex: [String, Number],
},
data() {
return {
showAddGame: false,
listEditActive: false,
gameDraggableOptions: {
handle: '.game-drag-handle',
ghostClass: 'card-placeholder',
animation: 500,
group: {
name: 'games',
},
},
};
},
computed: {
...mapState(['user', 'activeList']),
showSearch() {
return this.showAddGame && this.activeList === this.listIndex && this.listOptionsActive;
},
listOptionsActive() {
return this.listEditActive && this.activeList === this.listIndex;
},
hasGames() {
return this.games.length > 1;
},
},
methods: {
editList() {
this.listEditActive = true;
this.$store.commit('SET_ACTIVE_LIST', this.listIndex);
},
cancelListEdit() {
this.listEditActive = false;
this.showAddGame = false;
this.$store.commit('SET_ACTIVE_LIST', null);
},
addGame() {
this.listEditActive = true;
this.$store.commit('CLEAR_SEARCH_RESULTS');
this.$store.commit('SET_ACTIVE_LIST', this.listIndex);
this.showAddGame = true;
},
updateLists() {
this.$store.dispatch('UPDATE_LISTS');
},
sortList() {
this.$store.commit('SORT_LIST', this.listIndex);
this.updateLists();
},
validateMove({ from, to }) {
const isDifferentList = from.id !== to.id;
const isDuplicate = this.user.lists[to.id].games.includes(Number(this.draggingId));
const validMove = isDifferentList && isDuplicate;
return !validMove;
},
start({ item }) {
this.dragging = true;
this.draggingId = item.id;
},
end() {
this.$emit('end');
},
remove() {
this.$emit('remove');
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
@import "~styles/game-board.scss";
.list-actions {
display: flex;
flex-direction: row;
transition: max-width 100ms ease;
max-width: $iconSmallSize;
overflow: hidden;
.more-actions {
display: flex;
min-width: $iconSmallSize;
max-width: $iconSmallSize * 3;
}
&.show {
transition: max-width 300ms ease;
max-width: $iconSmallSize * 4;
}
}
</style>

View file

@ -0,0 +1,82 @@
<template lang="html">
<div class="list-name">
<input
v-if="editing"
class="small"
v-model="localListName"
ref="input"
:id="`list-${listIndex}`"
@keyup.enter="save"
@keyup.esc="save"
>
<span v-else @click="edit">
{{ listName }} ({{ gameCount }})
</span>
</div>
</template>
<script>
export default {
props: {
listName: String,
listIndex: [String, Number],
gameCount: Number,
},
data() {
return {
editing: false,
localListName: '',
};
},
methods: {
handleClick(e) {
const outsideClickEvent = e.target.id !== `list-${this.listIndex}`;
const hasChanged = this.listName !== this.localListName;
if (outsideClickEvent) {
this.exit();
}
if (outsideClickEvent && hasChanged) {
this.save();
}
},
save() {
this.$store.commit('UPDATE_LIST_NAME', {
listIndex: this.listIndex,
listName: this.localListName,
});
this.$emit('update');
},
edit() {
this.editing = true;
this.localListName = this.listName;
this.$nextTick(() => {
document.addEventListener('click', this.handleClick);
this.$refs.input.focus();
});
},
exit() {
this.editing = false;
this.$nextTick(() => {
document.removeEventListener('click', this.handleClick);
});
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
.list-name {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,169 @@
<template lang="html">
<div
v-if="gameId && games"
:class="['game-card', { nightMode, 'search-result': searchResult }]"
>
<img :src="coverUrl" v-if="searchResult" width="50" />
<router-link
v-else
:to="{ name: 'game-detail', params: { id: this.game.id, slug: this.game.slug } }"
>
<img :src="coverUrl" width="80" />
</router-link>
<div class="game-info">
<h4 class="game-title">{{ game.name }}</h4>
<game-rating
v-if="user.settings && user.settings.showGameRatings && !searchResult"
small
:rating="game.rating"
/>
<button class="primary hollow small" @click="addGame" v-if="searchResult">
<i class="fas fa-plus" />
Add to list
</button>
</div>
<div class="options" v-if="!searchResult">
<button
v-if="!searchResult"
class="game-drag-handle accent small hollow"
title="Drag game"
>
<i class="fas fa-hand-rock" />
</button>
<button
v-if="list.games.includes(gameId)"
@click="removeGame"
title="Delete game"
class="accent small hollow"
>
<i class="fas fa-trash" />
</button>
<button v-else @click="addGame" title="Add game">
<i class="fas fa-plus" />
</button>
</div>
</div>
</template>
<script>
import GameRating from '@/components/GameDetail/GameRating';
import { mapState } from 'vuex';
export default {
components: {
GameRating,
},
props: {
gameId: Number,
listId: Number,
searchResult: Boolean,
},
computed: {
...mapState(['user', 'games']),
list() {
return this.user.lists[this.listId];
},
game() {
return this.games[this.gameId];
},
coverUrl() {
const url = 'https://images.igdb.com/igdb/image/upload/t_cover_small/';
return this.games && this.games[this.gameId].cover
? `${url}${this.games[this.gameId].cover.cloudinary_id}.jpg`
: '/static/no-image.jpg';
},
nightMode() {
return this.user && this.user.settings ? this.user.settings.nightMode : null;
},
showGameRating() {
return this.user
&& this.user.settings.showGameRatings
&& Boolean(Number(this.game.rating));
},
},
methods: {
addGame() {
const data = {
listId: this.listId,
gameId: this.gameId,
};
this.$emit('added');
this.$store.commit('ADD_GAME', data);
this.$store.dispatch('UPDATE_LISTS');
},
removeGame() {
const data = {
listId: this.listId,
gameId: this.gameId,
};
this.$store.commit('REMOVE_GAME', data);
this.$store.dispatch('UPDATE_LISTS');
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
@import "~styles/game-board.scss";
.game-card {
background-color: $color-white;
margin-bottom: $gp / 2;
position: relative;
display: grid;
grid-template-columns: 80px auto 40px;
&.search-result {
grid-template-columns: 50px auto 0;
.game-title { margin-bottom: $gp / 2; }
.game-info { padding: $gp / 2; }
}
.game-info { padding: $gp / 2 $gp; }
.game-title { margin: 0; }
&:hover {
.options {
transition: all 300ms ease;
max-height: 100px;
opacity: 1;
}
}
.options {
opacity: 0;
transition: all 300ms ease;
max-height: 0;
overflow: hidden;
display: inline-flex;
align-items: center;
flex-direction: column;
transition: all 300ms ease;
.game-drag-handle {
@include drag-cursor;
}
}
}
</style>

View file

@ -0,0 +1,93 @@
<template lang="html">
<div class="game-header">
<div class="game-background" :style="style">
<img
:src="coverUrl"
:alt="game.name"
class="game-cover"
width="80"
>
<div class="game-rating" v-if="game.esrb || game.pegi">
<img
v-if="game.esrb"
:src='`/static/img/esrb/${esrb[game.esrb.rating]}.png`'
:alt="esrb.synopsis"
>
<img
v-if="game.pegi"
:src='`/static/img/pegi/${pegi[game.pegi.rating]}.png`'
:alt="game.pegi.synopsis"
>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
props: {
gameId: [Number, String],
},
computed: {
...mapState(['game', 'pegi', 'esrb']),
coverUrl() {
const url = 'https://images.igdb.com/igdb/image/upload/t_cover_small/';
return this.game && this.game.cover
? `${url}${this.game.cover.cloudinary_id}.jpg`
: '/static/no-image.jpg';
},
style() {
return this.game && this.game.screenshots
? `background: url(${this.getImageUrl(this.game.screenshots[0].cloudinary_id)}); background-size: cover;`
: '';
},
},
methods: {
getImageUrl(cloudinaryId) {
return cloudinaryId
? `https://images.igdb.com/igdb/image/upload/t_screenshot_med/${cloudinaryId}.jpg`
: null;
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.game-background {
display: flex;
min-height: 20vh;
width: 100%;
position: relative;
align-items: center;
background-color: $color-red;
background-size: cover;
background-position: center;
.game-cover {
margin: 0 $gp;
border: 5px solid $color-white;
background-size: contain;
box-shadow: 0 0 5px 0 $color-gray;
}
.game-rating {
position: absolute;
top: $gp;
right: $gp;
img {
height: 50px;
}
}
}
</style>

View file

@ -0,0 +1,64 @@
<template lang="html">
<div class="links" v-if="game && game.websites">
<a
v-for="{ category, url } in game.websites"
:key="category"
:href="url"
target="_blank"
>
<i :class="getIcon(category)" />
{{ linkTypes[category] }}
</a>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
data() {
return {
icons: {
official: 'fas fa-globe-americas',
facebook: 'fab fa-facebook-f',
steam: 'fab fa-steam',
youtube: 'fab fa-youtube',
twitter: 'fab fa-twitter',
instagram: 'fab fa-instagram',
iphone: 'fab fa-app-store-ios',
wikipedia: 'fab fa-wikipedia-w',
wikia: 'fas fa-link',
twitch: 'fab fa-twitch',
Reddit: 'fab fa-reddit',
reddit: 'fab fa-reddit',
},
};
},
computed: {
...mapState(['linkTypes', 'game']),
},
methods: {
getIcon(id) {
const icon = this.linkTypes[id];
return this.icons[icon];
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.links {
display: flex;
flex-direction: column;
margin-top: $gp;
a {
color: $color-blue;
text-transform: capitalize;
text-decoration: none;
}
}
</style>

View file

@ -0,0 +1,113 @@
<template lang="html">
<div :class="['rating', ratingClass, { 'small': small}]">
<span v-if="!isNaN(rating)">{{ parseInt(rating, 10) }}</span>
<span v-else>?</span>
</div>
</template>
<script>
export default {
props: {
rating: Number,
small: Boolean,
},
computed: {
ratingClass() {
const gameRating = Number(this.rating);
if (gameRating >= 90) { return 'excellent'; }
if (gameRating >= 80) { return 'good'; }
if (gameRating >= 70) { return 'average'; }
if (gameRating < 70) { return 'bad'; }
return '';
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.rating {
position: relative;
width: 100px;
height: 57.74px;
background-color: $color-gray;
margin: 28.87px 0;
font-size: 60px;
display: flex;
align-items: center;
color: $color-white;
font-weight: bold;
&.small {
width: 26px;
height: 15px;
font-size: 15px;
margin: 12px 0 8px;
&:before,
&:after {
border-left: 13px solid transparent;
border-right: 13px solid transparent;
}
&:before {
border-bottom: 8px solid $color-gray;
}
&:after {
border-top: 8px solid $color-gray;
}
}
span {
margin: 0 auto;
}
&:before,
&:after {
content: "";
position: absolute;
width: 0;
left: 0;
border-left: 50px solid transparent;
border-right: 50px solid transparent;
}
&:before {
bottom: 100%;
border-bottom: 28.87px solid $color-light-gray;
}
&:after {
top: 100%;
border-top: 28.87px solid $color-light-gray;
}
&.excellent {
background-color: $color-green;
&:before { border-bottom-color: $color-green; }
&:after { border-top-color: $color-green; }
}
&.good {
background-color: $color-light-green;
&:before { border-bottom-color: $color-light-green; }
&:after { border-top-color: $color-light-green; }
}
&.average {
background-color: $color-orange;
&:before { border-bottom-color: $color-orange; }
&:after { border-top-color: $color-orange; }
}
&.bad {
background-color: $color-red;
&:before { border-bottom-color: $color-red; }
&:after { border-top-color: $color-red; }
}
}
</style>

View file

@ -0,0 +1,80 @@
<template lang="html">
<div v-if="game" class="review-box">
<game-rating :rating="game.rating" />
<div class="info">
<section v-if="playerPerspectives">
<strong>Perspective</strong> {{ playerPerspectives }}
</section>
<section v-if="gameModes">
<strong>Game Mode</strong> {{ gameModes }}
</section>
<section v-if="genres">
<strong>Genre</strong> {{ genres }}
</section>
<section v-if="gamePlatforms">
<strong>Platforms</strong> {{ gamePlatforms }}
</section>
<section v-if="developers">
<strong>Developer</strong> {{ developers }}
</section>
<section v-if="publishers">
<strong>Publishers</strong> {{ publishers }}
</section>
<section v-if="releaseDate">
<strong>Release date</strong> {{ releaseDate }}
</section>
<game-links />
</div>
</div>
</template>
<script>
import GameLinks from '@/components/GameDetail/GameLinks';
import GameRating from '@/components/GameDetail/GameRating';
import { mapState, mapGetters } from 'vuex';
export default {
components: {
GameRating,
GameLinks,
},
computed: {
...mapState([
'game',
'platforms',
]),
...mapGetters([
'playerPerspectives',
'developers',
'gameModes',
'gamePlatforms',
'genres',
'publishers',
'releaseDate',
]),
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.review-box {
display: grid;
grid-template-columns: 100px auto;
grid-gap: $gp;
strong {
color: $color-red;
}
}
</style>

View file

@ -0,0 +1,43 @@
<template lang="html">
<section v-if="game.screenshots">
<h3>Screenshots ({{ game.screenshots.length }})</h3>
<div class="gallery">
<img
v-for="({ cloudinary_id }, index) in game.screenshots"
:key="cloudinary_id"
:src="`https://images.igdb.com/igdb/image/upload/t_screenshot_med/${cloudinary_id}.jpg`"
:alt="`Screenshot ${index + 1} of ${game.screenshots.length}`"
>
</div>
</section>
</template>
<script>
import { mapState } from 'vuex';
export default {
computed: {
...mapState(['game']),
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.gallery {
display: grid;
grid-template-columns: 50% 50%;
@media($small) {
grid-template-columns: 100%;
}
img {
padding: $gp / 4;
width: 100%;
height: auto;
}
}
</style>

View file

@ -0,0 +1,56 @@
<template lang="html">
<div class="game-videos no-wrap" v-if="game.videos">
<h3>Videos ({{ game.videos.length }})</h3>
<!-- TODO: create gallery with thumbs -->
<div
class="game-video"
v-for="{ video_id } in game.videos"
:key="video_id"
>
<iframe
:src="`https://www.youtube.com/embed/${video_id}?rel=0&autohide=1`"
frameborder="0"
width="426"
height="240"
allow="autoplay; encrypted-media"
allowfullscreen
/>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
computed: {
...mapState(['game']),
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.game-video {
margin-bottom: $gp / 2;
position: relative;
padding-bottom: 56.25%; /* 16:9 */
padding-top: 25px;
iframe {
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 50% !important;
@media($small) {
width: 100%;
height: 100% !important;
}
}
}
</style>

View file

@ -0,0 +1,127 @@
<template lang="html">
<form @submit.prevent="search" class="game-search">
<input ref="searchInput" type="text" v-model="searchText" placeholder="Type here">
<div class="search-results" v-if="!loading && filteredResults.length > 0">
<game-card
v-for="{ id } in filteredResults"
v-if="!list.games.includes(id)"
:key="id"
:game-id="id"
:listId="listId"
@added="added"
search-result
/>
</div>
<div v-if="!loading && filteredResults.length === 0">
No results
</div>
<content-placeholders v-for="n in 3" :key="n" v-if="loading">
<content-placeholders-heading :img="true" />
</content-placeholders>
</form>
</template>
<script>
import GameCard from '@/components/GameCard/GameCard';
import { debounce } from 'lodash';
import { mapState, mapGetters } from 'vuex';
export default {
components: {
GameCard,
},
props: {
listId: {
type: [Number, String, Boolean],
required: true,
default: 0,
},
},
data() {
return {
searchText: '',
loading: false,
styles: {
width: '95%',
'max-width': '800px',
},
};
},
computed: {
...mapState(['results', 'user']),
...mapGetters(['auth']),
list() {
return this.user.lists[this.listId];
},
filteredResults() {
return this.results.filter(({ id }) => !this.list.games.includes(id));
},
},
watch: {
searchText(value) {
if (value) {
this.search();
}
},
},
mounted() {
this.$refs.searchInput.focus();
},
methods: {
added() {
this.$emit('added');
},
search: debounce(
// eslint-disable-next-line
function() {
this.loading = true;
this.$store.dispatch('SEARCH', this.searchText)
.then(() => {
this.error = null;
this.loading = false;
})
.catch(({ data }) => {
this.loading = false;
this.error = data;
});
}, 300),
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.game-search {
background: $color-light-gray;
margin-top: 30px;
padding: $gp / 2;
}
.search-results {
// min-height: 120px;
overflow: auto;
max-height: calc(100vh - 160px);
}
input {
margin: 0 0 $gp / 2;
}
.vue-content-placeholders {
margin-bottom: $gp / 2;
}
</style>

138
src/components/Lists/AddList.vue Executable file
View file

@ -0,0 +1,138 @@
<template lang="html">
<div class="add-list">
<form @submit.prevent="addList" v-if="show">
<input
v-model="newListName"
type="text"
ref="newListName"
required
placeholder="List name"
/>
<!-- TODO: replace with toast -->
<small v-if="isDuplicate" v-html="errorMessage" />
<button
type="submit"
class="small primary"
v-if="!isDuplicate"
:disabled="!newListName.length"
>
Create
</button>
<button
class="small accent"
type="button"
@click="reset"
>
Cancel
</button>
</form>
<button class="add small info hollow" @click="toggleAddList" v-else>
<i class="fas fa-plus" />
</button>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
data() {
return {
show: false,
newListName: '',
};
},
computed: {
...mapState(['user']),
errorMessage() {
return `You already have a list named <strong>${this.newListName}</strong>. Please use a different name.`;
},
nightMode() {
return this.user.settings.nightMode;
},
isDuplicate() {
const newListName = this.newListName.toLowerCase();
// eslint-disable-next-line
return this.user.lists.filter(({ name }) => name.toLowerCase() === newListName).length > 0;
},
},
watch: {
show() {
this.$nextTick(() => {
if (this.$refs.newListName) {
this.$refs.newListName.focus();
}
});
},
},
methods: {
toggleAddList() {
if (!this.show) {
this.$nextTick(() => {
this.$emit('scroll');
});
}
this.show = !this.show;
},
addList() {
this.$store.commit('ADD_LIST', this.newListName);
this.$emit('update');
this.$emit('scroll');
this.reset();
this.$swal({
position: 'bottom-end',
title: 'List added',
type: 'success',
toast: true,
showConfirmButton: false,
timer: 1500,
});
},
reset() {
this.show = false;
this.newListName = '';
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.add {
margin-right: $gp;
}
form {
border-radius: $border-radius;
background: $color-light-gray;
padding: $gp / 2;
margin-right: $gp;
}
small {
background: $color-white;
margin-bottom: $gp / 2;
display: block;
padding: $gp / 2;
border-radius: $border-radius;
}
input {
width: 240px;
}
</style>

View file

@ -0,0 +1,75 @@
<template lang="html">
<nav>
<platforms-dropdown />
<strong>gamebrary</strong>
<div class="links">
<template v-if="auth">
<router-link tag="button" class="info" :to="{ name: 'admin' }" v-if="user.admin">
<i class="fas fa-screwdriver" />
</router-link>
<router-link tag="button" class="info" :to="{ name: 'settings' }">
<i class="fas fa-cog" />
</router-link>
</template>
<template v-else>
<router-link tag="button" class="info" :to="{ name: 'login' }">
Login
</router-link>
<router-link tag="button" class="primary" :to="{ name: 'register' }">
Register
</router-link>
</template>
</div>
</nav>
</template>
<script>
import PlatformsDropdown from '@/components/PlatformsDropdown/PlatformsDropdown';
import { mapGetters, mapState } from 'vuex';
export default {
components: {
PlatformsDropdown,
},
data() {
return {
msg: 'test',
};
},
computed: {
...mapState(['user']),
...mapGetters(['auth']),
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
nav {
height: $navHeight;
width: 100%;
background: $color-dark-gray;
padding-right: 4px;
display: flex;
align-items: center;
justify-content: space-between;
a {
color: $color-white;
}
strong {
color: $color-white;
text-transform: uppercase;
font-size: 18px;
}
}
</style>

View file

@ -0,0 +1,44 @@
<template lang="html">
<div class="panel">
<slot />
</div>
</template>
<script>
export default {
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.panel {
padding: $gp / 2;
border-radius: $border-radius;
margin: $gp;
p {
margin: $gp / 2 0;
}
&.warning {
background: $color-orange;
color: darken($color-orange, 40);
}
&.positive {
background: $color-green;
color: lighten($color-green, 50);
}
&.info {
background: $color-gray;
color: lighten($color-gray, 40);
}
&.error {
background: $color-red;
color: lighten($color-red, 40);
}
}
</style>

View file

@ -0,0 +1,86 @@
<template lang="html">
<div class="platform-switcher">
<router-link :to="{ name: 'home' }" v-if="!platform">
<i class="fas fa-home" />
</router-link>
<v-popover v-else>
<span>{{ platform.name }}</span>
<i class="fas fa-caret-down dropdown" />
<template slot="popover">
<div class="platforms">
<a
v-close-popover
v-for="platform in platforms"
:key="platform.name"
@click="changePlatform(platform)"
>
{{ platform.name }}
</a>
</div>
</template>
</v-popover>
</div>
</template>
<script>
import { mapState } from 'vuex';
// eslint-disable-next-line
import platforms from '@/platforms';
export default {
data() {
return {
platforms,
};
},
computed: {
...mapState(['platform']),
},
methods: {
changePlatform(platform) {
this.$store.commit('SET_PLATFORM', platform);
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.platform-switcher {
display: flex;
align-items: center;
margin: 0 $gp;
color: $color-white;
a {
color: $color-white;
}
}
.platforms {
background: $color-white;
border: 4px solid $color-dark-gray;
border-top: 0;
position: fixed;
left: 0;
width: 200px;
height: 300px;
overflow: auto;
overflow: overlay;
a {
display: flex;
align-items: center;
padding: $gp / 2;
}
}
.dropdown {
padding: $gp / 2;
}
</style>

View file

@ -0,0 +1,77 @@
<template lang="html">
<div class="password-strength-indicator" v-if="value">
<h4>Password requirements:</h4>
<span :class="{ valid: meetsLength }">
<i class="far fa-check-square" v-if="meetsLength"/>
<i class="far fa-square" v-else />
8 Character minimum
</span>
<span :class="{ valid: hasNumber }">
<i class="far fa-check-square" v-if="hasNumber"/>
<i class="far fa-square" v-else />
At least one number
</span>
<span :class="{ valid: hasUppercase }">
<i class="far fa-check-square" v-if="hasUppercase"/>
<i class="far fa-square" v-else />
At least one uppercase letter
</span>
<span :class="{ valid: hasLowercase }">
<i class="far fa-check-square" v-if="hasLowercase"/>
<i class="far fa-square" v-else />
At least one lowercase letter
</span>
</div>
</template>
<script>
export default {
props: {
value: String,
},
computed: {
meetsLength() {
return this.value && this.value.length > 7;
},
hasNumber() {
return /\d/.test(this.value);
},
hasUppercase() {
return (/[A-Z]/.test(this.value));
},
hasLowercase() {
return (/[a-z]/.test(this.value));
},
isValid() {
const { meetsLength, hasNumber, hasUppercase, hasLowercase } = this;
return meetsLength && hasNumber && hasUppercase && hasLowercase;
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.password-strength-indicator {
display: flex;
flex-direction: column;
span {
color: $color-red;
&.valid {
color: $color-green;
}
}
}
</style>

14
src/i18n.js Normal file
View file

@ -0,0 +1,14 @@
const messages = {
en: {
message: {
hello: 'hello world',
},
},
ja: {
message: {
hello: 'こんにちは、世界',
},
},
};
export default messages;

61
src/main.js Executable file
View file

@ -0,0 +1,61 @@
import VueDragscroll from 'vue-dragscroll';
import VueContentPlaceholders from 'vue-content-placeholders';
import axios from 'axios';
import VueSweetalert2 from 'vue-sweetalert2';
import VTooltip from 'v-tooltip';
import VueAxios from 'vue-axios';
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import store from './store/';
import App from './App';
import router from './router';
import messages from './i18n';
const EventBus = new Vue();
axios.interceptors.response.use(response => response, (error) => {
if (error && error.response && error.response.status === 401) {
window.location.href = '/#/session-expired';
}
});
Object.defineProperties(Vue.prototype, {
$bus: {
get() {
return EventBus;
},
},
});
Vue.use(VueI18n);
Vue.use(VTooltip);
Vue.use(VueSweetalert2);
Vue.use(VueContentPlaceholders);
Vue.use(VueDragscroll);
Vue.use(VueAxios, axios);
Vue.config.productionTip = false;
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !store.getters.auth) {
next('/');
} else {
next();
}
});
const i18n = new VueI18n({
// TODO: get locale from user settings store
locale: 'en',
messages,
});
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
i18n,
store,
components: { App },
template: '<App/>',
});

31
src/mixins/toasts.js Normal file
View file

@ -0,0 +1,31 @@
export default {
data() {
return {
timer: 1500,
};
},
methods: {
$error(title) {
this.$swal({
title,
toast: true,
timer: this.timer,
position: 'bottom-end',
type: 'error',
showConfirmButton: false,
});
},
$success(title) {
this.$swal({
position: 'bottom-end',
title,
type: 'success',
toast: true,
showConfirmButton: false,
timer: this.timer,
});
},
},
};

159
src/pages/Admin/Admin.vue Normal file
View file

@ -0,0 +1,159 @@
<template lang="html">
<div class="admin">
<div class="loading" v-if="!adminData">
<i class="fas fa-circle-notch fast-spin fa-3x" />
</div>
<div v-else>
<header class="stats">
<strong>{{ adminData.length }} Users</strong>
<button class="primary" @click="load">
<i class="fas fa-sync" />
Reload data
</button>
</header>
<div class="users">
<div class="user">
<strong>Avatar</strong>
<strong>Email</strong>
<strong>Lists</strong>
<strong>Game count</strong>
<strong>Joined</strong>
<strong>View</strong>
</div>
<div class="user" v-for="user in adminData" :key="user._id">
<gravatar :email="user.email" />
{{ user.email }}
<div class="lists">
<!-- {{ user.lists.length }} -->
{{ getListNames(user.lists) }}
</div>
<span>
{{ getGameCount(user.lists) }}
</span>
<span :title="moment(user.dateJoined).format('LL')">
{{ moment(user.dateJoined).fromNow() }}
</span>
<router-link
tag="button"
class="small info"
:to="{ name: 'share', params: { id: user._id }}"
>
<i class="fas fa-link"></i>
View
</router-link>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import Gravatar from 'vue-gravatar';
import moment from 'moment';
import toasts from '@/mixins/toasts';
export default {
components: {
Gravatar,
},
mixins: [toasts],
data() {
return {
moment,
users: null,
error: false,
};
},
computed: {
...mapState(['user', 'adminData']),
},
mounted() {
if (!this.adminData) {
this.load();
}
},
methods: {
getListNames(lists) {
return lists.map(({ name }) => name).join(', ');
},
getGameCount(lists) {
return lists.map(({ games }) => games.length).reduce((a, b) => a + b, 0);
},
load() {
if (this.user.admin) {
this.$store.dispatch('LOAD_USERS').catch(this.exit);
} else {
this.exit();
}
},
exit() {
this.$error('Admins only, sorry!');
this.$router.push({ name: 'home' });
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.loading {
height: calc(100vh - 48px);
color: $color-dark-gray;
display: flex;
align-items: center;
justify-content: center;
}
.stats {
padding: $gp;
display: flex;
align-items: center;
justify-content: space-between;
strong {
font-size: 24px;
color: $color-light-gray;
}
}
.users {
display: flex;
flex-direction: column;
overflow-x: auto;
.user {
padding: $gp / 3 $gp;
display: grid;
grid-template-columns: 50px 240px 300px 100px 120px 120px 120px;
grid-gap: 10px;
align-items: center;
&:hover {
background: $color-light-gray;
}
img {
width: 50px;
}
}
}
</style>

204
src/pages/GameBoard/GameBoard.vue Executable file
View file

@ -0,0 +1,204 @@
<template lang="html">
<div
class="lists"
ref="lists"
@dragscrollstart="dragScrollActive = true"
@dragscrollend="dragScrollActive = false"
:class="{ nightMode, 'drag-scroll-active': dragScrollActive }"
v-dragscroll:nochilddrag
>
<list
:name="list.name"
:games="list.games"
:listIndex="listIndex"
:key="list.name"
v-if="list"
v-for="(list, listIndex) in user.lists"
@end="dragEnd"
@remove="tryDelete(listIndex)"
/>
<add-list
@update="updateLists(true)"
@scroll="scroll"
/>
</div>
</template>
<script>
import { dragscroll } from 'vue-dragscroll';
import AddList from '@/components/Lists/AddList';
import toasts from '@/mixins/toasts';
import List from '@/components/GameBoard/List';
import draggable from 'vuedraggable';
import moment from 'moment';
import { mapState } from 'vuex';
export default {
components: {
draggable,
List,
AddList,
},
directives: {
dragscroll,
},
mixins: [toasts],
data() {
return {
dragging: false,
draggingId: null,
gameData: null,
loading: false,
activeList: null,
showDeleteConfirm: false,
dragScrollActive: false,
listDraggableOptions: {
animation: 500,
handle: '.list-drag-handle',
group: { name: 'lists' },
draggable: '.list',
ghostClass: 'list-placeholder',
},
};
},
computed: {
...mapState(['user']),
isEmpty() {
return !this.user.lists.filter(list => list && list.games && list.games.length).length;
},
nightMode() {
return this.user && this.user.settings ? this.user.settings.nightMode : false;
},
},
mounted() {
if (!this.isEmpty) {
this.loadGameData();
}
this.checkDataAge();
},
methods: {
checkDataAge() {
const lastUpdated = this.$store.state.dataUpdatedTimestamp;
const diff = moment.duration(moment().diff(lastUpdated));
if (diff.asMinutes() > 15) {
this.$store.dispatch('LOAD_LISTS');
}
},
scroll() {
this.$nextTick(() => {
const lists = this.$refs.lists;
lists.scrollLeft = lists.scrollWidth;
});
},
tryDelete(index) {
const hasGames = this.user.lists[index].games.length > 0;
if (hasGames) {
this.showDeleteConfirm = true;
this.activeList = index;
this.$swal({
title: 'Are you sure?',
text: 'This lists contains games, all games will be deleted as well.',
showCancelButton: true,
buttonsStyling: false,
confirmButtonClass: 'primary small',
cancelButtonClass: 'small',
confirmButtonText: 'Delete',
}).then(({ value }) => {
if (value) {
this.deleteList(this.activeList);
}
});
} else {
this.deleteList(index);
}
},
deleteList(index) {
this.$store.commit('REMOVE_LIST', index);
this.updateLists();
this.$success('List deleted');
},
dragEnd() {
this.dragging = false;
this.draggingId = null;
this.$store.commit('UPDATE_LIST', this.user.lists);
this.updateLists();
},
updateLists(forceReload) {
this.$store.dispatch('UPDATE_LISTS')
.then(() => {
if (this.user.lists.length === 1 && forceReload) {
location.reload();
}
});
},
loadGameData() {
const gameList = [];
this.user.lists.forEach((list) => {
if (list && list.games.length) {
list.games.forEach((id) => {
if (!gameList.includes(id)) {
gameList.push(id);
}
});
}
});
this.$store.dispatch('LOAD_GAMES', gameList)
.catch(() => {
this.$swal({
title: 'Uh no!',
text: 'There was an error loading your game data',
type: 'error',
showCancelButton: true,
confirmButtonClass: 'primary',
confirmButtonText: 'Retry',
}).then(({ value }) => {
if (value) {
this.loadGameData();
}
});
});
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
@import "~styles/game-board.scss";
.draggable * {
color: $color-white;
}
.nightMode {
background: #333 !important;
.games {
background: $color-dark-gray;
}
.list-header {
box-shadow: 0 0 5px 5px $color-dark-gray;
}
}
</style>

View file

@ -0,0 +1,64 @@
<template lang="html">
<div class="game" v-if="game">
<game-header />
<section class="game-details">
<h2 v-html="game.name" />
<p class="game-description" v-html="game.summary" />
<game-review-box />
<game-screenshots />
<game-videos />
<div class="source">
Source: <a href="https://www.igdb.com/">IGDB</a>
</div>
</section>
</div>
</template>
<script>
import { mapState } from 'vuex';
import GameHeader from '@/components/GameDetail/GameHeader';
import GameScreenshots from '@/components/GameDetail/GameScreenshots';
import GameVideos from '@/components/GameDetail/GameVideos';
import GameReviewBox from '@/components/GameDetail/GameReviewBox';
export default {
components: {
GameHeader,
GameScreenshots,
GameVideos,
GameReviewBox,
},
computed: {
...mapState(['game']),
},
mounted() {
this.$store.commit('SET_ACTIVE_GAME', this.$route.params.id);
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.game {
border-radius: $border-radius;
background-color: $color-light-gray;
overflow: hidden;
}
.game-details {
padding: 0 $gp;
}
.source {
padding-bottom: $gp;
text-align: center;
a {
color: $color-blue;
text-decoration: none;
}
}
</style>

41
src/pages/Home/Home.vue Executable file
View file

@ -0,0 +1,41 @@
<template lang="html">
<main>
<public-home v-if="!auth" />
<game-board v-if="auth" />
<!-- <game-board v-if="auth && platform" /> -->
<!-- <platforms v-else /> -->
</main>
</template>
<script>
import PublicHome from '@/pages/Home/PublicHome';
import GameBoard from '@/pages/GameBoard/GameBoard';
import Platforms from '@/pages/Platforms/Platforms';
import { mapGetters, mapState } from 'vuex';
export default {
components: {
PublicHome,
GameBoard,
Platforms,
},
computed: {
...mapState(['platform', 'user']),
...mapGetters(['auth']),
hasPlatforms() {
return this.user.platforms.length > 0 || this.platform;
},
},
watch: {
platform(value) {
// eslint-disable-next-line
console.log('reload game board!');
// eslint-disable-next-line
console.log(value);
},
},
};
</script>

53
src/pages/Home/PublicHome.vue Executable file
View file

@ -0,0 +1,53 @@
<template lang="html">
<div class="home">
<h1>GAMEBRARY</h1>
<strong>500+ users! Thank you!</strong>
<p>A simple and user friendly way to manage your video game collection.</p>
<div class="actions">
<router-link tag="button" class="primary" :to="{ name: 'register' }">
Create an account
</router-link>
<router-link tag="button" class="info" :to="{ name: 'login' }">
Login
</router-link>
</div>
</div>
</template>
<script>
import Panel from '@/components/Panel/Panel';
export default {
components: {
Panel,
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.home {
@include container-xs;
}
.actions {
display: flex;
align-items: center;
margin-bottom: $gp;
border-bottom: 1px solid $color-light-gray;
padding-bottom: $gp;
.info {
margin-left: $gp;
}
}
strong {
color: $color-green;
}
</style>

81
src/pages/Login/Login.vue Executable file
View file

@ -0,0 +1,81 @@
<template lang="html">
<form @submit.prevent="login" class="auth-form">
<h3>Login</h3>
<input type="email" ref="email" v-model="formModel.email" placeholder="Email" />
<input type="password" v-model="formModel.password" placeholder="Password">
<div class="checkbox">
<span class="toggle-switch">
<input type="checkbox" id="persist" v-model="formModel.persist" />
<label for="persist" />
</span>
Keep me logged in
</div>
<button type="submit" class="primary" @click="login" :disabled="loading">Login</button>
</form>
</template>
<script>
export default {
data() {
return {
formModel: {
email: '',
password: '',
persist: true,
},
loading: false,
error: null,
};
},
computed: {
disabled() {
return this.loading || !this.formModel.email || !this.formModel.password;
},
},
mounted() {
if (this.$store.state.user) {
this.$router.push({ name: 'home' });
}
this.$refs.email.focus();
},
methods: {
login() {
if (this.disabled) {
return;
}
this.loading = true;
this.$store.dispatch('LOGIN', this.formModel)
.then(() => {
this.$router.push({ name: 'home' });
})
.catch(() => {
this.loading = false;
this.error = true;
});
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
form {
@include container-xs;
}
.toggle-switch, .checkbox {
display: flex;
margin-bottom: $gp;
}
.toggle-switch {
margin-right: $gp / 2;
}
</style>

View file

@ -0,0 +1,37 @@
<template lang="html">
<div>
<div class="platforms">
<a
v-close-popover
v-for="platform in platforms"
:key="platform.name"
@click="changePlatform(platform)"
>
{{ platform.name }}
<br>
</a>
</div>
</div>
</template>
<script>
// eslint-disable-next-line
import platforms from '@/platforms';
export default {
data() {
return {
platforms,
};
},
methods: {
changePlatform(platform) {
this.$store.commit('SET_PLATFORM', platform);
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
</style>

88
src/pages/Register/Register.vue Executable file
View file

@ -0,0 +1,88 @@
<template lang="html">
<form @submit.prevent="register" class="auth-form">
<panel class="error" v-if="error">
There was an error creating the account
</panel>
<h3>Register</h3>
<input type="email" ref="email" v-model="formModel.email" placeholder="Email" />
<input type="password" v-model="formModel.password" placeholder="Password">
<panel class="warning" v-if="validationError">
Please enter a valid password
</panel>
<password-strength-indicator
v-model="formModel.password"
ref="passwordStrengthIndicator"
/>
<button type="submit" class="primary" @click="register" :disabled="loading">
Register
</button>
</form>
</template>
<script>
import PasswordStrengthIndicator from '@/components/Register/PasswordStrengthIndicator';
import Panel from '@/components/Panel/Panel';
export default {
components: {
PasswordStrengthIndicator,
Panel,
},
data() {
return {
formModel: {
email: '',
password: '',
},
error: false,
validationError: false,
loading: false,
};
},
mounted() {
if (this.$store.state.user) {
this.$router.push({ name: 'home' });
}
this.$refs.email.focus();
},
methods: {
register() {
this.validationError = false;
if (this.loading || !this.$refs.passwordStrengthIndicator.isValid) {
this.validationError = true;
return;
}
this.loading = true;
this.$store.dispatch('REGISTER', this.formModel)
.then(() => {
this.error = false;
this.loading = false;
this.$router.push({ name: 'home' });
})
.catch(() => {
this.error = true;
this.loading = false;
});
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
form {
@include container-xs;
}
</style>

View file

@ -0,0 +1,13 @@
<template lang="html">
<div>
<h1>Session has expired</h1>
</div>
</template>
<script>
export default {
mounted() {
this.$store.commit('CLEAR_SESSION');
},
};
</script>

View file

@ -0,0 +1,275 @@
<template lang="html">
<div class="settings">
<aside>
<gravatar :email="user.email" />
<div>
<p><strong>App ID:</strong> {{ user._id }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>
<p><strong>Joined:</strong> {{ dateJoined }}</p>
</div>
<div>
<button @click="promptDelete" class="small error">
<i class="fas fa-exclamation-triangle" />
Delete Account
</button>
<button class="small info" @click="logout">
<i class="fas fa-sign-out-alt" />
Logout
</button>
</div>
</aside>
<main class="settings-grid">
<section>
<i class="fas fa-share-alt" />
<h3>Share link</h3>
<input class="share-link value" type="text" v-model="shareUrl" readonly />
</section>
<section>
<i class="fas fa-moon" />
<h3>Night mode</h3>
<span class="toggle-switch value">
<input type="checkbox" id="nightMode" v-model="settings.nightMode" />
<label for="nightMode" />
</span>
</section>
<section>
<i class="fas fa-star-half-alt" />
<h3>Show Game Ratings</h3>
<span class="toggle-switch value">
<input type="checkbox" id="gameRatings" v-model="settings.showGameRatings" />
<label for="gameRatings" />
</span>
</section>
<!-- <section>
<i class="fas fa-th-large" />
<h3>Game Card Layout</h3>
<div class="value">
<button
:class="{ primary: settings.gameView === 'cover'}"
@click="setGameView('cover')"
>
<i class="fas fa-portrait" />
Game cover only
</button>
<button
:class="{ primary: settings.gameView === 'detailed' || !settings.gameView}"
@click="setGameView('detailed')"
>
<i class="far fa-id-card" />
Detailed view
</button>
</div>
</section> -->
<panel class="positive">
<p>
<i class="fas fa-comments" />
Have ideas, requests, feedback?
</p>
<a class="link accent small" href="https://goo.gl/forms/r0juBCsZaUtJ03qb2">
Submit feedback
</a>
</panel>
<panel class="info">
<p>
<i class="fas fa-bug" />
Found a bug?
</p>
<a class="link accent small" href="https://github.com/romancmx/switchlist/issues">
Report it in GitHub
</a>
</panel>
</main>
</div>
</template>
<script>
import { debounce } from 'lodash';
import { mapState } from 'vuex';
import Gravatar from 'vue-gravatar';
import Panel from '@/components/Panel/Panel';
import toasts from '@/mixins/toasts';
import moment from 'moment';
export default {
components: {
Panel,
Gravatar,
},
mixins: [toasts],
data() {
return {
settings: {},
};
},
computed: {
...mapState(['user']),
dateJoined() {
return moment(this.user.dateJoined).format('LL');
},
shareUrl() {
const url = process.env.NODE_ENV === 'development'
? 'http://localhost:3000'
: 'https://myswitchlist.com';
// eslint-disable-next-line
return `${url}/#/share/${this.user._id}`;
},
},
watch: {
settings: {
handler(oldValue, newValue) {
if (Object.keys(newValue).length) {
this.save();
}
},
deep: true,
},
},
mounted() {
this.settings = this.user.settings
? JSON.parse(JSON.stringify(this.user.settings))
: {};
},
methods: {
setGameView(view) {
this.settings.gameView = view;
this.save();
},
promptDelete() {
this.$swal({
title: 'Are you sure?',
text: 'Your account data will be deleted forever.',
// type: 'warning',
showCancelButton: true,
confirmButtonClass: 'error',
cancelButtonClass: 'accent',
buttonsStyling: false,
confirmButtonText: 'Yes, delete forever!',
}).then(({ value }) => {
if (value) {
this.deleteAccount();
}
});
},
logout() {
this.$store.commit('CLEAR_SESSION');
this.$router.push({ name: 'home' });
},
deleteAccount() {
this.$store.dispatch('DELETE_USER')
.then(() => {
this.$swal({
position: 'bottom-end',
title: 'Account deleted',
type: 'success',
toast: true,
showConfirmButton: false,
timer: 1500,
});
this.logout();
});
},
save: debounce(
// eslint-disable-next-line
function() {
this.$store.dispatch('UPDATE_SETTINGS', this.settings)
.then(() => {
this.$success('Settings saved');
})
.catch(() => {
this.$error('There was an error saving your settings');
});
}, 500),
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
.settings {
background: $color-white;
display: grid;
grid-template-columns: 1fr 3fr;
min-height: calc(100vh - #{$navHeight});
@media($small) {
grid-template-columns: auto;
}
aside {
padding: $gp;
background: $color-light-gray;
}
main {
section {
color: $color-dark-gray;
border-bottom: 1px solid $color-light-gray;
padding: $gp * 2 $gp;
display: flex;
align-items: center;
@media($small) {
padding: $gp;
}
&.active {
color: $color-green;
}
h3 {
margin: 0 $gp;
}
.value {
display: flex;
margin-left: auto;
}
}
}
.panel {
width: 46%;
float: left;
@media ($small) {
width: 90%;
}
}
.share-link {
max-width: 340px;
margin: 0;
}
}
</style>

View file

@ -0,0 +1,136 @@
<template lang="html">
<div class="public-game-board">
<span v-if="loading">Loading...</span>
<router-link
tag="button"
class="small primary back"
:to="{ name: 'admin' }"
v-if="user && user.admin && !loading"
>
<i class="fas fa-chevron-left" />
Back
</router-link>
<div class="lists" v-if="!loading && games">
<div class="list" v-for="list in lists" :key="list">
<div class="list-header">
{{ list.name }} ({{ list.games.length }})
</div>
<div class="empty-list" v-if="!list.games.length">
<img src="/static/img/empty-collection.png">
<h3>This collection is empty</h3>
</div>
<div class="games" v-else>
<img
v-for="game in list.games" :key="game"
:src="getGameCover(game)"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import Panel from '@/components/Panel/Panel';
import toasts from '@/mixins/toasts';
import { mapState } from 'vuex';
export default {
components: {
Panel,
},
mixins: [toasts],
data() {
return {
loading: true,
lists: null,
games: {},
};
},
computed: {
...mapState(['user']),
},
mounted() {
this.load();
},
methods: {
load() {
const listId = this.$route.params.id;
if (listId) {
this.$store.dispatch('LOAD_SHARE_LIST', listId)
.then((lists) => {
this.lists = lists;
this.loadGameData();
})
.catch(() => {
this.loading = false;
this.$error('Error loading list');
});
}
},
getGameCover(id) {
const url = 'https://images.igdb.com/igdb/image/upload/t_cover_small/';
return this.games && this.games[id].cover
? `${url}${this.games[id].cover.cloudinary_id}.jpg`
: '/static/no-image.jpg';
},
loadGameData() {
// TODO: Refactor this mess
const gameList = [];
this.lists.forEach((list) => {
if (list && list.games.length) {
list.games.forEach((id) => {
if (!gameList.includes(id)) {
gameList.push(id);
}
});
}
});
if (gameList.length > 0) {
this.$store.dispatch('LOAD_SHARE_GAMES', gameList)
.then((data) => {
this.loading = false;
data.forEach((game) => {
this.games[game.id] = { ...game };
});
});
} else {
this.loading = false;
}
},
},
};
</script>
<style lang="scss" rel="stylesheet/scss" scoped>
@import "~styles/styles.scss";
@import "~styles/game-board.scss";
.back {
margin: $gp $gp 0;
}
.games {
column-count: 3;
img {
padding: $gp / 4;
}
}
</style>

282
src/platforms.js Executable file
View file

@ -0,0 +1,282 @@
export default {
xboxone: {
name: 'Xbox One',
code: 'Xbox One',
id: 0,
},
nsw: {
name: 'Nintendo Switch',
code: 'Nintendo Switch',
id: 130,
},
nes: {
name: 'NES',
code: 'NES',
id: 0,
},
ps4: {
name: 'PlayStation 4',
code: 'PlayStation 4',
id: 0,
},
mac: {
name: 'Mac',
code: 'Mac',
id: 0,
},
win: {
name: 'PC',
code: 'PC',
id: 0,
},
steam: {
name: 'SteamOS',
code: 'SteamOS',
id: 0,
},
ps3: {
name: 'PlayStation 3',
code: 'PlayStation 3',
id: 0,
},
psvita: {
name: 'PlayStation Vita',
code: 'PlayStation Vita',
id: 0,
},
wiiu: {
name: 'Wii U',
code: 'Wii U',
id: 0,
},
n3ds: {
name: 'Nintendo 3DS',
code: 'Nintendo 3DS',
id: 0,
},
nds: {
name: 'Nintendo DS',
code: 'Nintendo DS',
id: 0,
},
xbox360: {
name: 'Xbox 360',
code: 'Xbox 360',
id: 0,
},
wii: {
name: 'Wii',
code: 'Wii',
id: 0,
},
psp: {
name: 'PSP',
code: 'PSP',
id: 0,
},
ps2: {
name: 'PlayStation 2',
code: 'PlayStation 2',
id: 0,
},
ngage: {
name: 'N-Gage',
code: 'N-Gage',
id: 0,
},
gba: {
name: 'Game Boy Advance',
code: 'Game Boy Advance',
id: 0,
},
n64: {
name: 'Nintendo 64',
code: 'Nintendo 64',
id: 0,
},
ngc: {
name: 'Nintendo GameCube',
code: 'Nintendo GameCube',
id: 0,
},
xbox: {
name: 'Xbox',
code: 'Xbox',
id: 0,
},
neogeopocketcolor: {
name: 'Neo Geo Pocket Color',
code: 'Neo Geo Pocket Color',
id: 0,
},
neogeopocket: {
name: 'Neo Geo Pocket',
code: 'Neo Geo Pocket',
id: 0,
},
dc: {
name: 'Dreamcast',
code: 'Dreamcast',
id: 0,
},
gbc: {
name: 'Game Boy Color',
code: 'Game Boy Color',
id: 0,
},
snes: {
name: 'SNES',
code: 'SNES',
id: 0,
},
gb: {
name: 'Game Boy',
code: 'Game Boy',
id: 0,
},
saturn: {
name: 'Sega Saturn',
code: 'Sega Saturn',
id: 0,
},
ps: {
name: 'PlayStation',
code: 'PlayStation',
id: 0,
},
sega32: {
name: 'Sega 32X',
code: 'Sega 32X',
id: 0,
},
virtualboy: {
name: 'Virtual Boy',
code: 'Virtual Boy',
id: 0,
},
neogeocd: {
name: 'Neo Geo CD',
code: 'Neo Geo CD',
id: 0,
},
p3do: {
name: '3DO',
code: '3DO',
id: 0,
},
jaguar: {
name: 'Atari Jaguar',
code: 'Atari Jaguar',
id: 0,
},
segacd: {
name: 'Sega CD',
code: 'Sega CD',
id: 0,
},
amiga: {
name: 'Amiga',
code: 'Amiga',
id: 0,
},
gamegear: {
name: 'Sega Game Gear',
code: 'Sega Game Gear',
id: 0,
},
philipscdi: {
name: 'Philips CD-i',
code: 'Philips CD-i',
id: 0,
},
lynx: {
name: 'Atari Lynx',
code: 'Atari Lynx',
id: 0,
},
turbografx: {
name: 'Turbografx-16/PC Engine CD',
code: 'Turbografx-16/PC Engine CD',
id: 0,
},
smd: {
name: 'Sega Genesis',
code: 'Sega Genesis',
id: 0,
},
sms: {
name: 'Sega Master System',
code: 'Sega Master System',
id: 0,
},
atari5200: {
name: 'Atari 5200',
code: 'Atari 5200',
id: 0,
},
atari7800: {
name: 'Atari 7800',
code: 'Atari 7800',
id: 0,
},
c64: {
name: 'Commodore C64/128',
code: 'Commodore C64/128',
id: 0,
},
atari2600: {
name: 'Atari 2600',
code: 'Atari 2600',
id: 0,
},
vectrex: {
name: 'Vectrex',
code: 'Vectrex',
id: 0,
},
msx: {
name: 'MSX',
code: 'MSX',
id: 0,
},
msx2: {
name: 'MSX2',
code: 'MSX2',
id: 0,
},
colecovision: {
name: 'ColecoVision',
code: 'ColecoVision',
id: 0,
},
intellivision: {
name: 'Intellivision',
code: 'Intellivision',
id: 0,
},
zxs: {
name: 'ZX Spectrum',
code: 'ZX Spectrum',
id: 0,
},
dos: {
name: 'PC DOS',
code: 'PC DOS',
id: 0,
},
atari8bit: {
name: 'Atari 8-bit',
code: 'Atari 8-bit',
id: 0,
},
microvision: {
name: 'Microvision',
code: 'Microvision',
id: 0,
},
appleii: {
name: 'Apple II',
code: 'Apple II',
id: 0,
},
};

58
src/router/index.js Executable file
View file

@ -0,0 +1,58 @@
import Vue from 'vue';
import Router from 'vue-router';
import Home from '@/pages/Home/Home';
import ShareList from '@/pages/ShareList/ShareList';
import SessionExpired from '@/pages/SessionExpired/SessionExpired';
import Login from '@/pages/Login/Login';
import GameDetail from '@/pages/GameDetail/GameDetail';
import Settings from '@/pages/Settings/Settings';
import Admin from '@/pages/Admin/Admin';
import Register from '@/pages/Register/Register';
Vue.use(Router);
export default new Router({
routes: [
{
path: '/share/:id',
name: 'share',
component: ShareList,
},
{
name: 'game-detail',
path: '/g/:id/:slug',
component: GameDetail,
},
{
name: 'settings',
path: '/settings',
component: Settings,
},
{
path: '/session-expired',
name: 'sessionExpired',
component: SessionExpired,
},
{
path: '/login',
name: 'login',
component: Login,
},
{
path: '/register',
name: 'register',
component: Register,
},
{
path: '/admin',
name: 'admin',
component: Admin,
},
{
path: '/',
name: 'home',
component: Home,
},
{ path: '*', component: Home },
],
});

131
src/store/actions.js Executable file
View file

@ -0,0 +1,131 @@
import axios from 'axios';
const API_URL = process.env.API_URL;
export default {
REGISTER({ commit }, payload) {
return new Promise((resolve, reject) => {
axios.post(`${API_URL}/auth/register`, payload)
.then(({ data }) => {
commit('SET_SESSION', data);
resolve(data);
}).catch(reject);
});
},
LOGIN({ commit }, payload) {
return new Promise((resolve, reject) => {
axios.post(`${API_URL}/auth/login`, payload)
.then(({ data }) => {
commit('SET_SESSION', data);
commit('SET_UPDATED_TIMESTAMP');
resolve(data);
}).catch(reject);
});
},
UPDATE_SETTINGS({ commit, state: { token } }, payload) {
return new Promise((resolve, reject) => {
const options = { headers: { token } };
axios.put(`${API_URL}/settings`, payload, options)
.then(({ data: { settings } }) => {
commit('SET_SETTINGS', settings);
resolve();
}).catch(reject);
});
},
UPDATE_LISTS({ commit, state: { user, token } }) {
return new Promise((resolve, reject) => {
const options = { headers: { token } };
const payload = {
lists: user.lists,
};
axios.put(`${API_URL}/lists`, payload, options)
.then(() => {
commit('SET_UPDATED_TIMESTAMP');
resolve();
}).catch(reject);
});
},
LOAD_LISTS({ commit, state: { token } }) {
return new Promise((resolve, reject) => {
const options = { headers: { token } };
axios.get(`${API_URL}/lists`, options)
.then(({ data: { lists } }) => {
commit('UPDATE_LIST', lists);
commit('SET_UPDATED_TIMESTAMP');
resolve();
}).catch(reject);
});
},
LOAD_USERS({ commit, state: { token } }) {
commit('CLEAR_ADMIN_DATA');
return new Promise((resolve, reject) => {
const options = { headers: { token } };
axios.get(`${API_URL}/users`, options)
.then(({ data }) => {
commit('SET_ADMIN_DATA', data.reverse());
resolve();
}).catch(reject);
});
},
LOAD_SHARE_LIST(context, listId) {
return new Promise((resolve, reject) => {
axios.get(`${API_URL}/share/${listId}`)
.then(({ data: { lists } }) => {
resolve(lists);
}).catch(reject);
});
},
LOAD_SHARE_GAMES(context, gameList) {
return new Promise((resolve, reject) => {
axios.get(`${API_URL}/games?games=${gameList.join(',')}`)
.then(({ data }) => {
resolve(data);
}).catch(reject);
});
},
LOAD_GAMES({ commit, state: { token } }, gameList) {
return new Promise((resolve, reject) => {
const options = { headers: { token } };
axios.get(`${API_URL}/games?games=${gameList.join(',')}`, options)
.then(({ data }) => {
commit('CACHE_GAME_DATA', data);
resolve();
}).catch(reject);
});
},
SEARCH({ commit, state: { token } }, searchText) {
return new Promise((resolve, reject) => {
const options = { headers: { token } };
axios.get(`${API_URL}/search?searchText=${searchText}&order=popularity:desc`, options)
.then(({ data }) => {
commit('SET_SEARCH_RESULTS', data);
commit('CACHE_GAME_DATA', data);
resolve();
}).catch(reject);
});
},
DELETE_USER({ state: { token } }) {
return new Promise((resolve, reject) => {
axios.delete(`${API_URL}/user?token=${token}`)
.then(() => {
resolve();
}).catch(reject);
});
},
};

54
src/store/getters.js Executable file
View file

@ -0,0 +1,54 @@
import moment from 'moment';
export default {
auth: state => Boolean(state.token && state.user),
developers: (state) => {
const developers = state.game.developers;
return developers
? developers.map(developer => developer.name).join(', ')
: null;
},
publishers: (state) => {
const publishers = state.game.publishers;
return publishers
? publishers.map(publisher => publisher.name).join(', ')
: null;
},
genres: (state) => {
const genres = state.game.genres;
return genres
? genres.map(genre => genre.name).join(', ')
: null;
},
playerPerspectives: (state) => {
const perspectives = state.game.player_perspectives;
return perspectives
? perspectives.map(perspective => perspective.name).join(', ')
: null;
},
gameModes: (state) => {
const gameModes = state.game.game_modes;
return gameModes
? gameModes.map(gameMode => gameMode.name).join(', ')
: null;
},
gamePlatforms: (state) => {
const gamePlatforms = state.game.platforms;
return gamePlatforms
? gamePlatforms.map(gamePlatform => gamePlatform.name).join(', ')
: null;
},
releaseDate: state => moment(new Date(state.game.release_dates.find(r => r.platform === state.platforms.nsw)).human).format('LL'),
};

23
src/store/index.js Executable file
View file

@ -0,0 +1,23 @@
/* eslint no-shadow: ["error", { "allow": ["state"] }] */
import Vue from 'vue';
import Vuex from 'vuex';
import VuexPersist from 'vuex-persist';
import state from './state';
import actions from './actions';
import mutations from './mutations';
import getters from './getters';
Vue.use(Vuex);
const vuexLocalStorage = new VuexPersist({
key: 'vuex',
storage: window.localStorage,
});
export default new Vuex.Store({
state,
actions,
mutations,
getters,
plugins: [vuexLocalStorage.plugin],
});

113
src/store/mutations.js Executable file
View file

@ -0,0 +1,113 @@
import moment from 'moment';
export default {
SET_SESSION(state, { user, token }) {
state.token = token;
state.user = user;
},
SET_SEARCH_RESULTS(state, results) {
state.results = results;
},
CLEAR_SEARCH_RESULTS(state) {
state.results = [];
},
SET_ACTIVE_GAME(state, game) {
state.game = state.games[game];
},
SET_EDIT_GAME(state, { listId, gameId }) {
state.editGame = gameId;
state.activeList = listId;
},
SET_ACTIVE_LIST(state, listIndex) {
state.activeList = listIndex;
},
SET_ADMIN_DATA(state, data) {
state.adminData = data;
},
CLEAR_ADMIN_DATA(state) {
state.adminData = null;
},
SET_PLATFORM(state, platform) {
state.platform = platform;
},
CLEAR_ACTIVE_GAME(state) {
state.game = null;
},
UPDATE_USER(state, user) {
state.user = user;
},
UPDATE_LIST(state, lists) {
state.user.lists = lists;
},
SORT_LIST(state, listIndex) {
const games = state.user.lists[listIndex].games;
games.sort((a, b) => {
const gameA = state.games[a].name.toUpperCase();
const gameB = state.games[b].name.toUpperCase();
if (gameA < gameB) {
return -1;
}
return gameA > gameB ? 1 : 0;
});
},
UPDATE_LIST_NAME(state, { listIndex, listName }) {
state.user.lists[listIndex].name = listName;
},
SET_UPDATED_TIMESTAMP(state) {
state.dataUpdatedTimestamp = moment().format();
},
SET_SETTINGS(state, settings) {
state.user.settings = settings;
},
REMOVE_LIST(state, index) {
state.user.lists.splice(index, 1);
},
ADD_GAME(state, { gameId, listId }) {
state.user.lists[listId].games.push(gameId);
},
ADD_LIST(state, listName) {
const newList = {
games: [],
name: listName,
};
state.user.lists.push(newList);
},
REMOVE_GAME(state, { gameId, listId }) {
state.user.lists[listId].games.splice(state.user.lists[listId].games.indexOf(gameId), 1);
},
CACHE_GAME_DATA(state, data) {
data.forEach((game) => {
state.games[game.id] = { ...game };
});
},
CLEAR_SESSION(state) {
state.token = null;
state.user = null;
state.platform = null;
},
};

52
src/store/state.js Executable file
View file

@ -0,0 +1,52 @@
export default {
token: null,
user: null,
activeList: null,
platform: null,
adminData: null,
dataUpdatedTimestamp: null,
results: null,
games: {},
game: null,
platforms: {
nsw: 130,
},
gameCategories: {
0: 'Main game',
1: 'DLC / Addon',
2: 'Expansion',
3: 'Bundle',
4: 'Standalone expansion',
},
pegi: {
1: '3',
2: '7',
3: '12',
4: '16',
5: '18',
},
esrb: {
1: 'RP',
2: 'EC',
3: 'E',
4: 'E10+',
5: 'T',
6: 'M',
7: 'AO',
},
linkTypes: {
1: 'official',
2: 'wikia',
3: 'wikipedia',
4: 'facebook',
5: 'twitter',
6: 'twitch',
8: 'instagram',
9: 'youtube',
10: 'iphone',
11: 'ipad',
12: 'android',
13: 'steam',
14: 'Reddit',
},
};

View file

@ -0,0 +1,79 @@
.lists {
display: flex;
align-items: flex-start;
height: calc(100vh - 48px);
padding: $gp;
box-sizing: border-box;
background: $color-gray;
overflow-x: auto;
overflow-x: overlay;
display: flex;
@include drag-cursor;
&.drag-scroll-active {
@include dragging-cursor;
}
&.empty {
background: $color-white;
}
}
.list {
flex-shrink: 0;
cursor: default;
background: $color-white;
position: relative;
width: 300px;
border-radius: $border-radius;
overflow: hidden;
margin-right: $gp;
max-height: calc(100vh - 81px);
}
.list-header {
align-items: center;
background: $color-dark-gray;
color: $color-white;
display: flex;
height: 30px;
justify-content: space-between;
padding: 0 $gp / 2;
position: absolute;
width: 100%;
.list-actions {
.list-drag-handle {
@include drag-cursor;
}
}
}
.list-placeholder {
opacity: 0.25;
}
.games {
height: 100%;
overflow: hidden;
min-height: 100px;
max-height: calc(100vh - 120px);
overflow-y: auto;
overflow-y: overlay;
column-gap: $gp;
background: $color-light-gray;
margin-top: 30px;
padding: $gp / 2 $gp / 2 0;
width: 100%;
}
.card-placeholder {
opacity: 0.25;
}
.empty-list {
display: flex;
align-items: center;
flex-direction: column;
padding: $gp;
}

View file

@ -0,0 +1,2 @@
@import "_colors";
@import "_utility";

View file

@ -0,0 +1,9 @@
$color-gray: #a5a2a2;
$color-red: #dd2020;
$color-orange: #ffae42;
$color-white: #fff;
$color-green: #1dbc60;
$color-light-green: #7CB490;
$color-blue: #00a8f0;
$color-dark-gray: #555555;
$color-light-gray: #e5e5e5;

View file

@ -0,0 +1,35 @@
@mixin container-xs {
width: 320px;
max-height: 100%;
margin: 60px auto;
background: $color-white;
padding: $gp;
border-radius: $border-radius;
}
@mixin container {
width: 1200px;
max-width: 100%;
margin: 100px auto;
}
@mixin drag-cursor {
cursor : -webkit-grab;
cursor : -moz-grab;
cursor : -o-grab;
cursor : grab;
}
@mixin dragging-cursor {
cursor : -webkit-grabbing;
cursor : -moz-grabbing;
cursor : -o-grabbing;
cursor : grabbing;
}
@mixin floating-close-button {
position: absolute;
top: 0;
right: 0;
margin: $gp / 2;
}

View file

@ -0,0 +1,4 @@
@import "_reset";
@import "_measurements";
@import "_buttons";
@import "_inputs";

View file

@ -0,0 +1,105 @@
$iconSmallSize: 30px;
a.link {
display: inline-block;
line-height: 30px;
text-decoration: none;
&.small {
line-height: $iconSmallSize;
}
}
button, a.link {
border: none;
margin: 0;
padding: 0 $gp;
width: auto;
overflow: visible;
background: transparent;
color: inherit;
font: inherit;
border-radius: $border-radius;
line-height: normal;
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
-webkit-appearance: none;
height: 40px;
border: none;
cursor: pointer;
font-weight: bold;
transition: background-color 300ms ease;
&.small {
height: $iconSmallSize;
min-width: $iconSmallSize;
padding: 0 $gp / 2;
}
&.primary {
background: $color-blue;
color: $color-white;
&:hover { background-color: lighten($color-blue, 10%); }
&:active { background-color: darken($color-blue, 10%); }
}
&.accent {
background: $color-light-gray;
color: $color-dark-gray;
&:hover { background-color: lighten($color-light-gray, 10%); }
&:active { background-color: darken($color-light-gray, 10%); }
}
&.info {
background: $color-dark-gray;
color: $color-white;
&:hover { background-color: lighten($color-dark-gray, 10%); }
&:active { background-color: darken($color-dark-gray, 10%); }
}
&.error {
background: $color-red;
color: $color-white;
&:hover { background-color: lighten($color-red, 10%); }
&:active { background-color: darken($color-red, 10%); }
}
&.warning {
background: $color-orange;
color: $color-white;
&:hover { background-color: lighten($color-orange, 10%); }
&:active { background-color: darken($color-orange, 10%); }
}
&.hollow {
&.primary {
color: $color-blue;
background: $color-white;
}
&.accent {
color: $color-gray;
background: transparent;
}
&.info {
color: $color-dark-gray;
background: $color-white;
}
&.error {
color: $color-red;
background: $color-white;
}
&.warning {
color: $color-orange;
background: $color-white;
}
}
}

View file

@ -0,0 +1,54 @@
input {
background: $color-white;
border: 1px solid $color-dark-gray;
border-radius: $border-radius;
height: 30px;
padding: 0 $gp / 2;
width: 100%;
margin-bottom: $gp;
&.small {
height: 20px;
margin: 0;
}
}
.toggle-switch {
input[type=checkbox]{
height: 0;
width: 0;
visibility: hidden;
&:checked + label {
background: $color-green;
}
&:checked + label:after {
left: calc(100% - 3px);
transform: translateX(-100%);
}
}
label {
cursor: pointer;
text-indent: -9999px;
width: 34px;
height: 20px;
background: grey;
display: block;
border-radius: 100px;
position: relative;
&:after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 90px;
transition: 0.3s;
}
}
}

View file

@ -0,0 +1,7 @@
$border-radius: 4px;
$gp: 16px;
$navHeight: 48px;
// Media queries
$small: "max-width: 780px";
$medium: "max-width: 1024px";

View file

@ -0,0 +1,7 @@
* {
box-sizing: border-box;
}
.fast-spin {
animation: a 500ms infinite linear;
}

View file

@ -0,0 +1,3 @@
// Typography
$font-weight-normal: 400;
$font-weight-bold: 700;

2
src/styles/styles.scss Executable file
View file

@ -0,0 +1,2 @@
@import "modules/all";
@import "partials/base";

0
static/.gitkeep Executable file
View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

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