├── .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/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 |
2 |
3 | Vue.js 3 Master Class Forum
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
23 |
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 |
--------------------------------------------------------------------------------
/src/assets/svg/vueschool-logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/components/AppAvatarImg.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
16 |
--------------------------------------------------------------------------------
/src/components/AppDate.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ diffForHumans }}
4 |
5 |
6 |
7 |
34 |
35 |
38 |
--------------------------------------------------------------------------------
/src/components/AppFormField.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 |
15 |
16 |
26 |
--------------------------------------------------------------------------------
/src/components/AppHead.vue:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/components/AppInfiniteScroll.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
36 |
44 |
--------------------------------------------------------------------------------
/src/components/AppNotifications.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 | {{ notification.message }}
11 |
12 |
13 |
14 |
15 |
16 |
25 |
57 |
--------------------------------------------------------------------------------
/src/components/AppSpinner.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
152 |
--------------------------------------------------------------------------------
/src/components/CategoryList.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
29 |
30 |
33 |
--------------------------------------------------------------------------------
/src/components/ForumList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ title }}
7 | {{ title }}
8 |
9 |
10 |
11 |
12 |
16 | {{ forum.name }}
17 |
18 |
{{ forum.description }}
19 |
20 |
21 |
22 |
23 | {{ forum.threads?.length }}
24 | {{ forumThreadsWord(forum) }}
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
61 |
62 |
65 |
--------------------------------------------------------------------------------
/src/components/PostEditor.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
32 |
33 |
36 |
--------------------------------------------------------------------------------
/src/components/PostList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
19 |
20 |
21 |
22 |
26 |
27 | {{post.text}}
28 |
29 |
30 |
38 |
39 |
40 |
41 |
42 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
88 |
89 |
92 |
--------------------------------------------------------------------------------
/src/components/TheNavbar.vue:
--------------------------------------------------------------------------------
1 |
2 |
79 |
80 |
81 |
100 |
101 |
104 |
--------------------------------------------------------------------------------
/src/components/ThreadEditor.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
11 |
12 |
13 |
14 |
15 |
54 |
--------------------------------------------------------------------------------
/src/components/ThreadList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Threads
6 |
7 |
8 |
9 |
17 |
18 |
19 |
20 | {{ thread.repliesCount }} replies
21 |
22 |
23 |
24 |
25 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | No Threads Available
38 |
39 |
40 |
41 |
42 |
69 |
70 |
73 |
--------------------------------------------------------------------------------
/src/components/UserProfileCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
{{ user.username }}
13 |
14 |
{{ user.name }}
15 |
16 |
{{ user.bio || "No bio specified." }}
17 |
18 |
{{ user.username }} is online
19 |
20 |
21 | {{ user.postsCount }} posts
22 | {{ user.threadsCount }} threads
23 |
24 |
25 |
26 |
27 |
28 |
29 | {{ user.website }}
30 |
31 |
32 |
33 |
36 | Edit Profile
37 |
38 |
39 |
40 |
41 |
42 |
52 |
--------------------------------------------------------------------------------
/src/components/UserProfileCardEditor.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {{ user.postsCount }} posts
26 | {{ user.threadsCount }} threads
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
48 |
49 |
50 |
51 |
126 |
--------------------------------------------------------------------------------
/src/components/UserProfileCardEditorRandomAvatar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
Powered by Pixabay
8 |
9 |
10 |
49 |
--------------------------------------------------------------------------------
/src/components/UserProfileCardEditorReauthenticate.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Login Again to Change Your Email
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
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 |
2 |
3 |
{{ category.name }}
4 |
8 |
9 |
10 |
11 |
46 |
47 |
50 |
--------------------------------------------------------------------------------
/src/pages/Forum.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{forum?.name}}
6 |
7 |
8 |
9 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
34 |
90 |
91 |
94 |
--------------------------------------------------------------------------------
/src/pages/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Welcome to the Vue.js 3 Master Class Forum
4 |
5 |
6 |
7 |
8 |
34 |
--------------------------------------------------------------------------------
/src/pages/NotFound.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Not Found
4 | Read some cool threads
5 |
6 |
7 |
8 |
15 |
16 |
19 |
--------------------------------------------------------------------------------
/src/pages/Profile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
20 |
21 |
22 |
23 |
53 |
--------------------------------------------------------------------------------
/src/pages/Register.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Register
6 |
7 |
8 |
9 |
10 |
11 |
12 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
38 |
39 |
40 |
41 |
42 |
79 |
--------------------------------------------------------------------------------
/src/pages/SignIn.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Login
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Create an account?
16 |
17 |
18 |
19 |
20 |
23 |
24 |
25 |
26 |
27 |
60 |
--------------------------------------------------------------------------------
/src/pages/ThreadCreate.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Create new thread in {{ forum.name }}
5 |
6 |
7 |
8 |
9 |
10 |
58 |
--------------------------------------------------------------------------------
/src/pages/ThreadEdit.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Editing {{ thread.title }}
5 |
6 |
7 |
14 |
15 |
16 |
69 |
--------------------------------------------------------------------------------
/src/pages/ThreadShow.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ thread.title }}
5 |
11 | Edit Thread
12 |
13 |
14 |
15 | By {{thread.author?.name}}, .
16 |
17 | {{thread.repliesCount}}
18 | {{thread.repliesCount === 1 ? 'reply' : 'replies'}}
19 | by {{thread.contributorsCount}}
20 | {{thread.contributorsCount === 1 ? 'contributor' : 'contributors'}}
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Sign In or Register to reply.
29 |
30 |
31 |
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 |
--------------------------------------------------------------------------------