├── .eslintrc.js ├── .gitignore ├── .postcssrc.js ├── README.md ├── babel.config.js ├── jsconfig.json ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.vue ├── assets │ ├── logo.png │ └── styles │ │ ├── global.css │ │ └── transitions.css ├── components │ ├── AppHeader.vue │ ├── LoadingSpinner.vue │ ├── PostCard.vue │ ├── SideMenu.vue │ ├── SubscribeSidebar.vue │ └── TagSidebar.vue ├── config │ ├── app.js │ ├── ghost.js │ ├── index.js │ └── local.js ├── lib │ └── ghost.js ├── main.js ├── plugins │ └── formatters.js ├── router.js └── views │ ├── PageItem.vue │ ├── PostItem.vue │ └── PostList.vue ├── vue.config.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/recommended', 8 | '@vue/standard' 9 | ], 10 | rules: { 11 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 12 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 13 | 'vue/no-v-html': 0 14 | }, 15 | parserOptions: { 16 | parser: '@babel/eslint-parser' 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghost-vue 2 | Frontend for [Ghost](https://ghost.org/) built with [Vue.js](https://vuejs.org/) and [Bulma](https://bulma.io/). 3 | 4 | ## Configuration 5 | All configuration can be overridden in /src/config/local.js instead of the individual config files. 6 | 7 | You should set the host to your ghost's installation as well as the clientSecret for the ghost_frontend client. Refer to the ghost api documentation on how to retrieve that. 8 | 9 | ### Example 10 | ```javascript 11 | export default { 12 | ghost: { 13 | host: 'https://ghostblog.com', 14 | sdkPath: '/public/ghost-sdk.min.js', 15 | clientSecret: 'abcdef123456' 16 | } 17 | } 18 | ``` 19 | 20 | ## Running/Building 21 | vue-ghost is built with vue-cli and uses all the default yarn/npm run scripts: 22 | 23 | dev: yarn serve 24 | 25 | prod: yarn build 26 | 27 | ## Production 28 | You can run vue-ghost on the same server as your ghost installation and use nginx to proxy the api to use the same domain. 29 | 30 | Example config with ghost running on port 2368: 31 | 32 | /src/config/local.js: 33 | ```javascript 34 | export default { 35 | ghost: { 36 | host: '', 37 | sdkPath: '/ghost-sdk', 38 | clientSecret: 'abcdef123456' 39 | } 40 | } 41 | ``` 42 | 43 | Nginx server config: 44 | ```nginx 45 | server { 46 | 47 | server_name blogserver.com; 48 | 49 | location / { 50 | root /var/www/ghost-vue/dist; 51 | try_files $uri $uri/ /index.html; 52 | } 53 | 54 | location /content { 55 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 56 | proxy_set_header X-Forwarded-Proto $scheme; 57 | proxy_set_header X-Real-IP $remote_addr; 58 | proxy_set_header Host $http_host; 59 | proxy_pass http://127.0.0.1:2368/content; 60 | } 61 | location /admin { 62 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 63 | proxy_set_header X-Forwarded-Proto $scheme; 64 | proxy_set_header X-Real-IP $remote_addr; 65 | proxy_set_header Host $http_host; 66 | proxy_pass http://127.0.0.1:2368/ghost; 67 | } 68 | location /ghost { 69 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 70 | proxy_set_header X-Forwarded-Proto $scheme; 71 | proxy_set_header X-Real-IP $remote_addr; 72 | proxy_set_header Host $http_host; 73 | proxy_pass http://127.0.0.1:2368/ghost; 74 | } 75 | location /ghost-sdk { 76 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 77 | proxy_set_header X-Forwarded-Proto $scheme; 78 | proxy_set_header X-Real-IP $remote_addr; 79 | proxy_set_header Host $http_host; 80 | proxy_pass http://127.0.0.1:2368/public/ghost-sdk.min.js; 81 | } 82 | 83 | listen 80; 84 | } 85 | ``` 86 | 87 | This example nginx config will load the ghost-vue build when accessing the root domain. Accessing /admin or /ghost on the domain will load the ghost admin app. 88 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghost-vue", 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 | }, 10 | "dependencies": { 11 | "@fortawesome/fontawesome-svg-core": "^1.2.0-14", 12 | "@fortawesome/free-brands-svg-icons": "^5.1.0-11", 13 | "@fortawesome/vue-fontawesome": "^0.1.0-5", 14 | "bulma": "^0.7.1", 15 | "core-js": "^3.25.0", 16 | "dayjs": "^1.6.4", 17 | "deepmerge": "^2.1.1", 18 | "sass": "^1.54.8", 19 | "vue": "^2.6.14", 20 | "vue-resource": "^1.5.1", 21 | "vue-router": "^3.5.1" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.12.16", 25 | "@babel/eslint-parser": "^7.12.16", 26 | "@vue/cli-plugin-babel": "~5.0.8", 27 | "@vue/cli-plugin-eslint": "~5.0.8", 28 | "@vue/cli-plugin-router": "~5.0.0", 29 | "@vue/cli-service": "~5.0.8", 30 | "@vue/eslint-config-standard": "^6.1.0", 31 | "eslint": "^7.32.0", 32 | "eslint-plugin-import": "^2.25.3", 33 | "eslint-plugin-node": "^11.1.0", 34 | "eslint-plugin-promise": "^5.1.0", 35 | "eslint-plugin-standard": "^4.0.0", 36 | "eslint-plugin-vue": "^8.0.3", 37 | "sass-loader": "^7.0.1", 38 | "vue-template-compiler": "^2.6.14" 39 | }, 40 | "browserslist": [ 41 | "> 1%", 42 | "last 2 versions", 43 | "not dead" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrohland/ghost-vue/7c36f38d35eedde8ff0b41708cd7a915c8be6d2c/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ghost-vue 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 83 | 84 | 108 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrohland/ghost-vue/7c36f38d35eedde8ff0b41708cd7a915c8be6d2c/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/styles/global.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --text-color: #2c3e50; 3 | --border-color: #95989a; 4 | --menu-icon-hover-color: #e4e4e4; 5 | --header-title-color: #8588c2; 6 | --header-title-hover-color: #775bb3; 7 | --header-height: 220px; 8 | --header-scrolled-height: 60px; 9 | --link-color: #8588c2; 10 | --link-hover-color: #775bb3; 11 | } 12 | 13 | @import url('https://fonts.googleapis.com/css?family=Caveat|Cormorant+Garamond'); 14 | 15 | html, body { 16 | font-family: 'Cormorant Garamond', 'Avenir', Helvetica, Arial, sans-serif; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | color: var(--text-color); 20 | } 21 | 22 | a { 23 | color: var(--link-color); 24 | } 25 | 26 | a:hover { 27 | color: var(--link-hover-color); 28 | } 29 | 30 | .standard-shadow { 31 | -webkit-box-shadow: 0px 0px 6px 0px rgba(0,0,0,0.16); 32 | -moz-box-shadow: 0px 0px 6px 0px rgba(0,0,0,0.16); 33 | box-shadow: 0px 0px 6px 0px rgba(0,0,0,0.16); 34 | } 35 | 36 | .text-align-center { 37 | text-align: center; 38 | } 39 | 40 | .h-100 { 41 | height: 100%; 42 | } -------------------------------------------------------------------------------- /src/assets/styles/transitions.css: -------------------------------------------------------------------------------- 1 | .page-enter-active { 2 | animation: coming .5s; 3 | opacity: 0; 4 | } 5 | .page-leave-active { 6 | animation: going .5s; 7 | } 8 | 9 | @keyframes going { 10 | from { 11 | transform: translateX(0); 12 | } 13 | to { 14 | transform: translateX(-50px); 15 | opacity: 0; 16 | } 17 | } 18 | @keyframes coming { 19 | from { 20 | transform: scale(0); 21 | opacity: 0; 22 | } 23 | to { 24 | transform: scale(1); 25 | opacity: 1; 26 | } 27 | } 28 | 29 | .slide-from-left-enter-active, 30 | .slide-from-left-leave-active { 31 | transition: all .5s ease; 32 | } 33 | 34 | .slide-from-left-enter, 35 | .slide-from-left-leave-to { 36 | transform: translate(-100%, 0); 37 | } 38 | 39 | .fade-enter-active, 40 | .fade-leave-active { 41 | opacity: .75; 42 | transition: opacity .3s; 43 | } 44 | 45 | .fade-enter, 46 | .fade-leave-to { 47 | opacity: 0; 48 | } -------------------------------------------------------------------------------- /src/components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 98 | 99 | 237 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.vue: -------------------------------------------------------------------------------- 1 | 7 | 25 | 66 | -------------------------------------------------------------------------------- /src/components/PostCard.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 57 | 58 | 78 | -------------------------------------------------------------------------------- /src/components/SideMenu.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 75 | 76 | 119 | -------------------------------------------------------------------------------- /src/components/SubscribeSidebar.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 73 | -------------------------------------------------------------------------------- /src/components/TagSidebar.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 48 | 49 | 55 | -------------------------------------------------------------------------------- /src/config/app.js: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'Blog Name', 3 | description: 'Blog Description', 4 | pages: [ 5 | { 6 | name: 'Contact Me', 7 | slug: 'contact' 8 | } 9 | ], 10 | social: { 11 | facebook: '', 12 | twitter: '', 13 | instagram: '' 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/config/ghost.js: -------------------------------------------------------------------------------- 1 | export default { 2 | host: 'https://ghostblog.com', 3 | sdkPath: '/public/ghost-sdk.min.js', 4 | clientId: 'ghost-frontend', 5 | clientSecret: '' 6 | } 7 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | import merge from 'deepmerge' 2 | import local from './local' 3 | 4 | const context = require.context('.', false, /\.js$/) 5 | const defaultConfig = {} 6 | context.keys().forEach((file) => { 7 | const key = file.replace('./', '').replace('.js', '') 8 | if (['index', 'local'].indexOf(key) === -1) defaultConfig[key] = context(file).default 9 | }) 10 | 11 | const config = merge(defaultConfig, local) 12 | 13 | export const { app, ghost } = config 14 | export default config 15 | -------------------------------------------------------------------------------- /src/config/local.js: -------------------------------------------------------------------------------- 1 | export default {} 2 | -------------------------------------------------------------------------------- /src/lib/ghost.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import config from '@/config' 3 | 4 | let initialized = false 5 | let isInitalizing = false 6 | 7 | const init = () => { 8 | if (initialized) return true 9 | 10 | if (isInitalizing) { 11 | return new Promise((resolve, reject) => { 12 | const handle = window.setInterval(() => { 13 | if (initialized) { 14 | clearInterval(handle) 15 | resolve() 16 | } 17 | ctr++ 18 | if (ctr > 1000) { 19 | reject(new Error('Unable to load ghost sdk')) 20 | clearInterval(handle) 21 | } 22 | }, 5) 23 | }) 24 | } 25 | 26 | isInitalizing = true 27 | 28 | const ghostScript = document.createElement('script') 29 | const src = config.ghost.host + config.ghost.sdkPath 30 | ghostScript.setAttribute('src', src) 31 | document.head.appendChild(ghostScript) 32 | 33 | let ctr = 0 34 | return new Promise((resolve, reject) => { 35 | const handle = window.setInterval(() => { 36 | if (window.ghost) { 37 | window.ghost.init({ 38 | clientId: config.ghost.clientId, 39 | clientSecret: config.ghost.clientSecret 40 | }) 41 | 42 | clearInterval(handle) 43 | initialized = true 44 | isInitalizing = false 45 | resolve() 46 | } 47 | ctr++ 48 | if (ctr > 1000) { 49 | reject(new Error('Unable to load ghost sdk')) 50 | clearInterval(handle) 51 | } 52 | }, 5) 53 | }) 54 | } 55 | 56 | export default { 57 | async getPosts (params) { 58 | if (!initialized) await init() 59 | 60 | return new Promise((resolve, reject) => { 61 | Vue.http 62 | .get(window.ghost.url.api('posts', params)) 63 | .then(result => { 64 | resolve(result.body) 65 | }) 66 | .catch(error => reject(error)) 67 | }) 68 | }, 69 | 70 | async getPostBySlug (slug) { 71 | if (!initialized) await init() 72 | 73 | return new Promise((resolve, reject) => { 74 | Vue.http 75 | .get(window.ghost.url.api('posts/slug/' + slug)) 76 | .then(result => { 77 | if (!result.body.posts || result.body.posts.length !== 1) reject(new Error('Post not found')) 78 | resolve(result.body.posts[0]) 79 | }) 80 | .catch(error => reject(error)) 81 | }) 82 | }, 83 | 84 | async addSubscription (data) { 85 | if (!initialized) await init() 86 | 87 | return new Promise((resolve, reject) => { 88 | Vue.http 89 | .post(window.ghost.url.api('subscribers/'), { 90 | subscribers: [data] 91 | }) 92 | .then(result => { 93 | resolve() 94 | }) 95 | .catch(error => reject(error)) 96 | }) 97 | }, 98 | 99 | async getTags (params) { 100 | if (!initialized) await init() 101 | 102 | return new Promise((resolve, reject) => { 103 | Vue.http 104 | .get(window.ghost.url.api('tags', params)) 105 | .then(result => { 106 | resolve(result.body.tags) 107 | }) 108 | .catch(error => reject(error)) 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import VueResource from 'vue-resource' 5 | import FormattersPlugin from '@/plugins/formatters' 6 | import LoadingSpinner from '@/components/LoadingSpinner' 7 | import { library } from '@fortawesome/fontawesome-svg-core' 8 | import { faFacebookSquare, faTwitterSquare, faInstagram } from '@fortawesome/free-brands-svg-icons' 9 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 10 | 11 | Vue.use(VueResource) 12 | Vue.use(FormattersPlugin) 13 | Vue.component('LoadingSpinner', LoadingSpinner) 14 | 15 | // Fontawesome icons 16 | library.add(faFacebookSquare, faTwitterSquare, faInstagram) 17 | Vue.component('FontAwesomeIcon', FontAwesomeIcon) 18 | 19 | Vue.config.productionTip = false 20 | 21 | new Vue({ 22 | router, 23 | render: h => h(App) 24 | }).$mount('#app') 25 | -------------------------------------------------------------------------------- /src/plugins/formatters.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | export default { 4 | install (Vue, options) { 5 | Vue.filter('date', (value) => { 6 | return dayjs(value).format('MMM D, YYYY') 7 | }) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import config from './config' 4 | 5 | Vue.use(Router) 6 | 7 | const pageRoutes = config.app.pages.map(page => { 8 | return { 9 | path: `/${page.slug}`, 10 | component: () => import(/* webpackChunkName: "page" */ './views/PageItem.vue'), 11 | props: { 12 | slug: page.slug 13 | } 14 | } 15 | }) 16 | 17 | export default new Router({ 18 | mode: 'history', 19 | routes: [ 20 | { 21 | path: '/', 22 | name: 'posts', 23 | component: () => import(/* webpackChunkName: "posts" */ './views/PostList.vue') 24 | }, 25 | ...pageRoutes, 26 | { 27 | path: '/tag/:tag', 28 | name: 'tag', 29 | component: () => import(/* webpackChunkName: "posts" */ './views/PostList.vue'), 30 | props: true 31 | }, 32 | { 33 | path: '/:slug', 34 | name: 'post', 35 | component: () => import(/* webpackChunkName: "post" */ './views/PostItem.vue'), 36 | props: true 37 | } 38 | ] 39 | }) 40 | -------------------------------------------------------------------------------- /src/views/PageItem.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 79 | 80 | 94 | -------------------------------------------------------------------------------- /src/views/PostItem.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 100 | 101 | 126 | -------------------------------------------------------------------------------- /src/views/PostList.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 135 | 136 | 147 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const ghostHost = 'https://ghostblog.com' 2 | 3 | module.exports = { 4 | lintOnSave: true, 5 | devServer: { 6 | inline: true, 7 | proxy: { 8 | '/content': { 9 | target: ghostHost, 10 | changeOrigin: true 11 | }, 12 | '/ghost-sdk': { 13 | target: ghostHost, 14 | pathRewrite: () => { 15 | return '/public/ghost-sdk.min.js' 16 | } 17 | } 18 | } 19 | } 20 | } 21 | --------------------------------------------------------------------------------