├── assets └── scss │ ├── _mixins.scss │ ├── mixins │ └── _hover-state.scss │ ├── _hljs-overrides.scss │ ├── main.scss │ ├── components │ ├── _pagination.scss │ ├── _button.scss │ ├── _dropdown.scss │ ├── _footer.scss │ ├── _post.scss │ ├── _single-post.scss │ └── _navbar.scss │ ├── _variables.scss │ ├── _animations.scss │ ├── _basics.scss │ └── _kg-styles.scss ├── .huskyrc ├── static └── icon.png ├── config ├── meta.js ├── dev.js ├── index.js ├── build.js ├── feed.js └── ghost.js ├── .env.example ├── .prettierrc ├── CHANGELOG ├── jsconfig.json ├── .editorconfig ├── plugins └── README.md ├── .eslintrc.js ├── components ├── ReadingTime.vue ├── DropdownItem.vue ├── DropdownContent.vue ├── Dropdown.vue ├── ScrollProgressBar.vue ├── Navbar.vue ├── Footer.vue └── Posts.vue ├── LICENSE ├── layouts ├── default.vue └── error.vue ├── .gitignore ├── pages ├── tags.vue ├── tag │ └── _slug.vue ├── 404.vue ├── index.vue ├── author │ └── _slug.vue └── _slug.vue ├── package.json ├── store └── index.js ├── nuxt.config.js └── README.md /assets/scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | // Mixins 2 | @import 'mixins/hover-state'; 3 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "yarn run lint" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moso/nuxt-ghost-blog/HEAD/static/icon.png -------------------------------------------------------------------------------- /config/meta.js: -------------------------------------------------------------------------------- 1 | // Small meta overrides 2 | 3 | export default { 4 | mobileAppIOS: true 5 | } 6 | -------------------------------------------------------------------------------- /config/dev.js: -------------------------------------------------------------------------------- 1 | // Checking if we're in dev mode 2 | 3 | export const isDev = process.env.NODE_ENV !== 'production' 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GHOST_URI=https://demo.ghost.io 2 | GHOST_KEY=22444f78447824223cefc48062 3 | BLOG_URL=https://demo.ghost.io 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "arrowParens": "always", 4 | "singleQuote": true, 5 | "tabs": false, 6 | "tabWidth": 4 7 | } 8 | -------------------------------------------------------------------------------- /assets/scss/mixins/_hover-state.scss: -------------------------------------------------------------------------------- 1 | @mixin hover-state { 2 | &:hover, 3 | &:focus, 4 | &:active, 5 | &:hover:active { 6 | @content; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | **1.0.2** 2 | - Added demo 3 | - Security update: updated depenencies 4 | 5 | **1.0.1**: 6 | - Security update: updated depenencies 7 | 8 | **1.0.0**: 9 | - Initial release 10 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | export { default as build } from './build.js' 2 | export { default as feed } from './feed.js' 3 | export { default as meta } from './meta.js' 4 | export * as dev from './dev.js' 5 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "~/*": ["./*"], 6 | "@/*": ["./*"], 7 | "~~/*": ["./*"], 8 | "@@/*": ["./*"] 9 | } 10 | }, 11 | "exclude": ["node_modules", ".nuxt", "dist"] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /assets/scss/_hljs-overrides.scss: -------------------------------------------------------------------------------- 1 | // Small highlightjs overrides 2 | pre { 3 | width: 100%; 4 | } 5 | 6 | .hljs { 7 | padding: 1rem; 8 | background-color: #2f3136; 9 | border-radius: $border-radius; 10 | font-family: $font-code; 11 | font-size: 85%; 12 | white-space: pre-wrap; 13 | } 14 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # PLUGINS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). 8 | -------------------------------------------------------------------------------- /assets/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | @import 'mixins'; 3 | @import 'basics'; 4 | @import 'animations'; 5 | 6 | @import 'components/post'; 7 | @import 'components/single-post'; 8 | @import 'kg-styles'; 9 | 10 | @import 'components/button'; 11 | @import 'components/navbar'; 12 | @import 'components/footer'; 13 | @import 'components/dropdown'; 14 | @import 'components/pagination'; 15 | 16 | @import '~nord-highlightjs/dist/nord'; 17 | @import 'hljs-overrides'; 18 | -------------------------------------------------------------------------------- /config/build.js: -------------------------------------------------------------------------------- 1 | // Extending `build` 2 | 3 | export default { 4 | extend(config, { isDev, isClient }) { 5 | if(isDev && isClient) { 6 | config.module.rules.push({ 7 | enforce: 'pre', 8 | test: /\.(js|vue)$/, 9 | loader: 'eslint-loader', 10 | exclude: /(node_modules)/ 11 | }) 12 | } 13 | }, 14 | 15 | babel: { 16 | plugins: [ 17 | ['@babel/plugin-proposal-export-namespace-from'] 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /assets/scss/components/_pagination.scss: -------------------------------------------------------------------------------- 1 | // Pagination 2 | .pagination { 3 | display: flex; 4 | margin: 0 0 2rem; 5 | padding: 0; 6 | list-style: none; 7 | 8 | .next { 9 | margin-left: auto; 10 | } 11 | 12 | .next, 13 | .prev { 14 | > a { 15 | cursor: pointer; 16 | } 17 | 18 | &.disabled { 19 | cursor: not-allowed; 20 | 21 | > a { 22 | color: rgba($dark, .30); 23 | pointer-events: none; 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | parserOptions: { 8 | parser: 'babel-eslint' 9 | }, 10 | extends: [ 11 | '@nuxtjs', 12 | 'prettier', 13 | 'prettier/vue', 14 | 'plugin:nuxt/recommended', 15 | 'plugin:vue/essential', 16 | ], 17 | plugins: [ 18 | 'vue' 19 | ], 20 | rules: { 21 | indent: ["error", 4], 22 | "space-before-function-paren": ["error", { 23 | "anonymous": "never", 24 | "named": "never", 25 | "asyncArrow": "always" 26 | }] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /components/ReadingTime.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 28 | -------------------------------------------------------------------------------- /components/DropdownItem.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 35 | -------------------------------------------------------------------------------- /components/DropdownContent.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 25 | 26 | 38 | -------------------------------------------------------------------------------- /assets/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // Variables 2 | 3 | // Fonts 4 | $font-stack: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,"Helvetica Neue",sans-serif; 5 | $font-body: "Source Sans Pro", $font-stack; 6 | $font-code: "Roboto Mono", "Liberation Mono", Menlo, Monaco, Consolas, "Courier New", monospace; 7 | $font-family-post: $font-body; 8 | 9 | 10 | // Colors 11 | $dark: #171717; 12 | $light: #fff; 13 | $blue: #0085c3; 14 | $green: #009274; 15 | $red: #c85e4b; 16 | $gray: #dcddde; 17 | 18 | $text-color: $dark; 19 | 20 | $twitter-color: #1da1f2; 21 | $facebook-color: #3b5998; 22 | 23 | $box-shadow: 0 1px 1px rgba(0,0,0,.05), 0 2px 2px rgba(0,0,0,.05), 0 4px 4px rgba(0,0,0,.05), 0 8px 8px rgba(0,0,0,.05), 0 16px 16px rgba(0,0,0,.05); 24 | 25 | 26 | // Magic numbers 27 | $navbar-height: 4.5rem; 28 | $sidebar-width: 16rem; 29 | $border-radius: 3px; 30 | -------------------------------------------------------------------------------- /components/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 - present Morten Sørensen (morten@moso.io) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 12 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 13 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 48 | -------------------------------------------------------------------------------- /assets/scss/_animations.scss: -------------------------------------------------------------------------------- 1 | // Animations 2 | @keyframes topbar-x { 3 | 0% { 4 | top: 0px; 5 | transform: rotate(0deg); 6 | } 7 | 8 | 45% { 9 | top: 6px; 10 | transform: rotate(145deg); 11 | } 12 | 13 | 75% { 14 | transform: rotate(130deg); 15 | } 16 | 17 | 100% { 18 | transform: rotate(135deg); 19 | } 20 | } 21 | 22 | @keyframes topbar-back { 23 | 0% { 24 | top: 6px; 25 | transform: rotate(135deg); 26 | } 27 | 28 | 45% { 29 | transform: rotate(-10deg); 30 | } 31 | 32 | 75% { 33 | transform: rotate(5deg); 34 | } 35 | 36 | 100% { 37 | top: 0px; 38 | transform: rotate(0); 39 | } 40 | } 41 | 42 | @keyframes bottombar-x { 43 | 0% { 44 | bottom: 0px; 45 | transform: rotate(0deg); 46 | } 47 | 48 | 45% { 49 | bottom: 6px; 50 | transform: rotate(-145deg); 51 | } 52 | 53 | 75% { 54 | transform: rotate(-130deg); 55 | } 56 | 57 | 100% { 58 | transform: rotate(-135deg); 59 | } 60 | } 61 | 62 | @keyframes bottombar-back { 63 | 0% { 64 | bottom: 6px; 65 | transform: rotate(-135deg); 66 | } 67 | 68 | 45% { 69 | transform: rotate(10deg); 70 | } 71 | 72 | 75% { 73 | transform: rotate(-5deg); 74 | } 75 | 76 | 100% { 77 | bottom: 0px; 78 | transform: rotate(0); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /assets/scss/components/_button.scss: -------------------------------------------------------------------------------- 1 | // Button 2 | .button { 3 | display: flex; 4 | background-color: $light; 5 | border: 1px solid rgba(0, 0, 0, .35); 6 | border-radius: $border-radius; 7 | font-weight: 600; 8 | text-decoration: none; 9 | outline: none; 10 | transition: background-color .2s ease; 11 | 12 | @include hover-state { 13 | background-color: darken($light, 10%); 14 | } 15 | 16 | &--blue { 17 | background-color: $blue; 18 | color: $light; 19 | 20 | @include hover-state { 21 | background-color: darken($blue, 10%); 22 | color: $light; 23 | } 24 | } 25 | 26 | &--green { 27 | background-color: $green; 28 | color: $light; 29 | 30 | @include hover-state { 31 | background-color: darken($green, 10%); 32 | color: $light; 33 | } 34 | } 35 | 36 | &--red { 37 | background-color: $red; 38 | color: $light; 39 | 40 | @include hover-state { 41 | background-color: darken($red, 10%); 42 | color: $light; 43 | } 44 | } 45 | 46 | &.tag-button { 47 | padding: 0.1875rem .9375rem; 48 | background-color: $blue; 49 | border: 1px solid transparent; 50 | font-size: .875rem; 51 | color: $light; 52 | text-transform: uppercase; 53 | 54 | @include hover-state { 55 | background-color: lighten($blue, 10%); 56 | color: $light; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /components/ScrollProgressBar.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 42 | 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # Mac OSX 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | -------------------------------------------------------------------------------- /pages/tags.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 41 | 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-ghost-blog", 3 | "version": "1.0.2", 4 | "description": "Ghost theme using the Ghost Content API", 5 | "private": true, 6 | "scripts": { 7 | "dev": "nuxt", 8 | "build": "nuxt build", 9 | "start": "nuxt start", 10 | "generate": "nuxt generate", 11 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore ." 12 | }, 13 | "dependencies": { 14 | "@nuxtjs/dotenv": "^1.4.1", 15 | "@nuxtjs/feed": "^1.1.0", 16 | "@nuxtjs/pwa": "latest", 17 | "@tryghost/content-api": "^1.4.1", 18 | "axios": "^0.19.2", 19 | "dayjs": "^1.8.24", 20 | "flexgrid.io": "^3.0.4", 21 | "highlight.js": "^9.18.1", 22 | "nord-highlightjs": "^0.1.0", 23 | "nuxt": "^2.12.2", 24 | "nuxt-webfontloader": "1.1.0", 25 | "vue": "^2.6.11", 26 | "vue-clickaway": "^2.2.2", 27 | "vue-cookies": "^1.7.0" 28 | }, 29 | "devDependencies": { 30 | "@babel/plugin-proposal-export-namespace-from": "^7.8.3", 31 | "@nuxtjs/eslint-config": "^2.0.2", 32 | "@nuxtjs/eslint-module": "^1.1.0", 33 | "babel-eslint": "^10.1.0", 34 | "eslint": "^6.8.0", 35 | "eslint-config-prettier": "^6.10.1", 36 | "eslint-friendly-formatter": "^4.0.1", 37 | "eslint-plugin-nuxt": ">=0.5.2", 38 | "eslint-plugin-prettier": "^3.1.3", 39 | "eslint-plugin-vue": "^6.2.2", 40 | "husky": "^4.2.5", 41 | "node-sass": "^4.13.1", 42 | "prettier": "^2.0.4", 43 | "sass-loader": "^8.0.2" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/moso/nuxt-ghost-blog/nuxt-ghost-blog.git" 48 | }, 49 | "author": { 50 | "name": "Morten Sørensen", 51 | "email": "morten@moso.io", 52 | "url": "https://moso.dev" 53 | }, 54 | "license": "MIT", 55 | "bugs": { 56 | "url": "https://github.com/moso/nuxt-ghost-blog/issues" 57 | }, 58 | "homepage": "https://moso.dev" 59 | } 60 | -------------------------------------------------------------------------------- /config/feed.js: -------------------------------------------------------------------------------- 1 | // Configuration for `@nuxtjs/feed`. 2 | // Docs at https://github.com/nuxt-community/feed-module 3 | 4 | import axios from 'axios' 5 | 6 | export default [ 7 | { 8 | path: '/rss.xml', // Change this if you want a different name and/or another location 9 | cacheTime: 1000 * 60 * 15, // Change this if you want your feed to cache more/less 10 | type: 'rss2', // Types: rss2, atom1, json1 11 | async create(feed) { 12 | const [ 13 | { data: { posts } }, 14 | { data: { settings } } 15 | ] = await Promise.all([ 16 | axios.get(process.env.GHOST_URI + '/ghost/api/v3/content/posts/?key=' + process.env.GHOST_KEY + '&v=3&include=authors'), 17 | axios.get(process.env.GHOST_URI + '/ghost/api/v3/content/settings/?key=' + process.env.GHOST_KEY + '&v=3') 18 | ]) 19 | posts.forEach(post => { 20 | feed.options = { 21 | title: settings.title, 22 | link: process.env.BLOG_URL, 23 | description: settings.description 24 | } 25 | 26 | feed.addItem({ 27 | title: post.title, 28 | id: process.env.BLOG_URL + '/' + post.slug, 29 | link: process.env.BLOG_URL + '/' + post.slug, 30 | image: post.feature_image, 31 | description: post.custom_excerpt ? post.custom_excerpt : post.excerpt, 32 | date: new Date(post.published_at), 33 | updated: new Date(post.updated_at), 34 | content: post.html, 35 | author: { 36 | name: post.primary_author.name, 37 | email: post.primary_author.email, 38 | link: post.primary_author.website 39 | } 40 | }) 41 | }) 42 | } 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /pages/tag/_slug.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 73 | -------------------------------------------------------------------------------- /layouts/error.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | 29 | 79 | -------------------------------------------------------------------------------- /pages/404.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | 29 | 93 | -------------------------------------------------------------------------------- /assets/scss/components/_dropdown.scss: -------------------------------------------------------------------------------- 1 | // Dropdown 2 | .dropdown { 3 | position: relative; 4 | margin: .5rem 0 0; 5 | 6 | @include hover-state { 7 | cursor: pointer; 8 | } 9 | 10 | @media (min-width: 992px) { 11 | margin: 0; 12 | margin-left: auto; 13 | } 14 | 15 | > span { 16 | margin: 0 .5rem 0 0; 17 | } 18 | 19 | > svg { 20 | position: relative; 21 | top: 2px; 22 | width: auto; 23 | height: 1rem; 24 | transition: fill .5s ease; 25 | fill: $dark; 26 | } 27 | 28 | @include hover-state { 29 | svg { 30 | fill: lighten($dark, 15%); 31 | } 32 | } 33 | } 34 | 35 | .dropdown-content-inner { 36 | position: absolute; 37 | display: flex; 38 | flex-direction: column; 39 | width: 7.5rem; 40 | margin: .5rem 0 0; 41 | padding: .5rem; 42 | background-color: $light; 43 | border: 1px solid $gray; 44 | border-radius: $border-radius; 45 | z-index: 3; 46 | 47 | @include hover-state { 48 | cursor: default; 49 | } 50 | 51 | @media (min-width: 992px) { 52 | right: 0; 53 | } 54 | 55 | .dropdown-header { 56 | margin: 0 0 .5rem; 57 | font-size: 1.25rem; 58 | font-weight: 600; 59 | color: $dark; 60 | } 61 | 62 | a { 63 | display: flex; 64 | align-items: center; 65 | height: 1.5rem; 66 | margin: .3rem 0; 67 | 68 | svg { 69 | width: auto; 70 | height: 1.5rem; 71 | margin: 0 .5rem 0 0; 72 | transition: fill .2s ease; 73 | } 74 | 75 | &.twitter { 76 | svg { 77 | fill: $twitter-color; 78 | } 79 | } 80 | 81 | &.facebook { 82 | svg { 83 | fill: $facebook-color; 84 | } 85 | } 86 | 87 | @include hover-state { 88 | &.twitter { 89 | svg { 90 | fill: lighten($twitter-color, 15%); 91 | } 92 | } 93 | 94 | &.facebook { 95 | svg { 96 | fill: lighten($facebook-color, 15%); 97 | } 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /assets/scss/components/_footer.scss: -------------------------------------------------------------------------------- 1 | // Footer 2 | .footer { 3 | display: flex; 4 | align-items: center; 5 | padding: 2rem 0; 6 | background-color: $dark; 7 | overflow: hidden; 8 | 9 | .footer-content { 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | 14 | @media (min-width: 992px) { 15 | flex-direction: initial; 16 | align-items: center; 17 | } 18 | 19 | .copy { 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: center; 23 | 24 | p { 25 | margin: 0 0 .1rem; 26 | color: $gray; 27 | 28 | &.muted { 29 | color: darken($gray, 25%); 30 | } 31 | } 32 | } 33 | 34 | .social { 35 | display: flex; 36 | margin: 1.5rem 0 0; 37 | 38 | @media (min-width: 992px) { 39 | margin: 0 0 0 auto; 40 | justify-content: flex-end; 41 | } 42 | 43 | ul { 44 | display: flex; 45 | margin: 0; 46 | padding: 0; 47 | list-style: none; 48 | 49 | li { 50 | margin: 0 .5rem; 51 | 52 | &:first-of-type { 53 | margin: 0 .5rem 0 0; 54 | 55 | @media (min-width: 992px) { 56 | margin: 0 .5rem; 57 | } 58 | } 59 | 60 | &:last-of-type { 61 | @media (min-width: 992px) { 62 | margin: 0 0 0 .5rem; 63 | } 64 | } 65 | 66 | .social-link { 67 | text-decoration: none; 68 | 69 | svg { 70 | height: 1.5rem; 71 | fill: rgba($light, .75); 72 | transition: fill .2s ease; 73 | } 74 | 75 | @include hover-state { 76 | svg { 77 | fill: $light; 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 77 | -------------------------------------------------------------------------------- /store/index.js: -------------------------------------------------------------------------------- 1 | import { ghostAPI, postsPerPage } from '~/config/ghost' 2 | 3 | export const state = () => ({ 4 | postNavigation: [], 5 | postsIndex: [], 6 | paginationIndex: [], 7 | singlePost: null, 8 | pageSettings: null, 9 | pageTags: null, 10 | pageAuthors: null, 11 | tagsIndex: [] 12 | }) 13 | 14 | export const mutations = { 15 | setPostNavigation(state, postNavigation) { 16 | state.postNavigation = postNavigation 17 | }, 18 | 19 | setPostsIndex(state, postsIndex) { 20 | state.postsIndex = postsIndex 21 | state.paginationIndex = postsIndex.meta.pagination 22 | }, 23 | 24 | setSinglePost(state, singlePost) { 25 | state.singlePost = singlePost 26 | }, 27 | 28 | setPageSettings(state, pageSettings) { 29 | state.pageSettings = pageSettings 30 | }, 31 | 32 | setPageTags(state, pageTags) { 33 | state.pageTags = pageTags 34 | }, 35 | 36 | setPageAuthors(state, pageAuthors) { 37 | state.pageAuthors = pageAuthors 38 | } 39 | } 40 | 41 | export const actions = { 42 | async nuxtServerInit({ commit }, { error }) { 43 | try { 44 | const settings = await ghostAPI().settings.browse() 45 | const tags = await ghostAPI().tags.browse({ 46 | limit: 'all', 47 | filter: 'visibility:public', 48 | include: 'count.posts' 49 | }) 50 | const authors = await ghostAPI().authors.browse({ 51 | limit: 'all', 52 | include: 'count.posts' 53 | }) 54 | const posts = await ghostAPI().posts.browse({ 55 | limit: 'all', 56 | fields: 'slug,title' 57 | }) 58 | 59 | const postsWithLinks = posts.map((post, index) => { 60 | const prevSlug = posts[index - 1] ? posts[index - 1].slug : null 61 | const nextSlug = posts[index + 1] ? posts[index + 1].slug : null 62 | 63 | return { 64 | ...post, 65 | prevSlug, 66 | nextSlug 67 | } 68 | }) 69 | 70 | commit('setPageSettings', settings) 71 | commit('setPageTags', tags) 72 | commit('setPageAuthors', authors) 73 | commit('setPostNavigation', postsWithLinks) 74 | } catch(err) { 75 | error({ 76 | statusCode: 500, 77 | message: err.message 78 | }) 79 | throw err 80 | } 81 | }, 82 | 83 | async getPostsIndex({ commit }, pagination) { 84 | const posts = await ghostAPI().posts.browse({ 85 | limit: postsPerPage, 86 | page: pagination.pageNumber, 87 | include: 'tags,authors', 88 | filter: pagination.filter, 89 | order: 'featured DESC, published_at DESC' 90 | }) 91 | commit('setPostsIndex', posts) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /nuxt.config.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { generateRoutes } from './config/ghost' 3 | import { build, feed, meta } from './config' 4 | import { isDev } from './config/dev' 5 | 6 | require('dotenv').config() 7 | 8 | export default async () => { 9 | const { data: { settings } } = await axios.get(process.env.GHOST_URI + '/ghost/api/v3/content/settings/?key=' + process.env.GHOST_KEY + '&v=3') 10 | return { 11 | mode: 'universal', 12 | modern: !isDev && 'client', 13 | 14 | watch: ['~/config/*'], 15 | 16 | meta, 17 | 18 | css: [ 19 | '~/node_modules/flexgrid.io/dist/flexgrid-utils.min.css', 20 | '~/assets/scss/main.scss' 21 | ], 22 | 23 | loading: { color: '#171717' }, 24 | 25 | modules: [ 26 | '@nuxtjs/pwa', 27 | 'nuxt-webfontloader' 28 | ], 29 | 30 | buildModules: [ 31 | '@nuxtjs/eslint-module', 32 | '@nuxtjs/feed' 33 | ], 34 | 35 | env: { 36 | ghostUri: process.env.GHOST_URI, 37 | ghostKey: process.env.GHOST_KEY, 38 | blogUrl: process.env.BLOG_URL 39 | }, 40 | 41 | webfontloader: { 42 | custom: { 43 | families: [ 44 | 'Source Sans Pro:n3,n4,n6', 45 | 'Roboto Mono:n4' 46 | ], 47 | urls: [ 48 | 'https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600&display=swap', 49 | 'https://fonts.googleapis.com/css?family=Roboto+Mono:400&display=swap' 50 | ] 51 | } 52 | }, 53 | 54 | feed, 55 | 56 | generate: { 57 | subFolders: false, 58 | routes: generateRoutes 59 | }, 60 | 61 | router: { 62 | extendRoutes(routes, resolve) { 63 | routes.push({ 64 | name: 'PostIndex', 65 | path: '/page/:pageNumber', 66 | component: resolve(__dirname, 'pages/index.vue') 67 | }) 68 | 69 | routes.push({ 70 | name: 'TagIndex', 71 | path: '/tag/:slug/page/:pageNumber', 72 | component: resolve(__dirname, 'pages/tag/_slug.vue') 73 | }) 74 | 75 | routes.push({ 76 | name: 'AuthorIndex', 77 | path: '/author/:slug/page/:pageNumber', 78 | component: resolve(__dirname, 'pages/author/_slug.vue') 79 | }) 80 | } 81 | }, 82 | 83 | plugins: [ 84 | // 85 | ], 86 | 87 | pwa: { 88 | manifest: { 89 | name: settings.title + ' - ' + settings.description, 90 | short_name: settings.title, 91 | description: settings.description, 92 | lang: settings.lang, 93 | start_url: '/', 94 | display: 'standalone' 95 | } 96 | }, 97 | 98 | build 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /assets/scss/components/_post.scss: -------------------------------------------------------------------------------- 1 | // Posts, tag-posts 2 | .post, .tag-post { 3 | margin: 0 0 2rem; 4 | padding: 0 0 1rem; 5 | border-bottom: 1px solid rgba(0, 0, 0, .1); 6 | font-family: $font-family-post; 7 | 8 | @media (min-width: 992px) { 9 | margin: 0 0 3rem; 10 | padding: 0 0 2rem; 11 | } 12 | 13 | .post-image { 14 | margin: 0 0 2rem; 15 | 16 | img { 17 | width: 100%; 18 | height: auto; 19 | } 20 | } 21 | 22 | .post-content { 23 | display: flex; 24 | flex-direction: column; 25 | 26 | .post-featured { 27 | font-weight: 600; 28 | } 29 | 30 | .post-meta { 31 | display: flex; 32 | flex-direction: column; 33 | justify-content: center; 34 | 35 | @media (min-width: 1200px) { 36 | flex-direction: row; 37 | justify-content: initial; 38 | align-items: center; 39 | } 40 | 41 | .post-meta-date { 42 | display: flex; 43 | align-items: flex-start; 44 | 45 | @media (min-width: 992px) { 46 | align-items: center; 47 | } 48 | 49 | .post-meta-date-icon { 50 | display: flex; 51 | align-items: center; 52 | } 53 | } 54 | } 55 | } 56 | 57 | .post-tags { 58 | margin: 0 0 .625rem; 59 | 60 | li { 61 | margin-right: 1rem; 62 | } 63 | } 64 | 65 | .post-title { 66 | max-width: 80%; 67 | margin: 0 0 1rem; 68 | font-size: 2rem; 69 | font-weight: 600; 70 | line-height: 1.2; 71 | 72 | a { 73 | color: $text-color; 74 | text-decoration: none; 75 | 76 | @include hover-state { 77 | color: lighten($text-color, 20%); 78 | } 79 | } 80 | } 81 | 82 | .post-author { 83 | display: flex; 84 | align-items: center; 85 | margin: 0 0 1rem; 86 | 87 | > span { 88 | font-weight: 600; 89 | } 90 | 91 | .post-author-avatar{ 92 | margin-right: 1rem; 93 | height: 50px; 94 | 95 | a { 96 | img { 97 | width: 50px; 98 | height: auto; 99 | border: 0; 100 | border-radius: 50%; 101 | filter: grayscale(100%); 102 | transition: filter .2s ease; 103 | } 104 | 105 | @include hover-state { 106 | img { 107 | filter: grayscale(0%); 108 | } 109 | } 110 | } 111 | } 112 | 113 | .post-author-info { 114 | display: flex; 115 | flex-direction: column; 116 | justify-content: center; 117 | 118 | .post-author-info-description { 119 | font-weight: 300; 120 | color: rgba($text-color, .54); 121 | transition: color .2s ease; 122 | } 123 | 124 | .post-author-info-name { 125 | font-weight: 600; 126 | } 127 | } 128 | } 129 | 130 | .post-read-more { 131 | margin-bottom: 2rem; 132 | font-weight: 600; 133 | color: $text-color; 134 | 135 | @include hover-state { 136 | color: lighten($text-color, 20%); 137 | } 138 | } 139 | 140 | .updated_at { 141 | margin-left: .2rem; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /assets/scss/_basics.scss: -------------------------------------------------------------------------------- 1 | // Basic styles 2 | html { 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | body { 8 | width: 100%; 9 | min-height: calc(100vh - #{$navbar-height + 1rem}); 10 | margin: 0; 11 | background-color: $light; 12 | font-family: $font-body; 13 | color: $text-color; 14 | } 15 | 16 | h1, h2, h3, 17 | h4, h5, h6 { 18 | margin-top: 0; 19 | } 20 | 21 | a { 22 | color: $blue; 23 | text-decoration: none; 24 | transition: color .2s ease; 25 | 26 | @include hover-state { 27 | color: lighten($blue, 10%); 28 | } 29 | } 30 | 31 | p { 32 | margin: 0 0 1rem; 33 | } 34 | 35 | .list { 36 | display: flex; 37 | flex-direction: column; 38 | margin: 0; 39 | padding: 0; 40 | list-style: none; 41 | 42 | &.inline { 43 | display: inline-flex; 44 | flex-direction: initial; 45 | } 46 | 47 | &.meta { 48 | flex-direction: column; 49 | 50 | @media (min-width: 1200px) { 51 | flex-direction: row; 52 | } 53 | 54 | li { 55 | display: flex; 56 | align-items: center; 57 | margin: 0 0 .5rem; 58 | 59 | @media (min-width: 1200px) { 60 | margin: 0 1.5rem 0 0; 61 | } 62 | 63 | &:last-of-type { 64 | margin: 0; 65 | } 66 | 67 | svg { 68 | width: 1rem; 69 | height: 1rem; 70 | margin: .125rem .5rem 0 0; 71 | fill: $dark; 72 | transition: fill .5s ease; 73 | 74 | @media (min-width: 992px) { 75 | margin: 0 .5rem 0 0; 76 | } 77 | } 78 | } 79 | } 80 | 81 | &.share { 82 | margin-top: .5rem; 83 | flex-direction: column; 84 | 85 | @media (min-width: 1200px) { 86 | flex-direction: row; 87 | } 88 | 89 | li { 90 | display: flex; 91 | align-items: center; 92 | margin: 0 0 .5rem; 93 | 94 | @media (min-width: 1200px) { 95 | margin: 0 .5rem 0 0; 96 | } 97 | 98 | &:last-of-type { 99 | margin: 0; 100 | } 101 | 102 | a { 103 | display: flex; 104 | align-items: center; 105 | } 106 | 107 | svg { 108 | width: 1.25rem; 109 | height: 1.25rem; 110 | fill: $dark; 111 | transition: fill .5s ease; 112 | } 113 | } 114 | } 115 | } 116 | 117 | blockquote { 118 | position: relative; 119 | margin: 2rem 0; 120 | padding: 1.5rem 2rem 1.5rem 3rem; 121 | background-color: lighten($gray, 10%); 122 | border-left: 6px solid lighten($dark, 75%); 123 | border-radius: $border-radius; 124 | font-style: italic; 125 | color: lighten($text-color, 15%); 126 | 127 | &::before { 128 | position: absolute; 129 | left: 10px; 130 | top: 0px; 131 | content: "\201C"; 132 | color: lighten($text-color, 15%); 133 | font-size: 3.25rem; 134 | 135 | } 136 | 137 | &::after { 138 | content: ''; 139 | } 140 | 141 | span { 142 | display: block; 143 | margin: 1rem 0 0; 144 | font-style: normal; 145 | font-weight: 600; 146 | color: $text-color; 147 | } 148 | } 149 | 150 | main { 151 | margin-top: $navbar-height + 1rem; 152 | } 153 | 154 | img { 155 | width: 100%; 156 | height: auto; 157 | } 158 | 159 | :not(pre) > code { 160 | padding: .25rem .375em; 161 | color: #2f3136; 162 | font-size: 85%; 163 | text-shadow: none; 164 | white-space: normal; 165 | background: lighten($gray, 5%); 166 | border-radius: $border-radius; 167 | } 168 | -------------------------------------------------------------------------------- /assets/scss/components/_single-post.scss: -------------------------------------------------------------------------------- 1 | // Single posts 2 | .single-post { 3 | margin: 0 0 1rem; 4 | font-family: $font-family-post; 5 | 6 | .post-image { 7 | margin-bottom: 2rem; 8 | 9 | img { 10 | width: 100%; 11 | height: auto; 12 | } 13 | } 14 | 15 | .post-content { 16 | display: flex; 17 | flex-direction: column; 18 | 19 | .post-featured { 20 | font-weight: 600; 21 | } 22 | 23 | .post-meta { 24 | display: flex; 25 | flex-direction: column; 26 | justify-content: center; 27 | margin-bottom: 2rem; 28 | 29 | @media (min-width: 1200px) { 30 | flex-direction: row; 31 | justify-content: initial; 32 | align-items: center; 33 | } 34 | 35 | .post-meta-date { 36 | display: flex; 37 | 38 | @media (min-width: 992px) { 39 | align-items: center; 40 | } 41 | 42 | .post-meta-date-icon { 43 | display: flex; 44 | align-items: center; 45 | } 46 | } 47 | } 48 | } 49 | 50 | .post-tags { 51 | margin: 0 0 .625rem; 52 | 53 | li { 54 | margin-right: 1rem; 55 | } 56 | } 57 | 58 | .post-title { 59 | max-width: 80%; 60 | margin: 0 0 1rem; 61 | font-size: 2rem; 62 | font-weight: 600; 63 | color: $text-color; 64 | line-height: 1.2; 65 | } 66 | 67 | .post-author { 68 | display: flex; 69 | align-items: center; 70 | margin: 0 0 1rem; 71 | 72 | > span { 73 | font-weight: 600; 74 | } 75 | 76 | .post-author-avatar{ 77 | margin-right: 1rem; 78 | height: 50px; 79 | 80 | a { 81 | img { 82 | width: 50px; 83 | height: auto; 84 | border: 0; 85 | border-radius: 50%; 86 | filter: grayscale(100%); 87 | transition: filter .2s ease; 88 | } 89 | 90 | @include hover-state { 91 | img { 92 | filter: none; 93 | } 94 | } 95 | } 96 | } 97 | 98 | .post-author-info { 99 | display: flex; 100 | flex-direction: column; 101 | justify-content: center; 102 | 103 | .post-author-info-description { 104 | font-weight: 300; 105 | color: rgba($text-color, .54); 106 | transition: color .2s ease; 107 | } 108 | 109 | .post-author-info-name { 110 | font-weight: 600; 111 | } 112 | } 113 | } 114 | 115 | .meta { 116 | .post-meta { 117 | display: flex; 118 | flex-direction: column; 119 | 120 | @media (min-width: 1200px) { 121 | flex-direction: initial; 122 | } 123 | } 124 | } 125 | 126 | .updated_at { 127 | margin-left: .2rem; 128 | } 129 | 130 | h2 { 131 | margin: 3rem 0 1.85rem; 132 | font-size: 2rem; 133 | font-weight: 600; 134 | line-height: 2.75rem; 135 | letter-spacing: -1px; 136 | } 137 | 138 | .post-wrap { 139 | h2 { 140 | &:first-of-type { 141 | margin: 1rem 0 2rem; 142 | } 143 | } 144 | 145 | p { 146 | margin: 0 0 1.5rem; 147 | } 148 | } 149 | 150 | h3 { 151 | margin: .75rem 0 .5rem; 152 | font-size: 1.75rem; 153 | font-weight: 600; 154 | line-height: 2rem; 155 | } 156 | 157 | h4 { 158 | margin: .5rem 0 .25rem; 159 | font-size: 1.25rem; 160 | font-weight: 600; 161 | line-height: 1.75rem; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /components/Navbar.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 113 | -------------------------------------------------------------------------------- /components/Footer.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 61 | -------------------------------------------------------------------------------- /config/ghost.js: -------------------------------------------------------------------------------- 1 | // Main difference from `nuxt-ghost-starter` is that we no longer limit 2 | // the output from Ghost with `postIndexFields`, as this was the main issue 3 | // for the post excerpt not being sent from the API. Reason is unknown. 4 | 5 | import GhostContentAPI from '@tryghost/content-api' 6 | 7 | const ghost = (url, key) => { 8 | return new GhostContentAPI({ 9 | url, 10 | key, 11 | version: "v3" 12 | }) 13 | } 14 | 15 | const postsPerPage = 10 16 | 17 | const generateRoutes = async () => { 18 | const host = process.env.GHOST_URI 19 | const key = process.env.GHOST_KEY 20 | 21 | const api = ghost(host, key) 22 | 23 | const routes = [] 24 | 25 | // 404 page 26 | const settings = await api.settings.browse() 27 | routes.push({ 28 | route: '/404', 29 | payload: settings 30 | }) 31 | 32 | // Pagination 33 | let nextPage = 1 34 | do { 35 | const posts = await api.posts.browse({ 36 | limit: postsPerPage, 37 | page: nextPage, 38 | include: 'author,tags' 39 | }) 40 | 41 | if(nextPage === 1) { 42 | routes.push({ 43 | route: '/', 44 | payload: posts 45 | }) 46 | } else { 47 | routes.push({ 48 | route: '/page/' + posts.meta.pagination.page, 49 | payload: posts 50 | }) 51 | } 52 | 53 | nextPage = posts.meta.pagination.next 54 | } while(nextPage) 55 | 56 | // Posts 57 | const posts = await api.posts.browse({ 58 | limit: 'all', 59 | include: 'authors,tags', 60 | filter: 'visibility:public' 61 | }) 62 | 63 | const postsWithLinks = posts.map((post, index) => { 64 | const prevSlug = posts[index - 1] ? posts[index - 1].slug : null 65 | const nextSlug = posts[index + 1] ? posts[index + 1].slug : null 66 | 67 | return { 68 | ...post, 69 | prevSlug, 70 | nextSlug 71 | } 72 | }) 73 | 74 | postsWithLinks.forEach((post) => { 75 | routes.push({ 76 | route: '/' + post.slug, 77 | payload: post 78 | }) 79 | }) 80 | 81 | // Pages 82 | const pages = await api.pages.browse({ 83 | limit: 'all', 84 | include: 'authors,tags', 85 | filter: 'visibility:public' 86 | }) 87 | 88 | pages.forEach((page) => { 89 | routes.push({ 90 | route: '/' + page.slug, 91 | payload: page 92 | }) 93 | }) 94 | 95 | // Tags 96 | const tags = await api.tags.browse({ 97 | fields: 'name,slug,id', 98 | limit: 'all', 99 | filter: 'visibility:public' 100 | }) 101 | 102 | for(const tag of tags) { 103 | let nextPage = 1 104 | do { 105 | const posts = await api.posts.browse({ 106 | limit: postsPerPage, 107 | page: nextPage, 108 | include: 'authors,tags', 109 | filter: `tag:${tag.slug}` 110 | }) 111 | 112 | if(nextPage === 1) { 113 | routes.push({ 114 | route: '/tag/' + tag.slug, 115 | payload: posts 116 | }) 117 | } else { 118 | routes.push({ 119 | route: '/tag/' + tag.slug + '/page/' + posts.meta.pagination.page, 120 | payload: posts 121 | }) 122 | } 123 | 124 | nextPage = posts.meta.pagination.next 125 | } while(nextPage) 126 | } 127 | 128 | // Author 129 | const authors = await api.authors.browse({ 130 | fields: 'name,slug,id', 131 | limit: 'all' 132 | }) 133 | 134 | for (const author of authors) { 135 | let nextPage = 1 136 | do { 137 | const posts = await api.posts.browse({ 138 | limit: postsPerPage, 139 | page: nextPage, 140 | include: 'authors,tags', 141 | filter: `author:${author.slug}` 142 | }) 143 | 144 | if(nextPage === 1) { 145 | routes.push({ 146 | route: '/author/' + author.slug, 147 | payload: posts 148 | }) 149 | } else { 150 | routes.push({ 151 | route: '/author/' + author.slug + '/page/' + posts.meta.pagination.page, 152 | payload: posts 153 | }) 154 | } 155 | 156 | nextPage = posts.meta.pagination.next 157 | } while(nextPage) 158 | } 159 | 160 | return routes 161 | } 162 | 163 | const ghostAPI = () => { 164 | return ghost(process.env.ghostUri, process.env.ghostKey) 165 | } 166 | 167 | export { ghostAPI, generateRoutes, postsPerPage } 168 | -------------------------------------------------------------------------------- /assets/scss/components/_navbar.scss: -------------------------------------------------------------------------------- 1 | // Navbar 2 | .navbar { 3 | position: fixed; 4 | top: 0; 5 | display: flex; 6 | width: 100%; 7 | height: $navbar-height; 8 | background-color: rgba($light, .87); 9 | box-shadow: $box-shadow; 10 | transition: background-color .2s ease; 11 | z-index: 4; 12 | 13 | &.solid { 14 | background-color: $light; 15 | } 16 | 17 | .nav-header { 18 | display: flex; 19 | padding: .5rem 0; 20 | 21 | .logo { 22 | display: flex; 23 | align-items: center; 24 | height: $navbar-height - 1rem; 25 | 26 | a { 27 | display: flex; 28 | align-items: center; 29 | margin: 0 1rem 0 0; 30 | height: $navbar-height - 1rem; 31 | 32 | @media (min-width: 992px) { 33 | margin: 0 2rem 0 0; 34 | } 35 | 36 | svg { 37 | width: auto; 38 | height: 100%; 39 | max-height: $navbar-height - 2rem; 40 | } 41 | } 42 | 43 | img { 44 | width: auto; 45 | height: 100%; 46 | max-height: $navbar-height - 2rem; 47 | } 48 | 49 | h1 { 50 | margin: 0; 51 | color: $light; 52 | } 53 | } 54 | } 55 | .nav-content { 56 | position: fixed; 57 | top: $navbar-height; 58 | right: 0; 59 | display: flex; 60 | flex-direction: column; 61 | width: $sidebar-width; 62 | height: calc(100vh - #{$navbar-height}); 63 | background-color: $light; 64 | transform: translate3d(105%, 0, 0); 65 | transition: all .5s cubic-bezier(.685, .0473, .346, 1); 66 | 67 | @media (min-width: 992px) { 68 | position: initial; 69 | top: initial; 70 | right: initial; 71 | flex-direction: initial; 72 | justify-content: flex-end; 73 | align-items: center; 74 | width: 100%; 75 | margin: 0 0 0 auto; 76 | height: $navbar-height - 1rem; 77 | background-color: transparent; 78 | transform: translate3d(0, 0, 0); 79 | } 80 | 81 | &.sidebar-open { 82 | transform: translate3d(0, 0, 0); 83 | } 84 | 85 | ul { 86 | display: flex; 87 | flex-direction: column; 88 | margin: 0; 89 | padding: 0 1rem 1rem; 90 | list-style: none; 91 | 92 | @media (min-width: 992px) { 93 | flex-direction: initial; 94 | height: 100%; 95 | padding: 0; 96 | } 97 | 98 | li { 99 | display: flex; 100 | align-items: center; 101 | height: 100%; 102 | 103 | @media (min-width: 992px) { 104 | justify-content: center; 105 | } 106 | 107 | a { 108 | padding: 1rem 0; 109 | color: $dark; 110 | transition: color .2s ease; 111 | 112 | @include hover-state { 113 | color: lighten($text-color, 35%); 114 | } 115 | 116 | @media (min-width: 992px) { 117 | $color: $dark; 118 | display: flex; 119 | align-items: center; 120 | height: 100%; 121 | padding: .5rem 1rem; 122 | color: $color; 123 | 124 | @include hover-state { 125 | color: darken($color, 15%); 126 | } 127 | } 128 | } 129 | } 130 | } 131 | } 132 | 133 | .nav-toggle { 134 | margin: 0 0 0 auto; 135 | padding: 0; 136 | background-color: transparent; 137 | border: 0; 138 | outline: 0; 139 | 140 | @media (min-width: 992px) { 141 | display: none; 142 | } 143 | 144 | @include hover-state { 145 | outline: 0; 146 | cursor: pointer; 147 | } 148 | 149 | .icon-bar { 150 | position: relative; 151 | display: block; 152 | width: 24px; 153 | height: 2px; 154 | border-radius: 1px; 155 | background-color: $dark; 156 | 157 | + .icon-bar { 158 | margin-top: 4px; 159 | } 160 | 161 | &.bar1 { 162 | top: 0; 163 | outline: 1px solid transparent; 164 | animation: topbar-back 500ms 0s; 165 | animation-fill-mode: forwards; 166 | } 167 | 168 | &.bar2 { 169 | outline: 1px solid transparent; 170 | opacity: 1; 171 | } 172 | 173 | &.bar3 { 174 | bottom: 0; 175 | outline: 1px solid transparent; 176 | animation: bottombar-back 500ms 0s; 177 | animation-fill-mode: forwards; 178 | } 179 | } 180 | 181 | &.toggled { 182 | .icon-bar { 183 | &.bar1 { 184 | top: 6px; 185 | animation: topbar-x 500ms 0s; 186 | animation-fill-mode: forwards; 187 | } 188 | &.bar2 { 189 | opacity: 0; 190 | } 191 | &.bar3 { 192 | bottom: 6px; 193 | animation: bottombar-x 500ms 0s; 194 | animation-fill-mode: forwards; 195 | } 196 | } 197 | } 198 | } 199 | } 200 | 201 | .sidebar-backdrop { 202 | position: fixed; 203 | top: 0; 204 | left: 0; 205 | right: 0; 206 | bottom: 0; 207 | background-color: rgba(0, 0, 0, .20); 208 | transition: background-color .5s cubic-bezier(.685, .0473, .346, 1); 209 | z-index: 3; 210 | } 211 | 212 | .sidebar-backdrop-enter-active { 213 | background-color: rgba(0,0,0,.20); 214 | } 215 | 216 | .sidebar-backdrop-enter, .sidebar-backdrop-leave-to { 217 | background-color: transparent; 218 | } 219 | -------------------------------------------------------------------------------- /assets/scss/_kg-styles.scss: -------------------------------------------------------------------------------- 1 | // Koenig styles - related to single posts only 2 | .single-post { 3 | .kg-image { 4 | max-width: 100%; 5 | } 6 | 7 | .kg-width-wide { 8 | margin-right: -5vw; 9 | margin-left: -5vw; 10 | 11 | @media (min-width: 576px) { 12 | margin: 0 auto; 13 | } 14 | 15 | .kg-image { 16 | max-width: 85vw; 17 | } 18 | } 19 | 20 | .kg-width-full { 21 | margin-right: -5vw; 22 | margin-left: -5vw; 23 | 24 | @media (min-width: 576px) { 25 | margin: 0; 26 | } 27 | 28 | .kg-image { 29 | max-width: 100vw; 30 | } 31 | } 32 | 33 | figure { 34 | display: flex; 35 | margin: .2rem 0 1rem; 36 | padding: 0; 37 | border: 0; 38 | 39 | @media (min-width: 992px) { 40 | margin: 1.5rem 0 1.5rem; 41 | } 42 | 43 | &.kg-bookmark-card { 44 | width: 100%; 45 | background-color: $light; 46 | 47 | .kg-bookmark-container { 48 | display: flex; 49 | flex-direction: column; 50 | min-height: 148px; 51 | border: 1px solid rgba($gray, .25); 52 | border-radius: $border-radius; 53 | color: $text-color; 54 | text-decoration: none; 55 | box-shadow: $box-shadow; 56 | 57 | @media (min-width: 576px) { 58 | flex-direction: row; 59 | } 60 | 61 | .kg-bookmark-content { 62 | display: flex; 63 | flex-grow: 1; 64 | flex-direction: column; 65 | justify-content: flex-start; 66 | align-items: flex-start; 67 | order: 2; 68 | padding: 1.125rem; 69 | 70 | @media (min-width: 576px) { 71 | order: initial; 72 | } 73 | 74 | .kg-bookmark-title { 75 | font-size: .875rem; 76 | font-weight: 600; 77 | line-height: 1.5; 78 | transition: color .2s ease; 79 | 80 | @media (min-width: 576px) { 81 | font-size: 1rem; 82 | } 83 | } 84 | 85 | .kg-bookmark-description { 86 | display: flex; 87 | display: -webkit-box; 88 | max-height: 44px; 89 | margin: .75rem 0 0; 90 | font-size: .875rem; 91 | line-height: 1.25rem; 92 | overflow-y: hidden; 93 | word-break: break-word; 94 | -webkit-line-clamp: 2; 95 | -webkit-box-orient: vertical; 96 | 97 | @media (min-width: 576px) { 98 | font-size: .9125rem; 99 | line-height: 1.5; 100 | } 101 | } 102 | 103 | .kg-bookmark-metadata { 104 | display: flex; 105 | flex-wrap: wrap; 106 | align-items: center; 107 | margin: .875rem 0 0; 108 | font-size: .875rem; 109 | 110 | @media (min-width: 576px) { 111 | font-size: .9125rem; 112 | line-height: 1.5; 113 | } 114 | 115 | .kg-bookmark-publisher { 116 | max-width: 240px; 117 | line-height: 1.5; 118 | text-overflow: ellipsis; 119 | overflow: hidden; 120 | white-space: nowrap; 121 | } 122 | } 123 | } 124 | } 125 | 126 | .kg-bookmark-icon { 127 | display: block; 128 | margin: 0 .5rem 0 0; 129 | width: 18px; 130 | height: auto; 131 | 132 | @media (min-width: 576px) { 133 | width: 22px; 134 | } 135 | } 136 | 137 | .kg-bookmark-thumbnail { 138 | order: 1; 139 | position: relative; 140 | min-height: 160px; 141 | width: 100%; 142 | min-width: 33%; 143 | max-height: 100%; 144 | 145 | @media (min-width: 576px) { 146 | order: initial; 147 | min-height: initial; 148 | width: auto; 149 | } 150 | 151 | img { 152 | position: absolute; 153 | top: 0; 154 | left: 0; 155 | width: 100%; 156 | height: 100%; 157 | border-radius: $border-radius $border-radius 0 0; 158 | object-fit: cover; 159 | 160 | @media (min-width: 576px) { 161 | border-radius: 0 $border-radius $border-radius 0; 162 | } 163 | } 164 | } 165 | } 166 | } 167 | 168 | h1 + figure, 169 | h2 + figure, 170 | h3 + figure, 171 | h4 + figure { 172 | margin-top: 1rem; 173 | 174 | @media (min-width: 992px) { 175 | margin-top: 2rem; 176 | } 177 | } 178 | 179 | figcaption { 180 | margin: 1rem 0; 181 | font-size: 75%; 182 | font-weight: 300; 183 | line-height: 1rem; 184 | text-align: center; 185 | } 186 | 187 | .kg-card { 188 | + .kg-bookmark-card { 189 | margin-top: 0; 190 | } 191 | } 192 | 193 | .kg-gallery-card { 194 | + .kg-image-card.kg-width-wide, 195 | + .kg-gallery-card { 196 | margin: -2rem 0 4rem; 197 | } 198 | 199 | .kg-gallery-container { 200 | display: flex; 201 | flex-direction: column; 202 | max-width: 1040px; 203 | width: 100vw; 204 | 205 | .kg-gallery-row { 206 | display: flex; 207 | flex-direction: row; 208 | justify-content: center; 209 | 210 | .kg-gallery-image { 211 | &:not(:first-of-type) { 212 | margin: 0 0 0 .75rem; 213 | } 214 | img { 215 | display: block; 216 | width: 100%; 217 | height: 100%; 218 | } 219 | } 220 | } 221 | } 222 | } 223 | 224 | .kg-code-card { 225 | width: 100%; 226 | 227 | pre { 228 | margin: 0; 229 | } 230 | } 231 | 232 | .kg-image-card { 233 | align-self: center; 234 | 235 | &.kg-width-wide { 236 | + .kg-gallery-card, 237 | + .kg-image-card.kg-width-wide { 238 | margin: -2rem 0 4rem; 239 | } 240 | } 241 | 242 | .kg-image { 243 | max-width: 75vw; 244 | } 245 | 246 | figcaption { 247 | padding: 0 2rem; 248 | } 249 | } 250 | 251 | .kg-embed-card { 252 | display: flex; 253 | flex-direction: column; 254 | align-items: center; 255 | width: 100%; 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /pages/author/_slug.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 108 | 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Project is no longer maintained 2 | 3 | **This project is no longer maintained**. It was created for Ghoost version 3, a version we (as of writing) have now surpassed by a whole major. Please only use this project as a stepping stone and not as a starter for your Ghost project. 4 | 5 | # Nuxt Ghost Blog 6 | 7 | > SSR blog generated by Nuxt.js using the Ghost Content API 8 | 9 | ## Features 10 | This project is built on the foundation of [Jacob Goodwin](https://github.com/Maxbrain0)'s [nuxt-ghost-starter](https://github.com/Maxbrain0/nuxt-ghost-starter) repo. However, this project contains a lot of features that doesn't come with nuxt-ghost-starter - besides the theme, of course. To mention some of them: 11 | 12 | - Fully hackable 13 | - Based on flexbox featuring [Flexgrid](https://flexgrid.io) 14 | - Mobile-first 15 | - Websafe fonts: [Source Sans Pro](https://fonts.google.com/specimen/Source+Sans+Pro) for main text, [Roboto Mono](https://fonts.google.com/specimen/Roboto+Mono) for code, inserted with [nuxt-webfontloader](https://github.com/Developmint/nuxt-webfontloader) including `display=swap` 16 | - Tags page (a collection of all the tags and post count) 17 | - Code injection for head/footer 18 | - Better vue-meta and structured data handling 19 | - Syntax highlight with [highlight.js](https://higlightjs.org) featuring the [Nord](https://github.com/arcticicestudio/nord-highlightjs) color palette 20 | - Support for menu links to external/absolute URLs 21 | - Automatic RSS feed generation (defaults to `/rss.xml`) 22 | 23 | Amongst other features, you'll find that this project utilises [modern mode](https://nuxtjs.org/api/configuration-modern) to serve either legacy javascript with polyfills for older browsers, or a modern bundle for the modern browser. By careful configuration, this is disabled while in dev mode to make development faster and easier. 24 | 25 | **Note**: To get the `/tags` page to work, you'll need to create the route in your Ghost admin panel under "Design". Simply add a desired label for the tags page and append "tags" to the URL already pre-filled in the URL field, and hit save. 26 | 27 | ## Ghost 28 | This project is not going to cover how to set up Ghost. They have their own, detailed [documentation](https://ghost.org/docs) on how to install and setup a Ghost site. 29 | 30 | **Note**: this project does **not** cover the new publishing and membership features in Ghost 3. 31 | 32 | ## Content API 33 | This project takes advantage of the [Ghost Content API](https://ghost.org/docs/api/v3/content) by using their [client library](https://ghost.org/docs/api/v3/javascript/content) to fill up a [Vuex](https://vuex.vuejs.org) store. This is smart, as we don't have to juggle many different xhr calls and just grab the data from the store. Click the links to read the Content API documentation should you want to expand the configuration, as it's fairly easy to use. 34 | 35 | #### API key 36 | To get the Content API working, you'll need to get the API key for your blog. Instructions how to get your key can be found [here](https://ghost.org/docs/api/v3/content/#key). Copy this API key into the `.env.example`-file, along with the rest of the information like the URI and URL of the blog. Rename the `.env.example` to `.env` and the `@nuxt/dotenv` module will pick it up and load the variables into your project. The example file contains the public Ghost demo values. 37 | 38 | **Note**: The `GHOST_URI` and the `BLOG_URL` can be either the same URL, or different URLs if you only use Ghost as an API. 39 | 40 | ## Netlify 41 | If you want to deploy this on Netlify, you'll need to declare the environment variables for your Netlify site. You can checkout how to on the [Netlify docs](https://docs.netlify.com/configure-builds/environment-variables). You find the variables you need to declare in the `.env.example` file. It's a little duplication of work, but that's how Netlify wants it. 42 | 43 | Example of another URL: 44 | ```bash 45 | GHOST_URI=https://example.com # URL of the Content API 46 | GHOST_KEY=12345 # Look above on how to get your API key 47 | BLOG_URL=https://anothersite.com # In case your API and blog aren't located on the same server/domain 48 | ``` 49 | 50 | Follow [Nuxt.js's instructions](https://nuxtjs.org/faq/netlify-deployment) on how to deploy your site on Netlify. 51 | 52 | #### Webhook 53 | When you make changes to your blog Netlify won't automagically rebuild your page, and since it's staticly generated, your content won't be generated without using a webhook. Luckily Ghost have an integration available for us to almost plug-and-play. 54 | 55 | You will need to follow [Ghost's instructions](https://ghost.org/integrations/netlify) on how to add a custom integration and set it up with Netlify. *You can skip step 4*. 56 | 57 | ## Theme 58 | As the theme shipped with this project is *the* theme, you obviously can't use the themes section of the Ghost admin panel anymore. However, you can customize this theme to bits, and if you're already using Netlify or another CI/CD, you have probably hooked this up with a Git repository. Thus, the easiest way to edit the theme is just to clone the repo and start editing. Once done, just commit and push, and your CI/CD will pick up your changes. 59 | 60 | Layout-wise, you can edit the post layout in `/components/Posts.vue`, as this component is almost universal to how the posts are presented in this theme. With one exception. Since single posts are a bit different, I've had to split them up. However, as is, they use the same layout. Just remember to reflect your changes into `/pages/_slug.vue` as well. 61 | 62 | Everything is made in SCSS and is compiled automagically with Nuxt.js upon build. All the styling, with very few exceptions, are placed in `/assets/scss/`. Everything should be named logically. 63 | 64 | You can read up on how Nuxt compiles assets in the [Nuxt.js docs](https://nuxtjs.org/guide/assets), and they also have a nice guide to understand the [directory structure](https://nuxtjs.org/guide/directory-structure). This is one of the features that I like the most about Nuxt.js. 65 | 66 | #### Dates 67 | Date formats are presented with [Day.js](https://github.com/iamkun/dayjs) which is a lightweight alternative to moment.js. If you need localication, please refer to their [documentation](https://github.com/iamkun/dayjs/blob/dev/docs/en/I18n.md). 68 | 69 | ## PWA 70 | This project is configured to act as a Progressive Web App (PWA), which means there is a worker that makes sure your site can both be installed as an app, and will work offline for repeating visitors. This is all automatically done via Nuxt.js's PWA module. It takes into account what values you've entered in your Ghost settings, like site name and description, and builds a `manifest.json` containing the values from your API. It also automatically compiles a favicon image into multiple, app-friendly icons. 71 | 72 | If you want a different favicon, you simply replace the image found in `/static/icon.png`. You can read more about Nuxt.js's PWA module in their [PWA documentation](https://pwa.nuxtjs.org). 73 | 74 | ## Other configuration 75 | Inside `/config/` you'll find different config files that are used to extend the Nuxt.js config while keeping it as clean as possible, and splitting the config files into relevant files instead. These are then imported into `nuxt.config.js`. Most relevant here is probably `/config/ghost.js` which handles how the Content API client library interacts with the Vuex store. 76 | 77 | ## Development and build Setup 78 | 79 | ``` bash 80 | # Install dependencies 81 | $ yarn install 82 | 83 | # Serve with hot reload at localhost:3000 84 | $ yarn dev 85 | 86 | # Build for production and launch server 87 | $ yarn build 88 | $ yarn start 89 | 90 | # Generate static project 91 | $ yarn generate 92 | ``` 93 | 94 | You can of course use NPM as well. 95 | 96 | ## Contributing 97 | PRs are ~~welcome~~ encouraged! If you see an error, fork, clone, fix, and send a PR my way. Credit will be given. 98 | 99 | As this project is hooked up with both [prettier](https://prettier.io) and [eslint](https://eslint.org), you'll likely bump into some errors once you start hacking, but will show you a friendly error message and how to fix it. [Husky](https://github.com/typicode/husky) will lint your files before pushing to Git. These are only for your protection, and makes sure the code follows a certain standard. 100 | 101 | This project use the standard [Nuxt ESLint Config](https://github.com/nuxt/eslint-config/tree/master/packages/eslint-config) and will also lint your `.vue` files. Prettier will do the same, however, less strict as I haven't set up too many rules for it. And the `.editorconfig`-file will configure your editor to follow a certain set of standard rules that makes sure your code should look the same (if your editor supports editorconfig). 102 | 103 | Happy hacking! 104 | 105 | ## License 106 | MIT 107 | -------------------------------------------------------------------------------- /components/Posts.vue: -------------------------------------------------------------------------------- 1 | 105 | 106 | 172 | -------------------------------------------------------------------------------- /pages/_slug.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | 233 | --------------------------------------------------------------------------------