├── .gitignore ├── README.md ├── babel.config.js ├── functions ├── identity-external-signup.js └── identity-signup.js ├── jest.config.js ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── _redirects ├── favicon.ico └── index.html ├── scripts └── bootstrap-db.js └── src ├── App.vue ├── assets ├── icons │ ├── eye-off.svg │ └── eye.svg └── styles │ ├── main.scss │ ├── reset.scss │ ├── theme-colours.scss │ └── utils.scss ├── components ├── Footer.vue ├── GithubCorner.vue ├── JournalCard.vue ├── LoginSignup.vue ├── Modal.vue ├── NavBar.vue ├── PostCard.vue ├── SetNetlifyURL.vue ├── SideBar.vue └── ThemeToggle.vue ├── helpers ├── authorise-tokens.js └── init-db.js ├── main.js ├── models ├── JournalsModel.js └── PostsModel.js ├── pages ├── AllJournals.vue ├── AllPosts.vue ├── Home.spec.js ├── Home.vue ├── Profile.vue └── RecoverAccount.vue ├── router.js └── store ├── index.js └── modules ├── app.js ├── app.spec.js ├── auth.js └── auth.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | 23 | # Local Netlify folder 24 | .netlify -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Netlify Status](https://api.netlify.com/api/v1/badges/4ae54114-0254-4a6d-8c69-50babf908578/deploy-status)](https://app.netlify.com/sites/vue-netlify-fauna/deploys) 2 | ![](https://user-images.githubusercontent.com/18376481/78156268-78aed080-7436-11ea-9da7-57d83ec5ec8a.png) 3 | # Vue - Netlify - Fauna 4 | ## A serverless stack with authentication ready to go! 5 | 6 | This serverless stack uses [Vue](https://vuejs.org/) for the front-end, [Netlify](https://www.netlify.com/) for APIs (via Netlify Functions) and [Fauna](https://www.netlify.com/) for persistent data storage. 7 | 8 | At the time of publishing this, Netlify and Fauna offer generous free tiers which means anyone can get started, deploy and host this completely free. 9 | 10 | ## Prerequisite project setup 11 | 12 | Don't skip these steps ❗❗ 13 | 14 | 1. If you don't have a Fauna account yet, sign up [here](https://dashboard.fauna.com/accounts/login). Create your first db and generate a server key in just few clicks. 15 | 16 | ![](https://user-images.githubusercontent.com/18376481/74045740-35e7f380-49c5-11ea-938f-48470242c1b3.gif) 17 | 18 | 2. With your server key ready, you can easily clone this repo and deploy this app in a few seconds by clicking this button: 19 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/chiubaca/vue-netlify-fauna-starter-kit) 20 | 21 | 22 | 3. Enable Identity on your newly deployed Netlify site otherwise signups and logins wont work. Also remember to enable Google as an External Provider. This is can found in "Settings & Usage" in the Idenity tab, then scroll down to "External Providers" and select Google in the add providers drop down. 23 | 24 | ![Enable Netlify Identity](https://user-images.githubusercontent.com/18376481/74047309-07b7e300-49c8-11ea-90c9-688bf226d0d5.gif) 25 | 26 | It will only take a few moments for Netlify to deploy the site, once ready, click on your assigned URL and you have an fully functioning CRUD application with persistent storage and a login system all ready to go! 27 | 28 | [Demo site](https://vue-netlify-fauna.netlify.com/) 29 | 30 | ## Further development setup 31 | ``` 32 | npm install 33 | ``` 34 | 35 | ### Compiles and hot-reloads for development via [Netlify Dev](https://www.netlify.com/products/dev/). (Make sure you have Netlify Dev installed with `npm install netlify-cli -g`) 36 | 37 | ``` 38 | npm start 39 | ``` 40 | 41 | ### Builds the app and deploys to Netlify on a preview URL 42 | ``` 43 | npm run deploy:preview 44 | ``` 45 | 46 | ### Builds the app and deploys to Netlify on your master URL 47 | ``` 48 | npm run deploy:prod 49 | ``` 50 | 51 | ### Runs unit tests with Jest 52 | ``` 53 | npm run test:unit 54 | ``` 55 | 56 | ## Breakdown 57 | I wrote a blog explaining how everything works: 58 | - https://dev.to/chiubaca/build-a-serverless-crud-app-using-vue-js-netlify-and-faunadb-5dno 59 | 60 | ## Why This Exists 61 | 62 | I built this template because pretty much all my side projects need persistent data storage and a login system. It was also a great opportunity to learn more about the Netlify eco system including Netlify Functions, Identity and Netlify Dev. Full credits need to go to the following repos which I have effectively mashed together. 63 | 64 | - https://github.com/shortdiv/gotruejs-in-vue 65 | - https://github.com/fauna/netlify-faunadb-todomvc 66 | - https://github.com/netlify/netlify-faunadb-example 67 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"] 3 | }; 4 | -------------------------------------------------------------------------------- /functions/identity-external-signup.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is a Netlify serverless function which leverages code from identity-signup.js 3 | As external signups are not triggered automatically by Netlify, some additional code is required. The main difference is 4 | that we need to make sure the function handler has an identity context. This is provided by the client calling this 5 | function imperatively. Without this, the function is unable to update the user app_metadata via the Netlify admin API. 6 | */ 7 | 8 | "use strict"; 9 | // eslint-disable-next-line import/no-extraneous-dependencies 10 | const fetch = require("node-fetch"); 11 | const identitySignup = require("./identity-signup"); 12 | 13 | /** 14 | * Update the app_metadata for a netlify user to include add the faunaDB token 15 | * @param {object} - appMetaDataObject - object containing any additional arbitrary data for the user 16 | * @param {string} - usersAdminUrl - url of eg "/.netlify/identity/admin/users/123-abc-456" 17 | * @param {string} - adminAuthHeader - authorisation JWT 18 | * @return {promise } - Netlify user 19 | */ 20 | function updateNetlifyUserAppMetaData(appMetaData, usersAdminUrl, JWT) { 21 | return fetch(usersAdminUrl, { 22 | method: "PUT", 23 | headers: { Authorization: `Bearer ${JWT}` }, 24 | body: JSON.stringify({ app_metadata: appMetaData }) 25 | }) 26 | .then(response => response.json()) 27 | .then(data => data) 28 | .catch(e => { 29 | console.error("error authorising user", e); 30 | }); 31 | } 32 | 33 | function handler(event, context, callback) { 34 | const { identity, user } = context.clientContext; 35 | 36 | // Guard if endpoint is hit directly and user has not provided a valid user JWT in the authorisation header. 37 | if (!user) { 38 | return callback(null, { 39 | statusCode: 401, 40 | body: "You shouldn't be here" 41 | }); 42 | } 43 | 44 | const userID = user.sub; 45 | const JWT = identity.token; 46 | const usersAdminUrl = `${identity.url}/admin/users/${userID}`; 47 | const userObject = { 48 | id: userID, 49 | user_metadata: user.user_metadata 50 | }; 51 | const password = identitySignup.generatePassword(); 52 | console.log("admin url check", usersAdminUrl); 53 | console.log("bearer token check", JWT); 54 | 55 | identitySignup 56 | .createDbUser(userObject, password) 57 | .then(user => identitySignup.obtainToken(user, password)) 58 | .then(key => 59 | updateNetlifyUserAppMetaData({ db_token: key.secret }, usersAdminUrl, JWT) 60 | ) 61 | .then(resp => { 62 | console.log("Received response: ", !!resp); 63 | console.log("Updated the user", resp.id); 64 | callback(null, { 65 | statusCode: 200, 66 | body: JSON.stringify(resp) 67 | }); 68 | }) 69 | .catch(error => { 70 | console.error("Unable to create a user account", error); 71 | callback(null, { 72 | statusCode: 418, 73 | body: JSON.stringify({ error: error }) 74 | }); 75 | }); 76 | } 77 | 78 | module.exports = { handler: handler }; 79 | -------------------------------------------------------------------------------- /functions/identity-signup.js: -------------------------------------------------------------------------------- 1 | /* 2 | This is a Netlify serverless function that first generates a new FaunaDB account based on the Netlify unique user ID. 3 | It will then update the Netlify user_metadata account with the FaunaDB token which is embedded in the user JWT for 4 | future authentication sessions. 5 | 6 | This function is triggered on a successfully email signup. This does not get triggered on external signups. Read more 7 | about event-triggered functions here: https://docs.netlify.com/functions/trigger-on-events/#available-triggers 8 | */ 9 | 10 | "use strict"; 11 | const faunadb = require("faunadb"); 12 | const generator = require("generate-password"); 13 | 14 | /* configure faunaDB Client with our secret */ 15 | const q = faunadb.query; 16 | const client = new faunadb.Client({ 17 | secret: process.env.FAUNADB_SERVER_SECRET 18 | }); 19 | 20 | /** 21 | * create and store Netlify metadata in a new user FaunaDB record. 22 | * @param {object} - Netlify userData 23 | * @property {string} - userData.id - netlify id nunmber 24 | * @property {object} - userData.user_metadata - additonal arbitary 25 | * @param {string} - password 26 | * @return {promise } - FaunaDB response object e.g 27 | * { 28 | ref: Ref(Collection("users"), "262617811824673300"), 29 | ts: 1586710712280000, 30 | data: { 31 | id: 'e362dc96-b891-4c81-9df4-506215498f39', 32 | user_metadata: { full_name: 'alexchiu.11@gmail.com' } 33 | } 34 | } 35 | */ 36 | 37 | function createDbUser(userData, password) { 38 | return client.query( 39 | q.Create(q.Collection("users"), { 40 | credentials: { 41 | password: password 42 | }, 43 | data: { 44 | id: userData.id, 45 | user_metadata: userData.user_metadata 46 | } 47 | }) 48 | ); 49 | } 50 | 51 | /** 52 | * Create a new record in the DB user table. 53 | * @param {string} - userID 54 | * @param {string} - password 55 | * @return {promise } - FaunaDB response object 56 | */ 57 | function obtainToken(user, password) { 58 | console.log("Generating new DB token"); 59 | return client.query(q.Login(q.Select("ref", user), { password })); 60 | } 61 | 62 | /** 63 | * Wrapper function to return a randomly generated password 64 | * @return {string} - randomly generated password 65 | */ 66 | function generatePassword() { 67 | return generator.generate({ 68 | length: 10, 69 | numbers: true 70 | }); 71 | } 72 | 73 | function handler(event, context, callback) { 74 | let payload = JSON.parse(event.body); 75 | let userData = payload.user; 76 | const password = generatePassword(); 77 | 78 | createDbUser(userData, password) 79 | .then(user => obtainToken(user, password)) 80 | .then(key => { 81 | console.log("Successfully created DB account"); 82 | callback(null, { 83 | //If return status is 200 or 204 the function will get blocked 84 | statusCode: 200, 85 | //the return body will update the netlify user 86 | body: JSON.stringify({ app_metadata: { db_token: key.secret } }) 87 | }); 88 | }) 89 | .catch(e => { 90 | console.error("Somethings gone wrong ", e); 91 | callback(null, { 92 | statusCode: 500, 93 | body: JSON.stringify({ error: e }) 94 | }); 95 | }); 96 | } 97 | 98 | module.exports = { 99 | handler: handler, 100 | createDbUser: createDbUser, 101 | obtainToken: obtainToken, 102 | generatePassword: generatePassword 103 | }; 104 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "@vue/cli-plugin-unit-jest", 3 | testMatch: [ 4 | "**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)", 5 | "**/src/**/*.spec.(js|jsx|ts|tsx)" 6 | ] 7 | }; 8 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | functions = "functions" # netlify dev uses this to know where to scaffold and serve your functions 4 | publish = "dist" 5 | 6 | [dev] 7 | port = 3000 # Port that the dev server will be listening on 8 | functionsPort = 34567 # port for functions server 9 | targetPort = 8080 # Port of target app server 10 | publish = "dist" # If you use a _redirect file, provide the path to your static content folder 11 | jwtRolePath = "app_metadata.authorization.roles" # Object path we should look for role values for JWT based redirects 12 | autoLaunch = true # a Boolean value that determines if Netlify Dev launches the local server address in your browser 13 | 14 | [template.environment] 15 | FAUNADB_SERVER_SECRET = "your FaunaDB server secret" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-vue-netlify-auth", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "bootstrap-db": "node ./scripts/bootstrap-db.js", 7 | "serve": "npm run bootstrap-db && vue-cli-service serve", 8 | "build": "vue-cli-service build", 9 | "build:local": "vue-cli-service build", 10 | "prebuild": "echo 'Check FaunaDB schemas' && npm run bootstrap-db", 11 | "lint": "vue-cli-service lint --no-fix ", 12 | "lint:--fix": "vue-cli-service lint --no-fix ", 13 | "deploy:preview": "npm run build:local && netlify deploy", 14 | "deploy:prod": "npm run build:local && netlify deploy --prod ", 15 | "test:unit": "vue-cli-service test:unit", 16 | "test:unit--watch": "vue-cli-service test:unit --watch", 17 | "start": "netlify dev" 18 | }, 19 | "dependencies": { 20 | "chalk": "^3.0.0", 21 | "core-js": "^3.3.2", 22 | "faunadb": "^2.10.0", 23 | "generate-password": "^1.4.2", 24 | "gotrue-js": "^0.9.25", 25 | "vue": "^2.6.10", 26 | "vue-router": "^3.1.3", 27 | "vuex": "^3.1.2", 28 | "vuex-persist": "^2.2.0" 29 | }, 30 | "devDependencies": { 31 | "@vue/cli-plugin-babel": "^4.0.0", 32 | "@vue/cli-plugin-eslint": "^4.0.0", 33 | "@vue/cli-plugin-unit-jest": "^4.1.2", 34 | "@vue/cli-service": "^4.0.0", 35 | "@vue/test-utils": "1.0.0-beta.29", 36 | "babel-eslint": "^10.0.3", 37 | "eslint": "^5.16.0", 38 | "eslint-config-airbnb-base": "^14.0.0", 39 | "eslint-config-prettier": "^6.9.0", 40 | "eslint-plugin-import": "^2.20.0", 41 | "eslint-plugin-prettier": "^3.1.2", 42 | "eslint-plugin-vue": "^5.2.3", 43 | "node-sass": "4.14.1", 44 | "sass-loader": "^8.0.0", 45 | "vue-template-compiler": "^2.6.10" 46 | }, 47 | "eslintConfig": { 48 | "root": true, 49 | "env": { 50 | "node": true, 51 | "jest": true 52 | }, 53 | "extends": [ 54 | "airbnb-base", 55 | "plugin:vue/essential", 56 | "plugin:prettier/recommended", 57 | "eslint:recommended" 58 | ], 59 | "rules": { 60 | "no-console": "off", 61 | "no-extra-boolean-cast": 1, 62 | "vue/attribute-hyphenation": "warning", 63 | "vue/html-closing-bracket-newline": "warning", 64 | "vue/html-closing-bracket-spacing": "warning", 65 | "vue/html-end-tags": "warning", 66 | "vue/html-indent": "warning", 67 | "vue/html-quotes": "warning", 68 | "vue/html-self-closing": "warning", 69 | "vue/max-attributes-per-line": "warning", 70 | "vue/multiline-html-element-content-newline": "warning", 71 | "vue/mustache-interpolation-spacing": "warning", 72 | "vue/name-property-casing": "warning", 73 | "vue/no-multi-spaces": "warning", 74 | "vue/no-spaces-around-equal-signs-in-attribute": "warning", 75 | "vue/no-template-shadow": "warning", 76 | "vue/prop-name-casing": "warning", 77 | "vue/require-default-prop": "warning", 78 | "vue/require-prop-types": "warning", 79 | "vue/singleline-html-element-content-newline": "warning", 80 | "vue/v-bind-style": "warning", 81 | "vue/v-on-style": "warning", 82 | "vue/attributes-order": "error", 83 | "vue/no-confusing-v-for-v-if": "error", 84 | "vue/no-v-html": "error", 85 | "vue/order-in-components": "error", 86 | "vue/this-in-template": "error", 87 | "vue/arrow-spacing": "warning", 88 | "vue/eqeqeq": "warning" 89 | }, 90 | "parserOptions": { 91 | "parser": "babel-eslint" 92 | }, 93 | "overrides": [ 94 | { 95 | "files": [ 96 | "**/__tests__/*.{j,t}s?(x)", 97 | "**/tests/unit/**/*.spec.{j,t}s?(x)" 98 | ], 99 | "env": { 100 | "jest": true 101 | } 102 | } 103 | ] 104 | }, 105 | "postcss": { 106 | "plugins": { 107 | "autoprefixer": {} 108 | } 109 | }, 110 | "browserslist": [ 111 | "> 1%", 112 | "last 2 versions" 113 | ] 114 | } 115 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | # Netlify settings for single-page application 2 | # https://stackoverflow.com/questions/56414707/vue-router-with-parameter-cant-run-deploy-on-netlify 3 | /* /index.html 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chiubaca/vue-netlify-fauna-starter-kit/ea62af22ad08ebf7c9872e75ee435f8fa628ef64/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Vue Netlify Fauna 10 | 11 | 12 | 13 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /scripts/bootstrap-db.js: -------------------------------------------------------------------------------- 1 | /* idempotent operation to bootstrap database */ 2 | const faunadb = require("faunadb"); 3 | const chalk = require("chalk"); 4 | 5 | const q = faunadb.query; 6 | 7 | /* */ 8 | function setupFaunaDB() { 9 | console.log(chalk.yellow("Attempting to create the DB schemas...")); 10 | 11 | let key = checkForFaunaKey(); 12 | 13 | const client = new faunadb.Client({ 14 | secret: key 15 | }); 16 | 17 | /* Based on your requirements, change the schema here */ 18 | return client 19 | .query( 20 | q.CreateCollection({ 21 | name: "users" 22 | }) 23 | ) 24 | .then(() => 25 | client.query( 26 | q.Do( 27 | q.CreateCollection({ 28 | name: "posts", 29 | permissions: { 30 | create: q.Collection("users") 31 | } 32 | }), 33 | q.CreateCollection({ 34 | name: "journals", 35 | permissions: { 36 | create: q.Collection("users") 37 | } 38 | }) 39 | ) 40 | ) 41 | ) 42 | .then(() => 43 | client.query( 44 | q.Do( 45 | q.CreateIndex({ 46 | name: "users_by_id", 47 | source: q.Collection("users"), 48 | terms: [ 49 | { 50 | field: ["data", "id"] 51 | } 52 | ], 53 | unique: true 54 | }), 55 | q.CreateIndex({ 56 | // this index is optional but useful in development for browsing users 57 | name: `all_users`, 58 | source: q.Collection("users") 59 | }), 60 | q.CreateIndex({ 61 | name: "all_posts", 62 | source: q.Collection("posts"), 63 | permissions: { 64 | read: q.Collection("users") 65 | } 66 | }), 67 | q.CreateIndex({ 68 | name: "all_journals", 69 | source: q.Collection("journals"), 70 | permissions: { 71 | read: q.Collection("users") 72 | } 73 | }), 74 | q.CreateIndex({ 75 | name: "posts_by_journal", 76 | source: q.Collection("posts"), 77 | terms: [ 78 | { 79 | field: ["data", "journal"] 80 | } 81 | ], 82 | permissions: { 83 | read: q.Collection("users") 84 | } 85 | }) 86 | ) 87 | ) 88 | ) 89 | .catch(e => { 90 | if (e.message === "instance already exists") { 91 | console.log("Schemas are already created... skipping"); 92 | process.exit(0); 93 | } else { 94 | console.error("There was a problem bootstrapping the db", e); 95 | throw e; 96 | } 97 | }); 98 | } 99 | 100 | function checkForFaunaKey() { 101 | if (!process.env.FAUNADB_SERVER_SECRET) { 102 | console.log( 103 | chalk.bold.red( 104 | "Required 'FAUNADB_SERVER_SECRET' environment variable not found." 105 | ) 106 | ); 107 | console.log( 108 | chalk.yellow.bold(` 109 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 110 | You can create a your fauna db server secret by following this: 111 | - https://docs.fauna.com/fauna/current/tutorials/authentication/user.html#setup-server-key 112 | 113 | Then ensure you have added the server secret into your Netlify site as an environment variable 114 | with the key 'FAUNADB_SERVER_SECRET'. 115 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 116 | `) 117 | ); 118 | process.exit(1); 119 | } 120 | 121 | console.log( 122 | chalk.green( 123 | `Found FAUNADB_SERVER_SECRET environment variable in Netlify site` 124 | ) 125 | ); 126 | return process.env.FAUNADB_SERVER_SECRET; 127 | } 128 | 129 | setupFaunaDB() 130 | .then(() => { 131 | console.log(chalk.green(`Bootstraping DB scheamas was successful!`)); 132 | }) 133 | .catch(err => { 134 | console.log( 135 | chalk.red.bold( 136 | `There was an issue bootstrapping the DB scheamas due to: ${err}` 137 | ) 138 | ); 139 | process.exit(1); 140 | }); 141 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | 23 | 24 | 33 | -------------------------------------------------------------------------------- /src/assets/icons/eye-off.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/eye.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import 'reset'; 2 | @import 'utils'; 3 | @import 'theme-colours'; 4 | 5 | * { 6 | 7 | font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 8 | 9 | } 10 | 11 | h1, h2, h3 , p , a, input, textarea{ 12 | color: var(--primary); 13 | } 14 | 15 | button { 16 | padding: 5px; 17 | text-align: center; 18 | border:1px solid #9e9e9e; 19 | transition: all 0.1s ease 0s; 20 | width: 100%; 21 | background: none; 22 | color: inherit; 23 | padding: 5px; 24 | font: inherit; 25 | cursor: pointer; 26 | outline: inherit; 27 | background: rgb(197, 196, 196); 28 | cursor:pointer; 29 | } 30 | 31 | button:hover { 32 | background: rgb(158, 158, 158) 33 | } 34 | 35 | button:active { 36 | background: rgb(112, 112, 112); 37 | box-shadow: 0px 0px 0px -4px rgba(0,0,0,0.75); 38 | } 39 | 40 | input[type=text] { 41 | background-color: var(--app-secondary-background-color); 42 | border: none; 43 | padding: 10px; 44 | border-bottom: 3px solid var(--primary); 45 | } 46 | input[type=password] { 47 | background-color: var(--app-secondary-background-color); 48 | border: none; 49 | padding: 10px; 50 | border-bottom: 3px solid var(--primary); 51 | } 52 | 53 | .shadow{ 54 | box-shadow: 10px 10px 0px -4px rgba(0,0,0,0.75); 55 | } 56 | 57 | .eye-open { 58 | background-image: url("./assets/icons/eye.svg"); 59 | background-repeat: no-repeat; 60 | width: 1.5rem; 61 | height: 1.5rem; 62 | 63 | } 64 | .eye-closed { 65 | background-image: url("./assets/icons/eye-off.svg"); 66 | background-repeat: no-repeat; 67 | width: 1.5rem; 68 | height: 1.5rem; 69 | } -------------------------------------------------------------------------------- /src/assets/styles/reset.scss: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } 350 | -------------------------------------------------------------------------------- /src/assets/styles/theme-colours.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * === COLORS === 3 | */ 4 | [data-theme="light"] { 5 | --app-background-color: #FCFAF6; 6 | --app-secondary-background-color: white; 7 | --primary: rgb(44, 44, 44); 8 | } 9 | 10 | [data-theme="dark"] { 11 | --app-background-color: #093050; 12 | --app-secondary-background-color: #094f88; 13 | --primary: #FFFFFF 14 | } 15 | 16 | [data-theme="sepia"] { 17 | --app-background-color: #F1E7D0; 18 | } 19 | 20 | /* 21 | * === STYLES === 22 | */ 23 | body{ 24 | background-color: var(--app-background-color); 25 | } -------------------------------------------------------------------------------- /src/assets/styles/utils.scss: -------------------------------------------------------------------------------- 1 | /* 2 | A set of reusable css utility classes 3 | */ 4 | 5 | // 58 bytes of css to look great nearly everywhere 6 | // https://jrl.ninja/etc/1/ 7 | .space { 8 | max-width: 38rem; 9 | padding: 2rem; 10 | margin: auto; 11 | } 12 | 13 | // helper vertically align items 14 | .stack { 15 | display: flex; 16 | flex-direction: column; 17 | } 18 | 19 | // horizontally align items 20 | .line { 21 | display: flex; 22 | align-items: center; 23 | } 24 | 25 | .rnd-corner-a { 26 | border-radius: 0px 15px 0px 15px; 27 | border: none; 28 | } 29 | 30 | .rnd-corner-b { 31 | border-radius: 15px 0px 15px 0px; 32 | border: none; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 34 | -------------------------------------------------------------------------------- /src/components/GithubCorner.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 30 | 31 | 58 | -------------------------------------------------------------------------------- /src/components/JournalCard.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 86 | 87 | 139 | -------------------------------------------------------------------------------- /src/components/LoginSignup.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 154 | 155 | 164 | -------------------------------------------------------------------------------- /src/components/Modal.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 52 | 53 | 54 | 95 | -------------------------------------------------------------------------------- /src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | 31 | 65 | -------------------------------------------------------------------------------- /src/components/PostCard.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 69 | 70 | 118 | -------------------------------------------------------------------------------- /src/components/SetNetlifyURL.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 44 | 45 | 81 | -------------------------------------------------------------------------------- /src/components/SideBar.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 58 | 59 | 161 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 38 | 39 | 109 | -------------------------------------------------------------------------------- /src/helpers/authorise-tokens.js: -------------------------------------------------------------------------------- 1 | /* 2 | Extract and validate tokens in the URL if they are present. 3 | */ 4 | import store from "../store"; 5 | import router from "../router"; 6 | 7 | /** 8 | * Reads the URL hash attempts and tries to detect if there is confirmation tokens from either an email signup or 9 | * external provider. If present it will call the relevant process to attempt to authorise the token. 10 | */ 11 | function detectTokens() { 12 | const emailToken = detectEmailConfirmationToken(); 13 | const externalAccessToken = detectExternalAccessToken(); 14 | const recoveryToken = detectRecoveryToken(); 15 | 16 | if (emailToken) { 17 | console.log("Detected email confirmation token", emailToken); 18 | confirmEmailToken(emailToken); 19 | return; 20 | } else if (externalAccessToken) { 21 | console.log( 22 | "Detected external access token, received object for external login", 23 | externalAccessToken 24 | ); 25 | confirmExternalAccessToken(externalAccessToken); 26 | return; 27 | } else if (recoveryToken) { 28 | console.log("found recovery token", recoveryToken); 29 | confirmRecoveryToken(recoveryToken); 30 | return; 31 | } 32 | 33 | console.log("No tokens detected in URL hash"); 34 | } 35 | 36 | /** 37 | * Checks URL hash for `confirmation_token=` then extracts the token which proceeds. 38 | */ 39 | function detectEmailConfirmationToken() { 40 | try { 41 | // split the hash where it detects `confirmation_token=`. The string which proceeds is the part which we want. 42 | const token = decodeURIComponent(document.location.hash).split( 43 | "confirmation_token=" 44 | )[1]; 45 | return token; 46 | } catch (error) { 47 | console.error( 48 | "Something went wrong when trying to extract email confirmation email", 49 | error 50 | ); 51 | return null; 52 | } 53 | } 54 | 55 | /** 56 | * Checks URL hash for `access_token` then extracts, cleans and creates a params object which contains the JWT along 57 | * with other metadata. 58 | */ 59 | function detectExternalAccessToken() { 60 | try { 61 | const externalTokenRegex = /access_token=/; 62 | // Clean the URL 63 | const hash = (document.location.hash || "").replace(/^#\/?/, ""); 64 | 65 | if (hash.match(externalTokenRegex)) { 66 | const params = {}; 67 | //create token param from url hash 68 | hash.split("&").forEach(pair => { 69 | //each pair e.g 'token=f8fjCu9_MX_V5Eoci2iV8g' needs to split again so that we can construct key value pairs. 70 | const [key, value] = pair.split("="); 71 | params[key] = value; 72 | }); 73 | return params; 74 | } 75 | } catch (error) { 76 | console.error( 77 | "Something went wrong trying to extract the access token", 78 | error 79 | ); 80 | return null; 81 | } 82 | } 83 | 84 | function detectRecoveryToken() { 85 | try { 86 | // split the hash where it detects `confirmation_token=`. The string which proceeds is the part which we want. 87 | const token = decodeURIComponent(document.location.hash).split( 88 | "recovery_token=" 89 | )[1]; 90 | return token; 91 | } catch (error) { 92 | console.error( 93 | "Something went wrong when trying to extract email confirmation email", 94 | error 95 | ); 96 | return null; 97 | } 98 | } 99 | 100 | /** 101 | * @param {string} token - authentication token used to confirm a user who has created an account via email signup. 102 | */ 103 | function confirmEmailToken(token) { 104 | store 105 | .dispatch("auth/attemptConfirmation", token) 106 | .then(resp => { 107 | alert(`${resp.email} has been confirmed, please login`); 108 | }) 109 | .catch(error => { 110 | alert(`Can't authorise your account right now. Please try again`); 111 | console.error(error, "Somethings gone wrong logging in"); 112 | }); 113 | } 114 | 115 | /** 116 | * @param {object} externalAccessTokenObject - object which includes the JWT along with metadata such as token type, 117 | * refresh token, expiry time. only th JWT token is used current, but we 118 | * send the whole object over just in case the other parts of the object 119 | * might be useful for other things later. 120 | */ 121 | function confirmExternalAccessToken(externalAccessTokenObject) { 122 | store 123 | .dispatch("auth/completeExternalLogin", externalAccessTokenObject) 124 | .then(() => { 125 | alert("You have successfully signed in via external provider"); 126 | }) 127 | .catch(error => { 128 | alert(`Can't Authorise your account right now, try again`); 129 | console.error(error, "Somethings gone wrong logging in"); 130 | }); 131 | } 132 | 133 | function confirmRecoveryToken(recoveryToken) { 134 | store 135 | .dispatch("auth/attemptPasswordRecovery", recoveryToken) 136 | .then(() => { 137 | router.push("profile?showUpdateUserModal=true"); 138 | alert("Account has been recovered. Update your password now."); 139 | }) 140 | .catch(() => { 141 | alert(`Can't recover password`); 142 | }); 143 | } 144 | 145 | export default function() { 146 | detectTokens(); 147 | } 148 | -------------------------------------------------------------------------------- /src/helpers/init-db.js: -------------------------------------------------------------------------------- 1 | import faunadb from "faunadb"; 2 | import store from "../store"; 3 | 4 | /* configure faunaDB Client with our secret */ 5 | export const q = faunadb.query; 6 | // client needs to be mutable so it can dynamically re-initialise when a new user logs in. 7 | // eslint-disable-next-line import/no-mutable-exports 8 | export let client = new faunadb.Client({ 9 | secret: store.getters["auth/currentUser"].app_metadata.db_token 10 | }); 11 | 12 | // Its important we watch the current user object and update the fauna db client 13 | // otherwise when switching accounts the previous users token is still cached in memory 14 | // https://github.com/chiubaca/vue-netlify-fauna-starter-kit/issues/3 15 | store.subscribe(mutation => { 16 | if (mutation.type === "auth/SET_CURRENT_USER") { 17 | client = new faunadb.Client({ 18 | secret: mutation.payload.app_metadata.db_token 19 | }); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./router"; 4 | import store from "./store"; 5 | import attemptToAuthoriseTokens from "./helpers/authorise-tokens"; 6 | 7 | new Vue({ 8 | el: "#app", 9 | render: h => h(App), 10 | router, 11 | store 12 | }); 13 | 14 | store.dispatch("auth/initAuth"); 15 | 16 | attemptToAuthoriseTokens(); 17 | 18 | Vue.config.productionTip = false; 19 | 20 | // Register a global custom directive called `v-focus` 21 | Vue.directive("focus", { 22 | // When the bound element is inserted into the DOM... 23 | // eslint-disable-next-line prettier/prettier 24 | inserted: function (el) { 25 | // Focus the element 26 | el.focus(); 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /src/models/JournalsModel.js: -------------------------------------------------------------------------------- 1 | import { q, client } from "../helpers/init-db"; 2 | 3 | /** 4 | * 5 | * @param {object} journalData - object containing the title of journal, could contain other data too in the future 6 | */ 7 | export function createJournal(journalData) { 8 | const me = q.Identity(); 9 | 10 | return client 11 | .query( 12 | q.Create(q.Collection("journals"), { 13 | data: { 14 | ...journalData, 15 | owner: me 16 | }, 17 | permissions: { 18 | read: me, 19 | write: me 20 | } 21 | }) 22 | ) 23 | .then(resp => resp) 24 | .catch(error => error); 25 | } 26 | 27 | export function getJournals() { 28 | return client 29 | .query( 30 | q.Map(q.Paginate(q.Match(q.Ref("indexes/all_journals"))), ref => 31 | q.Get(ref) 32 | ) 33 | ) 34 | .then(resp => resp); 35 | } 36 | 37 | /** 38 | * 39 | * @param {object} journal - Fauna journal object 40 | */ 41 | export function deleteJournal(journal) { 42 | return client 43 | .query( 44 | q.Map( 45 | q.Paginate( 46 | q.Match( 47 | // get all the posts within a given journal ref 48 | q.Index("posts_by_journal"), 49 | q.Ref(q.Collection("journals"), journal.ref.value.id) 50 | ) 51 | ), 52 | // then delete all of the posts within that given journal ref, 53 | // I used a FQL Lambda here because i couldn't get an inline arrow function to work 54 | q.Lambda("X", q.Delete(q.Select("ref", q.Get(q.Var("X"))))) 55 | ) 56 | ) 57 | .then(() => { 58 | // Once all of the posts in that given journals have been removed we delete the journal itself 59 | return client.query(q.Delete(journal.ref)); 60 | }) 61 | .catch(err => err); 62 | } 63 | 64 | /** 65 | * 66 | * @param {object} journalRefID - faunaDb journal collection reference ID 67 | * @param {string} newTitle - new title for journal 68 | */ 69 | export function updateJournalTitle(journalRefID, newTitle) { 70 | return client 71 | .query( 72 | q.Update(q.Ref(q.Collection("journals"), journalRefID), { 73 | //TODO - should think about spreading a journal object into here. See createJournal method. 74 | data: { title: newTitle } 75 | }) 76 | ) 77 | .then(resp => resp) 78 | .catch(err => err); 79 | } 80 | -------------------------------------------------------------------------------- /src/models/PostsModel.js: -------------------------------------------------------------------------------- 1 | import { q, client } from "../helpers/init-db"; 2 | 3 | /** 4 | * 5 | * @param {object} postData - posts data object 6 | * @param {string} journalID - the journal id which corresponds to a FuanaDB ref number 7 | */ 8 | export function addPost(postData, journalID) { 9 | const me = q.Identity(); 10 | 11 | return client 12 | .query( 13 | q.Create(q.Collection("posts"), { 14 | data: { 15 | ...postData, 16 | journal: q.Ref(q.Collection("journals"), journalID), 17 | owner: me 18 | }, 19 | permissions: { 20 | read: me, 21 | write: me 22 | } 23 | }) 24 | ) 25 | .then(resp => resp) 26 | .catch(error => error); 27 | } 28 | 29 | /** 30 | * 31 | * @param {string} journalID 32 | */ 33 | export function getPosts(journalID) { 34 | // Get the Current Journal reference object 35 | // TODO: Wonder if we could just store the current journal ID object into a vuex, 36 | // this could save an additonal request to get the journal ID 37 | 38 | return client 39 | .query(q.Get(q.Ref(`collections/journals/${journalID}`))) 40 | .then(journal => { 41 | return client 42 | .query( 43 | q.Map( 44 | q.Paginate(q.Match(q.Index("posts_by_journal"), journal.ref)), 45 | ref => q.Get(ref) // fauna lambda function , what does "Get()" do? 46 | ) 47 | ) 48 | .then(resp => resp); 49 | }) 50 | .catch(err => err); 51 | } 52 | 53 | /** 54 | * 55 | * @param {object} refID - fauna ref object 56 | */ 57 | export function deletePost(refID) { 58 | return client 59 | .query(q.Delete(refID)) 60 | .then(resp => resp) 61 | .catch(err => err); 62 | } 63 | 64 | /** 65 | * 66 | * @param {object} postRefID - faunaDb Post collection reference ID 67 | * @param {string} newPost - new post for journal 68 | */ 69 | export function updatePost(postRefID, newPostData) { 70 | return client 71 | .query( 72 | q.Update(q.Ref(q.Collection("posts"), postRefID), { 73 | data: newPostData 74 | }) 75 | ) 76 | .then(resp => resp) 77 | .catch(err => err); 78 | } 79 | -------------------------------------------------------------------------------- /src/pages/AllJournals.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 121 | 122 | 168 | -------------------------------------------------------------------------------- /src/pages/AllPosts.vue: -------------------------------------------------------------------------------- 1 |