├── .editorconfig ├── .env ├── .env.development ├── .env.production ├── .env.test ├── .eslintrc.js ├── .firebaserc ├── .gitignore ├── README.md ├── babel.config.js ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── firestoreImport.js ├── package-lock.json ├── package.json ├── public ├── _redirects ├── batman-pikachu.png ├── favicon.ico ├── index.html └── user-placeholder.png ├── src ├── App.vue ├── assets │ ├── logo.png │ ├── style.css │ └── svg │ │ ├── arrow-profile.svg │ │ └── vueschool-logo.svg ├── components │ ├── AppAvatarImg.vue │ ├── AppDate.vue │ ├── AppFormField.vue │ ├── AppHead.vue │ ├── AppInfiniteScroll.vue │ ├── AppNotifications.vue │ ├── AppSpinner.vue │ ├── CategoryList.vue │ ├── ForumList.vue │ ├── PostEditor.vue │ ├── PostList.vue │ ├── TheNavbar.vue │ ├── ThreadEditor.vue │ ├── ThreadList.vue │ ├── UserProfileCard.vue │ ├── UserProfileCardEditor.vue │ ├── UserProfileCardEditorRandomAvatar.vue │ └── UserProfileCardEditorReauthenticate.vue ├── composables │ └── useNotifications.js ├── config │ └── firebase.js ├── data.json ├── helpers │ ├── firebase.js │ └── index.js ├── main.js ├── mixins │ └── asyncDataStatus.js ├── pages │ ├── Category.vue │ ├── Forum.vue │ ├── Home.vue │ ├── NotFound.vue │ ├── Profile.vue │ ├── Register.vue │ ├── SignIn.vue │ ├── ThreadCreate.vue │ ├── ThreadEdit.vue │ └── ThreadShow.vue ├── plugins │ ├── ClickOutsideDirective.js │ ├── FontAwesome.js │ ├── PageScrollDirective.js │ ├── VeeValidatePlugin.js │ └── Vue3Pagination.js ├── router │ └── index.js └── store │ ├── actions.js │ ├── getters.js │ ├── index.js │ ├── modules │ ├── auth.js │ ├── categories.js │ ├── forums.js │ ├── posts.js │ ├── threads.js │ └── users.js │ └── mutations.js └── vue.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VUE_APP_FIREBASE_API_KEY="AIzaSyBEOEoSj1vPmcq_qSPdhQjlTdXYuz6afkM" 2 | VUE_APP_FIREBASE_AUTH_DOMAIN="vue-school-forum-19467.firebaseapp.com" 3 | VUE_APP_FIREBASE_PROJECT_ID="vue-school-forum-19467" 4 | VUE_APP_FIREBASE_STORAGE_BUCKET="vue-school-forum-19467.appspot.com" 5 | VUE_APP_FIREBASE_MESSAGING_SENDER_ID="49977500509" 6 | VUE_APP_FIREBASE_APP_ID="1:49977500509:web:7ce08908132b743fb0beb4" -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vue-masterclass/d17032231384a17288264dd4b67233ddd6c830a7/.env.development -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vue-masterclass/d17032231384a17288264dd4b67233ddd6c830a7/.env.production -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vue-masterclass/d17032231384a17288264dd4b67233ddd6c830a7/.env.test -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | env: { 5 | node: true 6 | }, 7 | 8 | extends: [ 9 | 'plugin:vue/vue3-essential', 10 | 'eslint:recommended' 11 | ], 12 | 13 | parserOptions: { 14 | parser: '@babel/eslint-parser' 15 | }, 16 | 17 | rules: { 18 | 'no-unused-vars': 'off', 19 | 'vue/multi-word-component-names': 'off', 20 | 'vue/no-deprecated-router-link-tag-prop': 'off', 21 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 22 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "vue-school-forum-19467" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | serviceAccount.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Vue 3 Masterclass 2 | 3 | [![](https://vueschool.io/media/f007f6057444d9a7f567163391d2b366/vuejs-3-master-class-not-transparent.jpg)](https://vueschool.io/the-vuejs-master-class) 4 | 5 | This repository contains the source code for the supercharged, remastered 2021 [Vue.js 3 Masterclass](https://vueschool.io/the-vuejs-master-class) course. 6 | 7 | We’ve taken all the feedback we got from The Vue.js 2 Masterclass, and supercharged The Vue.js Masterclass with all the new goodies from Vue.js 3 and the ecosystem. 8 | 9 | This is our signature course. It is probably the most thorough Vue.js course available online. 10 | 11 | The Vue.js Masterclass is so comprehensive that we **can not cover everything on this page**. Thus we’ve created a [separate page](https://vueschool.io/the-vuejs-master-class) where you can learn more about it. 12 | 13 | In our Masterclass you'll learn Vue.js by building a real-world application. Together we’ll create a complete forum from scratch using exciting technologies that synergize with Vue. 14 | 15 | The goal of the Masterclass is to teach you Vue.js along with Best Practices, Modern Javascript, and other exciting technologies, by building a Real World application - a forum. 16 | 17 | ### We cover the fundamentals, like: 18 | 19 | - Vue cli, router, and State management with Vuex 20 | - Modern Javascript (ES6/7/8) 21 | - User permissions & Route Guards 22 | - Third party authentication 23 | - Google Cloud Firestore 24 | - Automatic code review with ESLint 25 | - Consuming REST API 26 | - Application architecture and best practices 27 | 28 | ### We also dive into harder topics, like: 29 | 30 | - Higher Order Functions 31 | - Creating Vue Plugins 32 | - Code Splitting 33 | - Support for older Browsers 34 | - Webpack configuration 35 | - SEO and pre-rendering 36 | - Deployments 37 | 38 | 39 | By completing the Vue.js Masterclass, you will be able to land any Vue related job or optimize/improve your own projects! 40 | 41 | 42 | **Intrigued?** 43 | [Enroll now](https://vueschool.io/the-vuejs-master-class) 44 | 45 | --- 46 | 47 | ## Project setup 48 | ``` 49 | yarn install 50 | ``` 51 | 52 | ### Compiles and hot-reloads for development 53 | ``` 54 | yarn serve 55 | ``` 56 | 57 | ### Compiles and minifies for production 58 | ``` 59 | yarn build 60 | ``` 61 | 62 | ### Lints and fixes files 63 | ``` 64 | yarn lint 65 | ``` 66 | 67 | ### Customize configuration 68 | See [Configuration Reference](https://cli.vuejs.org/config/). 69 | 70 | 71 | --- 72 | 73 | ## Project setup 74 | ``` 75 | npm install 76 | ``` 77 | 78 | ### Compiles and hot-reloads for development 79 | ``` 80 | npm run serve 81 | ``` 82 | 83 | ### Compiles and minifies for production 84 | ``` 85 | npm run build 86 | ``` 87 | 88 | ### Lints and fixes files 89 | ``` 90 | npm run lint 91 | ``` 92 | 93 | ### Customize configuration 94 | See [Configuration Reference](https://cli.vuejs.org/config/). 95 | 96 | ## Looking for Firebase 9? 97 | Since the video course was created, Firebase has released a new version of it's JavaScript SDK. If you'd like to see code changes using Firebase 9 checkout the [firebase-9 branch](https://github.com/vueschool/vue-masterclass/tree/firebase-9), courtesy of [Michael Haslam](https://github.com/Ongomobile). Thanks Mike! 98 | 99 | ## Looking for Composition API, Pinia, TypeScript, etc? 100 | Since this video course was created, a lot has changed in the Vue.js ecosystem. [We have a brand new Master Class](https://vue.school/masterclass) geared towards addressing and teaching these updated technologies (WIP as of Q1 2024). We've had an amazing community member complete this course however, with these latest technologies. You can [see his source code in this github repo](https://github.com/JeremieLitzler/vueschool-course/tree/forum-vite). Thanks Jérémie! 101 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /{document=**} { 5 | allow read: if 6 | true 7 | } 8 | 9 | function userIsLoggedIn(){ 10 | return request.auth != null 11 | } 12 | function isNewResource(){ 13 | return resource == null 14 | } 15 | function resourceBelongsToUser(){ 16 | return request.auth.uid == resource.data.userId 17 | } 18 | 19 | //threads 20 | match /threads/{thread}{ 21 | function isOnlyAppendingPostAndContributor(){ 22 | return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['posts', 'contributors']) 23 | } 24 | allow write: if 25 | userIsLoggedIn() && (isNewResource() || resourceBelongsToUser()) 26 | allow update: if 27 | isOnlyAppendingPostAndContributor() 28 | } 29 | 30 | //posts 31 | match /posts/{post}{ 32 | allow write: if 33 | userIsLoggedIn() && (isNewResource() || resourceBelongsToUser()) 34 | } 35 | 36 | // forums 37 | match /forums/{forum}{ 38 | function isOnlyAppendingThread(){ 39 | return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['threads']) 40 | } 41 | allow update: if 42 | userIsLoggedIn() && isOnlyAppendingThread() 43 | } 44 | 45 | // users 46 | match /users/{user}{ 47 | allow create: if 48 | true 49 | allow update: if 50 | request.auth.uid == resource.id 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /firestoreImport.js: -------------------------------------------------------------------------------- 1 | // Imports 2 | const firestoreService = require('firestore-export-import') 3 | const firebaseConfig = require('./src/config/firebase.js') 4 | const serviceAccount = require('./serviceAccount.json') 5 | const fs = require('fs') 6 | const tempFileName = `${__dirname}/data-temp.json`; 7 | 8 | // procedure 9 | (async () => { 10 | const fileContents = fs.readFileSync(`${__dirname}/src/data.json`, 'utf8') 11 | const data = JSON.parse(fileContents) 12 | const transformed = transformDataForFirestore(data) 13 | fs.writeFileSync(tempFileName, JSON.stringify(transformed)) 14 | await jsonToFirestore() 15 | fs.unlinkSync(tempFileName) 16 | })() 17 | 18 | // Helper Functions 19 | // ------------------------------------- 20 | 21 | // JSON To Firestore 22 | async function jsonToFirestore () { 23 | try { 24 | console.log('Initialzing Firebase') 25 | await firestoreService.initializeApp(serviceAccount, firebaseConfig.databaseURL) 26 | console.log('Firebase Initialized') 27 | 28 | await firestoreService.restore(tempFileName) 29 | console.log('Upload Success') 30 | } catch (error) { 31 | console.log(error) 32 | } 33 | } 34 | 35 | // In order to preserve ids in data.json 36 | // as ids in firestore 37 | // must use keyed object (id being the key) instead of array of records 38 | function transformDataForFirestore (data) { 39 | const collections = data 40 | delete collections.stats 41 | const collectionsById = {} 42 | Object.keys(collections).forEach((collectionKey) => { 43 | collectionsById[collectionKey] = {} 44 | const collection = collections[collectionKey] 45 | collection.forEach((record) => { 46 | collectionsById[collectionKey][record.id] = record 47 | delete collectionsById[collectionKey][record.id].id 48 | }) 49 | }) 50 | return collectionsById 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vueschool-forum", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "db:seed": "firebase firestore:delete --all-collections --yes && node firestoreImport" 10 | }, 11 | "dependencies": { 12 | "@fortawesome/fontawesome-svg-core": "^1.2.35", 13 | "@fortawesome/free-solid-svg-icons": "^5.15.3", 14 | "@fortawesome/vue-fontawesome": "^3.0.0-3", 15 | "@hennge/vue3-pagination": "^1.0.17", 16 | "@vee-validate/i18n": "^4.4.7", 17 | "@vee-validate/rules": "^4.4.7", 18 | "@vueuse/head": "^0.6.0", 19 | "core-js": "^3.28.0", 20 | "dayjs": "^1.10.4", 21 | "firebase": "^8.4.1", 22 | "lodash": "^4.17.21", 23 | "nprogress": "^0.2.0", 24 | "vee-validate": "^4.4.11", 25 | "vue": "^3.0.0", 26 | "vue-final-modal": "^3.4.2", 27 | "vue-router": "^4.0.3", 28 | "vuex": "^4.0.0" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.21.0", 32 | "@babel/eslint-parser": "^7.19.1", 33 | "@vue/cli-plugin-babel": "^5.0.8", 34 | "@vue/cli-plugin-eslint": "^5.0.8", 35 | "@vue/cli-service": "^5.0.8", 36 | "@vue/compiler-sfc": "^3.0.0", 37 | "@vue/eslint-config-standard": "^5.1.2", 38 | "babel-eslint": "^10.1.0", 39 | "eslint": "^8.29.0", 40 | "eslint-plugin-import": "^2.20.2", 41 | "eslint-plugin-node": "^11.1.0", 42 | "eslint-plugin-promise": "^4.2.1", 43 | "eslint-plugin-standard": "^4.0.0", 44 | "eslint-plugin-vue": "^9.9.0", 45 | "firestore-export-import": "^1.3.5", 46 | "vue-cli-plugin-webpack-bundle-analyzer": "~4.0.0" 47 | }, 48 | "browserslist": [ 49 | "> 1%", 50 | "last 2 versions", 51 | "not dead" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/batman-pikachu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vue-masterclass/d17032231384a17288264dd4b67233ddd6c830a7/public/batman-pikachu.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vue-masterclass/d17032231384a17288264dd4b67233ddd6c830a7/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/user-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vue-masterclass/d17032231384a17288264dd4b67233ddd6c830a7/public/user-placeholder.png -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 58 | 59 | 66 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueschool/vue-masterclass/d17032231384a17288264dd4b67233ddd6c830a7/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #F6F8FF; 3 | min-height: 100vh; 4 | } 5 | 6 | *, *:after, *:before { 7 | box-sizing: border-box; 8 | } 9 | 10 | @media (min-width: 1024px) { 11 | html { 12 | font-size: 16px; 13 | } 14 | } 15 | 16 | @media (min-width: 240px) and (max-width: 1023px) { 17 | html { 18 | font-size: 14px; 19 | } 20 | } 21 | 22 | body { 23 | line-height: 1.5; 24 | margin: 0; 25 | padding: 0; 26 | } 27 | 28 | img { 29 | height: auto; 30 | max-width: 100%; 31 | } 32 | 33 | figure { 34 | margin: 0 0 20px 0; 35 | padding: 0; 36 | text-align: center; 37 | } 38 | 39 | figcaption { 40 | display: block; 41 | text-align: center; 42 | font-size: .8rem; 43 | } 44 | 45 | .list-title { 46 | background-color: #263959; 47 | border-bottom-left-radius: 20px; 48 | color: #f5f8fe; 49 | font-weight: 100; 50 | display: flex; 51 | width: 100%; 52 | justify-content: flex-start; 53 | position: relative; 54 | padding: 10px 20px; 55 | margin: 0; 56 | } 57 | 58 | .list-title a { 59 | color: white; 60 | } 61 | 62 | .list-title a:hover { 63 | color: #89c6af; 64 | } 65 | 66 | .img-round, .avatar, .avatar-xsmall, .avatar-small, .avatar-medium, .avatar-large, .avatar-xlarge { 67 | border-radius: 50%; 68 | max-width: 100%; 69 | } 70 | 71 | .forum-list { 72 | padding: 0; 73 | background: white; 74 | margin: 20px 0; 75 | } 76 | 77 | .forum-list .forum-listing { 78 | display: flex; 79 | flex-wrap: wrap; 80 | justify-content: space-between; 81 | align-items: center; 82 | padding: 20px 10px 20px 30px; 83 | } 84 | 85 | .forum-list .forum-listing:nth-child(odd) { 86 | background: rgba(73, 89, 96, 0.06); 87 | border-bottom-left-radius: 20px; 88 | } 89 | 90 | .forum-list .forum-listing:last-child { 91 | border-bottom-left-radius: 0; 92 | } 93 | 94 | .forum-list .forum-listing .forum-details { 95 | flex-basis: 52%; 96 | } 97 | 98 | @media (min-width: 240px) and (max-width: 720px) { 99 | .forum-list .forum-listing .forum-details { 100 | flex-basis: 100%; 101 | } 102 | } 103 | 104 | .forum-list .forum-listing .forum-details ul.subforums { 105 | padding-left: 5px; 106 | display: block; 107 | } 108 | 109 | .forum-list .forum-listing .forum-details ul.subforums::before { 110 | content: '⌙'; 111 | margin-right: 5px; 112 | } 113 | 114 | .forum-list .forum-listing .forum-details ul.subforums.subforums li { 115 | display: inline; 116 | } 117 | 118 | .forum-list .forum-listing .forum-details ul.subforums.subforums li:not(:last-of-type)::after { 119 | content: '\f111'; 120 | font-family: 'FontAwesome'; 121 | font-size: 4px; 122 | position: relative; 123 | top: -3px; 124 | left: 2px; 125 | padding: 0 3px; 126 | color: #878787; 127 | } 128 | 129 | .forum-list .forum-listing .threads-count { 130 | flex-basis: 12%; 131 | text-align: center; 132 | } 133 | 134 | .forum-list .forum-listing .threads-count .count { 135 | font-weight: 100; 136 | display: block; 137 | } 138 | 139 | .forum-list .forum-listing .last-thread { 140 | flex-basis: 32%; 141 | display: flex; 142 | justify-content: flex-start; 143 | align-items: center; 144 | } 145 | 146 | .forum-list .forum-listing .last-thread .avatar { 147 | margin-right: 10px; 148 | } 149 | 150 | .forum-header { 151 | display: flex; 152 | justify-content: space-between; 153 | align-items: flex-end; 154 | } 155 | 156 | .forum-stats ul { 157 | font-size: 0px; 158 | display: flex; 159 | justify-content: center; 160 | margin-bottom: 50px; 161 | } 162 | 163 | .forum-stats ul li { 164 | display: flex; 165 | font-weight: 100; 166 | margin: 0 20px; 167 | align-items: center; 168 | } 169 | 170 | .forum-stats ul li .fa { 171 | margin-right: 5px; 172 | } 173 | 174 | .forum-stats ul li .fa-comments-o { 175 | font-size: 26px; 176 | } 177 | 178 | .thread-list { 179 | padding: 0; 180 | background-color: white; 181 | } 182 | 183 | .thread-list .thread { 184 | display: flex; 185 | justify-content: space-between; 186 | align-items: center; 187 | padding: 5px 0 5px 20px; 188 | min-height: 45px; 189 | } 190 | 191 | .thread-list .thread:nth-child(odd) { 192 | background: rgba(73, 89, 96, 0.06); 193 | border-bottom-left-radius: 20px; 194 | } 195 | 196 | .thread-list .thread:last-child { 197 | border-bottom-left-radius: 0; 198 | } 199 | 200 | .thread-list .thread .replies-count { 201 | flex-basis: 35%; 202 | } 203 | 204 | .thread-list .thread .activity { 205 | flex-basis: 35%; 206 | display: flex; 207 | justify-content: flex-start; 208 | align-items: center; 209 | } 210 | 211 | .thread-list .thread .activity .avatar-medium { 212 | margin-right: 10px; 213 | } 214 | 215 | .thread-header { 216 | display: flex; 217 | justify-content: space-between; 218 | align-items: center; 219 | } 220 | 221 | .reactions { 222 | display: flex; 223 | justify-content: flex-end; 224 | flex: 100%; 225 | position: relative; 226 | } 227 | 228 | .reactions button { 229 | display: flex; 230 | align-items: center; 231 | padding: 5px 8px; 232 | margin-left: 2px; 233 | color: #545454; 234 | border-radius: 5px; 235 | } 236 | 237 | .reactions button:hover { 238 | background: rgba(115, 192, 151, 0.25) !important; 239 | color: #545454 !important; 240 | } 241 | 242 | .reactions button.active-reaction { 243 | background: rgba(115, 192, 151, 0.12); 244 | } 245 | 246 | .reactions button.active-reaction:hover { 247 | background: white !important; 248 | } 249 | 250 | .reactions button .emoji { 251 | margin-right: 3px; 252 | font-size: 18px; 253 | } 254 | 255 | .reactions button.add-reaction .emoji { 256 | margin-left: 3px; 257 | margin-right: 0px; 258 | } 259 | 260 | .reactions ul { 261 | position: absolute; 262 | display: flex; 263 | justify-content: flex-end; 264 | top: -45px; 265 | background-color: white !important; 266 | } 267 | 268 | .reactions ul li { 269 | font-size: 28px; 270 | display: flex; 271 | align-items: center; 272 | padding: 0px 5px; 273 | opacity: 0.7; 274 | } 275 | 276 | .reactions ul li:hover { 277 | opacity: 1; 278 | border-radius: 5px; 279 | cursor: pointer; 280 | } 281 | 282 | .pagination { 283 | display: flex; 284 | align-items: center; 285 | justify-content: center; 286 | margin-top: 40px; 287 | margin-bottom: 40px; 288 | color: #838486; 289 | } 290 | 291 | .pagination button { 292 | background: #95cbb7; 293 | display: flex; 294 | align-items: center; 295 | justify-content: center; 296 | margin: 0 15px; 297 | padding: 0px; 298 | height: 35px; 299 | width: 35px; 300 | font-size: 22px; 301 | } 302 | 303 | .pagination button:hover { 304 | background: #57AD8D; 305 | } 306 | 307 | .pagination button:disabled { 308 | cursor: not-allowed; 309 | } 310 | 311 | .pagination button:disabled:hover { 312 | background: #95cbb7; 313 | } 314 | 315 | .pagination button:disabled:active { 316 | animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; 317 | transform: translate3d(0, 0, 0); 318 | backface-visibility: hidden; 319 | perspective: 1000px; 320 | } 321 | 322 | @keyframes shake { 323 | 10%, 90% { 324 | transform: translate3d(-1px, 0, 0); 325 | } 326 | 20%, 80% { 327 | transform: translate3d(2px, 0, 0); 328 | } 329 | 30%, 50%, 70% { 330 | transform: translate3d(-4px, 0, 0); 331 | } 332 | 40%, 60% { 333 | transform: translate3d(4px, 0, 0); 334 | } 335 | } 336 | 337 | .post-list { 338 | margin-top: 20px; 339 | } 340 | 341 | .post { 342 | display: flex; 343 | flex-wrap: wrap; 344 | justify-content: space-between; 345 | background-color: white; 346 | padding: 20px 10px; 347 | padding-bottom: 7px; 348 | box-shadow: 2px 2px 1px rgba(136, 136, 136, 0.09); 349 | margin-bottom: 20px; 350 | } 351 | 352 | @media (max-width: 820px) { 353 | .post { 354 | padding: 0; 355 | } 356 | } 357 | 358 | .post .user-info { 359 | display: flex; 360 | flex-direction: column; 361 | align-items: center; 362 | justify-content: flex-start; 363 | text-align: center; 364 | flex: 1 1 15%; 365 | margin-right: 5px; 366 | } 367 | 368 | .post .user-info > * { 369 | margin-bottom: 10px; 370 | } 371 | 372 | @media (max-width: 820px) { 373 | .post .user-info { 374 | order: -2; 375 | flex-direction: row; 376 | justify-content: flex-start; 377 | background: rgba(73, 89, 96, 0.06); 378 | margin-right: 0; 379 | padding: 5px; 380 | padding-left: 10px; 381 | } 382 | 383 | .post .user-info .avatar-large { 384 | height: 35px; 385 | width: 35px; 386 | margin-right: 5px; 387 | order: 1; 388 | } 389 | 390 | .post .user-info .user-name { 391 | order: 2; 392 | } 393 | 394 | .post .user-info > * { 395 | margin-right: 5px; 396 | margin-bottom: 0; 397 | } 398 | } 399 | 400 | .post .post-date { 401 | flex-basis: 100%; 402 | font-size: 14px; 403 | text-align: right; 404 | margin-bottom: 5px; 405 | padding-right: 7px; 406 | } 407 | 408 | @media (max-width: 820px) { 409 | .post .post-date { 410 | order: -1; 411 | flex-basis: 40%; 412 | background: rgba(73, 89, 96, 0.06); 413 | padding-right: 10px; 414 | padding-top: 16px; 415 | margin-bottom: 0px; 416 | } 417 | } 418 | 419 | @media (max-width: 720px) { 420 | .post { 421 | padding: 0px; 422 | } 423 | } 424 | 425 | .post-content { 426 | display: flex; 427 | flex: 1 0 83%; 428 | padding-left: 15px; 429 | padding-right: 10px; 430 | font-size: 16px; 431 | text-align: justify; 432 | line-height: 1.5; 433 | word-break: break-word; 434 | } 435 | 436 | .post-content h1, .post-content h2, .post-content h3 { 437 | margin-bottom: 0; 438 | } 439 | 440 | .post-content p { 441 | margin-bottom: 20px; 442 | } 443 | 444 | .post-content pre { 445 | display: grid; 446 | overflow: auto; 447 | word-wrap: break-word; 448 | border-radius: 3px; 449 | padding: 10px; 450 | } 451 | 452 | .post-content blockquote { 453 | margin: 25px 0px; 454 | } 455 | 456 | .post-content blockquote.big { 457 | display: flex; 458 | position: relative; 459 | } 460 | 461 | .post-content blockquote.big::before { 462 | position: absolute; 463 | top: -25px; 464 | left: -25px; 465 | font-size: 42px; 466 | font-family: FontAwesome; 467 | content: "\f10e"; 468 | color: #263959; 469 | } 470 | 471 | @media (max-width: 820px) { 472 | .post-content blockquote.big::before { 473 | top: -15px; 474 | left: -18px; 475 | font-size: 32px; 476 | } 477 | } 478 | 479 | .post-content blockquote.big .quote { 480 | padding-left: 20px; 481 | padding-right: 15px; 482 | flex-basis: 95%; 483 | font-weight: 100; 484 | font-style: italic; 485 | font-size: 17px; 486 | } 487 | 488 | .post-content blockquote.big .author { 489 | display: flex; 490 | flex-direction: column; 491 | align-items: center; 492 | justify-content: flex-start; 493 | text-align: center; 494 | } 495 | 496 | .post-content blockquote.big .author img { 497 | flex: 1; 498 | flex-basis: 100%; 499 | margin-top: 10px; 500 | width: 80px; 501 | height: 80px; 502 | } 503 | 504 | .post-content blockquote.small { 505 | position: relative; 506 | flex-direction: column; 507 | border: 2px solid rgba(152, 152, 152, 0.15); 508 | border-bottom-left-radius: 5px; 509 | border-bottom-right-radius: 5px; 510 | } 511 | 512 | .post-content blockquote.small::before { 513 | position: absolute; 514 | top: -20px; 515 | left: -20px; 516 | font-size: 42px; 517 | font-family: FontAwesome; 518 | content: "\f10e"; 519 | color: #263959; 520 | } 521 | 522 | @media (max-width: 820px) { 523 | .post-content blockquote.small::before { 524 | top: -18px; 525 | left: -15px; 526 | font-size: 32px; 527 | } 528 | } 529 | 530 | .post-content blockquote.small .author { 531 | display: flex; 532 | flex-basis: 100%; 533 | padding: 3px 10px 3px 28px; 534 | background-color: rgba(152, 152, 152, 0.15); 535 | justify-content: center; 536 | align-items: center; 537 | } 538 | 539 | .post-content blockquote.small .author .time { 540 | margin-left: 10px; 541 | } 542 | 543 | .post-content blockquote.small .author .fa { 544 | margin-left: auto; 545 | font-size: 20px; 546 | } 547 | 548 | .post-content blockquote.small .author .fa:hover { 549 | cursor: pointer; 550 | } 551 | 552 | .post-content blockquote.small .quote { 553 | display: flex; 554 | flex-basis: 100%; 555 | flex-direction: column; 556 | padding: 10px; 557 | font-weight: 100; 558 | font-style: italic; 559 | font-size: 17px; 560 | } 561 | 562 | .post-content blockquote.simple { 563 | position: relative; 564 | padding: 0px 10px 0px 20px; 565 | font-weight: 100; 566 | font-style: italic; 567 | font-size: 17px; 568 | letter-spacing: .15px; 569 | } 570 | 571 | .post-content blockquote.simple::before { 572 | position: absolute; 573 | top: -25px; 574 | left: -25px; 575 | font-size: 42px; 576 | font-family: FontAwesome; 577 | content: "\f10e"; 578 | color: #263959; 579 | } 580 | 581 | @media (max-width: 820px) { 582 | .post-content blockquote.simple::before { 583 | top: -15px; 584 | left: -18px; 585 | font-size: 32px; 586 | } 587 | } 588 | 589 | .post-content blockquote.simple .author { 590 | display: block; 591 | margin-top: 10px; 592 | font-weight: normal; 593 | } 594 | 595 | .post-content blockquote.simple .author .time { 596 | margin-left: 10px; 597 | } 598 | 599 | .post-listing-editor { 600 | flex: 1 1 83%; 601 | } 602 | 603 | .profile-card { 604 | padding: 10px 20px 20px 20px; 605 | margin-bottom: 10px; 606 | background: white; 607 | box-shadow: 2px 2px 1px rgba(136, 136, 136, 0.09); 608 | align-self: self-end; 609 | } 610 | 611 | @media (min-width: 820px) { 612 | .profile-card { 613 | margin-right: 20px; 614 | } 615 | } 616 | 617 | .profile-card .title { 618 | word-break: break-all; 619 | } 620 | 621 | .profile-card .stats { 622 | display: flex; 623 | margin: 20px 0px; 624 | } 625 | 626 | .profile-card .stats span { 627 | flex-basis: 50%; 628 | } 629 | 630 | .profile-card .user-website { 631 | display: flex; 632 | justify-content: center; 633 | align-items: baseline; 634 | } 635 | 636 | .profile-header { 637 | display: flex; 638 | align-items: baseline; 639 | justify-content: space-between; 640 | padding: 0 0px; 641 | } 642 | 643 | @media (max-width: 720px) { 644 | .profile-header { 645 | flex-wrap: wrap; 646 | } 647 | } 648 | 649 | @media (min-width: 1024px) { 650 | .activity-list { 651 | padding: 0px 10px; 652 | } 653 | } 654 | 655 | .activity-list .activity { 656 | background-color: white; 657 | padding: 15px 10px; 658 | margin-bottom: 20px; 659 | box-shadow: 2px 2px 1px rgba(136, 136, 136, 0.09); 660 | } 661 | 662 | @media (max-width: 720px) { 663 | .activity-list .activity { 664 | padding: 10px 15px; 665 | } 666 | 667 | .activity-list .activity .post-content { 668 | padding-left: 0; 669 | } 670 | } 671 | 672 | .activity-list .activity .activity-header { 673 | margin: 0; 674 | flex: 1; 675 | display: flex; 676 | flex-wrap: wrap; 677 | align-items: flex-start; 678 | justify-content: flex-end; 679 | } 680 | 681 | .activity-list .activity .activity-header img { 682 | margin-top: 5px; 683 | margin-right: 10px; 684 | } 685 | 686 | .activity-list .activity .activity-header .title { 687 | flex-basis: 93%; 688 | margin: 0; 689 | padding: 0; 690 | } 691 | 692 | @media (max-width: 720px) { 693 | .activity-list .activity .activity-header .title { 694 | flex-basis: 100%; 695 | } 696 | } 697 | 698 | .activity-list .activity .activity-header .title span { 699 | display: block; 700 | font-weight: 100; 701 | } 702 | 703 | .activity-list .activity div.post-content { 704 | display: block; 705 | padding-right: 10px; 706 | margin: 12px 0px; 707 | word-break: break-word; 708 | } 709 | 710 | .activity-list .activity div.post-content p { 711 | margin-bottom: 12px; 712 | } 713 | 714 | .activity-list .activity .thread-details { 715 | text-align: right; 716 | } 717 | 718 | .activity-list .activity .thread-details span:not(:last-of-type) { 719 | margin-right: 20px; 720 | } 721 | 722 | textarea#user_bio { 723 | resize: vertical; 724 | } 725 | 726 | span.offline::before { 727 | font-family: FontAwesome; 728 | content: "\f1db"; 729 | font-size: 14px; 730 | margin-right: 5px; 731 | } 732 | 733 | span.online { 734 | color: #57AD8D; 735 | } 736 | 737 | span.online::before { 738 | font-family: FontAwesome; 739 | content: "\f2be"; 740 | font-size: 14px; 741 | margin-right: 5px; 742 | } 743 | 744 | .header { 745 | display: flex; 746 | justify-content: space-between; 747 | align-items: center; 748 | background: #263959; 749 | height: 80px; 750 | padding: 0 20px; 751 | } 752 | 753 | @media (min-width: 240px) and (max-width: 720px) { 754 | .header { 755 | justify-content: space-between; 756 | align-items: center; 757 | padding: 0 10px; 758 | height: 60px; 759 | } 760 | } 761 | 762 | .logo { 763 | float: left; 764 | } 765 | 766 | .svg-logo { 767 | height: 62px; 768 | width: 56px; 769 | } 770 | 771 | @media (min-width: 240px) and (max-width: 720px) { 772 | .svg-logo { 773 | height: 45px; 774 | width: 40px; 775 | } 776 | } 777 | 778 | @media (min-width: 240px) and (max-width: 400px) { 779 | .svg-logo { 780 | height: 40px; 781 | width: 35px; 782 | } 783 | } 784 | 785 | .wrap-right { 786 | float: right; 787 | padding: 10px 10px; 788 | } 789 | 790 | @media (min-width: 240px) and (max-width: 720px) { 791 | .wrap-right { 792 | padding: 16px 0; 793 | } 794 | } 795 | 796 | .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 { 797 | color: rgba(84, 84, 84, 0.7); 798 | } 799 | 800 | h1 { 801 | font-size: 32px; 802 | } 803 | 804 | @media (min-width: 240px) and (max-width: 720px) { 805 | h1 { 806 | font-size: 24px; 807 | } 808 | } 809 | 810 | h2 { 811 | font-size: 28px; 812 | } 813 | 814 | @media (min-width: 240px) and (max-width: 720px) { 815 | h2 { 816 | font-size: 20px; 817 | } 818 | } 819 | 820 | .text-lead, .forum-list .forum-listing .threads-count .count, .profile-card .stats span, .modal-container .modal .modal-header .title { 821 | font-size: 26px; 822 | line-height: 1.5; 823 | font-weight: 300; 824 | } 825 | 826 | @media (min-width: 240px) and (max-width: 720px) { 827 | .text-lead, .forum-list .forum-listing .threads-count .count, .profile-card .stats span, .modal-container .modal .modal-header .title { 828 | font-size: 22px; 829 | } 830 | } 831 | 832 | .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 { 833 | font-size: 16px; 834 | line-height: 1.5; 835 | } 836 | 837 | @media (min-width: 240px) and (max-width: 720px) { 838 | .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 { 839 | font-size: 15px; 840 | } 841 | } 842 | 843 | .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 { 844 | font-size: 13px; 845 | } 846 | 847 | @media (min-width: 240px) and (max-width: 720px) { 848 | .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 { 849 | font-size: 12px; 850 | } 851 | } 852 | 853 | .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 { 854 | font-size: 15px; 855 | } 856 | 857 | @media (min-width: 240px) and (max-width: 720px) { 858 | .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 { 859 | font-size: 14px; 860 | } 861 | } 862 | 863 | .text-large, .list-title, .forum-stats ul li, .profile-card .user-website, .activity-list .activity .activity-header .title, .btn-large { 864 | font-size: 18px; 865 | } 866 | 867 | @media (min-width: 240px) and (max-width: 720px) { 868 | .text-large, .list-title, .forum-stats ul li, .profile-card .user-website, .activity-list .activity .activity-header .title, .btn-large { 869 | font-size: 17px; 870 | } 871 | } 872 | 873 | .text-xlarge, .btn-xlarge { 874 | font-size: 22px; 875 | } 876 | 877 | @media (min-width: 240px) and (max-width: 720px) { 878 | .text-xlarge, .btn-xlarge { 879 | font-size: 20px; 880 | } 881 | } 882 | 883 | .text-bold, .activity-list .activity .activity-header .title { 884 | font-weight: bold; 885 | } 886 | 887 | .text-italic { 888 | font-style: italic; 889 | } 890 | 891 | .text-underline { 892 | text-decoration: underline; 893 | } 894 | 895 | .text-line-through { 896 | text-decoration: line-through; 897 | } 898 | 899 | .text-center, .profile-card .stats span, .profile-card .user-website { 900 | text-align: center; 901 | } 902 | 903 | .text-left, .activity-list .activity .activity-header .title { 904 | text-align: left; 905 | } 906 | 907 | .text-right { 908 | text-align: right; 909 | } 910 | 911 | .text-justify { 912 | text-align: justify; 913 | } 914 | 915 | ul { 916 | margin: 0; 917 | padding: 0; 918 | } 919 | 920 | .navbar { 921 | width: 100%; 922 | display:flex; 923 | flex-direction: row-reverse; 924 | justify-content: space-between; 925 | } 926 | 927 | .navbar ul { 928 | display: flex; 929 | align-items: center; 930 | justify-content: flex-start; 931 | /*height: 100%;*/ 932 | } 933 | 934 | .navbar-item, .navbar-mobile-item { 935 | display: inline-block; 936 | border-right: 1px solid #3c4d6a; 937 | vertical-align: middle; 938 | } 939 | 940 | ul .navbar-item:last-child, ul .navbar-mobile-item:last-child { 941 | border-right: none; 942 | } 943 | 944 | .navbar-item a, .navbar-mobile-item a { 945 | color: white; 946 | padding: 10px 20px; 947 | text-decoration: none; 948 | font-size: 18px; 949 | } 950 | 951 | @media (min-width: 240px) and (max-width: 720px) { 952 | .navbar-item a, .navbar-mobile-item a { 953 | padding: 10px 0px; 954 | } 955 | } 956 | 957 | .navbar-item a:hover, .navbar-mobile-item a:hover { 958 | color: #57AD8D; 959 | transition: all .3s ease; 960 | } 961 | 962 | .navbar-item a:active, .navbar-mobile-item a:active { 963 | color: #57AD8D; 964 | } 965 | 966 | @media (min-width: 240px) and (max-width: 720px) { 967 | .navbar-item, .navbar-mobile-item { 968 | display: block; 969 | border: none; 970 | margin: 20px 0; 971 | } 972 | } 973 | 974 | @media (min-width: 240px) and (max-width: 720px) { 975 | .navbar { 976 | display: none; 977 | position: absolute; 978 | z-index: 10; 979 | padding: 10px 10px 10px; 980 | background: #263959; 981 | width: 100%; 982 | left: 0; 983 | top: 60px; 984 | } 985 | } 986 | 987 | @media (min-width: 240px) and (max-width: 720px) { 988 | .navbar-open { 989 | display: flex; 990 | transition: all 0.6s ease; 991 | border-bottom-right-radius: 5px; 992 | border-bottom-left-radius: 5px; 993 | } 994 | 995 | .navbar-open .navbar-item, .navbar-open .navbar-mobile-item { 996 | margin: 6px 0; 997 | } 998 | 999 | .navbar-open ul { 1000 | flex: 1; 1001 | justify-content: flex-start; 1002 | align-items: flex-start; 1003 | flex-direction: column; 1004 | padding-left: 20px; 1005 | } 1006 | } 1007 | 1008 | .signs .navbar-item, .signs .navbar-mobile-item { 1009 | border-right: none; 1010 | } 1011 | 1012 | .a-active { 1013 | color: #57AD8D; 1014 | } 1015 | 1016 | .icon-profile { 1017 | width: 10px; 1018 | height: 8px; 1019 | } 1020 | 1021 | .navbar-user { 1022 | margin-left: auto; 1023 | } 1024 | 1025 | .navbar-user a { 1026 | display: flex; 1027 | align-items: center; 1028 | color: white; 1029 | } 1030 | 1031 | .navbar-user a:hover .icon-profile { 1032 | transition: all .4s ease; 1033 | transform: rotate(-180deg); 1034 | } 1035 | 1036 | .navbar-user img { 1037 | margin-right: 10px; 1038 | } 1039 | 1040 | .btn-hamburger { 1041 | cursor: pointer; 1042 | height: 30px; 1043 | width: 30px; 1044 | float: right; 1045 | position: relative; 1046 | margin-left: 20px; 1047 | display: none; 1048 | } 1049 | 1050 | .btn-hamburger .top { 1051 | top: 7px; 1052 | } 1053 | 1054 | .btn-hamburger .middle { 1055 | top: 16px; 1056 | } 1057 | 1058 | .btn-hamburger .bottom { 1059 | top: 26px; 1060 | } 1061 | 1062 | @media (min-width: 240px) and (max-width: 720px) { 1063 | .btn-hamburger { 1064 | display: block; 1065 | } 1066 | } 1067 | 1068 | .bar { 1069 | width: 30px; 1070 | height: 4px; 1071 | background: white; 1072 | position: absolute; 1073 | border-radius: 10px; 1074 | transition: all 0.5s; 1075 | } 1076 | 1077 | .btn-hamburger-active .top { 1078 | top: 16px; 1079 | } 1080 | 1081 | .btn-hamburger-active .middle { 1082 | opacity: 0; 1083 | overflow: hidden; 1084 | } 1085 | 1086 | .btn-hamburger-active .bottom { 1087 | top: 16px; 1088 | } 1089 | 1090 | header > a.logo { 1091 | width: 50px; 1092 | } 1093 | 1094 | @media (min-width: 240px) and (max-width: 720px) { 1095 | header > a.logo { 1096 | width: 35px; 1097 | } 1098 | } 1099 | 1100 | .title { 1101 | font-size: 38px; 1102 | text-align: center; 1103 | } 1104 | 1105 | @media (min-width: 1360px) { 1106 | .title { 1107 | font-size: 46px; 1108 | } 1109 | } 1110 | 1111 | @media (min-width: 600px) and (max-width: 1023px) { 1112 | .title { 1113 | font-size: 32px; 1114 | } 1115 | } 1116 | 1117 | @media (min-width: 720px) and (max-width: 820px) { 1118 | .title { 1119 | font-size: 30px; 1120 | } 1121 | } 1122 | 1123 | @media (min-width: 240px) and (max-width: 720px) { 1124 | .title { 1125 | font-size: 30px; 1126 | } 1127 | } 1128 | 1129 | .title-white { 1130 | color: white; 1131 | } 1132 | 1133 | .title-banner { 1134 | color: white; 1135 | text-transform: uppercase; 1136 | } 1137 | 1138 | .subtitle { 1139 | font-size: 26px; 1140 | } 1141 | 1142 | @media (min-width: 600px) and (max-width: 1023px) { 1143 | .subtitle { 1144 | font-size: 22px; 1145 | } 1146 | } 1147 | 1148 | @media (min-width: 240px) and (max-width: 720px) { 1149 | .subtitle { 1150 | font-size: 20px; 1151 | } 1152 | } 1153 | 1154 | @media (min-width: 240px) and (max-width: 400px) { 1155 | .subtitle { 1156 | font-size: 18px; 1157 | } 1158 | } 1159 | 1160 | #user-dropdown { 1161 | position: absolute; 1162 | top: 50px; 1163 | right: 20px; 1164 | z-index: 6; 1165 | display: none; 1166 | } 1167 | 1168 | @media (min-width: 240px) and (max-width: 720px) { 1169 | #user-dropdown { 1170 | position: relative; 1171 | width: 100%; 1172 | right: 0; 1173 | z-index: 10; 1174 | top: 98px; 1175 | } 1176 | } 1177 | 1178 | #user-dropdown.active-drop { 1179 | display: block; 1180 | } 1181 | 1182 | .active-drop { 1183 | display: block; 1184 | } 1185 | 1186 | .dropdown-menu, #user-dropdown > .dropdown-menu { 1187 | display: block; 1188 | background: white; 1189 | padding: 20px; 1190 | position: relative; 1191 | } 1192 | 1193 | .dropdown-menu-item, #user-dropdown > .dropdown-menu > .dropdown-menu-item { 1194 | margin-bottom: 5px; 1195 | } 1196 | 1197 | .dropdown-menu-item a, #user-dropdown > .dropdown-menu > .dropdown-menu-item a { 1198 | display: block; 1199 | color: #57AD8D; 1200 | font-size: 16px; 1201 | transition: all ease 0.6s; 1202 | } 1203 | 1204 | .dropdown-menu-item a:hover, #user-dropdown > .dropdown-menu > .dropdown-menu-item a:hover { 1205 | color: #41826a; 1206 | } 1207 | 1208 | .triangle-drop { 1209 | border-bottom: solid 8px white; 1210 | border-left: solid 8px transparent; 1211 | border-right: solid 8px transparent; 1212 | display: inline-block; 1213 | margin: 0; 1214 | position: relative; 1215 | left: 70%; 1216 | vertical-align: middle; 1217 | bottom: -8px; 1218 | } 1219 | 1220 | @media (min-width: 240px) and (max-width: 720px) { 1221 | .triangle-drop { 1222 | left: 5%; 1223 | } 1224 | } 1225 | 1226 | #user-dropdown a { 1227 | color: #57AD8D; 1228 | text-decoration: none; 1229 | transition: all .6s ease; 1230 | } 1231 | 1232 | #user-dropdown a:hover { 1233 | color: #41826a; 1234 | cursor: pointer; 1235 | } 1236 | 1237 | #user-dropdown ul { 1238 | display: block; 1239 | } 1240 | 1241 | .mentionsList { 1242 | position: absolute; 1243 | width: 160px; 1244 | z-index: 2; 1245 | background-color: #263959; 1246 | box-shadow: 2px 2px 1px rgba(136, 136, 136, 0.09); 1247 | color: white; 1248 | } 1249 | 1250 | .mentionsList li { 1251 | padding: 6px; 1252 | display: flex; 1253 | align-items: center; 1254 | } 1255 | 1256 | .mentionsList li img { 1257 | margin-right: 2px; 1258 | } 1259 | 1260 | .mentionsList li:hover { 1261 | cursor: pointer; 1262 | background-color: #57AD8D; 1263 | } 1264 | 1265 | .mentionsList li:not(:last-of-type) { 1266 | margin-bottom: 3px; 1267 | } 1268 | 1269 | .mentionsList::before { 1270 | border-bottom: solid 8px #263959; 1271 | border-left: solid 8px transparent; 1272 | border-right: solid 8px transparent; 1273 | content: ""; 1274 | display: inline-block; 1275 | left: 52px; 1276 | position: absolute; 1277 | top: -8px; 1278 | } 1279 | 1280 | .mentionsList .arrow-up { 1281 | width: 0; 1282 | height: 0; 1283 | border-left: 5px solid transparent; 1284 | border-right: 5px solid transparent; 1285 | border-bottom: 5px solid black; 1286 | } 1287 | 1288 | input { 1289 | box-shadow: none; 1290 | } 1291 | 1292 | form { 1293 | margin: 0; 1294 | } 1295 | 1296 | .form-input { 1297 | border: 1px solid #ddd; 1298 | border-radius: 5px; 1299 | box-sizing: border-box; 1300 | font: inherit; 1301 | padding: 5px 10px; 1302 | transition: all 0.3s ease; 1303 | width: 100%; 1304 | color: #505050; 1305 | background-color: #fdfdfd; 1306 | min-height: 43px; 1307 | } 1308 | 1309 | .form-input:disabled { 1310 | cursor: no-drop; 1311 | background: #F5F8FE; 1312 | color: #bbbbbb; 1313 | } 1314 | 1315 | .form-input:disabled::placeholder { 1316 | color: #bbbbbb; 1317 | } 1318 | 1319 | .form-input::placeholder { 1320 | font-size: inherit; 1321 | font-weight: 300; 1322 | color: #878787; 1323 | } 1324 | 1325 | .form-input:focus { 1326 | outline: none; 1327 | border: 1px solid #c7c7c7; 1328 | color: #434343; 1329 | background-color: white; 1330 | } 1331 | 1332 | .form-input:invalid { 1333 | border-color: #C82543; 1334 | } 1335 | 1336 | .form-input:invalid ~ .form-error { 1337 | display: block; 1338 | } 1339 | 1340 | @media (min-width: 240px) and (max-width: 400px) { 1341 | .form-input { 1342 | padding-left: 10px; 1343 | height: 50px; 1344 | } 1345 | } 1346 | 1347 | textarea.form-input { 1348 | padding-top: 7px; 1349 | padding-right: 2px; 1350 | padding-bottom: 0px; 1351 | min-height: 110px; 1352 | } 1353 | 1354 | .input-error { 1355 | border-color: #C82543; 1356 | } 1357 | 1358 | .input-error ~ .form-error { 1359 | display: block; 1360 | } 1361 | 1362 | .form-error { 1363 | background: #f4d3d9; 1364 | color: #C82543; 1365 | font-size: 0.8em; 1366 | float: left; 1367 | border-radius: 100px; 1368 | padding: 6px 20px; 1369 | margin-top: 10px; 1370 | } 1371 | 1372 | @media (min-width: 240px) and (max-width: 400px) { 1373 | .form-error { 1374 | width: 100%; 1375 | } 1376 | } 1377 | 1378 | .form-group { 1379 | margin-bottom: 12px; 1380 | width: 100%; 1381 | display: inline-block; 1382 | } 1383 | 1384 | .form-label, .form-group > label { 1385 | margin-bottom: 5px; 1386 | display: inline-block; 1387 | color: #767676; 1388 | } 1389 | 1390 | .form-label-password, .form-group > label-password { 1391 | margin-bottom: 0px; 1392 | } 1393 | 1394 | @media (min-width: 240px) and (max-width: 720px) { 1395 | .form-btn { 1396 | width: 100%; 1397 | } 1398 | } 1399 | 1400 | input[type="submit"], 1401 | button { 1402 | -webkit-appearance: none; 1403 | font-size: 18px; 1404 | cursor: pointer; 1405 | } 1406 | 1407 | button { 1408 | -webkit-appearance: none; 1409 | } 1410 | 1411 | button a { 1412 | color: white; 1413 | } 1414 | 1415 | .form-2cols { 1416 | display: flex; 1417 | flex-wrap: wrap; 1418 | } 1419 | 1420 | .form-2cols .form-group { 1421 | flex-basis: 47%; 1422 | } 1423 | 1424 | .form-2cols .form-group:nth-child(odd) { 1425 | margin-right: 10px; 1426 | } 1427 | 1428 | @media (min-width: 240px) and (max-width: 720px) { 1429 | .form-2cols .form-group { 1430 | flex-basis: 100%; 1431 | margin-right: 0; 1432 | } 1433 | } 1434 | 1435 | button { 1436 | border: none; 1437 | background: transparent; 1438 | appearance: none; 1439 | } 1440 | 1441 | .btn, .btn-blue, .btn-blue-outlined, .btn-brown, .btn-brown-outlined, .btn-green, .btn-green-outlined, .btn-red, .btn-red-outlined, .btn-ghost { 1442 | padding: 15px 30px; 1443 | border-radius: 5px; 1444 | border: none; 1445 | display: inline-block; 1446 | outline: 0; 1447 | } 1448 | 1449 | .btn:hover, .btn-blue:hover, .btn-blue-outlined:hover, .btn-brown:hover, .btn-brown-outlined:hover, .btn-green:hover, .btn-green-outlined:hover, .btn-red:hover, .btn-red-outlined:hover, .btn-ghost:hover { 1450 | transition: all 0.4s ease; 1451 | } 1452 | 1453 | @media (min-width: 240px) and (max-width: 720px) { 1454 | .btn, .btn-blue, .btn-blue-outlined, .btn-brown, .btn-brown-outlined, .btn-green, .btn-green-outlined, .btn-red, .btn-red-outlined, .btn-ghost { 1455 | padding: 10px 20px; 1456 | } 1457 | } 1458 | 1459 | .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 { 1460 | cursor: default; 1461 | } 1462 | 1463 | .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 { 1464 | cursor: default; 1465 | color: white; 1466 | } 1467 | 1468 | .btn-block { 1469 | width: 100%; 1470 | } 1471 | 1472 | .btn-xsmall { 1473 | padding: 6px 15px; 1474 | } 1475 | 1476 | .btn-small { 1477 | padding: 10px 20px; 1478 | } 1479 | 1480 | .btn-large { 1481 | padding: 20px 40px; 1482 | } 1483 | 1484 | .btn-xlarge { 1485 | padding: 20px 60px; 1486 | } 1487 | 1488 | .btn-circle { 1489 | height: 60px; 1490 | width: 60px; 1491 | background: #C82543; 1492 | border-radius: 50%; 1493 | padding: 0px; 1494 | font-size: 36px; 1495 | display: flex; 1496 | justify-content: center; 1497 | align-content: center; 1498 | color: white; 1499 | } 1500 | 1501 | .btn-circle:hover { 1502 | background: #b4213c; 1503 | transition: all ease 0.4s; 1504 | } 1505 | 1506 | .btn-circle:hover .icon-arrow-up { 1507 | transition: all ease 0.4s; 1508 | transform: translateY(-2px); 1509 | } 1510 | 1511 | .btn-circle:hover .icon-arrow { 1512 | transition: all ease 0.4s; 1513 | transform: translateX(2px); 1514 | } 1515 | 1516 | .btn-circle:hover .icon-arrow-left { 1517 | transition: all ease 0.4s; 1518 | transform: translateX(-2px) rotate(180deg); 1519 | } 1520 | 1521 | .btn-circle-default { 1522 | background: #878787; 1523 | } 1524 | 1525 | .btn-circle-default:hover { 1526 | background: #4c4c4c; 1527 | } 1528 | 1529 | @media (min-width: 240px) and (max-width: 720px) { 1530 | .btn-circle { 1531 | height: 45px; 1532 | width: 45px; 1533 | padding-top: 14px; 1534 | } 1535 | } 1536 | 1537 | .btn-blue { 1538 | color: white; 1539 | background: #263959; 1540 | } 1541 | 1542 | .btn-blue:hover:not(:disabled):not(.btn-disabled) { 1543 | color: white; 1544 | background: #1d2b43; 1545 | } 1546 | 1547 | .btn-blue-outlined { 1548 | color: #263959; 1549 | box-shadow: inset 0px 0px 0px 1.6px #263959; 1550 | } 1551 | 1552 | .btn-blue-outlined:hover { 1553 | color: white; 1554 | background: #263959; 1555 | } 1556 | 1557 | .btn-brown { 1558 | color: white; 1559 | background: #bf9268; 1560 | } 1561 | 1562 | .btn-brown:hover:not(:disabled):not(.btn-disabled) { 1563 | color: white; 1564 | background: #8f6e4e; 1565 | } 1566 | 1567 | .btn-brown-outlined { 1568 | color: #bf9268; 1569 | box-shadow: inset 0px 0px 0px 1.6px #bf9268; 1570 | } 1571 | 1572 | .btn-brown-outlined:hover { 1573 | color: white; 1574 | background: #bf9268; 1575 | } 1576 | 1577 | .btn-green { 1578 | color: white; 1579 | background: #57AD8D; 1580 | } 1581 | 1582 | .btn-green:hover:not(:disabled):not(.btn-disabled) { 1583 | color: white; 1584 | background: #4e9c7f; 1585 | } 1586 | 1587 | .btn-green-outlined { 1588 | color: #57AD8D; 1589 | box-shadow: inset 0px 0px 0px 1.6px #57AD8D; 1590 | } 1591 | 1592 | .btn-green-outlined:hover { 1593 | color: white; 1594 | background: #57AD8D; 1595 | } 1596 | 1597 | .btn-red { 1598 | color: white; 1599 | background: #C82543; 1600 | } 1601 | 1602 | .btn-red:hover:not(:disabled):not(.btn-disabled) { 1603 | color: white; 1604 | background: #b4213c; 1605 | } 1606 | 1607 | .btn-red-outlined { 1608 | color: #C82543; 1609 | box-shadow: inset 0px 0px 0px 1.6px #C82543; 1610 | } 1611 | 1612 | .btn-red-outlined:hover { 1613 | color: white; 1614 | background: #C82543; 1615 | } 1616 | 1617 | .btn-green { 1618 | color: white; 1619 | background: #57AD8D; 1620 | } 1621 | 1622 | .btn-green:hover:not(:disabled):not(.btn-disabled) { 1623 | color: white; 1624 | background: #4e9c7f; 1625 | } 1626 | 1627 | .btn-red { 1628 | color: white; 1629 | background: #C82543; 1630 | } 1631 | 1632 | .btn-red:hover:not(:disabled):not(.btn-disabled) { 1633 | color: white; 1634 | background: #b4213c; 1635 | } 1636 | 1637 | .btn-ghost { 1638 | flex-grow: 0; 1639 | } 1640 | 1641 | .btn-ghost:hover:not(:disabled):not(.btn-disabled) { 1642 | color: white; 1643 | background-color: rgba(152, 152, 152, 0.31); 1644 | } 1645 | 1646 | .btn-up { 1647 | height: 40px; 1648 | width: 40px; 1649 | padding-top: 10px; 1650 | text-align: center; 1651 | } 1652 | 1653 | .btn-input { 1654 | height: 52px; 1655 | line-height: 48px; 1656 | position: absolute; 1657 | border: none; 1658 | color: #fff; 1659 | cursor: pointer; 1660 | font-family: 'Open sans', sans-serif; 1661 | font-size: 18px; 1662 | padding: 0 25px; 1663 | right: 4px; 1664 | top: 4px; 1665 | transition: background 0.15s ease; 1666 | z-index: 10; 1667 | } 1668 | 1669 | @media (min-width: 240px) and (max-width: 720px) { 1670 | .btn-input { 1671 | position: static; 1672 | margin-top: 10px; 1673 | width: 100%; 1674 | } 1675 | } 1676 | 1677 | .btn-social { 1678 | margin-right: 6px; 1679 | } 1680 | 1681 | .btn-social svg { 1682 | height: 40px; 1683 | width: 40px; 1684 | transition: all ease 0.6s; 1685 | transform: rotate(0); 1686 | } 1687 | 1688 | .btn-social svg:hover { 1689 | transform: rotate(-40deg); 1690 | } 1691 | 1692 | .icon-arrow-up { 1693 | width: 12px; 1694 | height: 18px; 1695 | transform: translate(0, 0); 1696 | } 1697 | 1698 | .icon-arrow { 1699 | height: 21px; 1700 | transform: translate(0, 0); 1701 | width: 28px; 1702 | } 1703 | 1704 | @media (min-width: 240px) and (max-width: 720px) { 1705 | .icon-arrow { 1706 | height: 14px; 1707 | width: 21px; 1708 | } 1709 | } 1710 | 1711 | .icon-arrow-left { 1712 | width: 28px; 1713 | height: 21px; 1714 | transform: translate(0, 0); 1715 | transform: rotate(180deg); 1716 | } 1717 | 1718 | @media (min-width: 240px) and (max-width: 720px) { 1719 | .icon-arrow-left { 1720 | height: 14px; 1721 | width: 21px; 1722 | } 1723 | } 1724 | 1725 | .link { 1726 | color: #57AD8D; 1727 | text-decoration: underline; 1728 | transition: all ease 0.4s; 1729 | } 1730 | 1731 | .link:hover { 1732 | color: #468a71; 1733 | } 1734 | 1735 | button { 1736 | outline: 0; 1737 | } 1738 | 1739 | .form-actions, .btn-group { 1740 | display: flex; 1741 | justify-content: flex-end; 1742 | flex-basis: 100%; 1743 | margin-top: 10px; 1744 | margin-bottom: 10px; 1745 | } 1746 | 1747 | .form-actions > *:not(:last-child), .btn-group > *:not(:last-child) { 1748 | margin-right: 10px; 1749 | } 1750 | 1751 | @media (min-width: 240px) and (max-width: 720px) { 1752 | .form-actions, .btn-group { 1753 | flex-wrap: wrap; 1754 | } 1755 | 1756 | .form-actions > *:not(.btn-ghost), .btn-group > *:not(.btn-ghost) { 1757 | flex: 1 1; 1758 | margin-bottom: 5px; 1759 | } 1760 | } 1761 | 1762 | .space-between { 1763 | justify-content: space-between; 1764 | } 1765 | 1766 | .alert { 1767 | width: 100%; 1768 | padding: 10px 20px; 1769 | color: white; 1770 | opacity: 0.8; 1771 | position: relative; 1772 | z-index: 1; 1773 | display: flex; 1774 | align-items: center; 1775 | justify-content: space-between; 1776 | font-size: 0.9rem; 1777 | margin-bottom: 5px; 1778 | } 1779 | 1780 | .alert-error { 1781 | background: #C82543; 1782 | } 1783 | 1784 | .alert-success { 1785 | background: #57AD8D; 1786 | } 1787 | 1788 | .alert-info { 1789 | background: #51617a; 1790 | } 1791 | 1792 | @media (min-width: 240px) and (max-width: 720px) { 1793 | .alert { 1794 | padding: 10px; 1795 | } 1796 | } 1797 | 1798 | .close { 1799 | color: white; 1800 | background: transparent; 1801 | border: none; 1802 | } 1803 | 1804 | @media (min-width: 240px) and (max-width: 720px) { 1805 | .close { 1806 | align-self: flex-start; 1807 | margin-top: 4px; 1808 | } 1809 | } 1810 | 1811 | .close-icon { 1812 | stroke: #fff; 1813 | height: 12px; 1814 | width: 12px; 1815 | } 1816 | 1817 | .avatar { 1818 | width: 50px; 1819 | max-width: 50px; 1820 | height: 50px; 1821 | max-height: 50px; 1822 | } 1823 | 1824 | .avatar-xsmall { 1825 | width: 25px; 1826 | max-width: 25px; 1827 | height: 25px; 1828 | max-height: 25px; 1829 | } 1830 | 1831 | .avatar-small { 1832 | width: 35px; 1833 | max-width: 35px; 1834 | height: 35px; 1835 | max-height: 35px; 1836 | } 1837 | 1838 | .avatar-medium { 1839 | width: 35px; 1840 | max-width: 35px; 1841 | height: 35px; 1842 | max-height: 35px; 1843 | } 1844 | 1845 | .avatar-large { 1846 | width: 95px; 1847 | max-width: 95px; 1848 | height: 95px; 1849 | max-height: 95px; 1850 | } 1851 | 1852 | .avatar-xlarge { 1853 | width: 200px; 1854 | max-width: 200px; 1855 | height: 200px; 1856 | max-height: 200px; 1857 | } 1858 | 1859 | .card { 1860 | background: white; 1861 | margin-top: 20px; 1862 | } 1863 | 1864 | @media (min-width: 240px) and (max-width: 720px) { 1865 | .card { 1866 | margin-bottom: 20px; 1867 | } 1868 | } 1869 | 1870 | .card-form { 1871 | padding: 40px 60px; 1872 | position: relative; 1873 | z-index: 1; 1874 | margin-top: 40px; 1875 | } 1876 | 1877 | @media (min-width: 1360px) { 1878 | .card-form { 1879 | padding: 60px; 1880 | } 1881 | } 1882 | 1883 | @media (min-width: 600px) and (max-width: 1023px) { 1884 | .card-form { 1885 | padding: 40px; 1886 | } 1887 | } 1888 | 1889 | @media (min-width: 240px) and (max-width: 720px) { 1890 | .card-form { 1891 | padding: 40px 20px; 1892 | margin-top: 10px; 1893 | } 1894 | } 1895 | 1896 | .striped { 1897 | background: white; 1898 | box-shadow: 1px 1px 1px #f1f1f1; 1899 | } 1900 | 1901 | .striped li { 1902 | padding: 10px 5px 10px 12px; 1903 | box-shadow: 0 1px rgba(73, 89, 96, 0.06); 1904 | } 1905 | 1906 | .striped li:nth-child(even) { 1907 | background: rgba(73, 89, 96, 0.06); 1908 | } 1909 | 1910 | .sidebar { 1911 | display: none; 1912 | } 1913 | 1914 | @media (min-width: 1024px) { 1915 | .sidebar { 1916 | display: flex; 1917 | flex-basis: 29%; 1918 | margin: 0 0.5%; 1919 | margin-top: 118px; 1920 | flex-direction: column; 1921 | } 1922 | 1923 | .sidebar .widget { 1924 | background: white; 1925 | margin-bottom: 10px; 1926 | } 1927 | } 1928 | 1929 | .sidebar ul > li { 1930 | display: flex; 1931 | flex-wrap: wrap; 1932 | align-items: center; 1933 | } 1934 | 1935 | .sidebar ul > li > span { 1936 | flex-basis: 85%; 1937 | } 1938 | 1939 | .sidebar .unanswered-threads-list { 1940 | margin-top: 10px; 1941 | } 1942 | 1943 | ul.breadcrumbs { 1944 | list-style: none; 1945 | overflow: auto; 1946 | font-size: 0; 1947 | } 1948 | 1949 | ul.breadcrumbs li { 1950 | display: inline-block; 1951 | padding: 5px 0px; 1952 | font-weight: 100; 1953 | } 1954 | 1955 | ul.breadcrumbs li:not(:last-of-type)::after { 1956 | content: '\f105'; 1957 | font-family: FontAwesome; 1958 | margin: 0px 4px; 1959 | opacity: 0.6; 1960 | } 1961 | 1962 | ul.breadcrumbs li a { 1963 | color: #57AD8D; 1964 | text-decoration: none; 1965 | opacity: 0.7; 1966 | } 1967 | 1968 | ul.breadcrumbs li a:hover { 1969 | opacity: 1; 1970 | } 1971 | 1972 | #moderation { 1973 | display: flex; 1974 | } 1975 | 1976 | #moderation.justify-right { 1977 | margin-right: 20px; 1978 | } 1979 | 1980 | @media (min-width: 240px) and (max-width: 720px) { 1981 | #moderation.justify-right { 1982 | margin: 0; 1983 | } 1984 | } 1985 | 1986 | #moderation ul.toolbar { 1987 | z-index: 99; 1988 | display: flex; 1989 | flex-wrap: wrap; 1990 | position: fixed; 1991 | bottom: 20px; 1992 | box-shadow: 0px 0px 300px #ADADAD; 1993 | padding: 0 5px; 1994 | border-radius: 5px; 1995 | background-color: #313131; 1996 | } 1997 | 1998 | #moderation ul.toolbar li { 1999 | margin: 10px 0; 2000 | } 2001 | 2002 | #moderation ul.toolbar li.close-toolbar { 2003 | display: flex; 2004 | justify-content: center; 2005 | align-items: center; 2006 | padding: 0; 2007 | margin: 0; 2008 | } 2009 | 2010 | #moderation ul.toolbar li.close-toolbar .fa { 2011 | font-size: 30px; 2012 | } 2013 | 2014 | #moderation ul.toolbar li.close-toolbar a { 2015 | padding: 0 10px; 2016 | } 2017 | 2018 | #moderation ul.toolbar.open-toolbar { 2019 | display: none; 2020 | } 2021 | 2022 | #moderation ul.toolbar:not(:last-of-type) { 2023 | border-right: 1px solid rgba(255, 255, 255, 0.3); 2024 | } 2025 | 2026 | #moderation ul.toolbar a { 2027 | display: inline-block; 2028 | color: white; 2029 | line-height: 1.5; 2030 | padding: 5px 20px 5px 10px; 2031 | } 2032 | 2033 | #moderation ul.toolbar a:hover .fa { 2034 | opacity: 1; 2035 | } 2036 | 2037 | #moderation ul.toolbar a .fa { 2038 | opacity: 0.5; 2039 | margin: 0px 8px; 2040 | } 2041 | 2042 | #moderation ul.toolbar a:focus { 2043 | outline: none; 2044 | } 2045 | 2046 | @media (min-width: 240px) and (max-width: 720px) { 2047 | #moderation ul.toolbar { 2048 | position: fixed; 2049 | bottom: 0; 2050 | margin: 0; 2051 | padding: 0; 2052 | width: 100%; 2053 | border-bottom-left-radius: 0; 2054 | border-bottom-right-radius: 0; 2055 | } 2056 | 2057 | #moderation ul.toolbar li { 2058 | flex-basis: 100%; 2059 | margin: 0; 2060 | text-align: center; 2061 | border-right: none !important; 2062 | } 2063 | 2064 | #moderation ul.toolbar li a { 2065 | display: block; 2066 | border: none; 2067 | font-size: 18px; 2068 | padding: 7px 0; 2069 | } 2070 | 2071 | #moderation ul.toolbar li.close-toolbar .fa::before { 2072 | content: '\f107'; 2073 | font-family: FontAwesome; 2074 | } 2075 | } 2076 | 2077 | #moderation ul.toolbar-collapsed { 2078 | opacity: 0.6; 2079 | } 2080 | 2081 | #moderation ul.toolbar-collapsed:hover { 2082 | opacity: 1; 2083 | } 2084 | 2085 | #moderation ul.toolbar-collapsed li, #moderation ul.toolbar-collapsed li.close-toolbar { 2086 | display: none; 2087 | } 2088 | 2089 | #moderation ul.toolbar-collapsed li.open-toolbar { 2090 | display: inline-block; 2091 | border: none; 2092 | } 2093 | 2094 | #moderation ul.toolbar-collapsed li.open-toolbar a { 2095 | padding: 0 10px 0 0; 2096 | } 2097 | 2098 | @media (min-width: 240px) and (max-width: 720px) { 2099 | #moderation ul.toolbar-collapsed { 2100 | border-radius: 0; 2101 | } 2102 | 2103 | #moderation ul.toolbar-collapsed li.open-toolbar { 2104 | display: inline-block; 2105 | border: none; 2106 | } 2107 | 2108 | #moderation ul.toolbar-collapsed li.open-toolbar a { 2109 | padding: 0; 2110 | font-size: 0; 2111 | line-height: 0.95; 2112 | } 2113 | 2114 | #moderation ul.toolbar-collapsed li.open-toolbar a .fa { 2115 | margin: 0; 2116 | } 2117 | 2118 | #moderation ul.toolbar-collapsed li.open-toolbar a::before { 2119 | content: '\f106'; 2120 | font-family: FontAwesome; 2121 | font-size: 30px; 2122 | } 2123 | } 2124 | 2125 | @media (min-width: 240px) and (max-width: 720px) { 2126 | body { 2127 | padding-bottom: 20px; 2128 | } 2129 | } 2130 | 2131 | .modal-container { 2132 | position: fixed; 2133 | top: 0; 2134 | left: 0; 2135 | z-index: 100; 2136 | display: flex; 2137 | justify-content: center; 2138 | background: rgba(0, 0, 0, 0.4); 2139 | height: 100vh; 2140 | width: 100vw; 2141 | } 2142 | 2143 | .modal-container .modal { 2144 | display: flex; 2145 | flex-wrap: wrap; 2146 | z-index: 200; 2147 | position: fixed; 2148 | top: 10vh; 2149 | width: 50vw; 2150 | max-width: 550px; 2151 | min-height: 25vh; 2152 | background: #F5F8FE; 2153 | background: #fcfdff; 2154 | background-color: white; 2155 | border-radius: 8px; 2156 | } 2157 | 2158 | @media screen and (min-width: 240px) and (max-width: 900px) { 2159 | .modal-container .modal { 2160 | top: 5vh; 2161 | width: 95vw; 2162 | min-height: 40vh; 2163 | } 2164 | } 2165 | 2166 | .modal-container .modal hr { 2167 | margin: 5px; 2168 | } 2169 | 2170 | .modal-container .modal .btn-group { 2171 | margin: 0; 2172 | padding: 0; 2173 | } 2174 | 2175 | .modal-container .modal .modal-header, .modal-container .modal .modal-footer { 2176 | padding: 15px; 2177 | flex-basis: 100%; 2178 | } 2179 | 2180 | .modal-container .modal .modal-header { 2181 | border-bottom: 3px solid rgba(73, 89, 96, 0.06); 2182 | } 2183 | 2184 | .modal-container .modal .modal-header .title { 2185 | font-size: 32px; 2186 | } 2187 | 2188 | .modal-container .modal .modal-content { 2189 | padding: 10px 30px; 2190 | min-height: 200px; 2191 | } 2192 | 2193 | .modal-container .modal .modal-footer { 2194 | background: rgba(73, 89, 96, 0.06); 2195 | border-bottom-left-radius: 8px; 2196 | border-bottom-right-radius: 8px; 2197 | } 2198 | 2199 | a.close { 2200 | display: flex; 2201 | position: absolute; 2202 | right: 10px; 2203 | top: 10px; 2204 | color: #263959; 2205 | font-size: 22px; 2206 | opacity: .7; 2207 | } 2208 | 2209 | a.close:hover { 2210 | opacity: 1; 2211 | color: #263959; 2212 | } 2213 | 2214 | body { 2215 | font-family: 'Open Sans', sans-serif; 2216 | color: #545454; 2217 | font-size: 16px; 2218 | line-height: 1.5; 2219 | overflow-x: hidden; 2220 | box-sizing: border-box; 2221 | } 2222 | 2223 | @media (min-width: 240px) and (max-width: 720px) { 2224 | body { 2225 | font-size: 15px; 2226 | } 2227 | } 2228 | 2229 | body a { 2230 | color: #57AD8D; 2231 | text-decoration: none; 2232 | } 2233 | 2234 | body a:hover { 2235 | color: #41826a; 2236 | cursor: pointer; 2237 | transition: all .3s ease; 2238 | } 2239 | 2240 | h1, h2, h3, h4, h5 { 2241 | font-weight: 700; 2242 | margin-bottom: 10px; 2243 | margin-top: 0; 2244 | margin-left: 0; 2245 | margin-right: 0; 2246 | } 2247 | 2248 | ul { 2249 | padding: 0; 2250 | } 2251 | 2252 | li { 2253 | list-style: none; 2254 | } 2255 | 2256 | li a { 2257 | text-decoration: none; 2258 | } 2259 | 2260 | p { 2261 | margin: 0; 2262 | } 2263 | 2264 | figure { 2265 | margin: 0; 2266 | } 2267 | 2268 | .flex-column { 2269 | display: flex; 2270 | margin: 0 auto; 2271 | max-width: 1000px; 2272 | flex-direction: column; 2273 | } 2274 | 2275 | @media (min-width: 1360px) { 2276 | .flex-column { 2277 | max-width: 1300px; 2278 | } 2279 | } 2280 | 2281 | .flex-grid { 2282 | display: flex; 2283 | margin: 0 auto; 2284 | max-width: 1200px; 2285 | flex-grow: 1; 2286 | flex-wrap: wrap; 2287 | } 2288 | 2289 | @media (min-width: 1360px) { 2290 | .flex-grid { 2291 | max-width: 1300px; 2292 | } 2293 | } 2294 | 2295 | .flex-reverse { 2296 | flex-direction: row-reverse; 2297 | } 2298 | 2299 | .align-center { 2300 | align-items: center; 2301 | } 2302 | 2303 | .col { 2304 | margin: 0 1%; 2305 | } 2306 | 2307 | @media (min-width: 240px) and (max-width: 720px) { 2308 | .col { 2309 | margin: 0 4%; 2310 | } 2311 | } 2312 | 2313 | .col-70 { 2314 | flex-basis: 70%; 2315 | margin: 0 auto; 2316 | } 2317 | 2318 | @media (min-width: 1360px) { 2319 | .col-70 { 2320 | flex-basis: 70%; 2321 | } 2322 | } 2323 | 2324 | @media (min-width: 600px) and (max-width: 1023px) { 2325 | .col-70 { 2326 | flex-basis: 80%; 2327 | } 2328 | } 2329 | 2330 | @media (min-width: 240px) and (max-width: 720px) { 2331 | .col-70 { 2332 | flex-basis: 96%; 2333 | margin: 0 4%; 2334 | } 2335 | } 2336 | 2337 | .col-2 { 2338 | box-sizing: border-box; 2339 | flex-basis: 48%; 2340 | } 2341 | 2342 | @media (min-width: 240px) and (max-width: 720px) { 2343 | .col-2 { 2344 | flex-basis: 96%; 2345 | } 2346 | } 2347 | 2348 | .col-3 { 2349 | flex-basis: 31%; 2350 | } 2351 | 2352 | @media (min-width: 600px) and (max-width: 1023px) { 2353 | .col-3 { 2354 | flex-basis: 96%; 2355 | margin: 4% 2%; 2356 | } 2357 | } 2358 | 2359 | @media (min-width: 240px) and (max-width: 720px) { 2360 | .col-3 { 2361 | flex-basis: 96%; 2362 | margin: 4% 4%; 2363 | } 2364 | } 2365 | 2366 | .col-4 { 2367 | flex-basis: 23%; 2368 | } 2369 | 2370 | @media (min-width: 240px) and (max-width: 720px) { 2371 | .col-4 { 2372 | flex-basis: 46%; 2373 | } 2374 | } 2375 | 2376 | .col-5 { 2377 | flex-basis: 18%; 2378 | } 2379 | 2380 | @media (min-width: 240px) and (max-width: 720px) { 2381 | .col-5 { 2382 | flex-basis: 46%; 2383 | } 2384 | } 2385 | 2386 | .col-7 { 2387 | flex-basis: 68%; 2388 | } 2389 | 2390 | @media (min-width: 600px) and (max-width: 1023px) { 2391 | .col-7 { 2392 | flex-basis: 96%; 2393 | margin: 4% 2%; 2394 | } 2395 | } 2396 | 2397 | @media (min-width: 240px) and (max-width: 720px) { 2398 | .col-7 { 2399 | flex-basis: 96%; 2400 | margin: 4% 4%; 2401 | } 2402 | } 2403 | 2404 | .container { 2405 | display: flex; 2406 | max-width: 1200px; 2407 | margin: 0 auto; 2408 | align-items: center; 2409 | justify-content: center; 2410 | flex-wrap: wrap; 2411 | } 2412 | 2413 | @media (min-width: 240px) and (max-width: 720px) { 2414 | .container { 2415 | width: 96%; 2416 | } 2417 | } 2418 | 2419 | .col-full { 2420 | flex-basis: 98%; 2421 | margin: 0 1%; 2422 | } 2423 | 2424 | @media (max-width: 820px) { 2425 | .col-full { 2426 | flex-basis: 98%; 2427 | margin: 0 1%; 2428 | } 2429 | } 2430 | 2431 | .col-large { 2432 | flex-basis: 72%; 2433 | margin: 0 1%; 2434 | } 2435 | 2436 | @media (max-width: 1024px) { 2437 | .col-large { 2438 | flex-basis: 98%; 2439 | margin: 0 1%; 2440 | } 2441 | } 2442 | 2443 | .col-small { 2444 | flex-basis: 24%; 2445 | margin: 0 1%; 2446 | } 2447 | 2448 | @media (min-width: 240px) and (max-width: 720px) { 2449 | .col-small { 2450 | flex-basis: 98%; 2451 | margin: 0 1%; 2452 | } 2453 | } 2454 | 2455 | .align-center { 2456 | align-items: center; 2457 | justify-content: center; 2458 | } 2459 | 2460 | .justify-center { 2461 | justify-content: center; 2462 | } 2463 | 2464 | .justify-left { 2465 | justify-content: flex-start; 2466 | } 2467 | 2468 | .justify-right { 2469 | justify-content: flex-end; 2470 | } 2471 | 2472 | .justify-space-between { 2473 | justify-content: space-between; 2474 | } 2475 | 2476 | @media (max-width: 820px) { 2477 | .desktop-only, .forum-list .forum-listing .threads-count, .forum-list .forum-listing .last-thread, .forum-stats, .thread-list .thread .activity, .navbar-user { 2478 | display: none; 2479 | } 2480 | } 2481 | 2482 | @media (min-width: 820px) { 2483 | .mobile-only, .navbar-mobile-item { 2484 | display: none; 2485 | } 2486 | } 2487 | 2488 | @media (max-width: 720px) { 2489 | .hide-mobile { 2490 | display: none; 2491 | } 2492 | } 2493 | 2494 | @media (min-width: 720px) and (max-width: 820px) { 2495 | .hide-tablet { 2496 | display: none; 2497 | } 2498 | } 2499 | 2500 | @media (max-width: 720px) { 2501 | .hide-desktop { 2502 | display: none; 2503 | } 2504 | } 2505 | 2506 | section { 2507 | margin-top: 20px; 2508 | } 2509 | 2510 | .push-top { 2511 | margin-top: 20px; 2512 | } 2513 | 2514 | .no-margin { 2515 | margin: 0 !important; 2516 | } 2517 | 2518 | .link-white { 2519 | color: white; 2520 | } 2521 | 2522 | .link-unstyled, ul.breadcrumbs li a { 2523 | color: inherit; 2524 | } 2525 | 2526 | .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 { 2527 | opacity: 0.8; 2528 | } 2529 | 2530 | hr { 2531 | border: 0; 2532 | height: 1px; 2533 | background: #333; 2534 | background-image: linear-gradient(to right, #F7F9FE, #D1D3D7, #F7F9FE); 2535 | margin-bottom: 20px; 2536 | } 2537 | 2538 | .fa-btn { 2539 | padding-right: 3px; 2540 | } 2541 | 2542 | #app { 2543 | background: #F5F8FE; 2544 | min-height: 100vh; 2545 | } 2546 | /* style.css */ 2547 | .Pagination{ 2548 | justify-content: center; 2549 | margin: 40px !important; 2550 | } 2551 | .Pagination .Page-active{ 2552 | color:white; 2553 | } 2554 | .avatar-edit{ 2555 | position:relative; 2556 | } 2557 | .avatar-edit .avatar-upload-overlay{ 2558 | position:absolute; 2559 | top:50%; 2560 | left:50%; 2561 | transform: translate(-50%, -50%); 2562 | } -------------------------------------------------------------------------------- /src/assets/svg/arrow-profile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/svg/vueschool-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/AppAvatarImg.vue: -------------------------------------------------------------------------------- 1 | 4 | 11 | 16 | -------------------------------------------------------------------------------- /src/components/AppDate.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 34 | 35 | 38 | -------------------------------------------------------------------------------- /src/components/AppFormField.vue: -------------------------------------------------------------------------------- 1 | 16 | 26 | -------------------------------------------------------------------------------- /src/components/AppHead.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/components/AppInfiniteScroll.vue: -------------------------------------------------------------------------------- 1 | 4 | 36 | 44 | -------------------------------------------------------------------------------- /src/components/AppNotifications.vue: -------------------------------------------------------------------------------- 1 | 16 | 25 | 57 | -------------------------------------------------------------------------------- /src/components/AppSpinner.vue: -------------------------------------------------------------------------------- 1 | 13 | 29 | 152 | -------------------------------------------------------------------------------- /src/components/CategoryList.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 29 | 30 | 33 | -------------------------------------------------------------------------------- /src/components/ForumList.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 61 | 62 | 65 | -------------------------------------------------------------------------------- /src/components/PostEditor.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 32 | 33 | 36 | -------------------------------------------------------------------------------- /src/components/PostList.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 88 | 89 | 92 | -------------------------------------------------------------------------------- /src/components/TheNavbar.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 100 | 101 | 104 | -------------------------------------------------------------------------------- /src/components/ThreadEditor.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 54 | -------------------------------------------------------------------------------- /src/components/ThreadList.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 69 | 70 | 73 | -------------------------------------------------------------------------------- /src/components/UserProfileCard.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 52 | -------------------------------------------------------------------------------- /src/components/UserProfileCardEditor.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 126 | -------------------------------------------------------------------------------- /src/components/UserProfileCardEditorRandomAvatar.vue: -------------------------------------------------------------------------------- 1 | 10 | 49 | -------------------------------------------------------------------------------- /src/components/UserProfileCardEditorReauthenticate.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 49 | -------------------------------------------------------------------------------- /src/composables/useNotifications.js: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue' 2 | const notifications = reactive([]) 3 | const addNotification = ({ message, timeout = null, type = 'info' }) => { 4 | const id = Math.random() + Date.now() 5 | notifications.push({ 6 | id, 7 | message, 8 | type 9 | }) 10 | if (timeout) { 11 | setTimeout(() => removeNotification(id), timeout) 12 | } 13 | } 14 | 15 | const removeNotification = (id) => { 16 | const index = notifications.findIndex(item => item.id === id) 17 | notifications.splice(index, 1) 18 | } 19 | export default function useNotifications () { 20 | return { notifications, addNotification, removeNotification } 21 | } 22 | -------------------------------------------------------------------------------- /src/config/firebase.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apiKey: process.env.VUE_APP_FIREBASE_API_KEY, 3 | authDomain: process.env.VUE_APP_FIREBASE_AUTH_DOMAIN, 4 | projectId: process.env.VUE_APP_FIREBASE_PROJECT_ID, 5 | storageBucket: process.env.VUE_APP_FIREBASE_STORAGE_BUCKET, 6 | messagingSenderId: process.env.VUE_APP_FIREBASE_MESSAGING_SENDER_ID, 7 | appId: process.env.VUE_APP_FIREBASE_APP_ID 8 | } 9 | -------------------------------------------------------------------------------- /src/helpers/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app' 2 | import 'firebase/auth' 3 | import 'firebase/firestore' 4 | import 'firebase/storage' 5 | 6 | export default firebase 7 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | export const findById = (resources, id) => { 2 | if (!resources) return null 3 | return resources.find(r => r.id === id) 4 | } 5 | 6 | export const upsert = (resources, resource) => { 7 | const index = resources.findIndex(p => p.id === resource.id) 8 | if (resource.id && index !== -1) { 9 | resources[index] = resource 10 | } else { 11 | resources.push(resource) 12 | } 13 | } 14 | export const docToResource = (doc) => { 15 | if (typeof doc?.data !== 'function') return doc 16 | return { ...doc.data(), id: doc.id } 17 | } 18 | export const makeAppendChildToParentMutation = ({ parent, child }) => { 19 | return (state, { childId, parentId }) => { 20 | const resource = findById(state.items, parentId) 21 | if (!resource) { 22 | console.warn(`Appending ${child} ${childId} to ${parent} ${parentId} failed because the parent didn't exist`) 23 | return 24 | } 25 | resource[child] = resource[child] || [] 26 | 27 | if (!resource[child].includes(childId)) { 28 | resource[child].push(childId) 29 | } 30 | } 31 | } 32 | 33 | export const makeFetchItemAction = ({ emoji, resource }) => { 34 | return ({ dispatch }, payload) => dispatch('fetchItem', { emoji, resource, ...payload }, { root: true }) 35 | } 36 | export const makeFetchItemsAction = ({ emoji, resource }) => { 37 | return ({ dispatch }, payload) => dispatch('fetchItems', { emoji, resource, ...payload }, { root: true }) 38 | } 39 | 40 | export const arrayRandom = (array) => { 41 | const randomIndex = Math.floor(Math.random() * array.length) 42 | return array[randomIndex] 43 | } 44 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import router from '@/router' 4 | import store from '@/store' 5 | import firebase from '@/helpers/firebase' 6 | import firebaseConfig from '@/config/firebase' 7 | import FontAwesome from '@/plugins/FontAwesome' 8 | import ClickOutsideDirective from '@/plugins/ClickOutsideDirective' 9 | import PageScrollDirective from '@/plugins/PageScrollDirective' 10 | import Vue3Pagination from '@/plugins/Vue3Pagination' 11 | import VeeValidatePlugin from '@/plugins/VeeValidatePlugin' 12 | import { createHead } from '@vueuse/head' 13 | 14 | // Initialize Firebase 15 | firebase.initializeApp(firebaseConfig) 16 | 17 | const forumApp = createApp(App) 18 | forumApp.use(router) 19 | forumApp.use(store) 20 | forumApp.use(FontAwesome) 21 | forumApp.use(ClickOutsideDirective) 22 | forumApp.use(PageScrollDirective) 23 | forumApp.use(Vue3Pagination) 24 | forumApp.use(VeeValidatePlugin) 25 | forumApp.use(createHead()) 26 | 27 | const requireComponent = require.context('./components', true, /App[A-Z]\w+\.(vue|js)$/) 28 | requireComponent.keys().forEach(function (fileName) { 29 | let baseComponentConfig = requireComponent(fileName) 30 | baseComponentConfig = baseComponentConfig.default || baseComponentConfig 31 | const baseComponentName = baseComponentConfig.name || ( 32 | fileName 33 | .replace(/^.+\//, '') 34 | .replace(/\.\w+$/, '') 35 | ) 36 | forumApp.component(baseComponentName, baseComponentConfig) 37 | }) 38 | 39 | forumApp.mount('#app') 40 | -------------------------------------------------------------------------------- /src/mixins/asyncDataStatus.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data () { 3 | return { 4 | asyncDataStatus_ready: false 5 | } 6 | }, 7 | methods: { 8 | asyncDataStatus_fetched () { 9 | this.asyncDataStatus_ready = true 10 | this.$emit('ready') 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/Category.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 46 | 47 | 50 | -------------------------------------------------------------------------------- /src/pages/Forum.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 90 | 91 | 94 | -------------------------------------------------------------------------------- /src/pages/Home.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 34 | -------------------------------------------------------------------------------- /src/pages/NotFound.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | 16 | 19 | -------------------------------------------------------------------------------- /src/pages/Profile.vue: -------------------------------------------------------------------------------- 1 | 23 | 53 | -------------------------------------------------------------------------------- /src/pages/Register.vue: -------------------------------------------------------------------------------- 1 | 42 | 79 | -------------------------------------------------------------------------------- /src/pages/SignIn.vue: -------------------------------------------------------------------------------- 1 | 27 | 60 | -------------------------------------------------------------------------------- /src/pages/ThreadCreate.vue: -------------------------------------------------------------------------------- 1 | 10 | 58 | -------------------------------------------------------------------------------- /src/pages/ThreadEdit.vue: -------------------------------------------------------------------------------- 1 | 16 | 69 | -------------------------------------------------------------------------------- /src/pages/ThreadShow.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 117 | -------------------------------------------------------------------------------- /src/plugins/ClickOutsideDirective.js: -------------------------------------------------------------------------------- 1 | const ClickOutsideDirective = { 2 | mounted (el, binding) { 3 | el.__ClickOutsideHandler__ = event => { 4 | if (!(el === event.target || el.contains(event.target))) { 5 | binding.value(event) 6 | } 7 | } 8 | document.body.addEventListener('click', el.__ClickOutsideHandler__) 9 | }, 10 | unmounted (el) { 11 | document.body.removeEventListener('click', el.__ClickOutsideHandler__) 12 | } 13 | } 14 | export default (app) => { 15 | app.directive('click-outside', ClickOutsideDirective) 16 | } 17 | -------------------------------------------------------------------------------- /src/plugins/FontAwesome.js: -------------------------------------------------------------------------------- 1 | import { library } from '@fortawesome/fontawesome-svg-core' 2 | import { faPencilAlt, faCamera } from '@fortawesome/free-solid-svg-icons' 3 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 4 | 5 | library.add(faPencilAlt, faCamera) 6 | export default (app) => { 7 | app.component('fa', FontAwesomeIcon) 8 | } 9 | -------------------------------------------------------------------------------- /src/plugins/PageScrollDirective.js: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash/debounce' 2 | const PageScrollDirective = { 3 | mounted (el, binding) { 4 | el.__PageScroll__ = debounce(() => { 5 | binding.value() 6 | }, 200, { leading: true }) 7 | document.addEventListener('scroll', el.__PageScroll__) 8 | }, 9 | unmounted (el) { 10 | document.removeEventListener('scroll', el.__PageScroll__) 11 | } 12 | } 13 | export default (app) => { 14 | app.directive('page-scroll', PageScrollDirective) 15 | } 16 | -------------------------------------------------------------------------------- /src/plugins/VeeValidatePlugin.js: -------------------------------------------------------------------------------- 1 | import { Form, Field, ErrorMessage, defineRule, configure } from 'vee-validate' 2 | import { required, email, min, url } from '@vee-validate/rules' 3 | import { localize } from '@vee-validate/i18n' 4 | import firebase from '@/helpers/firebase' 5 | export default (app) => { 6 | defineRule('required', required) 7 | defineRule('email', email) 8 | defineRule('min', min) 9 | defineRule('url', url) 10 | defineRule('unique', async (value, args) => { 11 | let collection, field, excluding 12 | if (Array.isArray(args)) { 13 | [collection, field, excluding] = args 14 | } else { 15 | ({ collection, field, excluding } = args) 16 | } 17 | if (value === excluding) return true 18 | const querySnapshot = await firebase.firestore().collection(collection).where(field, '==', value).get() 19 | return querySnapshot.empty 20 | }) 21 | 22 | configure({ 23 | generateMessage: localize('en', { 24 | messages: { 25 | required: '{field} is required', 26 | email: '{field} must be a valid email', 27 | min: '{field} must be a minimum of 0:{min} characters', 28 | unique: '{field} is already taken', 29 | url: '{field} must be a valid URL' 30 | } 31 | }) 32 | }) 33 | 34 | app.component('VeeForm', Form) 35 | app.component('VeeField', Field) 36 | app.component('VeeErrorMessage', ErrorMessage) 37 | } 38 | -------------------------------------------------------------------------------- /src/plugins/Vue3Pagination.js: -------------------------------------------------------------------------------- 1 | import VPagination from '@hennge/vue3-pagination' 2 | import '@hennge/vue3-pagination/dist/vue3-pagination.css' 3 | export default (app) => { 4 | app.component('VPagination', VPagination) 5 | } 6 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import { findById } from '@/helpers' 3 | import store from '@/store' 4 | const routes = [ 5 | { 6 | path: '/', 7 | name: 'Home', 8 | component: () => import(/* webpackChunkName: "Home" */ '@/pages/Home') 9 | }, 10 | { 11 | path: '/me', 12 | name: 'Profile', 13 | component: () => import(/* webpackChunkName: "Profile" */'@/pages/Profile'), 14 | meta: { toTop: true, smoothScroll: true, requiresAuth: true } 15 | }, 16 | { 17 | path: '/me/edit', 18 | name: 'ProfileEdit', 19 | component: () => import(/* webpackChunkName: "Profile" */'@/pages/Profile'), 20 | props: { edit: true }, 21 | meta: { requiresAuth: true } 22 | }, 23 | { 24 | path: '/category/:id', 25 | name: 'Category', 26 | component: () => import(/* webpackChunkName: "Category" */'@/pages/Category'), 27 | props: true 28 | }, 29 | { 30 | path: '/forum/:id', 31 | name: 'Forum', 32 | component: () => import(/* webpackChunkName: "Forum" */'@/pages/Forum'), 33 | props: true 34 | }, 35 | { 36 | path: '/thread/:id', 37 | name: 'ThreadShow', 38 | component: () => import(/* webpackChunkName: "ThreadShow" */'@/pages/ThreadShow'), 39 | props: true, 40 | async beforeEnter (to, from, next) { 41 | await store.dispatch('threads/fetchThread', { id: to.params.id, once: true }) 42 | // check if thread exists 43 | const threadExists = findById(store.state.threads.items, to.params.id) 44 | // if exists continue 45 | if (threadExists) { 46 | return next() 47 | } else { 48 | next({ 49 | name: 'NotFound', 50 | params: { pathMatch: to.path.substring(1).split('/') }, 51 | // preserve existing query and hash 52 | query: to.query, 53 | hash: to.hash 54 | }) 55 | } 56 | // if doesnt exist redirect to not found 57 | } 58 | }, 59 | { 60 | path: '/forum/:forumId/thread/create', 61 | name: 'ThreadCreate', 62 | component: () => import(/* webpackChunkName: "ThreadCreate" */'@/pages/ThreadCreate'), 63 | props: true, 64 | meta: { requiresAuth: true } 65 | }, 66 | { 67 | path: '/thread/:id/edit', 68 | name: 'ThreadEdit', 69 | component: () => import(/* webpackChunkName: "ThreadEdit" */'@/pages/ThreadEdit'), 70 | props: true, 71 | meta: { requiresAuth: true } 72 | }, 73 | { 74 | path: '/register', 75 | name: 'Register', 76 | component: () => import(/* webpackChunkName: "Register" */'@/pages/Register'), 77 | meta: { requiresGuest: true } 78 | }, 79 | { 80 | path: '/signin', 81 | name: 'SignIn', 82 | component: () => import(/* webpackChunkName: "SignIn" */'@/pages/SignIn'), 83 | meta: { requiresGuest: true } 84 | }, 85 | { 86 | path: '/logout', 87 | name: 'SignOut', 88 | async beforeEnter (to, from) { 89 | await store.dispatch('auth/signOut') 90 | return { name: 'Home' } 91 | } 92 | }, 93 | { 94 | path: '/:pathMatch(.*)*', 95 | name: 'NotFound', 96 | component: () => import(/* webpackChunkName: "NotFound" */'@/pages/NotFound') 97 | } 98 | ] 99 | const router = createRouter({ 100 | history: createWebHistory(), 101 | routes, 102 | scrollBehavior (to) { 103 | const scroll = {} 104 | if (to.meta.toTop) scroll.top = 0 105 | if (to.meta.smoothScroll) scroll.behavior = 'smooth' 106 | return scroll 107 | } 108 | }) 109 | router.afterEach(() => { 110 | store.dispatch('clearItems', { modules: ['categories', 'forums', 'posts', 'threads'] }) 111 | }) 112 | 113 | router.beforeEach(async (to, from) => { 114 | await store.dispatch('auth/initAuthentication') 115 | store.dispatch('unsubscribeAllSnapshots') 116 | if (to.meta.requiresAuth && !store.state.auth.authId) { 117 | return { name: 'SignIn', query: { redirectTo: to.path } } 118 | } 119 | if (to.meta.requiresGuest && store.state.auth.authId) { 120 | return { name: 'Home' } 121 | } 122 | }) 123 | 124 | export default router 125 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | import firebase from '@/helpers/firebase' 2 | import { findById } from '@/helpers' 3 | export default { 4 | 5 | fetchItem ({ state, commit }, { id, resource, handleUnsubscribe = null, once = false, onSnapshot = null }) { 6 | return new Promise((resolve) => { 7 | const unsubscribe = firebase.firestore().collection(resource).doc(id).onSnapshot((doc) => { 8 | if (once) unsubscribe() 9 | if (doc.exists) { 10 | const item = { ...doc.data(), id: doc.id } 11 | let previousItem = findById(state[resource].items, id) 12 | previousItem = previousItem ? { ...previousItem } : null 13 | commit('setItem', { resource, item }) 14 | if (typeof onSnapshot === 'function') { 15 | const isLocal = doc.metadata.hasPendingWrites 16 | onSnapshot({ item: { ...item }, previousItem, isLocal }) 17 | } 18 | resolve(item) 19 | } else { 20 | resolve(null) 21 | } 22 | }) 23 | if (handleUnsubscribe) { 24 | handleUnsubscribe(unsubscribe) 25 | } else { 26 | commit('appendUnsubscribe', { unsubscribe }) 27 | } 28 | }) 29 | }, 30 | fetchItems ({ dispatch }, { ids, resource, emoji, onSnapshot = null }) { 31 | ids = ids || [] 32 | return Promise.all(ids.map(id => dispatch('fetchItem', { id, resource, emoji, onSnapshot }))) 33 | }, 34 | async unsubscribeAllSnapshots ({ state, commit }) { 35 | state.unsubscribes.forEach(unsubscribe => unsubscribe()) 36 | commit('clearAllUnsubscribes') 37 | }, 38 | clearItems ({ commit }, { modules = [] }) { 39 | commit('clearItems', { modules }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/store/getters.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'vuex' 2 | import getters from '@/store/getters' 3 | import actions from '@/store/actions' 4 | import mutations from '@/store/mutations' 5 | import categories from './modules/categories' 6 | import forums from './modules/forums' 7 | import threads from './modules/threads' 8 | import posts from './modules/posts' 9 | import users from './modules/users' 10 | import auth from './modules/auth' 11 | export default createStore({ 12 | modules: { 13 | categories, 14 | forums, 15 | threads, 16 | posts, 17 | users, 18 | auth 19 | }, 20 | state: { 21 | unsubscribes: [] 22 | }, 23 | getters, 24 | actions, 25 | mutations 26 | }) 27 | -------------------------------------------------------------------------------- /src/store/modules/auth.js: -------------------------------------------------------------------------------- 1 | import firebase from '@/helpers/firebase' 2 | import useNotifications from '@/composables/useNotifications' 3 | export default { 4 | namespaced: true, 5 | state: { 6 | authId: null, 7 | authUserUnsubscribe: null, 8 | authObserverUnsubscribe: null 9 | }, 10 | getters: { 11 | authUser: (state, getters, rootState, rootGetters) => { 12 | return rootGetters['users/user'](state.authId) 13 | } 14 | }, 15 | actions: { 16 | async updateEmail ({ state }, { email }) { 17 | return firebase.auth().currentUser.updateEmail(email) 18 | }, 19 | async reauthenticate ({ state }, { email, password }) { 20 | const credential = firebase.auth.EmailAuthProvider.credential(email, password) 21 | await firebase.auth().currentUser.reauthenticateWithCredential(credential) 22 | }, 23 | initAuthentication ({ dispatch, commit, state }) { 24 | if (state.authObserverUnsubscribe) state.authObserverUnsubscribe() 25 | return new Promise((resolve) => { 26 | const unsubscribe = firebase.auth().onAuthStateChanged(async (user) => { 27 | dispatch('unsubscribeAuthUserSnapshot') 28 | if (user) { 29 | await dispatch('fetchAuthUser') 30 | resolve(user) 31 | } else { 32 | resolve(null) 33 | } 34 | }) 35 | commit('setAuthObserverUnsubscribe', unsubscribe) 36 | }) 37 | }, 38 | async registerUserWithEmailAndPassword ({ dispatch }, { avatar = null, email, name, username, password }) { 39 | const result = await firebase.auth().createUserWithEmailAndPassword(email, password) 40 | avatar = await dispatch('uploadAvatar', { authId: result.user.uid, file: avatar }) 41 | await dispatch('users/createUser', { id: result.user.uid, email, name, username, avatar }, { root: true }) 42 | }, 43 | async uploadAvatar ({ state }, { authId, file, filename }) { 44 | if (!file) return null 45 | authId = authId || state.authId 46 | filename = filename || file.name 47 | try { 48 | const storageBucket = firebase.storage().ref().child(`uploads/${authId}/images/${Date.now()}-${filename}`) 49 | const snapshot = await storageBucket.put(file) 50 | const url = await snapshot.ref.getDownloadURL() 51 | return url 52 | } catch (error) { 53 | const { addNotification } = useNotifications() 54 | addNotification({ message: 'Error uploading avatar image', type: 'error' }) 55 | } 56 | }, 57 | signInWithEmailAndPassword (context, { email, password }) { 58 | return firebase.auth().signInWithEmailAndPassword(email, password) 59 | }, 60 | async signInWithGoogle ({ dispatch }) { 61 | const provider = new firebase.auth.GoogleAuthProvider() 62 | const response = await firebase.auth().signInWithPopup(provider) 63 | const user = response.user 64 | const userRef = firebase.firestore().collection('users').doc(user.uid) 65 | const userDoc = await userRef.get() 66 | if (!userDoc.exists) { 67 | return dispatch('users/createUser', 68 | { id: user.uid, name: user.displayName, email: user.email, username: user.email, avatar: user.photoURL }, 69 | { root: true } 70 | ) 71 | } 72 | }, 73 | async signOut ({ commit }) { 74 | await firebase.auth().signOut() 75 | 76 | commit('setAuthId', null) 77 | }, 78 | fetchAuthUser: async ({ dispatch, state, commit }) => { 79 | const userId = firebase.auth().currentUser?.uid 80 | if (!userId) return 81 | await dispatch('fetchItem', { 82 | emoji: '🙋', 83 | resource: 'users', 84 | id: userId, 85 | handleUnsubscribe: (unsubscribe) => { 86 | commit('setAuthUserUnsubscribe', unsubscribe) 87 | } 88 | }, 89 | { root: true } 90 | ) 91 | commit('setAuthId', userId) 92 | }, 93 | async fetchAuthUsersPosts ({ commit, state }, { startAfter }) { 94 | // limit(10) 95 | // startAfter(doc) 96 | // orderBy() 97 | let query = await firebase.firestore().collection('posts') 98 | .where('userId', '==', state.authId) 99 | .orderBy('publishedAt', 'desc') 100 | .limit(10) 101 | if (startAfter) { 102 | const doc = await firebase.firestore().collection('posts').doc(startAfter.id).get() 103 | query = query.startAfter(doc) 104 | } 105 | const posts = await query.get() 106 | posts.forEach(item => { 107 | commit('setItem', { resource: 'posts', item }, { root: true }) 108 | }) 109 | }, 110 | async unsubscribeAuthUserSnapshot ({ state, commit }) { 111 | if (state.authUserUnsubscribe) { 112 | state.authUserUnsubscribe() 113 | commit('setAuthUserUnsubscribe', null) 114 | } 115 | } 116 | }, 117 | mutations: { 118 | setAuthId (state, id) { 119 | state.authId = id 120 | }, 121 | setAuthUserUnsubscribe (state, unsubscribe) { 122 | state.authUserUnsubscribe = unsubscribe 123 | }, 124 | setAuthObserverUnsubscribe (state, unsubscribe) { 125 | state.authObserverUnsubscribe = unsubscribe 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/store/modules/categories.js: -------------------------------------------------------------------------------- 1 | import firebase from '@/helpers/firebase' 2 | import { makeFetchItemAction, makeFetchItemsAction } from '@/helpers' 3 | export default { 4 | namespaced: true, 5 | state: { 6 | items: [] 7 | }, 8 | getters: {}, 9 | actions: { 10 | fetchCategory: makeFetchItemAction({ emoji: '🏷', resource: 'categories' }), 11 | fetchCategories: makeFetchItemsAction({ emoji: '🏷', resource: 'categories' }), 12 | fetchAllCategories ({ commit }) { 13 | return new Promise((resolve) => { 14 | firebase.firestore().collection('categories').onSnapshot((querySnapshot) => { 15 | const categories = querySnapshot.docs.map(doc => { 16 | const item = { id: doc.id, ...doc.data() } 17 | commit('setItem', { resource: 'categories', item }, { root: true }) 18 | return item 19 | }) 20 | resolve(categories) 21 | }) 22 | }) 23 | } 24 | }, 25 | mutations: {} 26 | } 27 | -------------------------------------------------------------------------------- /src/store/modules/forums.js: -------------------------------------------------------------------------------- 1 | import { makeAppendChildToParentMutation, makeFetchItemAction, makeFetchItemsAction } from '@/helpers' 2 | export default { 3 | namespaced: true, 4 | state: { 5 | items: [] 6 | }, 7 | getters: {}, 8 | actions: { 9 | fetchForum: makeFetchItemAction({ emoji: '🏁', resource: 'forums' }), 10 | fetchForums: makeFetchItemsAction({ emoji: '🏁', resource: 'forums' }) 11 | }, 12 | mutations: { 13 | appendThreadToForum: makeAppendChildToParentMutation({ parent: 'forums', child: 'threads' }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/store/modules/posts.js: -------------------------------------------------------------------------------- 1 | import firebase from '@/helpers/firebase' 2 | import { makeFetchItemAction, makeFetchItemsAction } from '@/helpers' 3 | export default { 4 | namespaced: true, 5 | state: { 6 | items: [] 7 | }, 8 | getters: {}, 9 | actions: { 10 | async createPost ({ commit, state, rootState }, post) { 11 | post.userId = rootState.auth.authId 12 | post.publishedAt = firebase.firestore.FieldValue.serverTimestamp() 13 | post.firstInThread = post.firstInThread || false 14 | const batch = firebase.firestore().batch() 15 | const postRef = firebase.firestore().collection('posts').doc() 16 | const threadRef = firebase.firestore().collection('threads').doc(post.threadId) 17 | const userRef = firebase.firestore().collection('users').doc(rootState.auth.authId) 18 | batch.set(postRef, post) 19 | 20 | const threadUpdates = { 21 | posts: firebase.firestore.FieldValue.arrayUnion(postRef.id) 22 | } 23 | if (!post.firstInThread) threadUpdates.contributors = firebase.firestore.FieldValue.arrayUnion(rootState.auth.authId) 24 | batch.update(threadRef, threadUpdates) 25 | batch.update(userRef, { 26 | postsCount: firebase.firestore.FieldValue.increment(1) 27 | }) 28 | await batch.commit() 29 | const newPost = await postRef.get() 30 | commit('setItem', { resource: 'posts', item: { ...newPost.data(), id: newPost.id } }, { root: true }) // set the post 31 | commit('threads/appendPostToThread', { childId: newPost.id, parentId: post.threadId }, { root: true }) // append post to thread 32 | if (!post.firstInThread) { 33 | commit('threads/appendContributorToThread', { childId: rootState.auth.authId, parentId: post.threadId }, { root: true }) 34 | } 35 | }, 36 | async updatePost ({ commit, state, rootState }, { text, id }) { 37 | const post = { 38 | text, 39 | edited: { 40 | at: firebase.firestore.FieldValue.serverTimestamp(), 41 | by: rootState.auth.authId, 42 | moderated: false 43 | } 44 | } 45 | const postRef = firebase.firestore().collection('posts').doc(id) 46 | await postRef.update(post) 47 | const updatedPost = await postRef.get() 48 | commit('setItem', { resource: 'posts', item: updatedPost }, { root: true }) 49 | }, 50 | fetchPost: makeFetchItemAction({ emoji: '💬', resource: 'posts' }), 51 | fetchPosts: makeFetchItemsAction({ emoji: '💬', resource: 'posts' }) 52 | }, 53 | mutations: {} 54 | } 55 | -------------------------------------------------------------------------------- /src/store/modules/threads.js: -------------------------------------------------------------------------------- 1 | import { findById, docToResource, makeAppendChildToParentMutation, makeFetchItemAction, makeFetchItemsAction } from '@/helpers' 2 | import firebase from '@/helpers/firebase' 3 | import chunk from 'lodash/chunk' 4 | export default { 5 | namespaced: true, 6 | state: { 7 | items: [] 8 | }, 9 | getters: { 10 | thread: (state, getters, rootState) => { 11 | return (id) => { 12 | const thread = findById(state.items, id) 13 | if (!thread) return {} 14 | return { 15 | ...thread, 16 | get author () { 17 | return findById(rootState.users.items, thread.userId) 18 | }, 19 | get repliesCount () { 20 | if(!thread.posts) return 0; 21 | return thread.posts.length - 1 22 | }, 23 | get contributorsCount () { 24 | if (!thread.contributors) return 0 25 | return thread.contributors.length 26 | } 27 | } 28 | } 29 | } 30 | }, 31 | actions: { 32 | async createThread ({ commit, state, dispatch, rootState }, { text, title, forumId }) { 33 | const userId = rootState.auth.authId 34 | const publishedAt = firebase.firestore.FieldValue.serverTimestamp() 35 | const threadRef = firebase.firestore().collection('threads').doc() 36 | const thread = { forumId, title, publishedAt, userId, id: threadRef.id } 37 | const userRef = firebase.firestore().collection('users').doc(userId) 38 | const forumRef = firebase.firestore().collection('forums').doc(forumId) 39 | const batch = firebase.firestore().batch() 40 | 41 | batch.set(threadRef, thread) 42 | batch.update(userRef, { 43 | threads: firebase.firestore.FieldValue.arrayUnion(threadRef.id) 44 | }) 45 | batch.update(forumRef, { 46 | threads: firebase.firestore.FieldValue.arrayUnion(threadRef.id) 47 | }) 48 | await batch.commit() 49 | const newThread = await threadRef.get() 50 | 51 | commit('setItem', { 52 | resource: 'threads', 53 | item: { ...newThread.data(), id: newThread.id } 54 | }, 55 | { root: true } 56 | ) 57 | commit('users/appendThreadToUser', { parentId: userId, childId: threadRef.id }, { root: true }) 58 | commit('forums/appendThreadToForum', { parentId: forumId, childId: threadRef.id }, { root: true }) 59 | await dispatch('posts/createPost', { text, threadId: threadRef.id, firstInThread: true }, { root: true }) 60 | return findById(state.items, threadRef.id) 61 | }, 62 | async updateThread ({ commit, state, rootState }, { title, text, id }) { 63 | const thread = findById(state.items, id) 64 | const post = findById(rootState.posts.items, thread.posts[0]) 65 | let newThread = { ...thread, title } 66 | let newPost = { ...post, text } 67 | const threadRef = firebase.firestore().collection('threads').doc(id) 68 | const postRef = firebase.firestore().collection('posts').doc(post.id) 69 | const batch = firebase.firestore().batch() 70 | batch.update(threadRef, newThread) 71 | batch.update(postRef, newPost) 72 | await batch.commit() 73 | newThread = await threadRef.get() 74 | newPost = await postRef.get() 75 | commit('setItem', { resource: 'threads', item: newThread }, { root: true }) 76 | commit('setItem', { resource: 'posts', item: newPost }, { root: true }) 77 | return docToResource(newThread) 78 | }, 79 | fetchThread: makeFetchItemAction({ emoji: '📄', resource: 'threads' }), 80 | fetchThreads: makeFetchItemsAction({ emoji: '📄', resource: 'threads' }), 81 | fetchThreadsByPage: ({ dispatch, commit }, { ids, page, perPage = 10 }) => { 82 | commit('clearThreads') 83 | const chunks = chunk(ids, perPage) 84 | const limitedIds = chunks[page - 1] 85 | return dispatch('fetchThreads', { ids: limitedIds }) 86 | } 87 | }, 88 | mutations: { 89 | appendPostToThread: makeAppendChildToParentMutation({ parent: 'threads', child: 'posts' }), 90 | appendContributorToThread: makeAppendChildToParentMutation({ parent: 'threads', child: 'contributors' }), 91 | clearThreads (state) { 92 | state.items = [] 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/store/modules/users.js: -------------------------------------------------------------------------------- 1 | import firebase from '@/helpers/firebase' 2 | import { docToResource, makeAppendChildToParentMutation, findById, makeFetchItemAction, makeFetchItemsAction } from '@/helpers' 3 | export default { 4 | namespaced: true, 5 | state: { 6 | items: [] 7 | }, 8 | getters: { 9 | user: (state, getters, rootState) => { 10 | return (id) => { 11 | const user = findById(state.items, id) 12 | if (!user) return null 13 | return { 14 | ...user, 15 | get posts () { 16 | return rootState.posts.items.filter(post => post.userId === user.id) 17 | }, 18 | get postsCount () { 19 | return user.postsCount || 0 20 | }, 21 | get threads () { 22 | return rootState.threads.items.filter(post => post.userId === user.id) 23 | }, 24 | get threadIds () { 25 | return user.threads 26 | }, 27 | get threadsCount () { 28 | return user.threads?.length || 0 29 | } 30 | } 31 | } 32 | } 33 | }, 34 | actions: { 35 | async createUser ({ commit }, { id, email, name, username, avatar = null }) { 36 | const registeredAt = firebase.firestore.FieldValue.serverTimestamp() 37 | const usernameLower = username.toLowerCase() 38 | email = email.toLowerCase() 39 | const user = { avatar, email, name, username, usernameLower, registeredAt } 40 | const userRef = await firebase.firestore().collection('users').doc(id) 41 | userRef.set(user) 42 | const newUser = await userRef.get() 43 | commit('setItem', { resource: 'users', item: newUser }, { root: true }) 44 | return docToResource(newUser) 45 | }, 46 | async updateUser ({ commit }, user) { 47 | const updates = { 48 | avatar: user.avatar || null, 49 | username: user.username || null, 50 | name: user.name || null, 51 | bio: user.bio || null, 52 | website: user.website || null, 53 | email: user.email || null, 54 | location: user.location || null 55 | } 56 | const userRef = firebase.firestore().collection('users').doc(user.id) 57 | await userRef.update(updates) 58 | commit('setItem', { resource: 'users', item: user }, { root: true }) 59 | }, 60 | fetchUser: makeFetchItemAction({ emoji: '🙋', resource: 'users' }), 61 | fetchUsers: makeFetchItemsAction({ resource: 'users', emoji: '🙋' }) 62 | }, 63 | mutations: { 64 | appendThreadToUser: makeAppendChildToParentMutation({ parent: 'users', child: 'threads' }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | import { upsert, docToResource } from '@/helpers' 2 | export default { 3 | setItem (state, { resource, item }) { 4 | upsert(state[resource].items, docToResource(item)) 5 | }, 6 | appendUnsubscribe (state, { unsubscribe }) { 7 | state.unsubscribes.push(unsubscribe) 8 | }, 9 | clearAllUnsubscribes (state) { 10 | state.unsubscribes = [] 11 | }, 12 | clearItems (state, { modules = [] }) { 13 | modules.forEach(module => { 14 | state[module].items = [] 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pluginOptions: { 3 | webpackBundleAnalyzer: { 4 | analyzerMode: "disabled" 5 | } 6 | } 7 | } 8 | --------------------------------------------------------------------------------