├── .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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
16 |
17 |
18 |
19 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
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 |
2 |
3 |
36 |
37 |
38 |
39 |
40 |
41 |
98 |
99 |
237 |
--------------------------------------------------------------------------------
/src/components/LoadingSpinner.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
25 |
66 |
--------------------------------------------------------------------------------
/src/components/PostCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
11 |
12 |
13 | {{ post.created_at | date }}
14 |
15 |
16 |
17 | {{ post.title }}
18 |
19 |
20 |
21 |
22 |
23 |
57 |
58 |
78 |
--------------------------------------------------------------------------------
/src/components/SideMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
34 |
35 |
36 |
75 |
76 |
119 |
--------------------------------------------------------------------------------
/src/components/SubscribeSidebar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 | Couldn't subscribe!
9 |
10 |
11 |
12 | {{ subscribeError.errors[0].message }}
13 |
14 |
15 |
16 |
17 |
18 | Subscribe
19 |
20 | Keep up to date.
21 |
22 |
23 |
29 |
30 |
31 |
Sign Up
35 |
36 |
37 |
41 | Thanks for subscribing!
42 |
43 |
44 |
45 |
46 |
73 |
--------------------------------------------------------------------------------
/src/components/TagSidebar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Tags
5 |
6 |
7 |
8 | {{ error }}
9 |
10 |
11 |
12 |
18 | {{ tag.name }}
19 |
20 |
21 |
22 |
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 |
2 |
3 |
7 |
8 | Couldn't load page!
9 |
10 |
11 |
12 | {{ loadError.errors[0].message }}
13 |
14 |
15 |
16 |
17 |
18 |
19 | {{ post.title }}
20 |
21 |
22 |
26 |
27 |
28 |
29 |
33 |
37 |
38 |
39 |
40 |
41 |
79 |
80 |
94 |
--------------------------------------------------------------------------------
/src/views/PostItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 | Couldn't load post!
9 |
10 |
11 |
12 | {{ loadError.errors[0].message }}
13 |
14 |
15 |
16 |
17 |
22 |
23 |
24 |
25 | {{ post.created_at | date }}
26 |
27 |
28 |
29 | {{ post.title }}
30 |
31 |
32 |
36 |
37 |
38 |
39 |
42 |
46 |
47 |
48 |
49 |
50 |
100 |
101 |
126 |
--------------------------------------------------------------------------------
/src/views/PostList.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 | Couldn't load posts!
8 |
9 |
10 |
11 | {{ loadError.errors[0].message }}
12 |
13 |
14 |
15 |
16 |
20 | No posts found
21 |
22 |
23 |
24 |
31 |
32 |
36 | No more posts found
37 |
38 |
39 |
48 |
49 |
50 |
51 |
55 |
59 |
60 |
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 |
--------------------------------------------------------------------------------