[
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2017 Daniel Ireson\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Google Sheets CMS\nExample of a blog powered by Google Sheets, Google Forms and Google Apps Scripts. New posts are added via a Google Forms interface and stored in a Google Sheets spreadsheet. Google Apps Script is used to create an API to make the content accessible in an easy to use format. The blog is designed as a single page application with pagination and category filtering.\n\n![Blog screenshot](readme-screenshot.png)\n\n## Google Apps Script API\nGoogle Apps Script is a platform to  extend Google’s G Suite of online products through a scripting language derived from JavaScript. It’s analogous to VBA, which is built into the majority of Microsoft Office products. To get started with Google Apps Script you can access the online editor by going to *Tools > Script Editor* in the menu bar from a Google Sheets spreadsheet. A script can then be made publicly available by going to *Publish > Deploy as webapp* from the script editor menu bar. Ensure the app is being executed as *me* and that *anyone, even anonymous* has access.\n\n```\nvar API_KEY = '';\nvar SPREADSHEET_ID = '';\nvar RESULTS_PER_PAGE = 5;\n\nfunction doGet(e) {\n  if (!isAuthorized(e)) {\n    return buildErrorResponse('not authorized');\n  }\n  \n  var options = {\n    page: getPageParam(e),\n    category: getCategoryParam(e)\n  }\n  \n  var spreadsheet = SpreadsheetApp.openById(SPREADSHEET_ID);\n  var worksheet = spreadsheet.getSheets()[0];\n  var rows = worksheet.getDataRange().sort({column: 2, ascending: false}).getValues();\n\n  var headings = rows[0].map(String.toLowerCase);\n  var posts = rows.slice(1);\n  \n  var postsWithHeadings = addHeadings(posts, headings);\n  var postsPublic = removeDrafts(postsWithHeadings);\n  var postsFiltered = filter(postsPublic, options.category);\n  \n  var paginated = paginate(postsFiltered, options.page);\n    \n  return buildSuccessResponse(paginated.posts, paginated.pages);\n}\n\nfunction addHeadings(posts, headings) {\n  return posts.map(function(postAsArray) {\n    var postAsObj = {};\n    \n    headings.forEach(function(heading, i) {\n      postAsObj[heading] = postAsArray[i];\n    });\n    \n    return postAsObj;\n  });\n}\n\nfunction removeDrafts(posts, category) {\n  return posts.filter(function(post) {\n    return post['published'] === true;\n  });\n}\n\nfunction filter(posts, category) {\n  return posts.filter(function(post) {\n    if (category !== null) {\n      return post['category'].toLowerCase() === category.toLowerCase();\n    } else {\n      return true;\n    }\n  });\n}\n\nfunction paginate(posts, page) {\n  var postsCopy = posts.slice();\n  var postsChunked = [];\n  var postsPaginated = {\n    posts: [],\n    pages: {\n      previous: null,\n      next: null\n    }\n  };\n  \n  while (postsCopy.length > 0) {\n    postsChunked.push(postsCopy.splice(0, RESULTS_PER_PAGE));\n  }\n  \n  if (page - 1 in postsChunked) {\n    postsPaginated.posts = postsChunked[page - 1];\n  } else {\n    postsPaginated.posts = [];\n  }\n\n  if (page > 1 && page <= postsChunked.length) {\n    postsPaginated.pages.previous = page - 1;\n  }\n  \n  if (page >= 1 && page < postsChunked.length) {\n    postsPaginated.pages.next = page + 1;\n  }\n  \n  return postsPaginated;\n}\n\nfunction isAuthorized(e) {\n  return 'key' in e.parameters && e.parameters.key[0] === API_KEY;\n}\n\nfunction getPageParam(e) {\n  if ('page' in e.parameters) {\n    var page = parseInt(e.parameters['page'][0]);\n    if (!isNaN(page) && page > 0) {\n      return page;\n    }\n  }\n  \n  return 1\n}\n\nfunction getCategoryParam(e) {\n  if ('category' in e.parameters) {\n    return e.parameters['category'][0];\n  }\n  \n  return null\n}\n\nfunction buildSuccessResponse(posts, pages) {\n  var output = JSON.stringify({\n    status: 'success',\n    data: posts,\n    pages: pages\n  });\n  \n  return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.JSON);\n}\n\nfunction buildErrorResponse(message) {\n  var output = JSON.stringify({\n    status: 'error',\n    message: message\n  });\n  \n  return ContentService.createTextOutput(output).setMimeType(ContentService.MimeType.JSON);\n}\n```\n"
  },
  {
    "path": "src/css/main.css",
    "content": "html, body {\n\tbackground: #f8f8f8;\n\tcolor: #2d3436;\n\tfont-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n\tfont-size: normal;\n\tline-height: 1.5;\n}\n\na {\n\tcolor: #1a1a1a;\n\tborder-bottom: solid 1px #b2bec3;\n\ttext-decoration: none;\n}\n\na:hover {\n\tborder-color: #1a1a1a;\n}\n\nimg {\n\tmax-width: 100%;\n}\n\nbutton {\n\tborder-radius: 5px;\n\tborder: solid 1px #b2bec3;\n\tcursor: pointer;\n\tdisplay: inline-block;\n\tpadding: 0.2em 0.5em;\n}\n\nbutton:hover {\n\tbackground: #dfe6e9; \t\n\tborder-color: #b2bec3;\n}\n\nbutton.selected {\n\tborder-color: #2d3436;\n}\n\nheader, main, footer {\n\tpadding: 4em 5%;\n}\n\nheader {\n\tbackground: #2d3436;\n\tcolor: white;\n\ttext-align: center;\n}\n\nheader h1 {\n\tmargin-bottom: 0;\n}\n\n.information {\n\tbackground: #dfe6e9;\n\tmargin-bottom: 4em;\n\tpadding: 2em;\n}\n\nnav {\n\tmargin-bottom: 4em;\n}\n\nnav span {\n\tdisplay: block;\n\tfont-size: 0.9em;\n\tmargin-bottom: 1em;\n}\n\nnav button {\n\tmargin: 0 0.5em 1em 0;\n}\n\narticle {\n\tmargin-bottom: 6em;\n}\n\n.article-details {\t\n\tborder-bottom: solid 2px #dfe6e9;\n\tfont-size: 0.9em;\n\tpadding-bottom: 1em;\n}\n\n.article-details div:first-child {\n\tmargin-bottom: 0.5em;\n}\n\n#notice {\n\tfont-weight: bold;\n\ttext-align: center;\n}\n\nfooter {\n\tbackground: #dfe6e9;\n\ttext-align: center;\n}\n\nfooter p {\n\tmargin-top: 0;\n}\n\n@media only screen and (min-width : 992px) {\n\t.article-details {\n\t\talign-items: center;\n\t\tdisplay: flex;\n\t\tjustify-content: space-between;\n\t}\n\n\t.article-details div:first-child {\n\t\tmargin-bottom: 0;\n\t}\n\n\theader, main, footer {\n\t\tpadding: 4em 25%;\n\t}\n}\n\n@media only screen and (min-width : 1200px) {\n\theader, main, footer {\n\t\tpadding: 4em 30%;\n\t}\n}\n"
  },
  {
    "path": "src/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"UTF-8\">\n\t<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n\t<title>Google Sheets CMS</title>\n\t<link rel=\"stylesheet\" href=\"css/normalize.min.css\">\n\t<link rel=\"stylesheet\" href=\"css/main.css\">\n</head>\n<body>\n\t<header>\n\t\t<img src=\"img/google-sheets-logo.png\" alt=\"logo for google sheets\">\n\t\t<h1>Generic Blog</h1>\n\t</header>\n\t<main>\n\t\t<div class=\"information\">\n\t\t\tThis is an example of a frontend for a blog CMS built using Google Sheets, Google Forms and Google Apps Script which was published as a <a href=\"https://medium.com/p/c2eab3fb0b2b\">Medium article</a>. The blog is designed as a single page application with pagination and category filtering.\n\t\t</div>\n\t\t<nav id=\"filter\"></nav>\n\t\t<div id=\"container\"></div>\n\t\t<div id=\"notice\"></div>\n\t</main>\n\t<footer>\n\t\t<p>These blog posts are from a Google Sheets backend</p>\n\t\t<a href=\"https://medium.com/p/c2eab3fb0b2b\">Read the article on Medium</a>\n\t</footer>\n\t<script src=\"js/main.js\"></script>\n\t<script>\n\t\tdocument.addEventListener('DOMContentLoaded', app.init);\n\t</script>\n</body>\n</html>\n"
  },
  {
    "path": "src/js/main.js",
    "content": "const app = function () {\n\tconst API_BASE = 'https://script.google.com/macros/s/AKfycbyP5Rifn7Q05Qcd7CTfm-AOouFHHvUAvCVVuKSfQu-LCqJocP8/exec';\n\tconst API_KEY = 'abcdef';\n\tconst CATEGORIES = ['general', 'financial', 'technology', 'marketing'];\n\n\tconst state = {activePage: 1, activeCategory: null};\n\tconst page = {};\n\n\tfunction init () {\n\t\tpage.notice = document.getElementById('notice');\n\t\tpage.filter = document.getElementById('filter');\n\t\tpage.container = document.getElementById('container');\n\n\t\t_buildFilter();\n\t\t_getNewPosts();\n\t}\n\n\tfunction _getNewPosts () {\n\t\tpage.container.innerHTML = '';\n\t\t_getPosts();\n\t}\n\n\tfunction _getPosts () {\n\t\t_setNotice('Loading posts');\n\n\t\tfetch(_buildApiUrl(state.activePage, state.activeCategory))\n\t\t\t.then((response) => response.json())\n\t\t\t.then((json) => {\n\t\t\t\tif (json.status !== 'success') {\n\t\t\t\t\t_setNotice(json.message);\n\t\t\t\t}\n\n\t\t\t\t_renderPosts(json.data);\n\t\t\t\t_renderPostsPagination(json.pages);\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\t_setNotice('Unexpected error loading posts');\n\t\t\t})\n\t}\n\n\tfunction _buildFilter () {\n\t    page.filter.appendChild(_buildFilterLink('no filter', true));\n\n\t    CATEGORIES.forEach(function (category) {\n\t    \tpage.filter.appendChild(_buildFilterLink(category, false));\n\t    });\n\t}\n\n\tfunction _buildFilterLink (label, isSelected) {\n\t\tconst link = document.createElement('button');\n\t  \tlink.innerHTML = _capitalize(label);\n\t  \tlink.classList = isSelected ? 'selected' : '';\n\t  \tlink.onclick = function (event) {\n\t  \t\tlet category = label === 'no filter' ? null : label.toLowerCase();\n\n\t\t\t_resetActivePage();\n\t  \t\t_setActiveCategory(category);\n\t  \t\t_getNewPosts();\n\t  \t};\n\n\t  \treturn link;\n\t}\n\n\tfunction _buildApiUrl (page, category) {\n\t\tlet url = API_BASE;\n\t\turl += '?key=' + API_KEY;\n\t\turl += '&page=' + page;\n\t\turl += category !== null ? '&category=' + category : '';\n\n\t\treturn url;\n\t}\n\n\tfunction _setNotice (label) {\n\t\tpage.notice.innerHTML = label;\n\t}\n\n\tfunction _renderPosts (posts) {\n\t\tposts.forEach(function (post) {\n\t\t\tconst article = document.createElement('article');\n\t\t\tarticle.innerHTML = `\n\t\t\t\t<h2>${post.title}</h2>\n\t\t\t\t<div class=\"article-details\">\n\t\t\t\t\t<div>By ${post.author} on ${_formatDate(post.timestamp)}</div>\n\t\t\t\t\t<div>Posted in ${post.category}</div>\n\t\t\t\t</div>\n\t\t\t\t${_formatContent(post.content)}\n\t\t\t`;\n\t\t\tpage.container.appendChild(article);\n\t\t});\n\t}\n\n\tfunction _renderPostsPagination (pages) {\n\t\tif (pages.next) {\n\t\t\tconst link = document.createElement('button');\n\t\t\tlink.innerHTML = 'Load more posts';\n\t\t\tlink.onclick = function (event) {\n\t\t\t\t_incrementActivePage();\n\t\t\t\t_getPosts();\n\t\t\t};\n\n\t\t\tpage.notice.innerHTML = '';\n\t\t\tpage.notice.appendChild(link);\n\t\t} else {\n\t\t\t_setNotice('No more posts to display');\n\t\t}\n\t}\n\n\tfunction _formatDate (string) {\n\t\treturn new Date(string).toLocaleDateString('en-GB');\n\t}\n\n\tfunction _formatContent (string) {\n\t\treturn string.split('\\n')\n\t\t\t.filter((str) => str !== '')\n\t\t\t.map((str) => `<p>${str}</p>`)\n\t\t\t.join('');\n\t}\n\n\tfunction _capitalize (label) {\n\t\treturn label.slice(0, 1).toUpperCase() + label.slice(1).toLowerCase();\n\t}\n\n\tfunction _resetActivePage () {\n\t\tstate.activePage = 1;\n\t}\n\n\tfunction _incrementActivePage () {\n\t\tstate.activePage += 1;\n\t}\n\n\tfunction _setActiveCategory (category) {\n\t\tstate.activeCategory = category;\n\t\t\n\t\tconst label = category === null ? 'no filter' : category;\n\t\tArray.from(page.filter.children).forEach(function (element) {\n  \t\t\telement.classList = label === element.innerHTML.toLowerCase() ? 'selected' : '';\n  \t\t});\n\t}\n\n\treturn {\n\t\tinit: init\n \t};\n}();\n"
  }
]