├── .babelrc ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .firebaserc ├── .gitignore ├── .postcssrc.js ├── README.md ├── build ├── build.js ├── check-versions.js ├── dev-client.js ├── dev-server.js ├── utils.js ├── vue-loader.conf.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── config ├── dev.env.js ├── index.js └── prod.env.js ├── database.rules.json ├── firebase.json ├── index.html ├── package.json ├── src ├── App.vue ├── assets │ ├── css │ │ └── style.css │ ├── img │ │ ├── arrow-profile.svg │ │ └── vueschool-logo.svg │ └── logo.png ├── components │ ├── AppDate.vue │ ├── AppSpinner.vue │ ├── CategoryList.vue │ ├── CategoryListItem.vue │ ├── ForumList.vue │ ├── ForumListItem.vue │ ├── PostEditor.vue │ ├── PostList.vue │ ├── PostListItem.vue │ ├── TheNavbar.vue │ ├── ThreadEditor.vue │ ├── ThreadList.vue │ ├── ThreadListItem.vue │ ├── UserProfileCard.vue │ └── UserProfileCardEditor.vue ├── data.json ├── directives │ ├── click-outside.js │ └── handle-scroll.js ├── main.js ├── mixins │ └── asyncDataStatus.js ├── pages │ ├── PageCategory.vue │ ├── PageForum.vue │ ├── PageHome.vue │ ├── PageNotFound.vue │ ├── PageProfile.vue │ ├── PageRegister.vue │ ├── PageSignIn.vue │ ├── PageThreadCreate.vue │ ├── PageThreadEdit.vue │ └── PageThreadShow.vue ├── router │ └── index.js ├── store │ ├── actions.js │ ├── assetHelpers.js │ ├── getters.js │ ├── index.js │ ├── modules │ │ ├── auth.js │ │ ├── categories.js │ │ ├── forums.js │ │ ├── posts.js │ │ ├── threads.js │ │ └── users.js │ └── mutations.js └── utils │ ├── index.js │ └── validators.js ├── static └── .gitkeep └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }], 9 | "stage-2" 10 | ], 11 | "plugins": ["transform-runtime"], 12 | "env": { 13 | "test": { 14 | "presets": ["env", "stage-2"], 15 | "plugins": ["istanbul"] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | FIREBASE_API_KEY=AIzaSyC_WLhTuqbKU3unweXosx8zsKQccBOKs7c 2 | FIREBASE_AUTH_DOMAIN=vue-school-forum.firebaseapp.com 3 | FIREBASE_DATABASE_URL=https://vue-school-forum.firebaseio.com 4 | FIREBASE_PROJECT_ID=vue-school-forum 5 | FIREBASE_STORAGE_BUCKET=vue-school-forum.appspot.com 6 | FIREBASE_MESSAGING_ID=426987850952 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'babel-eslint', 6 | parserOptions: { 7 | sourceType: 'module' 8 | }, 9 | env: { 10 | browser: true, 11 | }, 12 | // https://github.com/standard/standard/blob/master/docs/RULES-en.md 13 | extends: 'standard', 14 | // required to lint *.vue files 15 | plugins: [ 16 | 'html' 17 | ], 18 | // add your custom rules here 19 | 'rules': { 20 | // allow paren-less arrow functions 21 | 'arrow-parens': 0, 22 | // allow async-await 23 | 'generator-star-spacing': 0, 24 | // allow debugger during development 25 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 26 | "no-unused-vars": process.env.NODE_ENV === 'production' ? 2 : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "vue-school-forum" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | .env 9 | 10 | # Editor directories and files 11 | .idea 12 | .vscode 13 | *.suo 14 | *.ntvs* 15 | *.njsproj 16 | *.sln 17 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | // to edit target browsers: use "browserslist" field in package.json 6 | "autoprefixer": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-master-class 2 | 3 | This repository contains the source code of [The Vue.js Master Class](https://vueschool.io). 4 | 5 | ## The Vue.js Master Class 6 | 7 | The goal of the Master Class is to teach you Vue.js along with Best Practices, Modern Javascript, and other exciting technologies, by building a Real World application - a forum. 8 | 9 | ## We cover the fundamentals, like 10 | 11 | - Vue cli, router and State management with Vuex 12 | - Modern Javascript (ES6/7/8) 13 | - User permissions & protected routes 14 | - Third party authentication 15 | - Firebase Realtime Database & Cloud functions 16 | - Automatic code review with ESLint 17 | - Deployment 18 | - Application architecture and best practices 19 | 20 | ## We also dive into harder topics, like: 21 | 22 | - Higher Order Functions 23 | - Creating Vue Plugins 24 | - Code Splitting 25 | - Support for older Browsers 26 | - Webpack configuration 27 | - SEO and pre-rendering 28 | - Reactive programming with RxJS 29 | 30 | By completing the Vue.js Master Class, you will be able to land any Vue related job or optimize/improve your own projects! 31 | 32 | Convinced? [Enroll now](https://vueschool.io/the-vuejs-master-class) 33 | 34 | 35 | 36 | ## Build Setup 37 | 38 | ``` bash 39 | # install dependencies 40 | yarn 41 | 42 | # serve with hot reload at localhost:8080 43 | yarn dev 44 | 45 | # build for production with minification 46 | yarn build 47 | 48 | # build for production and view the bundle analyzer report 49 | npm run build --report 50 | ``` 51 | 52 | For a detailed explanation on how things work, check out the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader). 53 | -------------------------------------------------------------------------------- /build/build.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('./check-versions')() 3 | 4 | process.env.NODE_ENV = 'production' 5 | 6 | const ora = require('ora') 7 | const rm = require('rimraf') 8 | const path = require('path') 9 | const chalk = require('chalk') 10 | const webpack = require('webpack') 11 | const config = require('../config') 12 | const webpackConfig = require('./webpack.prod.conf') 13 | 14 | const spinner = ora('building for production...') 15 | spinner.start() 16 | 17 | rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { 18 | if (err) throw err 19 | webpack(webpackConfig, function (err, stats) { 20 | spinner.stop() 21 | if (err) throw err 22 | process.stdout.write(stats.toString({ 23 | colors: true, 24 | modules: false, 25 | children: false, 26 | chunks: false, 27 | chunkModules: false 28 | }) + '\n\n') 29 | 30 | if (stats.hasErrors()) { 31 | console.log(chalk.red(' Build failed with errors.\n')) 32 | process.exit(1) 33 | } 34 | 35 | console.log(chalk.cyan(' Build complete.\n')) 36 | console.log(chalk.yellow( 37 | ' Tip: built files are meant to be served over an HTTP server.\n' + 38 | ' Opening index.html over file:// won\'t work.\n' 39 | )) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /build/check-versions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const chalk = require('chalk') 3 | const semver = require('semver') 4 | const packageConfig = require('../package.json') 5 | const shell = require('shelljs') 6 | function exec (cmd) { 7 | return require('child_process').execSync(cmd).toString().trim() 8 | } 9 | 10 | const versionRequirements = [ 11 | { 12 | name: 'node', 13 | currentVersion: semver.clean(process.version), 14 | versionRequirement: packageConfig.engines.node 15 | } 16 | ] 17 | 18 | if (shell.which('npm')) { 19 | versionRequirements.push({ 20 | name: 'npm', 21 | currentVersion: exec('npm --version'), 22 | versionRequirement: packageConfig.engines.npm 23 | }) 24 | } 25 | 26 | module.exports = function () { 27 | const warnings = [] 28 | for (let i = 0; i < versionRequirements.length; i++) { 29 | const mod = versionRequirements[i] 30 | if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { 31 | warnings.push(mod.name + ': ' + 32 | chalk.red(mod.currentVersion) + ' should be ' + 33 | chalk.green(mod.versionRequirement) 34 | ) 35 | } 36 | } 37 | 38 | if (warnings.length) { 39 | console.log('') 40 | console.log(chalk.yellow('To use this template, you must update following to modules:')) 41 | console.log() 42 | for (let i = 0; i < warnings.length; i++) { 43 | const warning = warnings[i] 44 | console.log(' ' + warning) 45 | } 46 | console.log() 47 | process.exit(1) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /build/dev-client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 'use strict' 3 | require('eventsource-polyfill') 4 | var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 5 | 6 | hotClient.subscribe(function (event) { 7 | if (event.action === 'reload') { 8 | window.location.reload() 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /build/dev-server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('./check-versions')() 3 | 4 | const config = require('../config') 5 | if (!process.env.NODE_ENV) { 6 | process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV) 7 | } 8 | 9 | const opn = require('opn') 10 | const path = require('path') 11 | const express = require('express') 12 | const webpack = require('webpack') 13 | const proxyMiddleware = require('http-proxy-middleware') 14 | const webpackConfig = require('./webpack.dev.conf') 15 | 16 | // default port where dev server listens for incoming traffic 17 | const port = process.env.PORT || config.dev.port 18 | // automatically open browser, if not set will be false 19 | const autoOpenBrowser = !!config.dev.autoOpenBrowser 20 | // Define HTTP proxies to your custom API backend 21 | // https://github.com/chimurai/http-proxy-middleware 22 | const proxyTable = config.dev.proxyTable 23 | 24 | const app = express() 25 | const compiler = webpack(webpackConfig) 26 | 27 | const devMiddleware = require('webpack-dev-middleware')(compiler, { 28 | publicPath: webpackConfig.output.publicPath, 29 | quiet: true 30 | }) 31 | 32 | const hotMiddleware = require('webpack-hot-middleware')(compiler, { 33 | log: false, 34 | heartbeat: 2000 35 | }) 36 | // force page reload when html-webpack-plugin template changes 37 | // currently disabled until this is resolved: 38 | // https://github.com/jantimon/html-webpack-plugin/issues/680 39 | // compiler.plugin('compilation', function (compilation) { 40 | // compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { 41 | // hotMiddleware.publish({ action: 'reload' }) 42 | // cb() 43 | // }) 44 | // }) 45 | 46 | // enable hot-reload and state-preserving 47 | // compilation error display 48 | app.use(hotMiddleware) 49 | 50 | // proxy api requests 51 | Object.keys(proxyTable).forEach(function (context) { 52 | let options = proxyTable[context] 53 | if (typeof options === 'string') { 54 | options = { target: options } 55 | } 56 | app.use(proxyMiddleware(options.filter || context, options)) 57 | }) 58 | 59 | // handle fallback for HTML5 history API 60 | app.use(require('connect-history-api-fallback')()) 61 | 62 | // serve webpack bundle output 63 | app.use(devMiddleware) 64 | 65 | // serve pure static assets 66 | const staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) 67 | app.use(staticPath, express.static('./static')) 68 | 69 | const uri = 'http://localhost:' + port 70 | 71 | var _resolve 72 | var _reject 73 | var readyPromise = new Promise((resolve, reject) => { 74 | _resolve = resolve 75 | _reject = reject 76 | }) 77 | 78 | var server 79 | var portfinder = require('portfinder') 80 | portfinder.basePort = port 81 | 82 | console.log('> Starting dev server...') 83 | devMiddleware.waitUntilValid(() => { 84 | portfinder.getPort((err, port) => { 85 | if (err) { 86 | _reject(err) 87 | } 88 | process.env.PORT = port 89 | var uri = 'http://localhost:' + port 90 | console.log('> Listening at ' + uri + '\n') 91 | // when env is testing, don't need open it 92 | if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') { 93 | opn(uri) 94 | } 95 | server = app.listen(port) 96 | _resolve() 97 | }) 98 | }) 99 | 100 | module.exports = { 101 | ready: readyPromise, 102 | close: () => { 103 | server.close() 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /build/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const config = require('../config') 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 5 | 6 | exports.assetsPath = function (_path) { 7 | const assetsSubDirectory = process.env.NODE_ENV === 'production' 8 | ? config.build.assetsSubDirectory 9 | : config.dev.assetsSubDirectory 10 | return path.posix.join(assetsSubDirectory, _path) 11 | } 12 | 13 | exports.cssLoaders = function (options) { 14 | options = options || {} 15 | 16 | const cssLoader = { 17 | loader: 'css-loader', 18 | options: { 19 | minimize: process.env.NODE_ENV === 'production', 20 | sourceMap: options.sourceMap 21 | } 22 | } 23 | 24 | // generate loader string to be used with extract text plugin 25 | function generateLoaders (loader, loaderOptions) { 26 | const loaders = [cssLoader] 27 | if (loader) { 28 | loaders.push({ 29 | loader: loader + '-loader', 30 | options: Object.assign({}, loaderOptions, { 31 | sourceMap: options.sourceMap 32 | }) 33 | }) 34 | } 35 | 36 | // Extract CSS when that option is specified 37 | // (which is the case during production build) 38 | if (options.extract) { 39 | return ExtractTextPlugin.extract({ 40 | use: loaders, 41 | fallback: 'vue-style-loader' 42 | }) 43 | } else { 44 | return ['vue-style-loader'].concat(loaders) 45 | } 46 | } 47 | 48 | // https://vue-loader.vuejs.org/en/configurations/extract-css.html 49 | return { 50 | css: generateLoaders(), 51 | postcss: generateLoaders(), 52 | less: generateLoaders('less'), 53 | sass: generateLoaders('sass', { indentedSyntax: true }), 54 | scss: generateLoaders('sass'), 55 | stylus: generateLoaders('stylus'), 56 | styl: generateLoaders('stylus') 57 | } 58 | } 59 | 60 | // Generate loaders for standalone style files (outside of .vue) 61 | exports.styleLoaders = function (options) { 62 | const output = [] 63 | const loaders = exports.cssLoaders(options) 64 | for (const extension in loaders) { 65 | const loader = loaders[extension] 66 | output.push({ 67 | test: new RegExp('\\.' + extension + '$'), 68 | use: loader 69 | }) 70 | } 71 | return output 72 | } 73 | -------------------------------------------------------------------------------- /build/vue-loader.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const utils = require('./utils') 3 | const config = require('../config') 4 | const isProduction = process.env.NODE_ENV === 'production' 5 | 6 | module.exports = { 7 | loaders: utils.cssLoaders({ 8 | sourceMap: isProduction 9 | ? config.build.productionSourceMap 10 | : config.dev.cssSourceMap, 11 | extract: isProduction 12 | }), 13 | transformToRequire: { 14 | video: 'src', 15 | source: 'src', 16 | img: 'src', 17 | image: 'xlink:href' 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const config = require('../config') 5 | const vueLoaderConfig = require('./vue-loader.conf') 6 | 7 | function resolve (dir) { 8 | return path.join(__dirname, '..', dir) 9 | } 10 | 11 | module.exports = { 12 | entry: { 13 | app: './src/main.js' 14 | }, 15 | output: { 16 | path: config.build.assetsRoot, 17 | filename: '[name].js', 18 | publicPath: process.env.NODE_ENV === 'production' 19 | ? config.build.assetsPublicPath 20 | : config.dev.assetsPublicPath 21 | }, 22 | resolve: { 23 | extensions: ['.js', '.vue', '.json'], 24 | alias: { 25 | 'vue$': 'vue/dist/vue.esm.js', 26 | '@': resolve('src'), 27 | } 28 | }, 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.(js|vue)$/, 33 | loader: 'eslint-loader', 34 | enforce: 'pre', 35 | include: [resolve('src'), resolve('test')], 36 | options: { 37 | formatter: require('eslint-friendly-formatter') 38 | } 39 | }, 40 | { 41 | test: /\.vue$/, 42 | loader: 'vue-loader', 43 | options: vueLoaderConfig 44 | }, 45 | { 46 | test: /\.js$/, 47 | loader: 'babel-loader', 48 | include: [resolve('src'), resolve('test')] 49 | }, 50 | { 51 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 52 | loader: 'url-loader', 53 | options: { 54 | limit: 10000, 55 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 56 | } 57 | }, 58 | { 59 | test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, 60 | loader: 'url-loader', 61 | options: { 62 | limit: 10000, 63 | name: utils.assetsPath('media/[name].[hash:7].[ext]') 64 | } 65 | }, 66 | { 67 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 68 | loader: 'url-loader', 69 | options: { 70 | limit: 10000, 71 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 72 | } 73 | } 74 | ] 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const utils = require('./utils') 3 | const webpack = require('webpack') 4 | const config = require('../config') 5 | const merge = require('webpack-merge') 6 | const baseWebpackConfig = require('./webpack.base.conf') 7 | const HtmlWebpackPlugin = require('html-webpack-plugin') 8 | const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 9 | 10 | // add hot-reload related code to entry chunks 11 | Object.keys(baseWebpackConfig.entry).forEach(function (name) { 12 | baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) 13 | }) 14 | 15 | module.exports = merge(baseWebpackConfig, { 16 | module: { 17 | rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) 18 | }, 19 | // cheap-module-eval-source-map is faster for development 20 | devtool: '#cheap-module-eval-source-map', 21 | plugins: [ 22 | new webpack.DefinePlugin({ 23 | 'process.env': config.dev.env 24 | }), 25 | // https://github.com/glenjamin/webpack-hot-middleware#installation--usage 26 | new webpack.HotModuleReplacementPlugin(), 27 | new webpack.NoEmitOnErrorsPlugin(), 28 | // https://github.com/ampedandwired/html-webpack-plugin 29 | new HtmlWebpackPlugin({ 30 | filename: 'index.html', 31 | template: 'index.html', 32 | inject: true 33 | }), 34 | new FriendlyErrorsPlugin() 35 | ] 36 | }) 37 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const utils = require('./utils') 4 | const webpack = require('webpack') 5 | const config = require('../config') 6 | const merge = require('webpack-merge') 7 | const baseWebpackConfig = require('./webpack.base.conf') 8 | const CopyWebpackPlugin = require('copy-webpack-plugin') 9 | const HtmlWebpackPlugin = require('html-webpack-plugin') 10 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 11 | const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') 12 | 13 | const env = config.build.env 14 | 15 | const webpackConfig = merge(baseWebpackConfig, { 16 | module: { 17 | rules: utils.styleLoaders({ 18 | sourceMap: config.build.productionSourceMap, 19 | extract: true 20 | }) 21 | }, 22 | devtool: config.build.productionSourceMap ? '#source-map' : false, 23 | output: { 24 | path: config.build.assetsRoot, 25 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 26 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 27 | }, 28 | plugins: [ 29 | // http://vuejs.github.io/vue-loader/en/workflow/production.html 30 | new webpack.DefinePlugin({ 31 | 'process.env': env 32 | }), 33 | // UglifyJs do not support ES6+, you can also use babel-minify for better treeshaking: https://github.com/babel/minify 34 | new webpack.optimize.UglifyJsPlugin({ 35 | compress: { 36 | warnings: false 37 | }, 38 | sourceMap: true 39 | }), 40 | // extract css into its own file 41 | new ExtractTextPlugin({ 42 | filename: utils.assetsPath('css/[name].[contenthash].css') 43 | }), 44 | // Compress extracted CSS. We are using this plugin so that possible 45 | // duplicated CSS from different components can be deduped. 46 | new OptimizeCSSPlugin({ 47 | cssProcessorOptions: { 48 | safe: true 49 | } 50 | }), 51 | // generate dist index.html with correct asset hash for caching. 52 | // you can customize output by editing /index.html 53 | // see https://github.com/ampedandwired/html-webpack-plugin 54 | new HtmlWebpackPlugin({ 55 | filename: config.build.index, 56 | template: 'index.html', 57 | inject: true, 58 | minify: { 59 | removeComments: true, 60 | collapseWhitespace: true, 61 | removeAttributeQuotes: true 62 | // more options: 63 | // https://github.com/kangax/html-minifier#options-quick-reference 64 | }, 65 | // necessary to consistently work with multiple chunks via CommonsChunkPlugin 66 | chunksSortMode: 'dependency' 67 | }), 68 | // keep module.id stable when vender modules does not change 69 | new webpack.HashedModuleIdsPlugin(), 70 | // split vendor js into its own file 71 | new webpack.optimize.CommonsChunkPlugin({ 72 | name: 'vendor', 73 | minChunks: function (module) { 74 | // any required modules inside node_modules are extracted to vendor 75 | return ( 76 | module.resource && 77 | /\.js$/.test(module.resource) && 78 | module.resource.indexOf( 79 | path.join(__dirname, '../node_modules') 80 | ) === 0 81 | ) 82 | } 83 | }), 84 | // extract webpack runtime and module manifest to its own file in order to 85 | // prevent vendor hash from being updated whenever app bundle is updated 86 | new webpack.optimize.CommonsChunkPlugin({ 87 | name: 'manifest', 88 | chunks: ['vendor'] 89 | }), 90 | // copy custom static assets 91 | new CopyWebpackPlugin([ 92 | { 93 | from: path.resolve(__dirname, '../static'), 94 | to: config.build.assetsSubDirectory, 95 | ignore: ['.*'] 96 | } 97 | ]) 98 | ] 99 | }) 100 | 101 | if (config.build.productionGzip) { 102 | const CompressionWebpackPlugin = require('compression-webpack-plugin') 103 | 104 | webpackConfig.plugins.push( 105 | new CompressionWebpackPlugin({ 106 | asset: '[path].gz[query]', 107 | algorithm: 'gzip', 108 | test: new RegExp( 109 | '\\.(' + 110 | config.build.productionGzipExtensions.join('|') + 111 | ')$' 112 | ), 113 | threshold: 10240, 114 | minRatio: 0.8 115 | }) 116 | ) 117 | } 118 | 119 | if (config.build.bundleAnalyzerReport) { 120 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 121 | webpackConfig.plugins.push(new BundleAnalyzerPlugin()) 122 | } 123 | 124 | module.exports = webpackConfig 125 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const prodEnv = require('./prod.env') 4 | 5 | module.exports = merge(prodEnv, { 6 | NODE_ENV: '"development"' 7 | }) 8 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict' 3 | // Template version: 1.1.3 4 | // see http://vuejs-templates.github.io/webpack for documentation. 5 | 6 | const path = require('path') 7 | 8 | module.exports = { 9 | build: { 10 | env: require('./prod.env'), 11 | index: path.resolve(__dirname, '../dist/index.html'), 12 | assetsRoot: path.resolve(__dirname, '../dist'), 13 | assetsSubDirectory: 'static', 14 | assetsPublicPath: '/', 15 | productionSourceMap: true, 16 | // Gzip off by default as many popular static hosts such as 17 | // Surge or Netlify already gzip all static assets for you. 18 | // Before setting to `true`, make sure to: 19 | // npm install --save-dev compression-webpack-plugin 20 | productionGzip: false, 21 | productionGzipExtensions: ['js', 'css'], 22 | // Run the build command with an extra argument to 23 | // View the bundle analyzer report after build finishes: 24 | // `npm run build --report` 25 | // Set to `true` or `false` to always turn it on or off 26 | bundleAnalyzerReport: process.env.npm_config_report 27 | }, 28 | dev: { 29 | env: require('./dev.env'), 30 | port: process.env.PORT || 8080, 31 | autoOpenBrowser: true, 32 | assetsSubDirectory: 'static', 33 | assetsPublicPath: '/', 34 | proxyTable: {}, 35 | // CSS Sourcemaps off by default because relative paths are "buggy" 36 | // with this option, according to the CSS-Loader README 37 | // (https://github.com/webpack/css-loader#sourcemaps) 38 | // In our experience, they generally work as expected, 39 | // just be aware of this issue when enabling this option. 40 | cssSourceMap: false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('dotenv').config() 3 | 4 | module.exports = { 5 | NODE_ENV: '"production"', 6 | FIREBASE_API_KEY: JSON.stringify(process.env.FIREBASE_API_KEY), 7 | FIREBASE_AUTH_DOMAIN: JSON.stringify(process.env.FIREBASE_AUTH_DOMAIN), 8 | FIREBASE_DATABASE_URL: JSON.stringify(process.env.FIREBASE_DATABASE_URL), 9 | FIREBASE_PROJECT_ID: JSON.stringify(process.env.FIREBASE_PROJECT_ID), 10 | FIREBASE_STORAGE_BUCKET: JSON.stringify(process.env.FIREBASE_STORAGE_BUCKET), 11 | FIREBASE_MESSAGING_ID: JSON.stringify(process.env.FIREBASE_MESSAGING_ID) 12 | } 13 | -------------------------------------------------------------------------------- /database.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | ".read": true, 4 | ".write": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database.rules.json" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | vueschool-forum 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vueschool-forum", 3 | "version": "1.0.0", 4 | "description": "Awesome full blown mega madness forum", 5 | "author": "Alex Kyriakidis ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "node build/dev-server.js", 9 | "start": "npm run dev", 10 | "build": "node build/build.js", 11 | "lint": "eslint --ext .js,.vue src", 12 | "db:seed": "firebase database:set / src/data.json -y" 13 | }, 14 | "dependencies": { 15 | "dotenv": "^5.0.1", 16 | "firebase": "^4.12.1", 17 | "moment": "^2.19.3", 18 | "nprogress": "^0.2.0", 19 | "vue": "^2.5.2", 20 | "vue-router": "^3.0.1", 21 | "vuelidate": "^0.7.4", 22 | "vuex": "^3.0.1" 23 | }, 24 | "devDependencies": { 25 | "autoprefixer": "^7.1.2", 26 | "babel-core": "^6.22.1", 27 | "babel-eslint": "^7.1.1", 28 | "babel-loader": "^7.1.1", 29 | "babel-plugin-transform-runtime": "^6.22.0", 30 | "babel-preset-env": "^1.3.2", 31 | "babel-preset-stage-2": "^6.22.0", 32 | "babel-register": "^6.22.0", 33 | "chalk": "^2.0.1", 34 | "connect-history-api-fallback": "^1.3.0", 35 | "copy-webpack-plugin": "^4.0.1", 36 | "css-loader": "^0.28.0", 37 | "eslint": "^3.19.0", 38 | "eslint-config-standard": "^10.2.1", 39 | "eslint-friendly-formatter": "^3.0.0", 40 | "eslint-loader": "^1.7.1", 41 | "eslint-plugin-html": "^3.0.0", 42 | "eslint-plugin-import": "^2.7.0", 43 | "eslint-plugin-node": "^5.2.0", 44 | "eslint-plugin-promise": "^3.4.0", 45 | "eslint-plugin-standard": "^3.0.1", 46 | "eventsource-polyfill": "^0.9.6", 47 | "express": "^4.14.1", 48 | "extract-text-webpack-plugin": "^3.0.0", 49 | "file-loader": "^1.1.4", 50 | "friendly-errors-webpack-plugin": "^1.6.1", 51 | "html-webpack-plugin": "^2.30.1", 52 | "http-proxy-middleware": "^0.17.3", 53 | "opn": "^5.1.0", 54 | "optimize-css-assets-webpack-plugin": "^3.2.0", 55 | "ora": "^1.2.0", 56 | "portfinder": "^1.0.13", 57 | "rimraf": "^2.6.0", 58 | "semver": "^5.3.0", 59 | "shelljs": "^0.7.6", 60 | "url-loader": "^0.5.8", 61 | "vue-loader": "^13.3.0", 62 | "vue-style-loader": "^3.0.1", 63 | "vue-template-compiler": "^2.5.2", 64 | "webpack": "^3.6.0", 65 | "webpack-bundle-analyzer": "^2.9.0", 66 | "webpack-dev-middleware": "^1.12.0", 67 | "webpack-hot-middleware": "^2.18.2", 68 | "webpack-merge": "^4.1.0" 69 | }, 70 | "engines": { 71 | "node": ">= 4.0.0", 72 | "npm": ">= 3.0.0" 73 | }, 74 | "browserslist": [ 75 | "> 1%", 76 | "last 2 versions", 77 | "not ie <= 8" 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 49 | 50 | 58 | -------------------------------------------------------------------------------- /src/assets/css/style.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | body { 4 | background-color: #F6F8FF; 5 | min-height: 100vh; 6 | } 7 | 8 | *, *:after, *:before { 9 | box-sizing: border-box; 10 | } 11 | 12 | @media (min-width: 1024px) { 13 | html { 14 | font-size: 16px; 15 | } 16 | } 17 | 18 | @media (min-width: 240px) and (max-width: 1023px) { 19 | html { 20 | font-size: 14px; 21 | } 22 | } 23 | 24 | body { 25 | line-height: 1.5; 26 | margin: 0; 27 | padding: 0; 28 | } 29 | 30 | img { 31 | height: auto; 32 | max-width: 100%; 33 | } 34 | 35 | figure { 36 | margin: 0 0 20px 0; 37 | padding: 0; 38 | text-align: center; 39 | } 40 | 41 | figcaption { 42 | display: block; 43 | text-align: center; 44 | font-size: .8rem; 45 | } 46 | 47 | .list-title { 48 | background-color: #263959; 49 | border-bottom-left-radius: 20px; 50 | color: #f5f8fe; 51 | font-weight: 100; 52 | display: flex; 53 | width: 100%; 54 | justify-content: flex-start; 55 | position: relative; 56 | padding: 10px 20px; 57 | margin: 0; 58 | } 59 | 60 | .list-title a { 61 | color: white; 62 | } 63 | 64 | .list-title a:hover { 65 | color: #89c6af; 66 | } 67 | 68 | .img-round, .avatar, .avatar-xsmall, .avatar-small, .avatar-medium, .avatar-large, .avatar-xlarge { 69 | border-radius: 50%; 70 | max-width: 100%; 71 | } 72 | 73 | .forum-list { 74 | padding: 0; 75 | background: white; 76 | margin: 20px 0; 77 | } 78 | 79 | .forum-list .forum-listing { 80 | display: flex; 81 | flex-wrap: wrap; 82 | justify-content: space-between; 83 | align-items: center; 84 | padding: 20px 10px 20px 30px; 85 | } 86 | 87 | .forum-list .forum-listing:nth-child(odd) { 88 | background: rgba(73, 89, 96, 0.06); 89 | border-bottom-left-radius: 20px; 90 | } 91 | 92 | .forum-list .forum-listing:last-child { 93 | border-bottom-left-radius: 0; 94 | } 95 | 96 | .forum-list .forum-listing .forum-details { 97 | flex-basis: 52%; 98 | } 99 | 100 | @media (min-width: 240px) and (max-width: 720px) { 101 | .forum-list .forum-listing .forum-details { 102 | flex-basis: 100%; 103 | } 104 | } 105 | 106 | .forum-list .forum-listing .forum-details ul.subforums { 107 | padding-left: 5px; 108 | display: block; 109 | } 110 | 111 | .forum-list .forum-listing .forum-details ul.subforums::before { 112 | content: '⌙'; 113 | margin-right: 5px; 114 | } 115 | 116 | .forum-list .forum-listing .forum-details ul.subforums.subforums li { 117 | display: inline; 118 | } 119 | 120 | .forum-list .forum-listing .forum-details ul.subforums.subforums li:not(:last-of-type)::after { 121 | content: '\f111'; 122 | font-family: 'FontAwesome'; 123 | font-size: 4px; 124 | position: relative; 125 | top: -3px; 126 | left: 2px; 127 | padding: 0 3px; 128 | color: #878787; 129 | } 130 | 131 | .forum-list .forum-listing .threads-count { 132 | flex-basis: 12%; 133 | text-align: center; 134 | } 135 | 136 | .forum-list .forum-listing .threads-count .count { 137 | font-weight: 100; 138 | display: block; 139 | } 140 | 141 | .forum-list .forum-listing .last-thread { 142 | flex-basis: 32%; 143 | display: flex; 144 | justify-content: flex-start; 145 | align-items: center; 146 | } 147 | 148 | .forum-list .forum-listing .last-thread .avatar { 149 | margin-right: 10px; 150 | } 151 | 152 | .forum-header { 153 | display: flex; 154 | justify-content: space-between; 155 | align-items: flex-end; 156 | } 157 | 158 | .forum-stats ul { 159 | font-size: 0px; 160 | display: flex; 161 | justify-content: center; 162 | margin-bottom: 50px; 163 | } 164 | 165 | .forum-stats ul li { 166 | display: flex; 167 | font-weight: 100; 168 | margin: 0 20px; 169 | align-items: center; 170 | } 171 | 172 | .forum-stats ul li .fa { 173 | margin-right: 5px; 174 | } 175 | 176 | .forum-stats ul li .fa-comments-o { 177 | font-size: 26px; 178 | } 179 | 180 | .thread-list { 181 | padding: 0; 182 | background-color: white; 183 | } 184 | 185 | .thread-list .thread { 186 | display: flex; 187 | justify-content: space-between; 188 | align-items: center; 189 | padding: 5px 0 5px 20px; 190 | min-height: 45px; 191 | } 192 | 193 | .thread-list .thread:nth-child(odd) { 194 | background: rgba(73, 89, 96, 0.06); 195 | border-bottom-left-radius: 20px; 196 | } 197 | 198 | .thread-list .thread:last-child { 199 | border-bottom-left-radius: 0; 200 | } 201 | 202 | .thread-list .thread .replies-count { 203 | flex-basis: 35%; 204 | } 205 | 206 | .thread-list .thread .activity { 207 | flex-basis: 35%; 208 | display: flex; 209 | justify-content: flex-start; 210 | align-items: center; 211 | } 212 | 213 | .thread-list .thread .activity .avatar-medium { 214 | margin-right: 10px; 215 | } 216 | 217 | .thread-header { 218 | display: flex; 219 | justify-content: space-between; 220 | align-items: center; 221 | } 222 | 223 | .reactions { 224 | display: flex; 225 | justify-content: flex-end; 226 | flex: 100%; 227 | position: relative; 228 | } 229 | 230 | .reactions button { 231 | display: flex; 232 | align-items: center; 233 | padding: 5px 8px; 234 | margin-left: 2px; 235 | color: #545454; 236 | border-radius: 5px; 237 | } 238 | 239 | .reactions button:hover { 240 | background: rgba(115, 192, 151, 0.25) !important; 241 | color: #545454 !important; 242 | } 243 | 244 | .reactions button.active-reaction { 245 | background: rgba(115, 192, 151, 0.12); 246 | } 247 | 248 | .reactions button.active-reaction:hover { 249 | background: white !important; 250 | } 251 | 252 | .reactions button .emoji { 253 | margin-right: 3px; 254 | font-size: 18px; 255 | } 256 | 257 | .reactions button.add-reaction .emoji { 258 | margin-left: 3px; 259 | margin-right: 0px; 260 | } 261 | 262 | .reactions ul { 263 | position: absolute; 264 | display: flex; 265 | justify-content: flex-end; 266 | top: -45px; 267 | background-color: white !important; 268 | } 269 | 270 | .reactions ul li { 271 | font-size: 28px; 272 | display: flex; 273 | align-items: center; 274 | padding: 0px 5px; 275 | opacity: 0.7; 276 | } 277 | 278 | .reactions ul li:hover { 279 | opacity: 1; 280 | border-radius: 5px; 281 | cursor: pointer; 282 | } 283 | 284 | .pagination { 285 | display: flex; 286 | align-items: center; 287 | justify-content: center; 288 | margin-top: 40px; 289 | margin-bottom: 40px; 290 | color: #838486; 291 | } 292 | 293 | .pagination button { 294 | background: #95cbb7; 295 | display: flex; 296 | align-items: center; 297 | justify-content: center; 298 | margin: 0 15px; 299 | padding: 0px; 300 | height: 35px; 301 | width: 35px; 302 | font-size: 22px; 303 | } 304 | 305 | .pagination button:hover { 306 | background: #57AD8D; 307 | } 308 | 309 | .pagination button:disabled { 310 | cursor: not-allowed; 311 | } 312 | 313 | .pagination button:disabled:hover { 314 | background: #95cbb7; 315 | } 316 | 317 | .pagination button:disabled:active { 318 | animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; 319 | transform: translate3d(0, 0, 0); 320 | backface-visibility: hidden; 321 | perspective: 1000px; 322 | } 323 | 324 | @keyframes shake { 325 | 10%, 90% { 326 | transform: translate3d(-1px, 0, 0); 327 | } 328 | 20%, 80% { 329 | transform: translate3d(2px, 0, 0); 330 | } 331 | 30%, 50%, 70% { 332 | transform: translate3d(-4px, 0, 0); 333 | } 334 | 40%, 60% { 335 | transform: translate3d(4px, 0, 0); 336 | } 337 | } 338 | 339 | .post-list { 340 | margin-top: 20px; 341 | } 342 | 343 | .post { 344 | display: flex; 345 | flex-wrap: wrap; 346 | justify-content: space-between; 347 | background-color: white; 348 | padding: 20px 10px; 349 | padding-bottom: 7px; 350 | box-shadow: 2px 2px 1px rgba(136, 136, 136, 0.09); 351 | margin-bottom: 20px; 352 | } 353 | 354 | @media (max-width: 820px) { 355 | .post { 356 | padding: 0; 357 | } 358 | } 359 | 360 | .post .user-info { 361 | display: flex; 362 | flex-direction: column; 363 | align-items: center; 364 | justify-content: flex-start; 365 | text-align: center; 366 | flex: 1 1 15%; 367 | margin-right: 5px; 368 | } 369 | 370 | .post .user-info > * { 371 | margin-bottom: 10px; 372 | } 373 | 374 | @media (max-width: 820px) { 375 | .post .user-info { 376 | order: -2; 377 | flex-direction: row; 378 | justify-content: flex-start; 379 | background: rgba(73, 89, 96, 0.06); 380 | margin-right: 0; 381 | padding: 5px; 382 | padding-left: 10px; 383 | } 384 | 385 | .post .user-info .avatar-large { 386 | height: 35px; 387 | width: 35px; 388 | margin-right: 5px; 389 | order: 1; 390 | } 391 | 392 | .post .user-info .user-name { 393 | order: 2; 394 | } 395 | 396 | .post .user-info > * { 397 | margin-right: 5px; 398 | margin-bottom: 0; 399 | } 400 | } 401 | 402 | .post .post-date { 403 | flex-basis: 100%; 404 | font-size: 14px; 405 | text-align: right; 406 | margin-bottom: 5px; 407 | padding-right: 7px; 408 | } 409 | 410 | @media (max-width: 820px) { 411 | .post .post-date { 412 | order: -1; 413 | flex-basis: 40%; 414 | background: rgba(73, 89, 96, 0.06); 415 | padding-right: 10px; 416 | padding-top: 16px; 417 | margin-bottom: 0px; 418 | } 419 | } 420 | 421 | @media (max-width: 720px) { 422 | .post { 423 | padding: 0px; 424 | } 425 | } 426 | 427 | .post-content { 428 | display: flex; 429 | flex: 1 0 83%; 430 | padding-left: 15px; 431 | padding-right: 10px; 432 | font-size: 16px; 433 | text-align: justify; 434 | line-height: 1.5; 435 | word-break: break-word; 436 | } 437 | 438 | .post-content h1, .post-content h2, .post-content h3 { 439 | margin-bottom: 0; 440 | } 441 | 442 | .post-content p { 443 | margin-bottom: 20px; 444 | } 445 | 446 | .post-content pre { 447 | display: grid; 448 | overflow: auto; 449 | word-wrap: break-word; 450 | border-radius: 3px; 451 | padding: 10px; 452 | } 453 | 454 | .post-content blockquote { 455 | margin: 25px 0px; 456 | } 457 | 458 | .post-content blockquote.big { 459 | display: flex; 460 | position: relative; 461 | } 462 | 463 | .post-content blockquote.big::before { 464 | position: absolute; 465 | top: -25px; 466 | left: -25px; 467 | font-size: 42px; 468 | font-family: FontAwesome; 469 | content: "\f10e"; 470 | color: #263959; 471 | } 472 | 473 | @media (max-width: 820px) { 474 | .post-content blockquote.big::before { 475 | top: -15px; 476 | left: -18px; 477 | font-size: 32px; 478 | } 479 | } 480 | 481 | .post-content blockquote.big .quote { 482 | padding-left: 20px; 483 | padding-right: 15px; 484 | flex-basis: 95%; 485 | font-weight: 100; 486 | font-style: italic; 487 | font-size: 17px; 488 | } 489 | 490 | .post-content blockquote.big .author { 491 | display: flex; 492 | flex-direction: column; 493 | align-items: center; 494 | justify-content: flex-start; 495 | text-align: center; 496 | } 497 | 498 | .post-content blockquote.big .author img { 499 | flex: 1; 500 | flex-basis: 100%; 501 | margin-top: 10px; 502 | width: 80px; 503 | height: 80px; 504 | } 505 | 506 | .post-content blockquote.small { 507 | / / display: flex; 508 | position: relative; 509 | flex-direction: column; 510 | border: 2px solid rgba(152, 152, 152, 0.15); 511 | border-bottom-left-radius: 5px; 512 | border-bottom-right-radius: 5px; 513 | } 514 | 515 | .post-content blockquote.small::before { 516 | position: absolute; 517 | top: -20px; 518 | left: -20px; 519 | font-size: 42px; 520 | font-family: FontAwesome; 521 | content: "\f10e"; 522 | color: #263959; 523 | } 524 | 525 | @media (max-width: 820px) { 526 | .post-content blockquote.small::before { 527 | top: -18px; 528 | left: -15px; 529 | font-size: 32px; 530 | } 531 | } 532 | 533 | .post-content blockquote.small .author { 534 | display: flex; 535 | flex-basis: 100%; 536 | padding: 3px 10px 3px 28px; 537 | background-color: rgba(152, 152, 152, 0.15); 538 | justify-content: center; 539 | align-items: center; 540 | } 541 | 542 | .post-content blockquote.small .author .time { 543 | margin-left: 10px; 544 | } 545 | 546 | .post-content blockquote.small .author .fa { 547 | margin-left: auto; 548 | font-size: 20px; 549 | } 550 | 551 | .post-content blockquote.small .author .fa:hover { 552 | cursor: pointer; 553 | } 554 | 555 | .post-content blockquote.small .quote { 556 | display: flex; 557 | flex-basis: 100%; 558 | flex-direction: column; 559 | padding: 10px; 560 | font-weight: 100; 561 | font-style: italic; 562 | font-size: 17px; 563 | } 564 | 565 | .post-content blockquote.simple { 566 | position: relative; 567 | padding: 0px 10px 0px 20px; 568 | font-weight: 100; 569 | font-style: italic; 570 | font-size: 17px; 571 | letter-spacing: .15px; 572 | } 573 | 574 | .post-content blockquote.simple::before { 575 | position: absolute; 576 | top: -25px; 577 | left: -25px; 578 | font-size: 42px; 579 | font-family: FontAwesome; 580 | content: "\f10e"; 581 | color: #263959; 582 | } 583 | 584 | @media (max-width: 820px) { 585 | .post-content blockquote.simple::before { 586 | top: -15px; 587 | left: -18px; 588 | font-size: 32px; 589 | } 590 | } 591 | 592 | .post-content blockquote.simple .author { 593 | display: block; 594 | margin-top: 10px; 595 | font-weight: normal; 596 | } 597 | 598 | .post-content blockquote.simple .author .time { 599 | margin-left: 10px; 600 | } 601 | 602 | .post-listing-editor { 603 | flex: 1 1 83%; 604 | } 605 | 606 | .profile-card { 607 | padding: 10px 20px 20px 20px; 608 | margin-bottom: 10px; 609 | background: white; 610 | box-shadow: 2px 2px 1px rgba(136, 136, 136, 0.09); 611 | align-self: self-end; 612 | } 613 | 614 | @media (min-width: 820px) { 615 | .profile-card { 616 | margin-right: 20px; 617 | } 618 | } 619 | 620 | .profile-card .title { 621 | word-break: break-all; 622 | } 623 | 624 | .profile-card .stats { 625 | display: flex; 626 | margin: 20px 0px; 627 | } 628 | 629 | .profile-card .stats span { 630 | flex-basis: 50%; 631 | } 632 | 633 | .profile-card .user-website { 634 | display: flex; 635 | justify-content: center; 636 | align-items: baseline; 637 | } 638 | 639 | .profile-header { 640 | display: flex; 641 | align-items: baseline; 642 | justify-content: space-between; 643 | padding: 0 0px; 644 | } 645 | 646 | @media (max-width: 720px) { 647 | .profile-header { 648 | flex-wrap: wrap; 649 | } 650 | } 651 | 652 | @media (min-width: 1024px) { 653 | .activity-list { 654 | padding: 0px 10px; 655 | } 656 | } 657 | 658 | .activity-list .activity { 659 | background-color: white; 660 | padding: 15px 10px; 661 | margin-bottom: 20px; 662 | box-shadow: 2px 2px 1px rgba(136, 136, 136, 0.09); 663 | } 664 | 665 | @media (max-width: 720px) { 666 | .activity-list .activity { 667 | padding: 10px 15px; 668 | } 669 | 670 | .activity-list .activity .post-content { 671 | padding-left: 0; 672 | } 673 | } 674 | 675 | .activity-list .activity .activity-header { 676 | margin: 0; 677 | flex: 1; 678 | display: flex; 679 | flex-wrap: wrap; 680 | align-items: flex-start; 681 | justify-content: flex-end; 682 | } 683 | 684 | .activity-list .activity .activity-header img { 685 | margin-top: 5px; 686 | margin-right: 10px; 687 | } 688 | 689 | .activity-list .activity .activity-header .title { 690 | flex-basis: 93%; 691 | margin: 0; 692 | padding: 0; 693 | } 694 | 695 | @media (max-width: 720px) { 696 | .activity-list .activity .activity-header .title { 697 | flex-basis: 100%; 698 | } 699 | } 700 | 701 | .activity-list .activity .activity-header .title span { 702 | display: block; 703 | font-weight: 100; 704 | } 705 | 706 | .activity-list .activity div.post-content { 707 | display: block; 708 | padding-right: 10px; 709 | margin: 12px 0px; 710 | word-break: break-word; 711 | } 712 | 713 | .activity-list .activity div.post-content p { 714 | margin-bottom: 12px; 715 | } 716 | 717 | .activity-list .activity .thread-details { 718 | text-align: right; 719 | } 720 | 721 | .activity-list .activity .thread-details span:not(:last-of-type) { 722 | margin-right: 20px; 723 | } 724 | 725 | textarea#user_bio { 726 | resize: vertical; 727 | } 728 | 729 | span.offline::before { 730 | font-family: FontAwesome; 731 | content: "\f1db"; 732 | font-size: 14px; 733 | margin-right: 5px; 734 | } 735 | 736 | span.online { 737 | color: #57AD8D; 738 | } 739 | 740 | span.online::before { 741 | font-family: FontAwesome; 742 | content: "\f2be"; 743 | font-size: 14px; 744 | margin-right: 5px; 745 | } 746 | 747 | .header { 748 | display: flex; 749 | justify-content: space-between; 750 | align-items: center; 751 | background: #263959; 752 | height: 80px; 753 | padding: 0 20px; 754 | } 755 | 756 | @media (min-width: 240px) and (max-width: 720px) { 757 | .header { 758 | justify-content: space-between; 759 | align-items: center; 760 | padding: 0 10px; 761 | height: 60px; 762 | } 763 | } 764 | 765 | .logo { 766 | float: left; 767 | padding-top: 5px; 768 | } 769 | 770 | @media (min-width: 240px) and (max-width: 720px) { 771 | .logo { 772 | padding-top: 10px; 773 | } 774 | } 775 | 776 | .svg-logo { 777 | height: 62px; 778 | width: 56px; 779 | } 780 | 781 | @media (min-width: 240px) and (max-width: 720px) { 782 | .svg-logo { 783 | height: 45px; 784 | width: 40px; 785 | } 786 | } 787 | 788 | @media (min-width: 240px) and (max-width: 400px) { 789 | .svg-logo { 790 | height: 40px; 791 | width: 35px; 792 | } 793 | } 794 | 795 | .wrap-right { 796 | float: right; 797 | padding: 10px 10px; 798 | } 799 | 800 | @media (min-width: 240px) and (max-width: 720px) { 801 | .wrap-right { 802 | padding: 16px 0; 803 | } 804 | } 805 | 806 | .text-faded, .forum-stats ul li, .thread-list .thread .created_at, .post-content blockquote.big .author span.time, .post-content blockquote.small .author .time, .post-content blockquote.simple .author, .activity-list .activity .activity-header .title span, .activity-list .activity .thread-details, span.offline { 807 | color: rgba(84, 84, 84, 0.7); 808 | } 809 | 810 | h1 { 811 | font-size: 32px; 812 | } 813 | 814 | @media (min-width: 240px) and (max-width: 720px) { 815 | h1 { 816 | font-size: 24px; 817 | } 818 | } 819 | 820 | h2 { 821 | font-size: 28px; 822 | } 823 | 824 | @media (min-width: 240px) and (max-width: 720px) { 825 | h2 { 826 | font-size: 20px; 827 | } 828 | } 829 | 830 | .text-lead, .forum-list .forum-listing .threads-count .count, .profile-card .stats span, .modal-container .modal .modal-header .title { 831 | font-size: 26px; 832 | line-height: 1.5; 833 | font-weight: 300; 834 | } 835 | 836 | @media (min-width: 240px) and (max-width: 720px) { 837 | .text-lead, .forum-list .forum-listing .threads-count .count, .profile-card .stats span, .modal-container .modal .modal-header .title { 838 | font-size: 22px; 839 | } 840 | } 841 | 842 | .text, p, .text-xsmall, .thread-list .thread .created_at, .pagination, .post-content blockquote.small .author .time, .post-content blockquote.simple .author .time, .activity-list .activity .thread-details, span.offline, span.online, .btn-xsmall, .btn-brown, ul.breadcrumbs li:not(:last-of-type)::after, .text-small, .forum-list .forum-listing .forum-details ul.subforums, .post-content blockquote.big .author, .post-content blockquote.small .author a, .post-content blockquote.simple .author, .activity-list .activity .activity-header .title span, .btn-small, ul.breadcrumbs li, .text-large, .list-title, .forum-stats ul li, .profile-card .user-website, .activity-list .activity .activity-header .title, .btn-large, .text-xlarge, .btn-xlarge, .btn, .btn-blue, .btn-blue-outlined, .btn-brown-outlined, .btn-green, .btn-green-outlined, .btn-red, .btn-red-outlined, .btn-ghost { 843 | font-size: 16px; 844 | line-height: 1.5; 845 | } 846 | 847 | @media (min-width: 240px) and (max-width: 720px) { 848 | .text, p, .text-xsmall, .thread-list .thread .created_at, .pagination, .post-content blockquote.small .author .time, .post-content blockquote.simple .author .time, .activity-list .activity .thread-details, span.offline, span.online, .btn-xsmall, .btn-brown, ul.breadcrumbs li:not(:last-of-type)::after, .text-small, .forum-list .forum-listing .forum-details ul.subforums, .post-content blockquote.big .author, .post-content blockquote.small .author a, .post-content blockquote.simple .author, .activity-list .activity .activity-header .title span, .btn-small, ul.breadcrumbs li, .text-large, .list-title, .forum-stats ul li, .profile-card .user-website, .activity-list .activity .activity-header .title, .btn-large, .text-xlarge, .btn-xlarge, .btn, .btn-blue, .btn-blue-outlined, .btn-brown-outlined, .btn-green, .btn-green-outlined, .btn-red, .btn-red-outlined, .btn-ghost { 849 | font-size: 15px; 850 | } 851 | } 852 | 853 | .text-xsmall, .thread-list .thread .created_at, .pagination, .post-content blockquote.small .author .time, .post-content blockquote.simple .author .time, .activity-list .activity .thread-details, span.offline, span.online, .btn-xsmall, .btn-brown, ul.breadcrumbs li:not(:last-of-type)::after { 854 | font-size: 13px; 855 | } 856 | 857 | @media (min-width: 240px) and (max-width: 720px) { 858 | .text-xsmall, .thread-list .thread .created_at, .pagination, .post-content blockquote.small .author .time, .post-content blockquote.simple .author .time, .activity-list .activity .thread-details, span.offline, span.online, .btn-xsmall, .btn-brown, ul.breadcrumbs li:not(:last-of-type)::after { 859 | font-size: 12px; 860 | } 861 | } 862 | 863 | .text-small, .forum-list .forum-listing .forum-details ul.subforums, .post-content blockquote.big .author, .post-content blockquote.small .author a, .post-content blockquote.simple .author, .activity-list .activity .activity-header .title span, .btn-small, ul.breadcrumbs li { 864 | font-size: 15px; 865 | } 866 | 867 | @media (min-width: 240px) and (max-width: 720px) { 868 | .text-small, .forum-list .forum-listing .forum-details ul.subforums, .post-content blockquote.big .author, .post-content blockquote.small .author a, .post-content blockquote.simple .author, .activity-list .activity .activity-header .title span, .btn-small, ul.breadcrumbs li { 869 | font-size: 14px; 870 | } 871 | } 872 | 873 | .text-large, .list-title, .forum-stats ul li, .profile-card .user-website, .activity-list .activity .activity-header .title, .btn-large { 874 | font-size: 18px; 875 | } 876 | 877 | @media (min-width: 240px) and (max-width: 720px) { 878 | .text-large, .list-title, .forum-stats ul li, .profile-card .user-website, .activity-list .activity .activity-header .title, .btn-large { 879 | font-size: 17px; 880 | } 881 | } 882 | 883 | .text-xlarge, .btn-xlarge { 884 | font-size: 22px; 885 | } 886 | 887 | @media (min-width: 240px) and (max-width: 720px) { 888 | .text-xlarge, .btn-xlarge { 889 | font-size: 20px; 890 | } 891 | } 892 | 893 | .text-bold, .activity-list .activity .activity-header .title { 894 | font-weight: bold; 895 | } 896 | 897 | .text-italic { 898 | font-style: italic; 899 | } 900 | 901 | .text-underline { 902 | text-decoration: underline; 903 | } 904 | 905 | .text-line-through { 906 | text-decoration: line-through; 907 | } 908 | 909 | .text-center, .profile-card .stats span, .profile-card .user-website { 910 | text-align: center; 911 | } 912 | 913 | .text-left, .activity-list .activity .activity-header .title { 914 | text-align: left; 915 | } 916 | 917 | .text-right { 918 | text-align: right; 919 | } 920 | 921 | .text-justify { 922 | text-align: justify; 923 | } 924 | 925 | ul { 926 | margin: 0; 927 | padding: 0; 928 | } 929 | 930 | .navbar { 931 | width: 100%; 932 | display:flex; 933 | flex-direction: row-reverse; 934 | justify-content: space-between; 935 | } 936 | 937 | .navbar ul { 938 | display: flex; 939 | align-items: center; 940 | justify-content: flex-start; 941 | height: 100%; 942 | } 943 | 944 | .navbar-item, .navbar-mobile-item { 945 | display: inline-block; 946 | border-right: 1px solid #3c4d6a; 947 | vertical-align: middle; 948 | } 949 | 950 | ul .navbar-item:last-child, ul .navbar-mobile-item:last-child { 951 | border-right: none; 952 | } 953 | 954 | .navbar-item a, .navbar-mobile-item a { 955 | color: white; 956 | padding: 10px 20px; 957 | text-decoration: none; 958 | transition: all .6s ease; 959 | font-size: 18px; 960 | } 961 | 962 | @media (min-width: 240px) and (max-width: 720px) { 963 | .navbar-item a, .navbar-mobile-item a { 964 | padding: 10px 0px; 965 | } 966 | } 967 | 968 | .navbar-item a:hover, .navbar-mobile-item a:hover { 969 | color: #57AD8D; 970 | } 971 | 972 | .navbar-item a:active, .navbar-mobile-item a:active { 973 | color: #57AD8D; 974 | } 975 | 976 | @media (min-width: 240px) and (max-width: 720px) { 977 | .navbar-item, .navbar-mobile-item { 978 | display: block; 979 | border: none; 980 | margin: 20px 0; 981 | } 982 | } 983 | 984 | @media (min-width: 240px) and (max-width: 720px) { 985 | .navbar { 986 | display: none; 987 | position: absolute; 988 | z-index: 10; 989 | padding: 10px 10px 10px; 990 | background: #263959; 991 | width: 100%; 992 | left: 0; 993 | top: 60px; 994 | } 995 | } 996 | 997 | @media (min-width: 240px) and (max-width: 720px) { 998 | .navbar-open { 999 | display: flex; 1000 | transition: all 0.6s ease; 1001 | border-bottom-right-radius: 5px; 1002 | border-bottom-left-radius: 5px; 1003 | } 1004 | 1005 | .navbar-open .navbar-item, .navbar-open .navbar-mobile-item { 1006 | margin: 6px 0; 1007 | } 1008 | 1009 | .navbar-open ul { 1010 | flex: 1; 1011 | justify-content: flex-start; 1012 | align-items: flex-start; 1013 | flex-direction: column; 1014 | padding-left: 20px; 1015 | } 1016 | } 1017 | 1018 | .signs .navbar-item, .signs .navbar-mobile-item { 1019 | border-right: none; 1020 | } 1021 | 1022 | .a-active { 1023 | color: #57AD8D; 1024 | } 1025 | 1026 | .icon-profile { 1027 | width: 10px; 1028 | height: 8px; 1029 | } 1030 | 1031 | .navbar-user { 1032 | margin-left: auto; 1033 | } 1034 | 1035 | .navbar-user a { 1036 | display: flex; 1037 | align-items: center; 1038 | color: white; 1039 | } 1040 | 1041 | .navbar-user a:hover .icon-profile { 1042 | transition: all .4s ease; 1043 | transform: rotate(-180deg); 1044 | } 1045 | 1046 | .navbar-user img { 1047 | margin-right: 10px; 1048 | } 1049 | 1050 | .btn-hamburger { 1051 | cursor: pointer; 1052 | height: 30px; 1053 | width: 30px; 1054 | float: right; 1055 | position: relative; 1056 | margin-left: 20px; 1057 | display: none; 1058 | } 1059 | 1060 | .btn-hamburger .top { 1061 | top: 7px; 1062 | } 1063 | 1064 | .btn-hamburger .middle { 1065 | top: 16px; 1066 | } 1067 | 1068 | .btn-hamburger .bottom { 1069 | top: 26px; 1070 | } 1071 | 1072 | @media (min-width: 240px) and (max-width: 720px) { 1073 | .btn-hamburger { 1074 | display: block; 1075 | } 1076 | } 1077 | 1078 | .bar { 1079 | width: 30px; 1080 | height: 4px; 1081 | background: white; 1082 | position: absolute; 1083 | border-radius: 10px; 1084 | transition: all 0.5s; 1085 | } 1086 | 1087 | .btn-hamburger-active .top { 1088 | top: 16px; 1089 | } 1090 | 1091 | .btn-hamburger-active .middle { 1092 | opacity: 0; 1093 | overflow: hidden; 1094 | } 1095 | 1096 | .btn-hamburger-active .bottom { 1097 | top: 16px; 1098 | } 1099 | 1100 | header > a.logo { 1101 | width: 50px; 1102 | } 1103 | 1104 | @media (min-width: 240px) and (max-width: 720px) { 1105 | header > a.logo { 1106 | width: 35px; 1107 | } 1108 | } 1109 | 1110 | .title { 1111 | font-size: 38px; 1112 | text-align: center; 1113 | } 1114 | 1115 | @media (min-width: 1360px) { 1116 | .title { 1117 | font-size: 46px; 1118 | } 1119 | } 1120 | 1121 | @media (min-width: 600px) and (max-width: 1023px) { 1122 | .title { 1123 | font-size: 32px; 1124 | } 1125 | } 1126 | 1127 | @media (min-width: 720px) and (max-width: 820px) { 1128 | .title { 1129 | font-size: 30px; 1130 | } 1131 | } 1132 | 1133 | @media (min-width: 240px) and (max-width: 720px) { 1134 | .title { 1135 | font-size: 30px; 1136 | } 1137 | } 1138 | 1139 | .title-white { 1140 | color: white; 1141 | } 1142 | 1143 | .title-banner { 1144 | color: white; 1145 | text-transform: uppercase; 1146 | } 1147 | 1148 | .subtitle { 1149 | font-size: 26px; 1150 | } 1151 | 1152 | @media (min-width: 600px) and (max-width: 1023px) { 1153 | .subtitle { 1154 | font-size: 22px; 1155 | } 1156 | } 1157 | 1158 | @media (min-width: 240px) and (max-width: 720px) { 1159 | .subtitle { 1160 | font-size: 20px; 1161 | } 1162 | } 1163 | 1164 | @media (min-width: 240px) and (max-width: 400px) { 1165 | .subtitle { 1166 | font-size: 18px; 1167 | } 1168 | } 1169 | 1170 | #user-dropdown { 1171 | position: absolute; 1172 | top: 50px; 1173 | right: 20px; 1174 | z-index: 6; 1175 | display: none; 1176 | } 1177 | 1178 | @media (min-width: 240px) and (max-width: 720px) { 1179 | #user-dropdown { 1180 | position: relative; 1181 | width: 100%; 1182 | right: 0; 1183 | z-index: 10; 1184 | top: 98px; 1185 | } 1186 | } 1187 | 1188 | #user-dropdown.active-drop { 1189 | display: block; 1190 | } 1191 | 1192 | .active-drop { 1193 | display: block; 1194 | } 1195 | 1196 | .dropdown-menu, #user-dropdown > .dropdown-menu { 1197 | display: block; 1198 | background: white; 1199 | padding: 20px; 1200 | position: relative; 1201 | } 1202 | 1203 | .dropdown-menu-item, #user-dropdown > .dropdown-menu > .dropdown-menu-item { 1204 | margin-bottom: 5px; 1205 | } 1206 | 1207 | .dropdown-menu-item a, #user-dropdown > .dropdown-menu > .dropdown-menu-item a { 1208 | display: block; 1209 | color: #57AD8D; 1210 | font-size: 16px; 1211 | transition: all ease 0.6s; 1212 | } 1213 | 1214 | .dropdown-menu-item a:hover, #user-dropdown > .dropdown-menu > .dropdown-menu-item a:hover { 1215 | color: #41826a; 1216 | } 1217 | 1218 | .triangle-drop { 1219 | border-bottom: solid 8px white; 1220 | border-left: solid 8px transparent; 1221 | border-right: solid 8px transparent; 1222 | display: inline-block; 1223 | margin: 0; 1224 | position: relative; 1225 | left: 70%; 1226 | vertical-align: middle; 1227 | bottom: -8px; 1228 | } 1229 | 1230 | @media (min-width: 240px) and (max-width: 720px) { 1231 | .triangle-drop { 1232 | left: 5%; 1233 | } 1234 | } 1235 | 1236 | #user-dropdown a { 1237 | color: #57AD8D; 1238 | text-decoration: none; 1239 | transition: all .6s ease; 1240 | } 1241 | 1242 | #user-dropdown a:hover { 1243 | color: #41826a; 1244 | cursor: pointer; 1245 | } 1246 | 1247 | #user-dropdown ul { 1248 | display: block; 1249 | } 1250 | 1251 | .mentionsList { 1252 | position: absolute; 1253 | width: 160px; 1254 | z-index: 2; 1255 | background-color: #263959; 1256 | box-shadow: 2px 2px 1px rgba(136, 136, 136, 0.09); 1257 | color: white; 1258 | } 1259 | 1260 | .mentionsList li { 1261 | padding: 6px; 1262 | display: flex; 1263 | align-items: center; 1264 | } 1265 | 1266 | .mentionsList li img { 1267 | margin-right: 2px; 1268 | } 1269 | 1270 | .mentionsList li:hover { 1271 | cursor: pointer; 1272 | background-color: #57AD8D; 1273 | } 1274 | 1275 | .mentionsList li:not(:last-of-type) { 1276 | margin-bottom: 3px; 1277 | } 1278 | 1279 | .mentionsList::before { 1280 | border-bottom: solid 8px #263959; 1281 | border-left: solid 8px transparent; 1282 | border-right: solid 8px transparent; 1283 | content: ""; 1284 | display: inline-block; 1285 | left: 52px; 1286 | position: absolute; 1287 | top: -8px; 1288 | } 1289 | 1290 | .mentionsList .arrow-up { 1291 | width: 0; 1292 | height: 0; 1293 | border-left: 5px solid transparent; 1294 | border-right: 5px solid transparent; 1295 | border-bottom: 5px solid black; 1296 | } 1297 | 1298 | input { 1299 | box-shadow: none; 1300 | } 1301 | 1302 | form { 1303 | margin: 0; 1304 | } 1305 | 1306 | .form-input { 1307 | border: 1px solid #ddd; 1308 | border-radius: 5px; 1309 | box-sizing: border-box; 1310 | font: inherit; 1311 | padding: 5px 10px; 1312 | transition: all 0.3s ease; 1313 | width: 100%; 1314 | color: #505050; 1315 | background-color: #fdfdfd; 1316 | min-height: 43px; 1317 | } 1318 | 1319 | .form-input:disabled { 1320 | cursor: no-drop; 1321 | background: #F5F8FE; 1322 | color: #bbbbbb; 1323 | } 1324 | 1325 | .form-input:disabled::placeholder { 1326 | color: #bbbbbb; 1327 | } 1328 | 1329 | .form-input::placeholder { 1330 | font-size: inherit; 1331 | font-weight: 300; 1332 | color: #878787; 1333 | } 1334 | 1335 | .form-input:focus { 1336 | outline: none; 1337 | border: 1px solid #c7c7c7; 1338 | color: #434343; 1339 | background-color: white; 1340 | } 1341 | 1342 | .form-input:invalid { 1343 | border-color: #C82543; 1344 | } 1345 | 1346 | .form-input:invalid ~ .form-error { 1347 | display: block; 1348 | } 1349 | 1350 | @media (min-width: 240px) and (max-width: 400px) { 1351 | .form-input { 1352 | padding-left: 10px; 1353 | height: 50px; 1354 | } 1355 | } 1356 | 1357 | textarea.form-input { 1358 | padding-top: 7px; 1359 | padding-right: 2px; 1360 | padding-bottom: 0px; 1361 | min-height: 110px; 1362 | } 1363 | 1364 | .input-error { 1365 | border-color: #C82543; 1366 | } 1367 | 1368 | .input-error ~ .form-error { 1369 | display: block; 1370 | } 1371 | 1372 | .form-error { 1373 | background: #f4d3d9; 1374 | color: #C82543; 1375 | font-size: 0.8em; 1376 | float: left; 1377 | border-radius: 100px; 1378 | padding: 6px 20px; 1379 | margin-top: 10px; 1380 | } 1381 | 1382 | @media (min-width: 240px) and (max-width: 400px) { 1383 | .form-error { 1384 | width: 100%; 1385 | } 1386 | } 1387 | 1388 | .form-group { 1389 | margin-bottom: 12px; 1390 | width: 100%; 1391 | display: inline-block; 1392 | } 1393 | 1394 | .form-label, .form-group > label { 1395 | margin-bottom: 5px; 1396 | display: inline-block; 1397 | color: #767676; 1398 | } 1399 | 1400 | .form-label-password, .form-group > label-password { 1401 | margin-bottom: 0px; 1402 | } 1403 | 1404 | @media (min-width: 240px) and (max-width: 720px) { 1405 | .form-btn { 1406 | width: 100%; 1407 | } 1408 | } 1409 | 1410 | input[type="submit"], 1411 | button { 1412 | -webkit-appearance: none; 1413 | font-size: 18px; 1414 | cursor: pointer; 1415 | } 1416 | 1417 | button { 1418 | -webkit-appearance: none; 1419 | } 1420 | 1421 | button a { 1422 | color: white; 1423 | } 1424 | 1425 | .form-2cols { 1426 | display: flex; 1427 | flex-wrap: wrap; 1428 | } 1429 | 1430 | .form-2cols .form-group { 1431 | flex-basis: 47%; 1432 | } 1433 | 1434 | .form-2cols .form-group:nth-child(odd) { 1435 | margin-right: 10px; 1436 | } 1437 | 1438 | @media (min-width: 240px) and (max-width: 720px) { 1439 | .form-2cols .form-group { 1440 | flex-basis: 100%; 1441 | margin-right: 0; 1442 | } 1443 | } 1444 | 1445 | button { 1446 | border: none; 1447 | background: transparent; 1448 | appearance: none; 1449 | } 1450 | 1451 | .btn, .btn-blue, .btn-blue-outlined, .btn-brown, .btn-brown-outlined, .btn-green, .btn-green-outlined, .btn-red, .btn-red-outlined, .btn-ghost { 1452 | padding: 15px 30px; 1453 | border-radius: 5px; 1454 | border: none; 1455 | display: inline-block; 1456 | outline: 0; 1457 | transition: all 0.4s ease; 1458 | } 1459 | 1460 | @media (min-width: 240px) and (max-width: 720px) { 1461 | .btn, .btn-blue, .btn-blue-outlined, .btn-brown, .btn-brown-outlined, .btn-green, .btn-green-outlined, .btn-red, .btn-red-outlined, .btn-ghost { 1462 | padding: 10px 20px; 1463 | } 1464 | } 1465 | 1466 | .btn:disabled, .btn-blue:disabled, .btn-blue-outlined:disabled, .btn-brown:disabled, .btn-brown-outlined:disabled, .btn-green:disabled, .btn-green-outlined:disabled, .btn-red:disabled, .btn-red-outlined:disabled, .btn-ghost:disabled, .btn-disabled { 1467 | cursor: default; 1468 | } 1469 | 1470 | .btn:disabled:hover, .btn-blue:disabled:hover, .btn-blue-outlined:disabled:hover, .btn-brown:disabled:hover, .btn-brown-outlined:disabled:hover, .btn-green:disabled:hover, .btn-green-outlined:disabled:hover, .btn-red:disabled:hover, .btn-red-outlined:disabled:hover, .btn-ghost:disabled:hover, .btn-disabled:hover { 1471 | cursor: default; 1472 | color: white; 1473 | } 1474 | 1475 | .btn-block { 1476 | width: 100%; 1477 | } 1478 | 1479 | .btn-xsmall { 1480 | padding: 6px 15px; 1481 | } 1482 | 1483 | .btn-small { 1484 | padding: 10px 20px; 1485 | } 1486 | 1487 | .btn-large { 1488 | padding: 20px 40px; 1489 | } 1490 | 1491 | .btn-xlarge { 1492 | padding: 20px 60px; 1493 | } 1494 | 1495 | .btn-circle { 1496 | height: 60px; 1497 | width: 60px; 1498 | background: #C82543; 1499 | border-radius: 50%; 1500 | transition: all ease 0.4s; 1501 | padding: 0px; 1502 | font-size: 36px; 1503 | display: flex; 1504 | justify-content: center; 1505 | align-content: center; 1506 | color: white; 1507 | } 1508 | 1509 | .btn-circle:hover { 1510 | background: #b4213c; 1511 | } 1512 | 1513 | .btn-circle:hover .icon-arrow-up { 1514 | transition: all ease 0.4s; 1515 | transform: translateY(-2px); 1516 | } 1517 | 1518 | .btn-circle:hover .icon-arrow { 1519 | transition: all ease 0.4s; 1520 | transform: translateX(2px); 1521 | } 1522 | 1523 | .btn-circle:hover .icon-arrow-left { 1524 | transition: all ease 0.4s; 1525 | transform: translateX(-2px) rotate(180deg); 1526 | } 1527 | 1528 | .btn-circle-default { 1529 | background: #878787; 1530 | } 1531 | 1532 | .btn-circle-default:hover { 1533 | background: #4c4c4c; 1534 | } 1535 | 1536 | @media (min-width: 240px) and (max-width: 720px) { 1537 | .btn-circle { 1538 | height: 45px; 1539 | width: 45px; 1540 | padding-top: 14px; 1541 | } 1542 | } 1543 | 1544 | .btn-blue { 1545 | color: white; 1546 | background: #263959; 1547 | } 1548 | 1549 | .btn-blue:hover:not(:disabled):not(.btn-disabled) { 1550 | color: white; 1551 | background: #1d2b43; 1552 | } 1553 | 1554 | .btn-blue-outlined { 1555 | color: #263959; 1556 | box-shadow: inset 0px 0px 0px 1.6px #263959; 1557 | } 1558 | 1559 | .btn-blue-outlined:hover { 1560 | color: white; 1561 | background: #263959; 1562 | } 1563 | 1564 | .btn-brown { 1565 | color: white; 1566 | background: #bf9268; 1567 | } 1568 | 1569 | .btn-brown:hover:not(:disabled):not(.btn-disabled) { 1570 | color: white; 1571 | background: #8f6e4e; 1572 | } 1573 | 1574 | .btn-brown-outlined { 1575 | color: #bf9268; 1576 | box-shadow: inset 0px 0px 0px 1.6px #bf9268; 1577 | } 1578 | 1579 | .btn-brown-outlined:hover { 1580 | color: white; 1581 | background: #bf9268; 1582 | } 1583 | 1584 | .btn-green { 1585 | color: white; 1586 | background: #57AD8D; 1587 | } 1588 | 1589 | .btn-green:hover:not(:disabled):not(.btn-disabled) { 1590 | color: white; 1591 | background: #4e9c7f; 1592 | } 1593 | 1594 | .btn-green-outlined { 1595 | color: #57AD8D; 1596 | box-shadow: inset 0px 0px 0px 1.6px #57AD8D; 1597 | } 1598 | 1599 | .btn-green-outlined:hover { 1600 | color: white; 1601 | background: #57AD8D; 1602 | } 1603 | 1604 | .btn-red { 1605 | color: white; 1606 | background: #C82543; 1607 | } 1608 | 1609 | .btn-red:hover:not(:disabled):not(.btn-disabled) { 1610 | color: white; 1611 | background: #b4213c; 1612 | } 1613 | 1614 | .btn-red-outlined { 1615 | color: #C82543; 1616 | box-shadow: inset 0px 0px 0px 1.6px #C82543; 1617 | } 1618 | 1619 | .btn-red-outlined:hover { 1620 | color: white; 1621 | background: #C82543; 1622 | } 1623 | 1624 | .btn-green { 1625 | color: white; 1626 | background: #57AD8D; 1627 | } 1628 | 1629 | .btn-green:hover:not(:disabled):not(.btn-disabled) { 1630 | color: white; 1631 | background: #4e9c7f; 1632 | } 1633 | 1634 | .btn-red { 1635 | color: white; 1636 | background: #C82543; 1637 | } 1638 | 1639 | .btn-red:hover:not(:disabled):not(.btn-disabled) { 1640 | color: white; 1641 | background: #b4213c; 1642 | } 1643 | 1644 | .btn-ghost { 1645 | flex-grow: 0; 1646 | } 1647 | 1648 | .btn-ghost:hover:not(:disabled):not(.btn-disabled) { 1649 | color: white; 1650 | background-color: rgba(152, 152, 152, 0.31); 1651 | } 1652 | 1653 | .btn-up { 1654 | height: 40px; 1655 | width: 40px; 1656 | padding-top: 10px; 1657 | text-align: center; 1658 | } 1659 | 1660 | .btn-input { 1661 | height: 52px; 1662 | line-height: 48px; 1663 | position: absolute; 1664 | border: none; 1665 | color: #fff; 1666 | cursor: pointer; 1667 | font-family: 'Open sans', sans-serif; 1668 | font-size: 18px; 1669 | padding: 0 25px; 1670 | right: 4px; 1671 | top: 4px; 1672 | transition: background 0.15s ease; 1673 | z-index: 10; 1674 | } 1675 | 1676 | @media (min-width: 240px) and (max-width: 720px) { 1677 | .btn-input { 1678 | position: static; 1679 | margin-top: 10px; 1680 | width: 100%; 1681 | } 1682 | } 1683 | 1684 | .btn-social { 1685 | margin-right: 6px; 1686 | } 1687 | 1688 | .btn-social svg { 1689 | height: 40px; 1690 | width: 40px; 1691 | transition: all ease 0.6s; 1692 | transform: rotate(0); 1693 | } 1694 | 1695 | .btn-social svg:hover { 1696 | transform: rotate(-40deg); 1697 | } 1698 | 1699 | .icon-arrow-up { 1700 | width: 12px; 1701 | height: 18px; 1702 | transform: translate(0, 0); 1703 | } 1704 | 1705 | .icon-arrow { 1706 | height: 21px; 1707 | transform: translate(0, 0); 1708 | width: 28px; 1709 | } 1710 | 1711 | @media (min-width: 240px) and (max-width: 720px) { 1712 | .icon-arrow { 1713 | height: 14px; 1714 | width: 21px; 1715 | } 1716 | } 1717 | 1718 | .icon-arrow-left { 1719 | width: 28px; 1720 | height: 21px; 1721 | transform: translate(0, 0); 1722 | transform: rotate(180deg); 1723 | } 1724 | 1725 | @media (min-width: 240px) and (max-width: 720px) { 1726 | .icon-arrow-left { 1727 | height: 14px; 1728 | width: 21px; 1729 | } 1730 | } 1731 | 1732 | .link { 1733 | color: #57AD8D; 1734 | text-decoration: underline; 1735 | transition: all ease 0.4s; 1736 | } 1737 | 1738 | .link:hover { 1739 | color: #468a71; 1740 | } 1741 | 1742 | button { 1743 | outline: 0; 1744 | } 1745 | 1746 | .form-actions, .btn-group { 1747 | display: flex; 1748 | justify-content: flex-end; 1749 | flex-basis: 100%; 1750 | margin-top: 10px; 1751 | margin-bottom: 10px; 1752 | } 1753 | 1754 | .form-actions > *:not(:last-child), .btn-group > *:not(:last-child) { 1755 | margin-right: 10px; 1756 | } 1757 | 1758 | @media (min-width: 240px) and (max-width: 720px) { 1759 | .form-actions, .btn-group { 1760 | flex-wrap: wrap; 1761 | } 1762 | 1763 | .form-actions > *:not(.btn-ghost), .btn-group > *:not(.btn-ghost) { 1764 | flex: 1 1; 1765 | margin-bottom: 5px; 1766 | } 1767 | } 1768 | 1769 | .space-between { 1770 | justify-content: space-between; 1771 | } 1772 | 1773 | .alert { 1774 | width: 100%; 1775 | padding: 10px 20px; 1776 | color: white; 1777 | opacity: 0.8; 1778 | position: relative; 1779 | z-index: 1; 1780 | display: flex; 1781 | align-items: center; 1782 | justify-content: space-between; 1783 | font-size: 0.9rem; 1784 | margin-bottom: 5px; 1785 | } 1786 | 1787 | .alert-error { 1788 | background: #C82543; 1789 | } 1790 | 1791 | .alert-success { 1792 | background: #57AD8D; 1793 | } 1794 | 1795 | .alert-info { 1796 | background: #51617a; 1797 | } 1798 | 1799 | @media (min-width: 240px) and (max-width: 720px) { 1800 | .alert { 1801 | padding: 10px; 1802 | } 1803 | } 1804 | 1805 | .close { 1806 | color: white; 1807 | background: transparent; 1808 | border: none; 1809 | } 1810 | 1811 | @media (min-width: 240px) and (max-width: 720px) { 1812 | .close { 1813 | align-self: flex-start; 1814 | margin-top: 4px; 1815 | } 1816 | } 1817 | 1818 | .close-icon { 1819 | stroke: #fff; 1820 | height: 12px; 1821 | width: 12px; 1822 | } 1823 | 1824 | .avatar { 1825 | width: 50px; 1826 | max-width: 50px; 1827 | height: 50px; 1828 | max-height: 50px; 1829 | } 1830 | 1831 | .avatar-xsmall { 1832 | width: 25px; 1833 | max-width: 25px; 1834 | height: 25px; 1835 | max-height: 25px; 1836 | } 1837 | 1838 | .avatar-small { 1839 | width: 35px; 1840 | max-width: 35px; 1841 | height: 35px; 1842 | max-height: 35px; 1843 | } 1844 | 1845 | .avatar-medium { 1846 | width: 35px; 1847 | max-width: 35px; 1848 | height: 35px; 1849 | max-height: 35px; 1850 | } 1851 | 1852 | .avatar-large { 1853 | width: 95px; 1854 | max-width: 95px; 1855 | height: 95px; 1856 | max-height: 95px; 1857 | } 1858 | 1859 | .avatar-xlarge { 1860 | width: 200px; 1861 | max-width: 200px; 1862 | height: 200px; 1863 | max-height: 200px; 1864 | } 1865 | 1866 | .card { 1867 | background: white; 1868 | margin-top: 20px; 1869 | } 1870 | 1871 | @media (min-width: 240px) and (max-width: 720px) { 1872 | .card { 1873 | margin-bottom: 20px; 1874 | } 1875 | } 1876 | 1877 | .card-form { 1878 | padding: 40px 60px; 1879 | position: relative; 1880 | z-index: 1; 1881 | margin-top: 40px; 1882 | } 1883 | 1884 | @media (min-width: 1360px) { 1885 | .card-form { 1886 | padding: 60px; 1887 | } 1888 | } 1889 | 1890 | @media (min-width: 600px) and (max-width: 1023px) { 1891 | .card-form { 1892 | padding: 40px; 1893 | } 1894 | } 1895 | 1896 | @media (min-width: 240px) and (max-width: 720px) { 1897 | .card-form { 1898 | padding: 40px 20px; 1899 | margin-top: 10px; 1900 | } 1901 | } 1902 | 1903 | .striped { 1904 | background: white; 1905 | box-shadow: 1px 1px 1px #f1f1f1; 1906 | } 1907 | 1908 | .striped li { 1909 | padding: 10px 5px 10px 12px; 1910 | box-shadow: 0 1px rgba(73, 89, 96, 0.06); 1911 | } 1912 | 1913 | .striped li:nth-child(even) { 1914 | background: rgba(73, 89, 96, 0.06); 1915 | } 1916 | 1917 | .sidebar { 1918 | display: none; 1919 | } 1920 | 1921 | @media (min-width: 1024px) { 1922 | .sidebar { 1923 | display: flex; 1924 | flex-basis: 29%; 1925 | margin: 0 0.5%; 1926 | margin-top: 118px; 1927 | flex-direction: column; 1928 | } 1929 | 1930 | .sidebar .widget { 1931 | background: white; 1932 | margin-bottom: 10px; 1933 | } 1934 | } 1935 | 1936 | .sidebar ul > li { 1937 | display: flex; 1938 | flex-wrap: wrap; 1939 | align-items: center; 1940 | } 1941 | 1942 | .sidebar ul > li > span { 1943 | flex-basis: 85%; 1944 | } 1945 | 1946 | .sidebar .unanswered-threads-list { 1947 | margin-top: 10px; 1948 | } 1949 | 1950 | ul.breadcrumbs { 1951 | list-style: none; 1952 | overflow: auto; 1953 | font-size: 0; 1954 | } 1955 | 1956 | ul.breadcrumbs li { 1957 | display: inline-block; 1958 | padding: 5px 0px; 1959 | font-weight: 100; 1960 | } 1961 | 1962 | ul.breadcrumbs li:not(:last-of-type)::after { 1963 | content: '\f105'; 1964 | font-family: FontAwesome; 1965 | margin: 0px 4px; 1966 | opacity: 0.6; 1967 | } 1968 | 1969 | ul.breadcrumbs li a { 1970 | color: #57AD8D; 1971 | text-decoration: none; 1972 | opacity: 0.7; 1973 | } 1974 | 1975 | ul.breadcrumbs li a:hover { 1976 | opacity: 1; 1977 | } 1978 | 1979 | #moderation { 1980 | display: flex; 1981 | } 1982 | 1983 | #moderation.justify-right { 1984 | margin-right: 20px; 1985 | } 1986 | 1987 | @media (min-width: 240px) and (max-width: 720px) { 1988 | #moderation.justify-right { 1989 | margin: 0; 1990 | } 1991 | } 1992 | 1993 | #moderation ul.toolbar { 1994 | z-index: 99; 1995 | display: flex; 1996 | flex-wrap: wrap; 1997 | position: fixed; 1998 | bottom: 20px; 1999 | box-shadow: 0px 0px 300px #ADADAD; 2000 | padding: 0 5px; 2001 | border-radius: 5px; 2002 | background-color: #313131; 2003 | } 2004 | 2005 | #moderation ul.toolbar li { 2006 | margin: 10px 0; 2007 | } 2008 | 2009 | #moderation ul.toolbar li.close-toolbar { 2010 | display: flex; 2011 | justify-content: center; 2012 | align-items: center; 2013 | padding: 0; 2014 | margin: 0; 2015 | } 2016 | 2017 | #moderation ul.toolbar li.close-toolbar .fa { 2018 | font-size: 30px; 2019 | } 2020 | 2021 | #moderation ul.toolbar li.close-toolbar a { 2022 | padding: 0 10px; 2023 | } 2024 | 2025 | #moderation ul.toolbar.open-toolbar { 2026 | display: none; 2027 | } 2028 | 2029 | #moderation ul.toolbar:not(:last-of-type) { 2030 | border-right: 1px solid rgba(255, 255, 255, 0.3); 2031 | } 2032 | 2033 | #moderation ul.toolbar a { 2034 | display: inline-block; 2035 | color: white; 2036 | line-height: 1.5; 2037 | padding: 5px 20px 5px 10px; 2038 | } 2039 | 2040 | #moderation ul.toolbar a:hover .fa { 2041 | opacity: 1; 2042 | } 2043 | 2044 | #moderation ul.toolbar a .fa { 2045 | opacity: 0.5; 2046 | margin: 0px 8px; 2047 | } 2048 | 2049 | #moderation ul.toolbar a:focus { 2050 | outline: none; 2051 | } 2052 | 2053 | @media (min-width: 240px) and (max-width: 720px) { 2054 | #moderation ul.toolbar { 2055 | position: fixed; 2056 | bottom: 0; 2057 | margin: 0; 2058 | padding: 0; 2059 | width: 100%; 2060 | border-bottom-left-radius: 0; 2061 | border-bottom-right-radius: 0; 2062 | } 2063 | 2064 | #moderation ul.toolbar li { 2065 | flex-basis: 100%; 2066 | margin: 0; 2067 | text-align: center; 2068 | border-right: none !important; 2069 | } 2070 | 2071 | #moderation ul.toolbar li a { 2072 | display: block; 2073 | border: none; 2074 | font-size: 18px; 2075 | padding: 7px 0; 2076 | } 2077 | 2078 | #moderation ul.toolbar li.close-toolbar .fa::before { 2079 | content: '\f107'; 2080 | font-family: FontAwesome; 2081 | } 2082 | } 2083 | 2084 | #moderation ul.toolbar-collapsed { 2085 | opacity: 0.6; 2086 | } 2087 | 2088 | #moderation ul.toolbar-collapsed:hover { 2089 | opacity: 1; 2090 | } 2091 | 2092 | #moderation ul.toolbar-collapsed li, #moderation ul.toolbar-collapsed li.close-toolbar { 2093 | display: none; 2094 | } 2095 | 2096 | #moderation ul.toolbar-collapsed li.open-toolbar { 2097 | display: inline-block; 2098 | border: none; 2099 | } 2100 | 2101 | #moderation ul.toolbar-collapsed li.open-toolbar a { 2102 | padding: 0 10px 0 0; 2103 | } 2104 | 2105 | @media (min-width: 240px) and (max-width: 720px) { 2106 | #moderation ul.toolbar-collapsed { 2107 | border-radius: 0; 2108 | } 2109 | 2110 | #moderation ul.toolbar-collapsed li.open-toolbar { 2111 | display: inline-block; 2112 | border: none; 2113 | } 2114 | 2115 | #moderation ul.toolbar-collapsed li.open-toolbar a { 2116 | padding: 0; 2117 | font-size: 0; 2118 | line-height: 0.95; 2119 | } 2120 | 2121 | #moderation ul.toolbar-collapsed li.open-toolbar a .fa { 2122 | margin: 0; 2123 | } 2124 | 2125 | #moderation ul.toolbar-collapsed li.open-toolbar a::before { 2126 | content: '\f106'; 2127 | font-family: FontAwesome; 2128 | font-size: 30px; 2129 | } 2130 | } 2131 | 2132 | @media (min-width: 240px) and (max-width: 720px) { 2133 | body { 2134 | padding-bottom: 20px; 2135 | } 2136 | } 2137 | 2138 | .modal-container { 2139 | position: fixed; 2140 | top: 0; 2141 | left: 0; 2142 | z-index: 100; 2143 | display: flex; 2144 | justify-content: center; 2145 | background: rgba(0, 0, 0, 0.4); 2146 | height: 100vh; 2147 | width: 100vw; 2148 | } 2149 | 2150 | .modal-container .modal { 2151 | display: flex; 2152 | flex-wrap: wrap; 2153 | z-index: 200; 2154 | position: fixed; 2155 | top: 10vh; 2156 | width: 50vw; 2157 | max-width: 550px; 2158 | min-height: 25vh; 2159 | background: #F5F8FE; 2160 | background: #fcfdff; 2161 | background-color: white; 2162 | border-radius: 8px; 2163 | } 2164 | 2165 | @media screen and (min-width: 240px) and (max-width: 900px) { 2166 | .modal-container .modal { 2167 | top: 5vh; 2168 | width: 95vw; 2169 | min-height: 40vh; 2170 | } 2171 | } 2172 | 2173 | .modal-container .modal hr { 2174 | margin: 5px; 2175 | } 2176 | 2177 | .modal-container .modal .btn-group { 2178 | margin: 0; 2179 | padding: 0; 2180 | } 2181 | 2182 | .modal-container .modal .modal-header, .modal-container .modal .modal-footer { 2183 | padding: 15px; 2184 | flex-basis: 100%; 2185 | } 2186 | 2187 | .modal-container .modal .modal-header { 2188 | border-bottom: 3px solid rgba(73, 89, 96, 0.06); 2189 | } 2190 | 2191 | .modal-container .modal .modal-header .title { 2192 | font-size: 32px; 2193 | } 2194 | 2195 | .modal-container .modal .modal-content { 2196 | padding: 10px 30px; 2197 | min-height: 200px; 2198 | } 2199 | 2200 | .modal-container .modal .modal-footer { 2201 | background: rgba(73, 89, 96, 0.06); 2202 | border-bottom-left-radius: 8px; 2203 | border-bottom-right-radius: 8px; 2204 | } 2205 | 2206 | a.close { 2207 | display: flex; 2208 | position: absolute; 2209 | right: 10px; 2210 | top: 10px; 2211 | color: #263959; 2212 | font-size: 22px; 2213 | opacity: .7; 2214 | } 2215 | 2216 | a.close:hover { 2217 | opacity: 1; 2218 | color: #263959; 2219 | } 2220 | 2221 | body { 2222 | font-family: 'Open Sans', sans-serif; 2223 | color: #545454; 2224 | font-size: 16px; 2225 | line-height: 1.5; 2226 | overflow-x: hidden; 2227 | box-sizing: border-box; 2228 | } 2229 | 2230 | @media (min-width: 240px) and (max-width: 720px) { 2231 | body { 2232 | font-size: 15px; 2233 | } 2234 | } 2235 | 2236 | body a { 2237 | color: #57AD8D; 2238 | text-decoration: none; 2239 | transition: all .6s ease; 2240 | } 2241 | 2242 | body a:hover { 2243 | color: #41826a; 2244 | cursor: pointer; 2245 | } 2246 | 2247 | h1, h2, h3, h4, h5 { 2248 | font-weight: 700; 2249 | margin-bottom: 10px; 2250 | margin-top: 0; 2251 | margin-left: 0; 2252 | margin-right: 0; 2253 | } 2254 | 2255 | ul { 2256 | padding: 0; 2257 | } 2258 | 2259 | li { 2260 | list-style: none; 2261 | } 2262 | 2263 | li a { 2264 | text-decoration: none; 2265 | } 2266 | 2267 | p { 2268 | margin: 0; 2269 | } 2270 | 2271 | figure { 2272 | margin: 0; 2273 | } 2274 | 2275 | .flex-column { 2276 | display: flex; 2277 | margin: 0 auto; 2278 | max-width: 1000px; 2279 | flex-direction: column; 2280 | } 2281 | 2282 | @media (min-width: 1360px) { 2283 | .flex-column { 2284 | max-width: 1300px; 2285 | } 2286 | } 2287 | 2288 | .flex-grid { 2289 | display: flex; 2290 | margin: 0 auto; 2291 | max-width: 1200px; 2292 | flex-grow: 1; 2293 | flex-wrap: wrap; 2294 | } 2295 | 2296 | @media (min-width: 1360px) { 2297 | .flex-grid { 2298 | max-width: 1300px; 2299 | } 2300 | } 2301 | 2302 | .flex-reverse { 2303 | flex-direction: row-reverse; 2304 | } 2305 | 2306 | .align-center { 2307 | align-items: center; 2308 | } 2309 | 2310 | .col { 2311 | margin: 0 1%; 2312 | } 2313 | 2314 | @media (min-width: 240px) and (max-width: 720px) { 2315 | .col { 2316 | margin: 0 4%; 2317 | } 2318 | } 2319 | 2320 | .col-70 { 2321 | flex-basis: 70%; 2322 | margin: 0 auto; 2323 | } 2324 | 2325 | @media (min-width: 1360px) { 2326 | .col-70 { 2327 | flex-basis: 70%; 2328 | } 2329 | } 2330 | 2331 | @media (min-width: 600px) and (max-width: 1023px) { 2332 | .col-70 { 2333 | flex-basis: 80%; 2334 | } 2335 | } 2336 | 2337 | @media (min-width: 240px) and (max-width: 720px) { 2338 | .col-70 { 2339 | flex-basis: 96%; 2340 | margin: 0 4%; 2341 | } 2342 | } 2343 | 2344 | .col-2 { 2345 | box-sizing: border-box; 2346 | flex-basis: 48%; 2347 | } 2348 | 2349 | @media (min-width: 240px) and (max-width: 720px) { 2350 | .col-2 { 2351 | flex-basis: 96%; 2352 | } 2353 | } 2354 | 2355 | .col-3 { 2356 | flex-basis: 31%; 2357 | } 2358 | 2359 | @media (min-width: 600px) and (max-width: 1023px) { 2360 | .col-3 { 2361 | flex-basis: 96%; 2362 | margin: 4% 2%; 2363 | } 2364 | } 2365 | 2366 | @media (min-width: 240px) and (max-width: 720px) { 2367 | .col-3 { 2368 | flex-basis: 96%; 2369 | margin: 4% 4%; 2370 | } 2371 | } 2372 | 2373 | .col-4 { 2374 | flex-basis: 23%; 2375 | } 2376 | 2377 | @media (min-width: 240px) and (max-width: 720px) { 2378 | .col-4 { 2379 | flex-basis: 46%; 2380 | } 2381 | } 2382 | 2383 | .col-5 { 2384 | flex-basis: 18%; 2385 | } 2386 | 2387 | @media (min-width: 240px) and (max-width: 720px) { 2388 | .col-5 { 2389 | flex-basis: 46%; 2390 | } 2391 | } 2392 | 2393 | .col-7 { 2394 | flex-basis: 68%; 2395 | } 2396 | 2397 | @media (min-width: 600px) and (max-width: 1023px) { 2398 | .col-7 { 2399 | flex-basis: 96%; 2400 | margin: 4% 2%; 2401 | } 2402 | } 2403 | 2404 | @media (min-width: 240px) and (max-width: 720px) { 2405 | .col-7 { 2406 | flex-basis: 96%; 2407 | margin: 4% 4%; 2408 | } 2409 | } 2410 | 2411 | .container { 2412 | display: flex; 2413 | max-width: 1200px; 2414 | margin: 0 auto; 2415 | align-items: center; 2416 | justify-content: center; 2417 | flex-wrap: wrap; 2418 | } 2419 | 2420 | @media (min-width: 240px) and (max-width: 720px) { 2421 | .container { 2422 | width: 96%; 2423 | } 2424 | } 2425 | 2426 | .col-full { 2427 | flex-basis: 98%; 2428 | margin: 0 1%; 2429 | } 2430 | 2431 | @media (max-width: 820px) { 2432 | .col-full { 2433 | flex-basis: 98%; 2434 | margin: 0 1%; 2435 | } 2436 | } 2437 | 2438 | .col-large { 2439 | flex-basis: 72%; 2440 | margin: 0 1%; 2441 | } 2442 | 2443 | @media (max-width: 1024px) { 2444 | .col-large { 2445 | flex-basis: 98%; 2446 | margin: 0 1%; 2447 | } 2448 | } 2449 | 2450 | .col-small { 2451 | flex-basis: 24%; 2452 | margin: 0 1%; 2453 | } 2454 | 2455 | @media (min-width: 240px) and (max-width: 720px) { 2456 | .col-small { 2457 | flex-basis: 98%; 2458 | margin: 0 1%; 2459 | } 2460 | } 2461 | 2462 | .align-center { 2463 | align-items: center; 2464 | justify-content: center; 2465 | } 2466 | 2467 | .justify-center { 2468 | justify-content: center; 2469 | } 2470 | 2471 | .justify-left { 2472 | justify-content: flex-start; 2473 | } 2474 | 2475 | .justify-right { 2476 | justify-content: flex-end; 2477 | } 2478 | 2479 | .justify-space-between { 2480 | justify-content: space-between; 2481 | } 2482 | 2483 | @media (max-width: 820px) { 2484 | .desktop-only, .forum-list .forum-listing .threads-count, .forum-list .forum-listing .last-thread, .forum-stats, .thread-list .thread .activity, .navbar-user { 2485 | display: none; 2486 | } 2487 | } 2488 | 2489 | @media (min-width: 820px) { 2490 | .mobile-only, .navbar-mobile-item { 2491 | display: none; 2492 | } 2493 | } 2494 | 2495 | @media (max-width: 720px) { 2496 | .hide-mobile { 2497 | display: none; 2498 | } 2499 | } 2500 | 2501 | @media (min-width: 720px) and (max-width: 820px) { 2502 | .hide-tablet { 2503 | display: none; 2504 | } 2505 | } 2506 | 2507 | @media (max-width: 720px) { 2508 | .hide-desktop { 2509 | display: none; 2510 | } 2511 | } 2512 | 2513 | section { 2514 | margin-top: 20px; 2515 | } 2516 | 2517 | .push-top { 2518 | margin-top: 20px; 2519 | } 2520 | 2521 | .no-margin { 2522 | margin: 0 !important; 2523 | } 2524 | 2525 | .link-white { 2526 | color: white; 2527 | } 2528 | 2529 | .link-unstyled, ul.breadcrumbs li a { 2530 | color: inherit; 2531 | } 2532 | 2533 | .faded, .btn:disabled, .btn-blue:disabled, .btn-blue-outlined:disabled, .btn-brown:disabled, .btn-brown-outlined:disabled, .btn-green:disabled, .btn-green-outlined:disabled, .btn-red:disabled, .btn-red-outlined:disabled, .btn-ghost:disabled, .btn-disabled, a > img:hover { 2534 | opacity: 0.8; 2535 | } 2536 | 2537 | a > img { 2538 | transition: all .6s ease; 2539 | } 2540 | 2541 | hr { 2542 | border: 0; 2543 | height: 1px; 2544 | background: #333; 2545 | background-image: linear-gradient(to right, #F7F9FE, #D1D3D7, #F7F9FE); 2546 | margin-bottom: 20px; 2547 | } 2548 | 2549 | .fa-btn { 2550 | padding-right: 3px; 2551 | } 2552 | 2553 | #app { 2554 | background: #F5F8FE; 2555 | min-height: 100vh; 2556 | } -------------------------------------------------------------------------------- /src/assets/img/arrow-profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/img/vueschool-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vue-master-class/4a2febe07c9873c96c84d5108f03768096af3084/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/AppDate.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 26 | 27 | 30 | -------------------------------------------------------------------------------- /src/components/AppSpinner.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 21 | 22 | 159 | -------------------------------------------------------------------------------- /src/components/CategoryList.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | 27 | 30 | -------------------------------------------------------------------------------- /src/components/CategoryListItem.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 37 | 38 | 41 | -------------------------------------------------------------------------------- /src/components/ForumList.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | 27 | 30 | -------------------------------------------------------------------------------- /src/components/ForumListItem.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 42 | 43 | 46 | -------------------------------------------------------------------------------- /src/components/PostEditor.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 95 | 96 | 99 | -------------------------------------------------------------------------------- /src/components/PostList.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | 27 | 30 | -------------------------------------------------------------------------------- /src/components/PostListItem.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 74 | 75 | 78 | -------------------------------------------------------------------------------- /src/components/TheNavbar.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 92 | 93 | 96 | -------------------------------------------------------------------------------- /src/components/ThreadEditor.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 90 | 91 | 94 | -------------------------------------------------------------------------------- /src/components/ThreadList.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | -------------------------------------------------------------------------------- /src/components/ThreadListItem.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 50 | -------------------------------------------------------------------------------- /src/components/UserProfileCard.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 68 | 69 | 72 | -------------------------------------------------------------------------------- /src/components/UserProfileCardEditor.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 147 | 148 | 151 | -------------------------------------------------------------------------------- /src/directives/click-outside.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bind (el, binding) { 3 | el.__ClickOutsideHandler__ = event => { 4 | // check if event's target is the el or contained by el 5 | if (!(el === event.target || el.contains(event.target))) { 6 | binding.value(event) 7 | } 8 | } 9 | document.body.addEventListener('click', el.__ClickOutsideHandler__) 10 | }, 11 | unbind (el) { 12 | document.body.removeEventListener('click', el.__ClickOutsideHandler__) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/directives/handle-scroll.js: -------------------------------------------------------------------------------- 1 | export default { 2 | bind (el, binding) { 3 | el.__HandleScrollHandler__ = event => binding.value(event) 4 | document.body.addEventListener('mousewheel', el.__HandleScrollHandler__) 5 | document.body.addEventListener('touchmove', el.__HandleScrollHandler__) 6 | }, 7 | 8 | unbind (el) { 9 | document.body.removeEventListener('mousewheel', el.__HandleScrollHandler__) 10 | document.body.removeEventListener('touchmove', el.__HandleScrollHandler__) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import Vue from 'vue' 4 | import firebase from 'firebase' 5 | import App from './App' 6 | import router from './router' 7 | import store from '@/store' 8 | import AppDate from '@/components/AppDate' 9 | import vuelidate from 'vuelidate' 10 | 11 | Vue.use(vuelidate) 12 | Vue.component('AppDate', AppDate) 13 | 14 | Vue.config.productionTip = false 15 | 16 | // Initialize Firebase 17 | const config = { 18 | apiKey: process.env.FIREBASE_API_KEY, 19 | authDomain: process.env.FIREBASE_AUTH_DOMAIN, 20 | databaseURL: process.env.FIREBASE_DATABASE_URL, 21 | projectId: process.env.FIREBASE_PROJECT_ID, 22 | storageBucket: process.env.FIREBASE_STORAGE_BUCKET, 23 | messagingSenderId: process.env.FIREBASE_MESSAGING_ID 24 | } 25 | firebase.initializeApp(config) 26 | 27 | /* eslint-disable no-new */ 28 | new Vue({ 29 | el: '#app', 30 | router, 31 | store, 32 | template: '', 33 | components: { App } 34 | }) 35 | -------------------------------------------------------------------------------- /src/mixins/asyncDataStatus.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data () { 3 | return { 4 | asyncDataStatus_ready: false 5 | } 6 | }, 7 | 8 | methods: { 9 | asyncDataStatus_fetched () { 10 | this.asyncDataStatus_ready = true 11 | this.$emit('ready') 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/PageCategory.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 45 | 46 | 49 | -------------------------------------------------------------------------------- /src/pages/PageForum.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 68 | 69 | 74 | -------------------------------------------------------------------------------- /src/pages/PageHome.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 40 | -------------------------------------------------------------------------------- /src/pages/PageNotFound.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 24 | 25 | 37 | -------------------------------------------------------------------------------- /src/pages/PageProfile.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 64 | 65 | 68 | -------------------------------------------------------------------------------- /src/pages/PageRegister.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 150 | 151 | 154 | -------------------------------------------------------------------------------- /src/pages/PageSignIn.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 100 | 101 | 104 | -------------------------------------------------------------------------------- /src/pages/PageThreadCreate.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 88 | 89 | 92 | -------------------------------------------------------------------------------- /src/pages/PageThreadEdit.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 91 | 92 | 95 | -------------------------------------------------------------------------------- /src/pages/PageThreadShow.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 102 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import store from '@/store' 3 | import Router from 'vue-router' 4 | import Home from '@/pages/PageHome' 5 | import ThreadShow from '@/pages/PageThreadShow' 6 | import ThreadCreate from '@/pages/PageThreadCreate' 7 | import ThreadEdit from '@/pages/PageThreadEdit' 8 | import Category from '@/pages/PageCategory' 9 | import Forum from '@/pages/PageForum' 10 | import Profile from '@/pages/PageProfile' 11 | import Register from '@/pages/PageRegister' 12 | import SignIn from '@/pages/PageSignIn' 13 | import NotFound from '@/pages/PageNotFound' 14 | Vue.use(Router) 15 | 16 | const router = new Router({ 17 | routes: [ 18 | { 19 | path: '/', 20 | name: 'Home', 21 | component: Home 22 | }, 23 | { 24 | path: '/category/:id', 25 | name: 'Category', 26 | component: Category, 27 | props: true 28 | }, 29 | { 30 | path: '/forum/:id', 31 | name: 'Forum', 32 | component: Forum, 33 | props: true 34 | }, 35 | { 36 | path: '/thread/create/:forumId', 37 | name: 'ThreadCreate', 38 | component: ThreadCreate, 39 | props: true, 40 | meta: { requiresAuth: true } 41 | }, 42 | { 43 | path: '/thread/:id', 44 | name: 'ThreadShow', 45 | component: ThreadShow, 46 | props: true 47 | }, 48 | { 49 | path: '/thread/:id/edit', 50 | name: 'ThreadEdit', 51 | component: ThreadEdit, 52 | props: true, 53 | meta: { requiresAuth: true } 54 | }, 55 | { 56 | path: '/me', 57 | name: 'Profile', 58 | component: Profile, 59 | props: true, 60 | meta: { requiresAuth: true } 61 | }, 62 | { 63 | path: '/me/edit', 64 | name: 'ProfileEdit', 65 | component: Profile, 66 | props: {edit: true}, 67 | meta: { requiresAuth: true } 68 | }, 69 | { 70 | path: '/register', 71 | name: 'Register', 72 | component: Register, 73 | meta: { requiresGuest: true } 74 | }, 75 | { 76 | path: '/signin', 77 | name: 'SignIn', 78 | component: SignIn, 79 | meta: { requiresGuest: true } 80 | }, 81 | { 82 | path: '/logout', 83 | name: 'SignOut', 84 | meta: { requiresAuth: true }, 85 | beforeEnter (to, from, next) { 86 | store.dispatch('signOut') 87 | .then(() => next({name: 'Home'})) 88 | } 89 | }, 90 | { 91 | path: '*', 92 | name: 'NotFound', 93 | component: NotFound 94 | } 95 | ], 96 | mode: 'history' 97 | }) 98 | 99 | router.beforeEach((to, from, next) => { 100 | console.log(`🚦 navigating to ${to.name} from ${from.name}`) 101 | 102 | store.dispatch('auth/initAuthentication') 103 | .then(user => { 104 | if (to.matched.some(route => route.meta.requiresAuth)) { 105 | // protected route 106 | if (user) { 107 | next() 108 | } else { 109 | next({name: 'SignIn', query: {redirectTo: to.path}}) 110 | } 111 | } else if (to.matched.some(route => route.meta.requiresGuest)) { 112 | // protected route 113 | if (!user) { 114 | next() 115 | } else { 116 | next({name: 'Home'}) 117 | } 118 | } else { 119 | next() 120 | } 121 | }) 122 | }) 123 | 124 | export default router 125 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase' 2 | 3 | export default { 4 | fetchItem ({state, commit}, {id, emoji, resource}) { 5 | console.log('🔥‍', emoji, id) 6 | return new Promise((resolve, reject) => { 7 | firebase.database().ref(resource).child(id).once('value', snapshot => { 8 | commit('setItem', {resource, id: snapshot.key, item: snapshot.val()}) 9 | resolve(state[resource].items[id]) 10 | }) 11 | }) 12 | }, 13 | 14 | fetchItems ({dispatch}, {ids, resource, emoji}) { 15 | ids = Array.isArray(ids) ? ids : Object.keys(ids) 16 | return Promise.all(ids.map(id => dispatch('fetchItem', {id, resource, emoji}))) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/store/assetHelpers.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | export const makeAppendChildToParentMutation = ({parent, child}) => 4 | (state, {childId, parentId}) => { 5 | const resource = state.items[parentId] 6 | if (!resource[child]) { 7 | Vue.set(resource, child, {}) 8 | } 9 | Vue.set(resource[child], childId, childId) 10 | } 11 | -------------------------------------------------------------------------------- /src/store/getters.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import actions from './actions' 4 | import mutations from './mutations' 5 | import getters from './getters' 6 | import categories from './modules/categories' 7 | import forums from './modules/forums' 8 | import threads from './modules/threads' 9 | import posts from './modules/posts' 10 | import users from './modules/users' 11 | import auth from './modules/auth' 12 | 13 | Vue.use(Vuex) 14 | 15 | export default new Vuex.Store({ 16 | state: {}, 17 | getters, 18 | actions, 19 | mutations, 20 | modules: { 21 | categories, 22 | forums, 23 | threads, 24 | posts, 25 | users, 26 | auth 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /src/store/modules/auth.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase' 2 | export default { 3 | namespaced: true, 4 | 5 | state: { 6 | authId: null, 7 | unsubscribeAuthObserver: null 8 | }, 9 | 10 | getters: { 11 | authUser (state, getters, rootState) { 12 | return state.authId ? rootState.users.items[state.authId] : null 13 | } 14 | }, 15 | 16 | actions: { 17 | initAuthentication ({dispatch, commit, state}) { 18 | return new Promise((resolve, reject) => { 19 | // unsubscribe observer if already listening 20 | if (state.unsubscribeAuthObserver) { 21 | state.unsubscribeAuthObserver() 22 | } 23 | 24 | const unsubscribe = firebase.auth().onAuthStateChanged(user => { 25 | console.log('👣 the user has changed') 26 | if (user) { 27 | dispatch('fetchAuthUser') 28 | .then(dbUser => resolve(dbUser)) 29 | } else { 30 | resolve(null) 31 | } 32 | }) 33 | commit('setUnsubscribeAuthObserver', unsubscribe) 34 | }) 35 | }, 36 | 37 | registerUserWithEmailAndPassword ({dispatch}, {email, name, username, password, avatar = null}) { 38 | return firebase.auth().createUserWithEmailAndPassword(email, password) 39 | .then(user => { 40 | return dispatch('users/createUser', {id: user.uid, email, name, username, password, avatar}, {root: true}) 41 | }) 42 | .then(() => dispatch('fetchAuthUser')) 43 | }, 44 | 45 | signInWithEmailAndPassword (context, {email, password}) { 46 | return firebase.auth().signInWithEmailAndPassword(email, password) 47 | }, 48 | 49 | signInWithGoogle ({dispatch}) { 50 | const provider = new firebase.auth.GoogleAuthProvider() 51 | return firebase.auth().signInWithPopup(provider) 52 | .then(data => { 53 | const user = data.user 54 | firebase.database().ref('users').child(user.uid).once('value', snapshot => { 55 | if (!snapshot.exists()) { 56 | return dispatch('users/createUser', {id: user.uid, name: user.displayName, email: user.email, username: user.email, avatar: user.photoURL}, {root: true}) 57 | .then(() => dispatch('fetchAuthUser')) 58 | } 59 | }) 60 | }) 61 | }, 62 | 63 | signOut ({commit}) { 64 | return firebase.auth().signOut() 65 | .then(() => { 66 | commit('setAuthId', null) 67 | }) 68 | }, 69 | 70 | fetchAuthUser ({dispatch, commit}) { 71 | const userId = firebase.auth().currentUser.uid 72 | return new Promise((resolve, reject) => { 73 | // check if user exists in the database 74 | firebase.database().ref('users').child(userId).once('value', snapshot => { 75 | if (snapshot.exists()) { 76 | return dispatch('users/fetchUser', {id: userId}, {root: true}) 77 | .then(user => { 78 | commit('setAuthId', userId) 79 | resolve(user) 80 | }) 81 | } else { 82 | resolve(null) 83 | } 84 | }) 85 | }) 86 | } 87 | }, 88 | 89 | mutations: { 90 | setAuthId (state, id) { 91 | state.authId = id 92 | }, 93 | 94 | setUnsubscribeAuthObserver (state, unsubscribe) { 95 | state.unsubscribeAuthObserver = unsubscribe 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/store/modules/categories.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase' 2 | 3 | export default { 4 | namespaced: true, 5 | 6 | state: { 7 | items: {} 8 | }, 9 | 10 | actions: { 11 | fetchAllCategories ({state, commit}) { 12 | console.log('🔥', '🏷', 'all') 13 | return new Promise((resolve, reject) => { 14 | firebase.database().ref('categories').once('value', snapshot => { 15 | const categoriesObject = snapshot.val() 16 | Object.keys(categoriesObject).forEach(categoryId => { 17 | const category = categoriesObject[categoryId] 18 | commit('setItem', {resource: 'categories', id: categoryId, item: category}, {root: true}) 19 | }) 20 | resolve(Object.values(state.items)) 21 | }) 22 | }) 23 | }, 24 | 25 | fetchCategory: ({dispatch}, {id}) => dispatch('fetchItem', {resource: 'categories', id, emoji: '🏷'}, {root: true}), 26 | fetchCategories: ({dispatch}, {ids}) => dispatch('fetchItems', {resource: 'categories', ids, emoji: '🏷'}, {root: true}) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/store/modules/forums.js: -------------------------------------------------------------------------------- 1 | import {makeAppendChildToParentMutation} from '@/store/assetHelpers' 2 | 3 | export default { 4 | namespaced: true, 5 | 6 | state: { 7 | items: {} 8 | }, 9 | 10 | actions: { 11 | fetchForum: ({dispatch}, {id}) => dispatch('fetchItem', {resource: 'forums', id, emoji: '🌧'}, {root: true}), 12 | fetchForums: ({dispatch}, {ids}) => dispatch('fetchItems', {resource: 'forums', ids, emoji: '🌧'}, {root: true}) 13 | }, 14 | 15 | mutations: { 16 | appendThreadToForum: makeAppendChildToParentMutation({parent: 'forums', child: 'threads'}) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/store/modules/posts.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import firebase from 'firebase' 3 | 4 | export default { 5 | namespaced: true, 6 | 7 | state: { 8 | items: {} 9 | }, 10 | 11 | actions: { 12 | createPost ({commit, state, rootState}, post) { 13 | const postId = firebase.database().ref('posts').push().key 14 | post.userId = rootState.auth.authId 15 | post.publishedAt = Math.floor(Date.now() / 1000) 16 | 17 | const updates = {} 18 | updates[`posts/${postId}`] = post 19 | updates[`threads/${post.threadId}/posts/${postId}`] = postId 20 | updates[`threads/${post.threadId}/contributors/${post.userId}`] = post.userId 21 | updates[`users/${post.userId}/posts/${postId}`] = postId 22 | firebase.database().ref().update(updates) 23 | .then(() => { 24 | commit('setItem', {resource: 'posts', item: post, id: postId}, {root: true}) 25 | commit('threads/appendPostToThread', {parentId: post.threadId, childId: postId}, {root: true}) 26 | commit('threads/appendContributorToThread', {parentId: post.threadId, childId: post.userId}, {root: true}) 27 | commit('users/appendPostToUser', {parentId: post.userId, childId: postId}, {root: true}) 28 | return Promise.resolve(state.items[postId]) 29 | }) 30 | }, 31 | 32 | updatePost ({state, commit, rootState}, {id, text}) { 33 | return new Promise((resolve, reject) => { 34 | const post = state.items[id] 35 | const edited = { 36 | at: Math.floor(Date.now() / 1000), 37 | by: rootState.auth.authId 38 | } 39 | 40 | const updates = {text, edited} 41 | firebase.database().ref('posts').child(id).update(updates) 42 | .then(() => { 43 | commit('setPost', {postId: id, post: {...post, text, edited}}) 44 | resolve(post) 45 | }) 46 | }) 47 | }, 48 | 49 | fetchPost: ({dispatch}, {id}) => dispatch('fetchItem', {resource: 'posts', id, emoji: '💬'}, {root: true}), 50 | fetchPosts: ({dispatch}, {ids}) => dispatch('fetchItems', {resource: 'posts', ids, emoji: '💬'}, {root: true}) 51 | }, 52 | 53 | mutations: { 54 | setPost (state, {post, postId}) { 55 | Vue.set(state.items, postId, post) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/store/modules/threads.js: -------------------------------------------------------------------------------- 1 | import {countObjectProperties} from '@/utils' 2 | import firebase from 'firebase' 3 | import Vue from 'vue' 4 | import {makeAppendChildToParentMutation} from '@/store/assetHelpers' 5 | 6 | export default { 7 | namespaced: true, 8 | 9 | state: { 10 | items: {} 11 | }, 12 | 13 | getters: { 14 | threadRepliesCount: state => id => countObjectProperties(state.items[id].posts) - 1 15 | }, 16 | 17 | actions: { 18 | createThread ({state, commit, dispatch, rootState}, {text, title, forumId}) { 19 | return new Promise((resolve, reject) => { 20 | const threadId = firebase.database().ref('threads').push().key 21 | const postId = firebase.database().ref('posts').push().key 22 | const userId = rootState.auth.authId 23 | const publishedAt = Math.floor(Date.now() / 1000) 24 | 25 | const thread = {title, forumId, publishedAt, userId, firstPostId: postId, posts: {}} 26 | thread.posts[postId] = postId 27 | const post = {text, publishedAt, threadId, userId} 28 | 29 | const updates = {} 30 | updates[`threads/${threadId}`] = thread 31 | updates[`forums/${forumId}/threads/${threadId}`] = threadId 32 | updates[`users/${userId}/threads/${threadId}`] = threadId 33 | 34 | updates[`posts/${postId}`] = post 35 | updates[`users/${userId}/posts/${postId}`] = postId 36 | firebase.database().ref().update(updates) 37 | .then(() => { 38 | // update thread 39 | commit('setItem', {resource: 'threads', id: threadId, item: thread}, {root: true}) 40 | commit('forums/appendThreadToForum', {parentId: forumId, childId: threadId}, {root: true}) 41 | commit('users/appendThreadToUser', {parentId: userId, childId: threadId}, {root: true}) 42 | // update post 43 | commit('setItem', {resource: 'posts', item: post, id: postId}, {root: true}) 44 | commit('appendPostToThread', {parentId: post.threadId, childId: postId}) 45 | commit('users/appendPostToUser', {parentId: post.userId, childId: postId}, {root: true}) 46 | 47 | resolve(state.items[threadId]) 48 | }) 49 | }) 50 | }, 51 | 52 | updateThread ({state, commit, dispatch, rootState}, {title, text, id}) { 53 | return new Promise((resolve, reject) => { 54 | const thread = state.items[id] 55 | const post = rootState.posts.items[thread.firstPostId] 56 | 57 | const edited = { 58 | at: Math.floor(Date.now() / 1000), 59 | by: rootState.auth.authId 60 | } 61 | 62 | const updates = {} 63 | updates[`posts/${thread.firstPostId}/text`] = text 64 | updates[`posts/${thread.firstPostId}/edited`] = edited 65 | updates[`threads/${id}/title`] = title 66 | 67 | firebase.database().ref().update(updates) 68 | .then(() => { 69 | commit('setThread', {thread: {...thread, title}, threadId: id}) 70 | commit('posts/setPost', {postId: thread.firstPostId, post: {...post, text, edited}}, {root: true}) 71 | resolve(post) 72 | }) 73 | }) 74 | }, 75 | fetchThread: ({dispatch}, {id}) => dispatch('fetchItem', {resource: 'threads', id, emoji: '📄'}, {root: true}), 76 | fetchThreads: ({dispatch}, {ids}) => dispatch('fetchItems', {resource: 'threads', ids, emoji: '🌧'}, {root: true}) 77 | }, 78 | mutations: { 79 | setThread (state, {thread, threadId}) { 80 | Vue.set(state.items, threadId, thread) 81 | }, 82 | 83 | appendPostToThread: makeAppendChildToParentMutation({parent: 'threads', child: 'posts'}), 84 | appendContributorToThread: makeAppendChildToParentMutation({parent: 'threads', child: 'contributors'}) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/store/modules/users.js: -------------------------------------------------------------------------------- 1 | import {countObjectProperties, removeEmptyProperties} from '@/utils' 2 | import firebase from 'firebase' 3 | import Vue from 'vue' 4 | import {makeAppendChildToParentMutation} from '@/store/assetHelpers' 5 | 6 | export default { 7 | namespaced: true, 8 | 9 | state: { 10 | items: {} 11 | }, 12 | 13 | getters: { 14 | userPosts: (state, getters, rootState) => id => { 15 | const user = state.items[id] 16 | if (user.posts) { 17 | return Object.values(rootState.posts.items) 18 | .filter(post => post.userId === id) 19 | } 20 | return [] 21 | }, 22 | 23 | userThreadsCount: state => id => countObjectProperties(state.items[id].threads), 24 | userPostsCount: state => id => countObjectProperties(state.items[id].posts) 25 | }, 26 | 27 | actions: { 28 | createUser ({state, commit}, {id, email, name, username, avatar = null}) { 29 | return new Promise((resolve, reject) => { 30 | const registeredAt = Math.floor(Date.now() / 1000) 31 | const usernameLower = username.toLowerCase() 32 | email = email.toLowerCase() 33 | const user = {avatar, email, name, username, usernameLower, registeredAt} 34 | firebase.database().ref('users').child(id).set(user) 35 | .then(() => { 36 | commit('setItem', {resource: 'users', id: id, item: user}, {root: true}) 37 | resolve(state.items[id]) 38 | }) 39 | }) 40 | }, 41 | 42 | updateUser ({commit}, user) { 43 | const updates = { 44 | avatar: user.avatar, 45 | username: user.username, 46 | name: user.name, 47 | bio: user.bio, 48 | website: user.website, 49 | email: user.email, 50 | location: user.location 51 | } 52 | return new Promise((resolve, reject) => { 53 | firebase.database().ref('users').child(user['.key']).update(removeEmptyProperties(updates)) 54 | .then(() => { 55 | commit('setUser', {userId: user['.key'], user}) 56 | resolve(user) 57 | }) 58 | }) 59 | }, 60 | 61 | fetchUser: ({dispatch}, {id}) => dispatch('fetchItem', {resource: 'users', id, emoji: '🙋'}, {root: true}), 62 | fetchUsers: ({dispatch}, {ids}) => dispatch('fetchItems', {resource: 'users', ids, emoji: '🙋'}, {root: true}) 63 | }, 64 | 65 | mutations: { 66 | setUser (state, {user, userId}) { 67 | Vue.set(state.items, userId, user) 68 | }, 69 | 70 | appendPostToUser: makeAppendChildToParentMutation({parent: 'users', child: 'posts'}), 71 | appendThreadToUser: makeAppendChildToParentMutation({parent: 'users', child: 'threads'}) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | export default { 4 | setItem (state, {item, id, resource}) { 5 | item['.key'] = id 6 | Vue.set(state[resource].items, id, item) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | const countObjectProperties = obj => { 2 | if (typeof obj === 'object') { 3 | return Object.keys(obj).length 4 | } 5 | return 0 6 | } 7 | 8 | const removeEmptyProperties = obj => { 9 | const objCopy = {...obj} 10 | Object.keys(objCopy).forEach(key => { 11 | if ([null, undefined].includes(objCopy[key])) { 12 | delete objCopy[key] 13 | } 14 | }) 15 | return objCopy 16 | } 17 | 18 | export { 19 | countObjectProperties, 20 | removeEmptyProperties 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/validators.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase' 2 | import {helpers as vuelidateHelpers} from 'vuelidate/lib/validators' 3 | 4 | export const uniqueUsername = (value) => { 5 | if (!vuelidateHelpers.req(value)) { 6 | return true 7 | } 8 | return new Promise((resolve, reject) => { 9 | firebase.database().ref('users').orderByChild('usernameLower').equalTo(value.toLowerCase()) 10 | .once('value', snapshot => resolve(!snapshot.exists())) 11 | }) 12 | } 13 | 14 | export const supportedImageFile = (value) => { 15 | if (!vuelidateHelpers.req(value)) { 16 | return true 17 | } 18 | const supported = ['jpg', 'jpeg', 'gif', 'png', 'svg'] 19 | const suffix = value.split('.').pop() 20 | return supported.includes(suffix) 21 | } 22 | 23 | export const responseOk = (value) => { 24 | if (!vuelidateHelpers.req(value)) { 25 | return true 26 | } 27 | return new Promise((resolve, reject) => { 28 | fetch(value) 29 | .then(response => resolve(response.ok)) 30 | .catch(() => resolve(false)) 31 | }) 32 | } 33 | 34 | export const uniqueEmail = (value) => { 35 | if (!vuelidateHelpers.req(value)) { 36 | return true 37 | } 38 | return new Promise((resolve, reject) => { 39 | firebase.database().ref('users').orderByChild('email').equalTo(value.toLowerCase()) 40 | .once('value', snapshot => resolve(!snapshot.exists())) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vue-master-class/4a2febe07c9873c96c84d5108f03768096af3084/static/.gitkeep --------------------------------------------------------------------------------