├── .editorconfig ├── .env.example ├── .eslintrc ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── LICENSE.md ├── README.md ├── app.js ├── assets ├── css │ ├── edit_post.css │ ├── edit_tag.css │ ├── edit_user.css │ ├── editor.css │ ├── install.css │ ├── lib.css │ ├── login.css │ ├── navigation.css │ ├── posts.css │ ├── quick_post.css │ ├── recover_password.css │ ├── reset_password.css │ ├── settings.css │ ├── tags.css │ ├── theme_toolbar.css │ ├── users.css │ └── zen_mode.css ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── images │ ├── app_icon.png │ ├── favicon.png │ ├── postleaf_logo.svg │ ├── postleaf_wordmark.svg │ ├── sample_cover.jpg │ ├── sample_post_image.jpg │ └── zen_toggle.svg └── js │ ├── edit_post.bundle.js │ ├── edit_tag.bundle.js │ ├── edit_user.bundle.js │ ├── install.bundle.js │ ├── lib.bundle.js │ ├── login.bundle.js │ ├── navigation.bundle.js │ ├── posts.bundle.js │ ├── quick_post.bundle.js │ ├── recover_password.bundle.js │ ├── reset_password.bundle.js │ ├── settings.bundle.js │ ├── tags.bundle.js │ ├── tinymce.bundle.js │ └── users.bundle.js ├── gulpfile.js ├── index.js ├── nodemon.json ├── package-lock.json ├── package.json └── source ├── controllers ├── admin │ ├── dashboard_controller.js │ ├── edit_post_controller.js │ ├── edit_tag_controller.js │ ├── edit_user_controller.js │ ├── install_controller.js │ ├── login_controller.js │ ├── logout_controller.js │ ├── navigation_controller.js │ ├── posts_controller.js │ ├── quick_post_controller.js │ ├── recover_password_controller.js │ ├── reset_password_controller.js │ ├── settings_controller.js │ ├── tags_controller.js │ └── users_controller.js ├── api │ ├── auth_controller.js │ ├── backup_controller.js │ ├── embed_controller.js │ ├── install_controller.js │ ├── navigation_controller.js │ ├── posts_controller.js │ ├── revisions_controller.js │ ├── search_controller.js │ ├── settings_controller.js │ ├── tags_controller.js │ ├── uploads_controller.js │ └── users_controller.js ├── error_controller.js └── theme │ ├── author_controller.js │ ├── blog_controller.js │ ├── feed_controller.js │ ├── post_controller.js │ ├── robots_controller.js │ ├── search_controller.js │ ├── sitemap_controller.js │ └── tag_controller.js ├── emails ├── invitation.txt └── password_reset.txt ├── images ├── app_icon.png ├── favicon.png ├── postleaf_logo.svg ├── postleaf_wordmark.svg ├── sample_cover.jpg ├── sample_post_image.jpg └── zen_toggle.svg ├── languages ├── de_de.json ├── en_us.json ├── es_ar.json ├── fr_fr.json ├── pl_pl.json ├── pt_br.json ├── sv_se.json └── zh_tw.json ├── middleware ├── auth_middleware.js ├── install_middleware.js ├── upload_middleware.js └── view_middleware.js ├── models ├── navigation_model.js ├── post_model.js ├── revision_model.js ├── setting_model.js ├── tag_model.js ├── upload_model.js └── user_model.js ├── modules ├── admin_menu.js ├── auto_embed.js ├── autocomplete_suggestions.js ├── database.js ├── dust_engine.js ├── dynamic_images.js ├── editor.js ├── email.js ├── file_manager.js ├── helpers │ ├── html_helpers.js │ ├── theme_helpers.js │ └── utility_helpers.js ├── i18n.js ├── includes │ ├── admin_menu.js │ ├── ajax_submit_defaults.js │ ├── alertable_defaults.js │ ├── dropdown_animations.js │ ├── html_classes.js │ ├── image_control.js │ ├── locater.js │ ├── panel.js │ ├── shortcuts.js │ ├── stretch.js │ ├── toggle_password.js │ └── xhr_progress.js ├── make_url.js ├── markdown.js ├── metaphor_engine.js ├── paginate.js ├── signed_url.js ├── slug.js └── themes.js ├── routers ├── admin_router.js ├── api_router.js └── theme_router.js ├── scripts ├── edit_post.js ├── edit_tag.js ├── edit_user.js ├── install.js ├── lib.js ├── login.js ├── navigation.js ├── posts.js ├── quick_post.js ├── recover_password.js ├── reset_password.js ├── settings.js ├── tags.js ├── tinymce.js └── users.js ├── styles ├── edit_post.scss ├── edit_tag.scss ├── edit_user.scss ├── editor.scss ├── install.scss ├── lib.scss ├── login.scss ├── navigation.scss ├── partials │ ├── _admin_menu.scss │ ├── _admin_toolbar.scss │ ├── _alertable.scss │ ├── _announce.scss │ ├── _box.scss │ ├── _card_cover.scss │ ├── _empty_state.scss │ ├── _file_manager.scss │ ├── _image_control.scss │ ├── _locater.scss │ ├── _main_container.scss │ ├── _nprogress.scss │ ├── _overrides.scss │ ├── _panel.scss │ ├── _revisions_table.scss │ ├── _search_engine_preview.scss │ ├── _selectize.scss │ ├── _shortcuts.scss │ ├── _stretch.scss │ ├── _typeahead.scss │ └── _variables.scss ├── posts.scss ├── quick_post.scss ├── recover_password.scss ├── reset_password.scss ├── settings.scss ├── tags.scss ├── theme_toolbar.scss ├── users.scss └── zen_mode.scss └── views ├── admin ├── edit_post.dust ├── edit_tag.dust ├── edit_user.dust ├── install.dust ├── layout.dust ├── login.dust ├── navigation.dust ├── partials │ ├── admin_menu.dust │ ├── embed_panel.dust │ ├── file_manager.dust │ ├── file_manager_items.dust │ ├── image_panel.dust │ ├── link_panel.dust │ ├── locater.dust │ ├── locater_results.dust │ ├── nav_item.dust │ ├── post_items.dust │ ├── revisions_table.dust │ ├── settings_panel.dust │ ├── shortcuts.dust │ ├── tag_cards.dust │ ├── theme_toolbar.dust │ └── user_cards.dust ├── posts.dust ├── quick_post.dust ├── recover_password.dust ├── reset_password.dust ├── rss_feed.dust ├── settings.dust ├── tags.dust └── users.dust ├── application_error.dust ├── not_allowed.dust ├── not_found.dust ├── robots.dust ├── sitemap.dust └── zen_mode.dust /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # App 2 | NODE_ENV=production 3 | APP_URL=https://example.com/ 4 | APP_PORT=3000 5 | APP_HOST=127.0.0.1 6 | 7 | # Slugs 8 | APP_ADMIN_SLUG=admin 9 | APP_AUTHOR_SLUG=author 10 | APP_API_SLUG=api 11 | APP_BLOG_SLUG=blog 12 | APP_FEED_SLUG=feed 13 | APP_PAGE_SLUG=page 14 | APP_SEARCH_SLUG=search 15 | APP_TAG_SLUG=tag 16 | 17 | # Security 18 | AUTH_LIFETIME=180 19 | AUTH_SECRET=CHANGE_THIS_TO_A_RANDOM_STRING 20 | 21 | # SMTP 22 | SMTP_HOST=smtp.example.com 23 | SMTP_USERNAME=username 24 | SMTP_PASSWORD=******** 25 | SMTP_PORT=587 26 | SMTP_SECURE=false 27 | SMTP_FROM_NAME=Your Name 28 | SMTP_FROM_EMAIL=you@example.com 29 | 30 | # Dynamic image processing 31 | # Maximum width for dynamically resized images. If not set, original image size will be used as max width. 32 | # IMG_MAX_WIDTH=800 33 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "comma-dangle": ["warn"], 4 | "eqeqeq": ["warn"], 5 | "no-console": ["off"], 6 | "indent": ["error", 2], 7 | "quotes": ["error", "single"], 8 | "linebreak-style": ["error", "unix"], 9 | "no-unused-vars": ["warn", { "vars": "all", "args": "after-used" }], 10 | "prefer-arrow-callback": ["warn"], 11 | "semi": ["error", "always"] 12 | }, 13 | "env": { 14 | "node": true, 15 | "es6": true 16 | }, 17 | "extends": "eslint:recommended", 18 | "globals": { 19 | "__basedir": true, 20 | "__version": true, 21 | "Postleaf": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | Please summarize your issue here. 4 | 5 | ### Steps to Reproduce 6 | 7 | 1. Step one 8 | 2. Step two 9 | 3. ... 10 | 11 | ### Additional info 12 | 13 | - Postleaf version: 14 | - Node version: 15 | - Affected browsers: 16 | - Operating system: 17 | 18 | --- 19 | 20 | Note: This issue tracker is ONLY for bug reports and feature requests. If this is a personal support issue, please visit postleaf.org/support. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Pull Request Summary 2 | 3 | Please describe what your PR does here. Be detailed so the changes you're proposing are obvious. 4 | 5 | --- 6 | 7 | _All code contributions are subject to the terms of the Contributor License Agreement described in CONTRIBUTING.md._ 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files 2 | .DS_Store 3 | .env 4 | 5 | # Directories 6 | .vscode/ 7 | cache/ 8 | data/ 9 | design/ 10 | node_modules/ 11 | themes/ 12 | uploads/ 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 A Beautiful Site, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | 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 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Environment 4 | require('dotenv').config(); 5 | process.env.TZ = 'UTC'; 6 | 7 | // Node modules 8 | const Chalk = require('chalk'); 9 | const Express = require('express'); 10 | const Fs = require('fs'); 11 | const Path = require('path'); 12 | const Promise = require('bluebird'); 13 | 14 | // Local modules 15 | const Postleaf = require(Path.join(__dirname, 'index.js')); 16 | 17 | // Express app 18 | const app = Express(); 19 | 20 | // Configuration options 21 | const options = { 22 | databasePath: Path.join(__dirname, 'data/database.sq3'), 23 | themePath: Path.join(__dirname, 'themes'), 24 | uploadPath: Path.join(__dirname, 'uploads') 25 | }; 26 | 27 | Promise.resolve() 28 | // Make sure .env exists 29 | .then(() => { 30 | if(!Fs.existsSync(Path.join(__dirname, '.env'))) { 31 | throw new Error('Required config file .env is missing.'); 32 | } 33 | }) 34 | // Initialize Postleaf 35 | .then(() => Postleaf(app, options)) 36 | .then(() => { 37 | 38 | // Start sailing! ⚓️ 39 | app.listen(process.env.APP_PORT, process.env.APP_HOST || '::', () => { 40 | console.info('Postleaf publishing on port %d! 🌱', process.env.APP_PORT); 41 | }); 42 | }) 43 | .catch((err) => { 44 | console.error( 45 | Chalk.red('Error: ') + 'Postleaf failed to start! 🐛\n\n' + 46 | Chalk.red(err.stack) 47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /assets/css/edit_post.css: -------------------------------------------------------------------------------- 1 | .main-container{padding:0} 2 | .dropdown-menu .h2 .fa{transform:scale(1.4)} 3 | .dropdown-menu .h3 .fa{transform:scale(1.2)} 4 | .dropdown-menu .h4 .fa{transform:scale(1)} 5 | #editor-frame{position:relative;width:100%;height:100%;border:none;background:#fff;display:block;overflow:auto;transition:opacity .25s} 6 | #status-bar{position:fixed;right:1rem;bottom:1rem} 7 | #status-bar>div{vertical-align:middle;min-height:1.7rem;display:inline-block} 8 | #status-bar>div:not(:first-child){margin-left:.5rem} 9 | #word-count .word-count-many,#word-count .word-count-none,#word-count .word-count-one{background:rgba(39,47,51,.8);font-size:.9rem;color:#fff;padding:.25rem .5rem;border-radius:.25rem;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none} 10 | #zen-mode-theme button{padding:0;transform:rotate(0);transition:.4s transform;display:block} 11 | #zen-mode-theme button[data-zen-theme=night]{transform:rotate(180deg)} 12 | #zen-mode-theme img{width:1.5em;height:1.5em;display:block} 13 | #dropzone{position:fixed;z-index:9999;top:0;right:0;bottom:0;left:0;background:rgba(39,47,51,.9);padding:3rem} 14 | #dropzone .dropzone-target{width:100%;font-size:2rem;font-weight:700;color:#fff;border:solid .5rem #fff;border-radius:5px;text-align:center;transition:.2s transform,.2s color,.2s background-color,.2s border-color;padding:1rem;display:table} 15 | #dropzone .dropzone-target.active{color:#fff;background-color:#09d;border-color:#09d} 16 | #dropzone .dropzone-target:first-child{height:calc(30% - 3rem);margin-bottom:3rem} 17 | #dropzone .dropzone-target:last-child{height:70%} 18 | #dropzone .dropzone-target .dropzone-text{display:table-cell;vertical-align:middle} 19 | .ios .stretch-down{overflow:visible} -------------------------------------------------------------------------------- /assets/css/edit_tag.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | #sidebar{width:12rem;float:left} 10 | #tag-form{margin-left:14rem} 11 | #tag-form h3{margin-bottom:1rem} 12 | #tag-form h3:not(:first-child){margin-top:4rem} 13 | @media (max-width:767px){ 14 | #sidebar{width:100%;float:none;margin-bottom:1rem} 15 | #tag-form{margin:0} 16 | } -------------------------------------------------------------------------------- /assets/css/edit_user.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | #sidebar{width:12rem;float:left} 10 | #user-form{margin-left:14rem} 11 | #user-form h3{margin-bottom:1rem} 12 | #user-form h3:not(:first-child){margin-top:4rem} 13 | @media (max-width:767px){ 14 | #sidebar{width:100%;float:none;margin-bottom:1rem} 15 | #user-form{margin:0} 16 | } -------------------------------------------------------------------------------- /assets/css/editor.css: -------------------------------------------------------------------------------- 1 | body{-webkit-text-size-adjust:none} 2 | a[href]{cursor:not-allowed} 3 | .mce-drag-container{display:none!important} 4 | a[data-mce-selected],code[data-mce-selected]{outline:dotted 1px currentColor} 5 | img[data-mce-selected]{outline:0!important} 6 | hr[data-mce-selected]{outline:0!important} 7 | [data-postleaf-region]{min-width:1rem;min-height:1rem;outline:0} 8 | [data-postleaf-region] a[href]{cursor:text} 9 | [data-embed]{position:relative!important;margin-bottom:1rem} 10 | [data-embed]::after{position:absolute;top:0;left:0;width:100%;height:100%;content:'';background:#09d;opacity:0;cursor:text} 11 | [data-embed][data-mce-selected]{outline:solid 2px #09d;outline-offset:.25rem} 12 | figure.image[data-mce-selected] img{outline:solid 2px #09d;outline-offset:.25rem} 13 | figure.image figcaption:focus{outline:0} 14 | .mce-floatpanel,.mce-panel{display:none!important} 15 | .mce-offscreen-selection{display:none!important} -------------------------------------------------------------------------------- /assets/css/install.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | .logo{width:15rem;margin:2rem auto;display:block} 10 | .install .box{max-width:28rem;margin:0 auto} -------------------------------------------------------------------------------- /assets/css/login.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | body{padding:10vh 0} 10 | .logo{width:15rem;margin:2rem auto;display:block} 11 | .login .box{max-width:24rem;margin:0 auto} 12 | .meta{text-align:center;max-width:24rem;margin:1.5rem auto 0 auto} 13 | @media screen and (max-height:750px){ 14 | body{padding:0} 15 | } -------------------------------------------------------------------------------- /assets/css/navigation.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | .sortable-ghost{opacity:0} 10 | .nav-item{position:relative;width:100%;background:#fff;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);margin-bottom:1px;display:table;cursor:move;padding-left:1.5rem} 11 | .nav-item:first-child{border-top-left-radius:3px;border-top-right-radius:3px} 12 | .nav-item:last-child{border-bottom-left-radius:3px;border-bottom-right-radius:3px} 13 | .nav-item::before{position:absolute;top:0;left:0;bottom:0;width:2rem;content:'';background-image:url('data:image/svg+xml;charset=utf8,');background-position:1rem 50%;background-repeat:no-repeat;pointer-events:none} 14 | .nav-item .nav-item-field{width:calc(50% - 1.5rem);padding:1rem;display:table-cell} 15 | .nav-item .nav-item-field:first-child{padding-right:.5rem} 16 | .nav-item .nav-item-field:nth-child(2){padding-left:.5rem} 17 | .nav-item .nav-item-remove{width:3rem;display:table-cell;vertical-align:middle;text-align:center;padding-right:1rem} 18 | .nav-create{text-align:center;margin:2rem 0} 19 | @media (max-width:575px){ 20 | .nav-item{position:relative;padding-left:1.5rem;padding-right:3rem;display:block} 21 | .nav-item .nav-item-field{width:auto;display:block} 22 | .nav-item .nav-item-field:first-child{padding:1rem 1rem .25rem 1rem} 23 | .nav-item .nav-item-field:nth-child(2){padding:.25rem 1rem 1rem 1rem} 24 | .nav-item .nav-item-remove{position:absolute;top:50%;right:.5rem;margin-top:-1.125rem} 25 | } 26 | .empty-state{height:20vh} -------------------------------------------------------------------------------- /assets/css/posts.css: -------------------------------------------------------------------------------- 1 | .main-container{padding:0} 2 | .main-container>.row{height:100%;margin-left:0;margin-right:0} 3 | .main-container>.row>[class^=col-]{height:100%;padding:0} 4 | #posts{height:100%;border-right:solid 1px #d1e0e8;overflow-y:auto;-webkit-overflow-scrolling:touch} 5 | #empty{border-right:solid 1px #d1e0e8} 6 | #many-selected,#none-selected{height:100%} 7 | @keyframes preview-spinner{ 8 | 0%{transform:rotate(0)} 9 | 100%{transform:rotate(360deg)} 10 | } 11 | #preview{position:relative;height:100%;overflow:hidden;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;padding:2rem} 12 | #preview.loading::after{position:absolute;top:calc(50% - 1.5rem);right:calc(50% - 1.5rem);width:3rem;height:3rem;content:'';border:solid 4px transparent;border-top-color:#09d;border-left-color:#09d;border-radius:50%;animation:preview-spinner .5s linear infinite} 13 | #preview.loading #preview-frame{display:none} 14 | #preview #preview-wrap{position:relative;width:200%;height:200%;transform:scale(.5);transform-origin:0 0;box-shadow:0 .2rem .2rem rgba(39,47,51,.05);transition:.1s box-shadow} 15 | #preview #preview-wrap:hover{box-shadow:0 0 0 .5rem #09d} 16 | #preview #preview-frame{width:100%;height:100%;border:none;background:#fff;overflow-y:auto;display:block;-webkit-overflow-scrolling:touch} 17 | @media (max-width:1199px){ 18 | #preview{padding:1rem} 19 | } 20 | @media (max-width:991px){ 21 | #preview{padding:0} 22 | } 23 | .post-item{color:inherit;background:#fff;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);border-left:solid 0 #09d;margin-bottom:1px;padding:1.25rem 1.5rem;display:block;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;transition:.1s border,.1s padding} 24 | .post-item::after{display:block;content:"";clear:both} 25 | .post-item.selected{border-left-width:.5rem;padding-right:1rem} 26 | .post-item .post-item-title{font-size:1.1rem;line-height:1.4;margin-bottom:.5rem} 27 | .post-item .post-item-details{font-size:.9rem;color:#8aaab9} 28 | .post-item .post-item-avatar{width:1.5em;height:auto;vertical-align:bottom;border-radius:3px;margin-right:.25rem} 29 | .post-item .post-item-meta{float:right} 30 | #post-filter.active .dropdown-toggle i{color:#09d} 31 | #post-filter .dropdown-item{padding:.1rem 1rem} 32 | #post-filter .dropdown-item:hover{background:0 0} 33 | #post-filter .dropdown-item label{display:block;margin:0} -------------------------------------------------------------------------------- /assets/css/quick_post.css: -------------------------------------------------------------------------------- 1 | .quick-post .quick-post-header{background:#fff;border-bottom:solid 1px #e2ecf1;padding:.5rem} 2 | .quick-post .quick-post-header input[name=title]{font-size:1.2rem;border:none;border-top-left-radius:3px;border-top-right-radius:3px;border-bottom-left-radius:0;border-bottom-right-radius:0} 3 | .quick-post .quick-post-header select[name=template]{color:#8aaab9;font-size:.9rem;border-color:transparent;background-color:transparent;background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='%238aaab9' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E");cursor:pointer} 4 | .quick-post .quick-post-body{background:#fff;padding:.5rem} 5 | .quick-post .quick-post-body textarea[name=content]{border:none;border-radius:0;resize:none} 6 | .quick-post .quick-post-footer{background:#fff;border-bottom-left-radius:3px;border-bottom-right-radius:3px;text-align:right;padding:1rem;margin-bottom:1rem} 7 | @media screen and (min-height:30rem){ 8 | .quick-post{padding-top:10vh} 9 | } -------------------------------------------------------------------------------- /assets/css/recover_password.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | body{padding:10vh 0} 10 | .logo{width:15rem;margin:2rem auto;display:block} 11 | .login .box,.recover-password .box{max-width:24rem;margin:0 auto} 12 | .meta{text-align:center;max-width:24rem;margin:1.5rem auto 0 auto} 13 | @media screen and (max-height:750px){ 14 | body{padding:0} 15 | } -------------------------------------------------------------------------------- /assets/css/reset_password.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | body{padding:10vh 0} 10 | .logo{width:15rem;margin:2rem auto;display:block} 11 | .login .box,.reset-password .box{max-width:24rem;margin:0 auto} 12 | .meta{text-align:center;max-width:24rem;margin:1.5rem auto 0 auto} 13 | @media screen and (max-height:750px){ 14 | body{padding:0} 15 | } -------------------------------------------------------------------------------- /assets/css/settings.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | #sidebar{width:12rem;float:left} 10 | #settings-form{margin-left:14rem} 11 | #settings-form h3{margin-bottom:1rem} 12 | #settings-form h3:not(:first-child){margin-top:4rem} 13 | #about h3:first-child{color:#8aaab9} 14 | #about h3:first-child img{width:15rem;vertical-align:top} 15 | @media (max-width:991px){ 16 | #about td:first-child{display:block} 17 | #about td:last-child{border-top:none;display:block} 18 | } 19 | @media (max-width:767px){ 20 | #sidebar{width:100%;float:none;margin-bottom:1rem} 21 | #settings-form{margin:0} 22 | } 23 | #theme .card{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;margin-bottom:2rem} -------------------------------------------------------------------------------- /assets/css/tags.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | .card-cover{position:relative} 10 | .card-cover .card-cover-header{position:relative;height:10rem;overflow:hidden} 11 | .card-cover .card-cover-header+.card-block{border-top:solid 1px #dae4e9} 12 | .card-cover .card-cover-image{height:100%;background-color:#f3f7f9;background-position:center;background-size:cover;background-repeat:no-repeat;border-top-left-radius:3px;border-top-right-radius:3px} 13 | .card-cover .card-cover-image::before{position:absolute;top:0;left:0;width:100%;content:'\f06c';line-height:10rem;font-size:5rem;font-family:FontAwesome;color:#8aaabb;text-align:center;opacity:.25} 14 | .card-cover .card-cover-image[style]:not([style=""])::before{display:none} 15 | .card-cover .card-cover-avatar img{position:absolute;top:.5rem;left:calc(50% - 4.5rem);width:9rem;height:9rem;border-radius:50%} 16 | .card{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;margin-bottom:2rem} 17 | .card .badge{margin-left:.5rem} -------------------------------------------------------------------------------- /assets/css/theme_toolbar.css: -------------------------------------------------------------------------------- 1 | .pl-toolbar,.pl-toolbar *{background:0 0;border:none;box-sizing:border-box;padding:0;margin:0;transition:none} 2 | .pl-toolbar{position:fixed;z-index:9999;bottom:24px;left:24px;background:#272f33;border-radius:3px;color:#fff} 3 | .pl-item{color:#fff;text-decoration:none;text-align:center;float:left;display:inline-block;cursor:pointer;transition:.1s transform} 4 | .pl-item:hover{transform:scale(1.1)} 5 | .pl-item img{width:auto;height:44px;vertical-align:middle;padding:8px} -------------------------------------------------------------------------------- /assets/css/users.css: -------------------------------------------------------------------------------- 1 | .box{background:#fff;border-radius:.25rem;box-shadow:0 .1rem .1rem rgba(39,47,51,.05);padding:3rem;margin-bottom:2rem} 2 | .box>:last-child{margin-bottom:0} 3 | @media (max-width:767px){ 4 | .box{padding:2rem} 5 | } 6 | @media (max-width:575px){ 7 | .box{padding:1.5rem} 8 | } 9 | .card-cover{position:relative} 10 | .card-cover .card-cover-header{position:relative;height:10rem;overflow:hidden} 11 | .card-cover .card-cover-header+.card-block{border-top:solid 1px #dae4e9} 12 | .card-cover .card-cover-image{height:100%;background-color:#f3f7f9;background-position:center;background-size:cover;background-repeat:no-repeat;border-top-left-radius:3px;border-top-right-radius:3px} 13 | .card-cover .card-cover-image::before{position:absolute;top:0;left:0;width:100%;content:'\f06c';line-height:10rem;font-size:5rem;font-family:FontAwesome;color:#8aaabb;text-align:center;opacity:.25} 14 | .card-cover .card-cover-image[style]:not([style=""])::before{display:none} 15 | .card-cover .card-cover-avatar img{position:absolute;top:.5rem;left:calc(50% - 4.5rem);width:9rem;height:9rem;border-radius:50%} 16 | .card{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;margin-bottom:2rem} 17 | .card .badge{margin-left:.5rem} -------------------------------------------------------------------------------- /assets/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/assets/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /assets/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/assets/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /assets/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/assets/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /assets/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/assets/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /assets/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/assets/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /assets/images/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/assets/images/app_icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/postleaf_logo.svg: -------------------------------------------------------------------------------- 1 | Logo (color) -------------------------------------------------------------------------------- /assets/images/postleaf_wordmark.svg: -------------------------------------------------------------------------------- 1 | slice -------------------------------------------------------------------------------- /assets/images/sample_cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/assets/images/sample_cover.jpg -------------------------------------------------------------------------------- /assets/images/sample_post_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/assets/images/sample_post_image.jpg -------------------------------------------------------------------------------- /assets/images/zen_toggle.svg: -------------------------------------------------------------------------------- 1 | zen_toggle -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "assets/*", 4 | "cache/*", 5 | "source/scripts/*", 6 | "source/styles/*", 7 | "themes/*", 8 | "uploads/*" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postleaf", 3 | "version": "1.0.0-beta.1", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "git@github.com:Postleaf/postleaf.git" 8 | }, 9 | "scripts": { 10 | "start": "node app.js" 11 | }, 12 | "devDependencies": { 13 | "@claviska/jquery-ajax-submit": "^2.0.4", 14 | "@claviska/jquery-alertable": "^1.0.2", 15 | "@claviska/jquery-animate-css": "^1.0.0", 16 | "@claviska/jquery-announce": "^1.0.0", 17 | "@claviska/jquery-offscreen": "^1.0.1", 18 | "@claviska/jquery-selectable": "^1.0.6", 19 | "animate.css": "^3.5.2", 20 | "babel-preset-es2015": "^6.22.0", 21 | "bootstrap": "^4.0.0-alpha.6", 22 | "chalk": "^1.1.3", 23 | "clipboard": "^1.6.1", 24 | "del": "^2.2.1", 25 | "eslint": "^3.10.1", 26 | "font-awesome": "^4.7.0", 27 | "gulp-autoprefixer": "^3.1.0", 28 | "gulp-babel": "^6.1.2", 29 | "gulp-browserify": "^0.5.1", 30 | "gulp-clean-css": "^3.0.3", 31 | "gulp-eslint": "^3.0.1", 32 | "gulp-help": "^1.6.1", 33 | "gulp-imagemin": "^3.0.2", 34 | "gulp-preprocess": "^2.0.0", 35 | "gulp-rename": "^1.2.2", 36 | "gulp-sass": "^2.3.2", 37 | "gulp-uglify": "^1.5.4", 38 | "gulp-watch": "^4.3.9", 39 | "gulp": "^3.9.1", 40 | "jquery": "^3.1.1", 41 | "js-cookie": "^2.1.3", 42 | "nprogress": "^0.2.0", 43 | "path": "^0.12.7", 44 | "screenfull": "^3.0.2", 45 | "selectize": "^0.12.4", 46 | "sortablejs": "^1.5.0", 47 | "tether": "^1.3.2", 48 | "tinymce": "^4.5.4", 49 | "typeahead.js": "^0.11.1" 50 | }, 51 | "dependencies": { 52 | "autolinker": "^1.4.2", 53 | "bcryptjs": "^2.4.3", 54 | "bluebird": "^3.5.0", 55 | "body-parser": "^1.15.2", 56 | "chalk": "^1.1.3", 57 | "cheerio": "^0.22.0", 58 | "compression": "^1.6.2", 59 | "connect-slashes": "^1.3.1", 60 | "cookie-parser": "^1.4.3", 61 | "crypto": "0.0.3", 62 | "del": "^2.2.1", 63 | "dotenv": "^4.0.0", 64 | "dustjs-helpers": "^1.7.3", 65 | "dustjs-linkedin": "^2.7.5", 66 | "express": "^4.14.0", 67 | "extend": "^3.0.0", 68 | "format-number": "^2.0.1", 69 | "fs": "0.0.1-security", 70 | "gm": "^1.23.0", 71 | "he": "^1.1.1", 72 | "http-codes": "^1.0.0", 73 | "jsonwebtoken": "^7.1.9", 74 | "jszip": "^3.1.3", 75 | "lunr": "^1.0.0", 76 | "marked": "^0.3.6", 77 | "metaphor": "^3.8.2", 78 | "mime": "^1.3.4", 79 | "mkdirp": "^0.5.1", 80 | "moment": "^2.17.1", 81 | "moment-timezone": "^0.5.11", 82 | "multer": "^1.2.0", 83 | "nodemailer": "^2.7.0", 84 | "recursive-readdir": "^2.1.1", 85 | "sanitize-filename": "^1.6.1", 86 | "sequelize": "^3.29.0", 87 | "slugify": "^1.1.0", 88 | "sqlite3": "^3.1.8", 89 | "striptags": "^2.2.1", 90 | "tmp": "0.0.31", 91 | "trim": "0.0.1", 92 | "truncate-html": "^0.1.2", 93 | "undo-manager": "^1.0.5", 94 | "url": "^0.11.0" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /source/controllers/admin/dashboard_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Path = require('path'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Reserved for future use. Currently redirects to posts. 10 | // 11 | view: (req, res) => { 12 | const MakeUrl = require(Path.join(__basedir, 'source/modules/make_url.js'))(req.app.locals.Settings); 13 | 14 | res.redirect(MakeUrl.admin('posts')); 15 | } 16 | 17 | }; 18 | -------------------------------------------------------------------------------- /source/controllers/admin/edit_tag_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const HttpCodes = require('http-codes'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Renders the edit tag page. 10 | // 11 | view: (req, res, next) => { 12 | const I18n = req.app.locals.I18n; 13 | const models = req.app.locals.Database.sequelize.models; 14 | 15 | let create = typeof req.params.id === 'undefined'; 16 | 17 | // Fetch the tag 18 | models.tag 19 | .findOne({ 20 | where: { 21 | id: req.params.id 22 | } 23 | }) 24 | .then((tag) => { 25 | if(!create && !tag) { 26 | res.status(HttpCodes.NOT_FOUND); 27 | throw new Error('Page Not Found'); 28 | } 29 | 30 | // Render the template 31 | res.render('admin/edit_tag', { 32 | meta: { 33 | bodyClass: 'edit-tag', 34 | title: I18n.term(create ? 'new_tag' : 'edit_tag') 35 | }, 36 | tag: tag, 37 | scripts: ['/assets/js/edit_tag.bundle.js'], 38 | styles: ['/assets/css/edit_tag.css'] 39 | }); 40 | }) 41 | .catch((err) => next(err)); 42 | } 43 | 44 | }; 45 | -------------------------------------------------------------------------------- /source/controllers/admin/edit_user_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const HttpCodes = require('http-codes'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Renders the edit user page. 10 | // 11 | view: (req, res, next) => { 12 | const I18n = req.app.locals.I18n; 13 | const User = req.User; 14 | const models = req.app.locals.Database.sequelize.models; 15 | let create = typeof req.params.id === 'undefined'; 16 | 17 | // Fetch the user 18 | models.user 19 | .findOne({ 20 | where: { 21 | id: req.params.id 22 | } 23 | }) 24 | .then((user) => { 25 | if(!create && !user) { 26 | res.status(HttpCodes.NOT_FOUND); 27 | throw new Error('Page Not Found'); 28 | } 29 | 30 | // Only the owner can edit the owner profile 31 | if(!create && user.role === 'owner' && User.role !== 'owner') { 32 | res.status(HttpCodes.UNAUTHORIZED); 33 | throw new Error('Unauthorized'); 34 | } 35 | 36 | // Render the template 37 | res.render('admin/edit_user', { 38 | meta: { 39 | bodyClass: 'edit-user', 40 | title: I18n.term(create ? 'new_user' : 'edit_user') 41 | }, 42 | user: user, 43 | scripts: ['/assets/js/edit_user.bundle.js'], 44 | styles: ['/assets/css/edit_user.css'] 45 | }); 46 | }) 47 | .catch((err) => next(err)); 48 | } 49 | 50 | }; 51 | -------------------------------------------------------------------------------- /source/controllers/admin/install_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const HttpCodes = require('http-codes'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Renders the installation page. 10 | // 11 | view: (req, res, next) => { 12 | const I18n = req.app.locals.I18n; 13 | 14 | // If the app is installed, pretend the page doesn't exist 15 | if(req.app.locals.isInstalled) { 16 | res.status(HttpCodes.NOT_FOUND); 17 | return next('Page Not Found'); 18 | } 19 | 20 | // Render the template 21 | res.render('admin/install', { 22 | meta: { 23 | bodyClass: 'install no-menu', 24 | title: I18n.term('welcome_to_postleaf') 25 | }, 26 | scripts: ['/assets/js/install.bundle.js'], 27 | styles: ['/assets/css/install.css'] 28 | }); 29 | } 30 | 31 | }; 32 | -------------------------------------------------------------------------------- /source/controllers/admin/login_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | 5 | // 6 | // Renders the login page. 7 | // 8 | view: (req, res) => { 9 | const I18n = req.app.locals.I18n; 10 | 11 | // Render the template 12 | res.render('admin/login', { 13 | meta: { 14 | bodyClass: 'login no-menu', 15 | title: I18n.term('login') 16 | }, 17 | scripts: ['/assets/js/login.bundle.js'], 18 | styles: ['/assets/css/login.css'] 19 | }); 20 | } 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /source/controllers/admin/logout_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Path = require('path'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Logs the user out and redirects them to the login page. 10 | // 11 | view: (req, res) => { 12 | const MakeUrl = require(Path.join(__basedir, 'source/modules/make_url.js'))(req.app.locals.Settings); 13 | 14 | // Remove the auth cookie for supportive clients 15 | res.cookie('authToken', '', { expires: new Date() }); 16 | 17 | // Redirect to the login page 18 | res.redirect( 19 | MakeUrl.admin('login') 20 | ); 21 | } 22 | 23 | }; 24 | -------------------------------------------------------------------------------- /source/controllers/admin/navigation_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Path = require('path'); 5 | const Promise = require('bluebird'); 6 | 7 | // Node modules 8 | const AutocompleteSuggestions = require(Path.join(__basedir, 'source/modules/autocomplete_suggestions.js')); 9 | 10 | module.exports = { 11 | 12 | // 13 | // Renders the navigation page. 14 | // 15 | view: (req, res, next) => { 16 | const I18n = req.app.locals.I18n; 17 | 18 | Promise.resolve() 19 | // Get autocomplete suggestions 20 | .then(() => AutocompleteSuggestions.getLinks(req, ['users', 'tags', 'posts'])) 21 | .then((links) => { 22 | // Render the template 23 | res.render('admin/navigation', { 24 | meta: { 25 | bodyClass: 'navigation', 26 | title: I18n.term('navigation') 27 | }, 28 | linkSuggestions: links, 29 | navigation: req.app.locals.Navigation, 30 | scripts: ['/assets/js/navigation.bundle.js'], 31 | styles: ['/assets/css/navigation.css'] 32 | }); 33 | }) 34 | .catch((err) => next(err)); 35 | } 36 | 37 | }; 38 | -------------------------------------------------------------------------------- /source/controllers/admin/posts_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Promise = require('bluebird'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Renders the posts page. 10 | // 11 | view: (req, res, next) => { 12 | const I18n = req.app.locals.I18n; 13 | const User = req.User; 14 | const models = req.app.locals.Database.sequelize.models; 15 | let itemsPerPage = 50; 16 | let where = {}; 17 | let status = []; 18 | let flag = []; 19 | let postFilters = (req.cookies.postFilters || '').split(','); 20 | 21 | // All posts for owners/admins/editors, only yours for contributors 22 | if(!['owner', 'admin', 'editor'].includes(User.role)) { 23 | where.userId = User.id; 24 | } 25 | 26 | // Restore status from cookie 27 | ['draft', 'pending', 'rejected', 'published'].forEach((key) => { 28 | if(postFilters.includes(key)) status.push(key); 29 | }); 30 | 31 | // Restore flags from cookie 32 | ['isPage', 'isFeatured', 'isSticky'].forEach((key) => { 33 | if(postFilters.includes(key)) flag.push(key); 34 | }); 35 | 36 | // Filter by status 37 | if(status && status.length) where.status = { $in: status }; 38 | 39 | // Filter by flag 40 | if(flag && flag.length) { 41 | if(flag.includes('isPage')) where.isPage = 1; 42 | if(flag.includes('isFeatured')) where.isFeatured = 1; 43 | if(flag.includes('isSticky')) where.isSticky = 1; 44 | } 45 | 46 | Promise.resolve() 47 | // Fetch posts 48 | .then(() => { 49 | return models.post 50 | .findAll({ 51 | where: where, 52 | include: [ 53 | { 54 | model: models.user, 55 | as: 'author', 56 | attributes: { exclude: ['password', 'resetToken'] }, 57 | where: req.query.author ? { 58 | username: { 59 | $in: req.query.author.split(',') 60 | } 61 | } : null 62 | }, 63 | { 64 | model: models.tag, 65 | through: { attributes: [] }, // exclude postTags 66 | where: null // also return posts that don't have tags 67 | } 68 | ], 69 | limit: itemsPerPage, 70 | offset: 0, 71 | order: [ 72 | ['publishedAt', 'DESC'] 73 | ] 74 | }); 75 | }) 76 | // Render the template 77 | .then((posts) => { 78 | res.render('admin/posts', { 79 | meta: { 80 | bodyClass: 'posts', 81 | title: I18n.term('posts') 82 | }, 83 | posts: posts, 84 | itemsPerPage: itemsPerPage, 85 | scripts: ['/assets/js/posts.bundle.js'], 86 | styles: ['/assets/css/posts.css'] 87 | }); 88 | }) 89 | .catch((err) => next(err)); 90 | } 91 | 92 | }; 93 | -------------------------------------------------------------------------------- /source/controllers/admin/quick_post_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Promise = require('bluebird'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Renders the posts page. 10 | // 11 | view: (req, res, next) => { 12 | const I18n = req.app.locals.I18n; 13 | const Settings = req.app.locals.Settings; 14 | const Themes = req.app.locals.Themes; 15 | 16 | let templates; 17 | 18 | Promise.resolve() 19 | // Fetch post templates 20 | .then(() => Themes.getPostTemplates(Settings.theme)) 21 | .then((result) => templates = result) 22 | // Render the template 23 | .then(() => { 24 | res.render('admin/quick_post', { 25 | meta: { 26 | bodyClass: 'quick-post', 27 | title: I18n.term('quick_post') 28 | }, 29 | templates: templates, 30 | scripts: ['/assets/js/quick_post.bundle.js'], 31 | styles: ['/assets/css/quick_post.css'] 32 | }); 33 | }) 34 | .catch((err) => next(err)); 35 | } 36 | 37 | }; 38 | -------------------------------------------------------------------------------- /source/controllers/admin/recover_password_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | 5 | // 6 | // Renders the password recovery page. 7 | // 8 | view: (req, res) => { 9 | const I18n = req.app.locals.I18n; 10 | 11 | // Render the template 12 | res.render('admin/recover_password', { 13 | meta: { 14 | bodyClass: 'recover-password no-menu', 15 | title: I18n.term('recover_password') 16 | }, 17 | scripts: ['/assets/js/recover_password.bundle.js'], 18 | styles: ['/assets/css/recover_password.css'] 19 | }); 20 | } 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /source/controllers/admin/reset_password_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | 5 | // 6 | // Renders the password reset page. 7 | // 8 | view: (req, res) => { 9 | const I18n = req.app.locals.I18n; 10 | 11 | // Render the template 12 | res.render('admin/reset_password', { 13 | meta: { 14 | bodyClass: 'reset-password no-menu', 15 | title: I18n.term('reset_your_password') 16 | }, 17 | scripts: ['/assets/js/reset_password.bundle.js'], 18 | styles: ['/assets/css/reset_password.css'] 19 | }); 20 | } 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /source/controllers/admin/settings_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Moment = require('moment'); 5 | const Path = require('path'); 6 | const Promise = require('bluebird'); 7 | 8 | module.exports = { 9 | 10 | // 11 | // Renders the settings page. 12 | // 13 | view: (req, res, next) => { 14 | const I18n = req.app.locals.I18n; 15 | const MakeUrl = require(Path.join(__basedir, 'source/modules/make_url.js'))(req.app.locals.Settings); 16 | const Themes = req.app.locals.Themes; 17 | const sequelize = req.app.locals.Database.sequelize; 18 | const models = sequelize.models; 19 | 20 | let queue = []; 21 | let timeZones = []; 22 | 23 | // Get a list of all possible time zones 24 | Moment.tz.names().map((zone) => { 25 | timeZones.push({ id: zone, name: zone.replace(/_/g, ' ')}); 26 | }); 27 | 28 | // Get themes 29 | queue.push(Themes.getThemes()); 30 | 31 | // Get language packs 32 | queue.push(I18n.getLanguagePacks()); 33 | 34 | // Get all posts that are eligible to use as a custom homepage 35 | queue.push( 36 | models.post.findAll({ 37 | attributes: ['id', 'slug', 'title'], 38 | where: { 39 | isPage: 1, 40 | status: 'published', 41 | publishedAt: { $lt: Moment().utc().toDate() } 42 | }, 43 | order: [ 44 | sequelize.fn('lower', sequelize.col('title')) 45 | ] 46 | }) 47 | ); 48 | 49 | // Wait for all queue to resolve 50 | Promise.all(queue) 51 | .then((result) => { 52 | let themes = result[0]; 53 | let languages = result[1]; 54 | let homepagePosts = result[2]; 55 | 56 | // Render the template 57 | res.render('admin/settings', { 58 | meta: { 59 | bodyClass: 'settings', 60 | title: I18n.term('settings') 61 | }, 62 | homepagePosts: homepagePosts, 63 | languages: languages, 64 | themes: themes, 65 | timeZones: timeZones, 66 | scripts: ['/assets/js/settings.bundle.js'], 67 | styles: ['/assets/css/settings.css'], 68 | uploadAction: MakeUrl.api('uploads') 69 | }); 70 | }) 71 | .catch((err) => next(err)); 72 | } 73 | 74 | }; 75 | -------------------------------------------------------------------------------- /source/controllers/admin/tags_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Promise = require('bluebird'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Renders the tags page. 10 | // 11 | view: (req, res, next) => { 12 | const I18n = req.app.locals.I18n; 13 | const sequelize = req.app.locals.Database.sequelize; 14 | const models = sequelize.models; 15 | 16 | Promise.resolve() 17 | // Fetch tags 18 | .then(() => { 19 | return models.tag 20 | .findAll({ 21 | order: [ 22 | sequelize.fn('lower', sequelize.col('name')) 23 | ] 24 | }); 25 | }) 26 | .then((tags) => { 27 | // Render the template 28 | res.render('admin/tags', { 29 | meta: { 30 | bodyClass: 'tags', 31 | title: I18n.term('tags') 32 | }, 33 | tags: tags, 34 | scripts: ['/assets/js/tags.bundle.js'], 35 | styles: ['/assets/css/tags.css'] 36 | }); 37 | }) 38 | .catch((err) => next(err)); 39 | } 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /source/controllers/admin/users_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Promise = require('bluebird'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Renders the users page. 10 | // 11 | view: (req, res, next) => { 12 | const I18n = req.app.locals.I18n; 13 | const sequelize = req.app.locals.Database.sequelize; 14 | const models = sequelize.models; 15 | 16 | // Fetch users 17 | Promise.resolve() 18 | .then(() => { 19 | return models.user 20 | .findAll({ 21 | order: [ 22 | sequelize.fn('lower', sequelize.col('name')) 23 | ] 24 | }); 25 | }) 26 | .then((users) => { 27 | // Render the template 28 | res.render('admin/users', { 29 | meta: { 30 | bodyClass: 'users', 31 | title: I18n.term('users') 32 | }, 33 | users: users, 34 | scripts: ['/assets/js/users.bundle.js'], 35 | styles: ['/assets/css/users.css'] 36 | }); 37 | }) 38 | .catch((err) => next(err)); 39 | } 40 | 41 | }; 42 | -------------------------------------------------------------------------------- /source/controllers/api/embed_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Path = require('path'); 5 | 6 | // Local modules 7 | const MetaphorEngine = require(Path.join(__basedir, 'source/modules/metaphor_engine.js')); 8 | 9 | module.exports = { 10 | 11 | // 12 | // Fetches metadata, oEmbed data, and a preview of the given URL. 13 | // 14 | // Returns a JSON response with a Metaphor description object. 15 | // 16 | // Details: https://github.com/hueniverse/metaphor 17 | // 18 | getFromProvider: (req, res) => { 19 | const engine = MetaphorEngine.create(); 20 | 21 | // Fetch metadata and send a response 22 | engine.describe(req.query.url, (description) => res.json(description)); 23 | } 24 | 25 | }; 26 | -------------------------------------------------------------------------------- /source/controllers/api/navigation_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const HttpCodes = require('http-codes'); 5 | const Promise = require('bluebird'); 6 | 7 | module.exports = { 8 | 9 | // 10 | // Gets all navigation menu items. 11 | // 12 | // Returns a JSON response: 13 | // 14 | // { navigation: [] } 15 | // 16 | index: (req, res) => { 17 | res.json({ 18 | navigation: req.app.locals.Navigation 19 | }); 20 | }, 21 | 22 | // 23 | // Updates the navigation menu. 24 | // 25 | // navigation* (array) - An arry of { label: link } objects. 26 | // 27 | // Returns a JSON response: 28 | // 29 | // { navigation: {} } 30 | // 31 | update: (req, res, next) => { 32 | const I18n = req.app.locals.I18n; 33 | const models = req.app.locals.Database.sequelize.models; 34 | let navigation = req.body.navigation || []; 35 | 36 | // Remove existing items 37 | models.navigation.destroy({ truncate: true }) 38 | // Insert new items in the correct order 39 | .then(() => { 40 | return Promise.each(navigation, (item) => { 41 | return models.navigation.create({ 42 | label: item.label, 43 | link: item.link 44 | }); 45 | }); 46 | }) 47 | // Update locals 48 | .then(() => req.app.locals.Navigation = navigation) 49 | // Send a response 50 | .then(() => { 51 | res.json({ 52 | navigation: navigation 53 | }); 54 | }) 55 | .catch(() => { 56 | res.status(HttpCodes.INTERNAL_SERVER_ERROR); 57 | return next(I18n.term('your_changes_could_not_be_saved_at_this_time')); 58 | }); 59 | } 60 | 61 | }; 62 | -------------------------------------------------------------------------------- /source/controllers/error_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const HttpCodes = require('http-codes'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Renders the Application Error page 10 | // 11 | applicationError: (err, req, res, next) => { // eslint-disable-line 12 | const I18n = req.app.locals.I18n; 13 | let template, viewData; 14 | 15 | switch(res.statusCode) { 16 | // Not found 17 | case HttpCodes.NOT_FOUND: 18 | template = 'not_found'; 19 | viewData = { 20 | title: I18n.term('not_found'), 21 | message: req.xhr ? 22 | I18n.term('the_requested_resource_could_not_be_found') : 23 | I18n.term('the_requested_page_could_not_be_found') 24 | }; 25 | break; 26 | 27 | // Forbidden 28 | case HttpCodes.UNAUTHORIZED: 29 | template = 'application_error'; 30 | viewData = { 31 | title: I18n.term('unauthorized'), 32 | message: I18n.term('you_are_not_authorized_to_make_this_request') 33 | }; 34 | break; 35 | 36 | // Application error 37 | default: 38 | template = 'application_error'; 39 | viewData = { 40 | title: I18n.term('application_error'), 41 | message: process.env.NODE_ENV !== 'production' ? 42 | err.message : 43 | I18n.term('sorry_but_something_isnt_working_right_at_the_moment') 44 | }; 45 | // Log dev error messages 46 | if(process.env.NODE_ENV !== 'production') { 47 | console.error(err); 48 | } 49 | break; 50 | } 51 | 52 | if(req.xhr) { 53 | // Response to XHR requests 54 | res.send({ message: viewData.message }); 55 | } else { 56 | // Render the appropriate error template 57 | res.render(template, viewData); 58 | } 59 | }, 60 | 61 | // 62 | // Renders the Not Found page 63 | // 64 | notFound: (req, res, next) => { 65 | res.status(HttpCodes.NOT_FOUND); 66 | return next('Page Not Found'); 67 | } 68 | 69 | }; 70 | -------------------------------------------------------------------------------- /source/controllers/theme/robots_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Promise = require('bluebird'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Renders the robots.txt page. 10 | // 11 | view: (req, res, next) => { 12 | Promise.resolve() 13 | .then(() => { 14 | res.header('Content-Type', 'text/plain').render('robots'); 15 | }) 16 | .catch((err) => next(err)); 17 | } 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /source/controllers/theme/search_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const HttpCodes = require('http-codes'); 5 | const Moment = require('moment'); 6 | const Path = require('path'); 7 | 8 | // Local modules 9 | const Paginate = require(Path.join(__basedir, 'source/modules/paginate.js')); 10 | 11 | module.exports = { 12 | 13 | // 14 | // Renders the search page. 15 | // 16 | view: (req, res, next) => { 17 | const MakeUrl = require(Path.join(__basedir, 'source/modules/make_url.js'))(req.app.locals.Settings); 18 | const sequelize = req.app.locals.Database.sequelize; 19 | const models = sequelize.models; 20 | const Settings = req.app.locals.Settings; 21 | let page = req.params.page || 1; 22 | let limit = Settings.postsPerPage; 23 | let offset = limit * (page - 1); 24 | 25 | models.post 26 | // Fetch search results 27 | .search(req.query.s, { 28 | where: { 29 | status: 'published', 30 | isPage: 0, 31 | publishedAt: { $lt: Moment().utc().toDate() } 32 | }, 33 | limit: limit, 34 | offset: offset 35 | }) 36 | // Render the view 37 | .then((posts) => { 38 | if(page > 1 && !posts.rows.length) { 39 | res.status(HttpCodes.NOT_FOUND); 40 | throw new Error('Page Not Found'); 41 | } 42 | 43 | // Assemble view data 44 | let pagination = Paginate.get(posts.count, limit, page, (page) => { 45 | return MakeUrl.search(req.query.s, { absolute: true, page: page }); 46 | }); 47 | let websiteImage = Settings.cover ? MakeUrl.raw(Settings.cover, { absolute: true }) : null; 48 | let websiteUrl = MakeUrl.raw({ absolute: true }); 49 | 50 | // Render the template 51 | res.render('search', { 52 | query: req.query.s, 53 | posts: posts.rows, 54 | pagination: pagination, 55 | meta: { 56 | title: Settings.title, 57 | description: Settings.tagline, 58 | // JSON linked data 59 | jsonLD: { 60 | '@context': 'https://schema.org', 61 | '@type': 'Website', 62 | 'publisher': Settings.title, 63 | 'url': websiteUrl, 64 | 'image': websiteImage, 65 | 'description': Settings.tagline 66 | }, 67 | // Open Graph 68 | openGraph: { 69 | 'og:type': 'website', 70 | 'og:site_name': Settings.title, 71 | 'og:title': Settings.title, 72 | 'og:description': Settings.tagline, 73 | 'og:url': websiteUrl, 74 | 'og:image': websiteImage 75 | }, 76 | // Twitter Card 77 | twitterCard: { 78 | 'twitter:card': Settings.cover ? 'summary_large_image' : 'summary', 79 | 'twitter:site': Settings.twitter ? '@' + Settings.twitter : null, 80 | 'twitter:title': Settings.title, 81 | 'twitter:description': Settings.tagline, 82 | 'twitter:url': websiteUrl, 83 | 'twitter:image': websiteImage 84 | } 85 | } 86 | }); 87 | }) 88 | .catch((err) => next(err)); 89 | } 90 | 91 | }; 92 | -------------------------------------------------------------------------------- /source/controllers/theme/sitemap_controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Moment = require('moment'); 5 | const Promise = require('bluebird'); 6 | 7 | module.exports = { 8 | 9 | // 10 | // Renders the sitemap.xml page. 11 | // 12 | view: (req, res, next) => { 13 | const Settings = req.app.locals.Settings; 14 | const sequelize = req.app.locals.Database.sequelize; 15 | const models = sequelize.models; 16 | 17 | Promise 18 | .all([ 19 | // Get homepage info 20 | models.post.findOne({ 21 | attributes: ['slug', 'publishedAt', 'createdAt', 'updatedAt'], 22 | where: Settings.homepage ? { 23 | // Custom homepage, return last updated date from that post 24 | slug: Settings.homepage 25 | } : { 26 | // No custom homepage, return last updated date from all public posts 27 | status: 'published', 28 | isPage: 0, 29 | publishedAt: { $lt: Moment().utc().toDate() } 30 | }, 31 | order: [ 32 | ['updatedAt', 'DESC'] 33 | ] 34 | }), 35 | // Fetch posts 36 | models.post.findAll({ 37 | attributes: ['slug', 'publishedAt', 'createdAt', 'updatedAt'], 38 | where: { 39 | status: 'published', 40 | isPage: 0, 41 | publishedAt: { $lt: Moment().utc().toDate() } 42 | }, 43 | order: [ 44 | ['publishedAt', 'DESC'] 45 | ] 46 | }), 47 | // Fetch pages 48 | models.post.findAll({ 49 | attributes: ['slug', 'publishedAt', 'createdAt', 'updatedAt'], 50 | where: { 51 | status: 'published', 52 | isPage: 1, 53 | publishedAt: { $lt: Moment().utc().toDate() } 54 | }, 55 | order: [ 56 | ['publishedAt', 'DESC'] 57 | ] 58 | }), 59 | // Fetch authors 60 | models.user.findAll({ 61 | attributes: ['username', 'createdAt', 'updatedAt'], 62 | order: [ 63 | sequelize.fn('lower', sequelize.col('name')) 64 | ] 65 | }), 66 | // Fetch tags 67 | models.tag.findAll({ 68 | attributes: ['slug', 'createdAt', 'updatedAt'], 69 | order: [ 70 | sequelize.fn('lower', sequelize.col('name')) 71 | ] 72 | }) 73 | ]) 74 | // Send the response 75 | .then((result) => { 76 | res.header('Content-Type', 'text/xml').render('sitemap', { 77 | homepage: result[0], 78 | posts: result[1], 79 | pages: result[2], 80 | authors: result[3], 81 | tags: result[4] 82 | }); 83 | }) 84 | .catch((err) => next(err)); 85 | } 86 | 87 | }; 88 | -------------------------------------------------------------------------------- /source/emails/invitation.txt: -------------------------------------------------------------------------------- 1 | {name}, 2 | 3 | {welcomeToPostleaf} 4 | 5 | {yourUsernameIs} 6 | {yourPasswordIs} 7 | 8 | {followTheLinkBelow} 9 | 10 | {adminUrl} 11 | 12 | {changeYourPassword} 13 | 14 | ----- 15 | 16 | {websiteTitle} <{websiteUrl}> 17 | -------------------------------------------------------------------------------- /source/emails/password_reset.txt: -------------------------------------------------------------------------------- 1 | {name}, 2 | 3 | {forgotYourPassword} 4 | 5 | {followThisLink} 6 | 7 | {resetUrl} 8 | 9 | ----- 10 | 11 | {websiteTitle} <{websiteUrl}> 12 | -------------------------------------------------------------------------------- /source/images/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/source/images/app_icon.png -------------------------------------------------------------------------------- /source/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/source/images/favicon.png -------------------------------------------------------------------------------- /source/images/postleaf_logo.svg: -------------------------------------------------------------------------------- 1 | Logo (color) -------------------------------------------------------------------------------- /source/images/postleaf_wordmark.svg: -------------------------------------------------------------------------------- 1 | slice -------------------------------------------------------------------------------- /source/images/sample_cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/source/images/sample_cover.jpg -------------------------------------------------------------------------------- /source/images/sample_post_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Postleaf/postleaf/c6a79365ccb21bf289b9b0fbc434e6635f452c1e/source/images/sample_post_image.jpg -------------------------------------------------------------------------------- /source/images/zen_toggle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | zen_toggle 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /source/middleware/auth_middleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const HttpCodes = require('http-codes'); 5 | const Path = require('path'); 6 | 7 | module.exports = { 8 | 9 | // 10 | // Looks for an auth header or cookie and sets req.User and res.locals.User if the token is valid. 11 | // 12 | attachUser: (req, res, next) => { 13 | const models = req.app.locals.Database.sequelize.models; 14 | 15 | // Check for an auth token in headers or cookies and set req.user if the token is valid 16 | let authToken = req.get('X-Auth-Token') || req.cookies.authToken; 17 | 18 | // Decode the token 19 | models.user 20 | .decodeAuthToken(authToken) 21 | .then((user) => { 22 | // Attach the user to req and res.locals 23 | req.User = user; 24 | res.locals.User = user; 25 | 26 | next(); 27 | 28 | // Supress Bluebird warning 29 | return null; 30 | }) 31 | // Missing or invalid token, don't attach anything 32 | .catch(() => next()); 33 | }, 34 | 35 | // 36 | // Forwards authenticated users to the dashboard. 37 | // 38 | forwardAuth: (req, res, next) => { 39 | const MakeUrl = require(Path.join(__basedir, 'source/modules/make_url.js'))(req.app.locals.Settings); 40 | 41 | if(req.User) { 42 | return res.redirect(MakeUrl.admin()); 43 | } 44 | 45 | next(); 46 | }, 47 | 48 | // 49 | // Requires an authorized user before allowing the request to complete. 50 | // 51 | requireAuth: (req, res, next) => { 52 | const MakeUrl = require(Path.join(__basedir, 'source/modules/make_url.js'))(req.app.locals.Settings); 53 | 54 | if(req.User) return next(); 55 | 56 | // XHR requests 57 | if(req.xhr) { 58 | res.status(HttpCodes.UNAUTHORIZED); 59 | return next('Unauthorized'); 60 | } 61 | 62 | // Redirect non-XHR requests to the login page 63 | res.redirect( 64 | MakeUrl.admin('login', { 65 | query: { redirect: req.originalUrl } 66 | }) 67 | ); 68 | }, 69 | 70 | // 71 | // Requires the authorized user to have a certain role before allowing the request to complete. 72 | // 73 | // role* (string|array) - The role(s) to require. 74 | // 75 | requireRole: (role) => { 76 | return (req, res, next) => { 77 | if(!Array.isArray(role)) role = [role]; 78 | 79 | if(!role.includes(req.User.role)) { 80 | res.status(HttpCodes.UNAUTHORIZED); 81 | return next('Unauthorized'); 82 | } 83 | 84 | return next(); 85 | }; 86 | } 87 | 88 | }; 89 | -------------------------------------------------------------------------------- /source/middleware/install_middleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Path = require('path'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Checks for an owner account. 10 | // 11 | checkInstallation: (req, res, next) => { 12 | const MakeUrl = require(Path.join(__basedir, 'source/modules/make_url.js'))(req.app.locals.Settings); 13 | const models = req.app.locals.Database.sequelize.models; 14 | let installUrl = MakeUrl.admin('install'); 15 | let apiUrl = MakeUrl.api('install'); 16 | 17 | // If the app is already installed, there's nothing to do 18 | if(req.app.locals.isInstalled) { 19 | return next(); 20 | } 21 | 22 | // Make the installation view and API endpoint accessible 23 | if(req.originalUrl === installUrl || req.originalUrl === apiUrl) { 24 | return next(); 25 | } 26 | 27 | // Check for an owner account 28 | models.user 29 | .findOne({ 30 | where: { 31 | role: 'owner' 32 | } 33 | }) 34 | .then((owner) => { 35 | // If an owner exists, assume the app is installed 36 | if(owner) { 37 | req.app.locals.isInstalled = true; 38 | return next(); 39 | } 40 | 41 | // If not, send them to the installation page 42 | res.redirect(installUrl); 43 | }); 44 | 45 | } 46 | 47 | }; 48 | -------------------------------------------------------------------------------- /source/middleware/upload_middleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Fs = require('fs'); 5 | const Mkdirp = require('mkdirp'); 6 | const Multer = require('multer'); 7 | const Path = require('path'); 8 | const SanitizeFilename = require('sanitize-filename'); 9 | 10 | module.exports = { 11 | 12 | // 13 | // Middleware that returns a Multer instance with custom fileFilter and storage options. 14 | // 15 | // options* (object) 16 | // - destination* (function|string) - The folder where files should be uploaded to. 17 | // - allowedTypes (array|null) - Array of acceptable mime types or null to allow all files 18 | // (default null). 19 | // - overwrite (boolean) - Whether or not to overwrite files of the same name (default false). 20 | // If false, conflicting images will be renamed with an incremental suffix: image_1.png 21 | // 22 | getMulter: (options) => { 23 | return Multer({ 24 | // Filter out files of the wrong type 25 | fileFilter: (req, file, cb) => { 26 | const I18n = req.app.locals.I18n; 27 | 28 | // Check file extension against allowed types 29 | if(options.allowedTypes && !options.allowedTypes.includes(file.mimetype)) { 30 | return cb(new Error(I18n.term('invalid_file_format'))); 31 | } 32 | 33 | cb(null, true); 34 | }, 35 | 36 | // Store uploads to disk 37 | storage: Multer.diskStorage({ 38 | destination: (req, file, cb) => { 39 | let destination = typeof options.destination === 'function' ? options.destination() : options.destination; 40 | 41 | // Set the target filename to the original filename and sanitize it 42 | file.targetName = SanitizeFilename(file.originalname); 43 | 44 | // Create the destination folder if it doesn't exist 45 | if(Fs.existsSync(destination)) { 46 | let parsed = Path.parse(file.targetName); 47 | let i = 0; 48 | 49 | // If a file with this name already exists, append a counter to the target filename 50 | if(!options.overwrite) { 51 | while(Fs.existsSync(Path.join(destination, file.targetName))) { 52 | file.targetName = parsed.name + '_' + (++i) + parsed.ext; 53 | } 54 | } 55 | 56 | cb(null, destination); 57 | } else { 58 | Mkdirp.sync(destination); 59 | cb(null, destination); 60 | } 61 | }, 62 | 63 | // Generate filename 64 | filename: function(req, file, cb) { 65 | cb(null, file.targetName); 66 | } 67 | }) 68 | 69 | }); 70 | } 71 | 72 | }; 73 | -------------------------------------------------------------------------------- /source/middleware/view_middleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const HttpCodes = require('http-codes'); 5 | 6 | module.exports = { 7 | 8 | // 9 | // Attaches view data to res.locals so it becomes available to views and helpers. 10 | // 11 | attachViewData: (req, res, next) => { 12 | // Request data 13 | res.locals.Request = { 14 | body: req.body, 15 | cookies: req.cookies, 16 | host: req.get('Host'), 17 | hostname: req.hostname, 18 | isHomepage: req.path === '/', 19 | path: req.path, 20 | query: req.query, 21 | secure: req.secure, 22 | url: req.originalUrl 23 | }; 24 | 25 | next(); 26 | }, 27 | 28 | // 29 | // Serves a Not Found error for routes ending in /page/1 since it would create duplicate content. 30 | // 31 | checkPageNumbers: (req, res, next) => { 32 | if(req.params.page && parseInt(req.params.page) <= 1) { 33 | res.status(HttpCodes.NOT_FOUND); 34 | throw new Error('Page Not Found'); 35 | } 36 | 37 | next(); 38 | } 39 | 40 | }; 41 | -------------------------------------------------------------------------------- /source/models/navigation_model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (sequelize, DataTypes) => { 4 | 5 | const navigation = sequelize.define('navigation', { 6 | label: { 7 | type: DataTypes.STRING, 8 | allowNull: false 9 | }, 10 | link: { 11 | type: DataTypes.STRING, 12 | allowNull: false 13 | } 14 | }, { 15 | primaryKey: false, 16 | tableName: 'navigation', 17 | 18 | // Class methods 19 | classMethods: { 20 | 21 | // 22 | // Loads all navigation data into an array. 23 | // 24 | // Returns a promise that resolves with an array of navigation objects. 25 | // 26 | getArray: function() { 27 | return sequelize.models.navigation 28 | .findAll() 29 | .then((navigation) => { 30 | let result = []; 31 | navigation.forEach((item) => result.push(item.get({ plain: true }))); 32 | return result; 33 | }); 34 | } 35 | 36 | }, 37 | 38 | // Instance methods 39 | instanceMethods: { }, 40 | 41 | // Hooks 42 | hooks: { } 43 | }); 44 | 45 | return navigation; 46 | 47 | }; 48 | -------------------------------------------------------------------------------- /source/models/revision_model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (sequelize, DataTypes) => { 4 | 5 | const revision = sequelize.define('revision', { 6 | // Schema 7 | id: { 8 | type: DataTypes.UUID, 9 | primaryKey: true, 10 | defaultValue: DataTypes.UUIDV4 11 | }, 12 | postId: { 13 | type: DataTypes.UUID, 14 | allowNull: false, 15 | references: { 16 | model: sequelize.models.post, 17 | key: 'id' 18 | } 19 | }, 20 | userId: { 21 | type: DataTypes.UUID, 22 | allowNull: false, 23 | references: { 24 | model: sequelize.models.user, 25 | key: 'id' 26 | } 27 | }, 28 | title: DataTypes.TEXT, 29 | content: DataTypes.TEXT 30 | }, { 31 | 32 | // Class methods 33 | classMethods: { }, 34 | 35 | // Instance methods 36 | instanceMethods: { } 37 | 38 | }); 39 | 40 | return revision; 41 | 42 | }; 43 | -------------------------------------------------------------------------------- /source/models/setting_model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (sequelize, DataTypes) => { 4 | 5 | const setting = sequelize.define('setting', { 6 | key: { 7 | type: DataTypes.STRING, 8 | unique: true, 9 | allowNull: false, 10 | validate: { 11 | is: { 12 | args: /^[a-zA-Z_-]+$/, 13 | msg: 'Invalid key!' 14 | } 15 | } 16 | }, 17 | value: DataTypes.TEXT 18 | }, { 19 | 20 | // Class methods 21 | classMethods: { 22 | 23 | // 24 | // Loads all settings data into a key/value object. 25 | // 26 | // Returns a promise that resolves with an object. 27 | // 28 | getObject: function() { 29 | return sequelize.models.setting 30 | .findAll() 31 | .then((settings) => { 32 | let result = {}; 33 | 34 | settings.map((setting) => result[setting.key] = setting.value); 35 | 36 | return result; 37 | }); 38 | } 39 | 40 | }, 41 | 42 | // Instance methods 43 | instanceMethods: { }, 44 | 45 | // Hooks 46 | hooks: { } 47 | 48 | }); 49 | 50 | return setting; 51 | 52 | }; 53 | -------------------------------------------------------------------------------- /source/models/upload_model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Extend = require('extend'); 5 | 6 | module.exports = (sequelize, DataTypes) => { 7 | 8 | const upload = sequelize.define('upload', { 9 | // Schema 10 | id: { 11 | type: DataTypes.UUID, 12 | primaryKey: true, 13 | defaultValue: DataTypes.UUIDV4 14 | }, 15 | userId: { 16 | type: DataTypes.UUID, 17 | allowNull: false, 18 | references: { 19 | model: sequelize.models.user, 20 | key: 'id' 21 | } 22 | }, 23 | filename: { 24 | type: DataTypes.STRING, 25 | allowNull: false 26 | }, 27 | extension: { 28 | type: DataTypes.STRING, 29 | allowNull: false 30 | }, 31 | path: { 32 | type: DataTypes.STRING, 33 | allowNull: false 34 | }, 35 | mimeType: { 36 | type: DataTypes.STRING, 37 | allowNull: false 38 | }, 39 | size: { 40 | type: DataTypes.INTEGER, 41 | allowNull: false 42 | }, 43 | width: { 44 | type: DataTypes.INTEGER 45 | }, 46 | height: { 47 | type: DataTypes.INTEGER 48 | } 49 | }, { 50 | // Class methods 51 | classMethods: { 52 | // 53 | // Performs a search. 54 | // 55 | // query* (string) - The term(s) to search for. 56 | // options (object) 57 | // - where (object) - An object to pass to post.findAll to limit results (default null). 58 | // - limit (int) - Max number of posts to return (default null). 59 | // - offset (int) - Return posts from this offset (default 0). 60 | // 61 | // Returns a promise. 62 | // 63 | search: (query, options) => { 64 | options = Extend(true, { 65 | where: null, 66 | limit: null, 67 | offset: 0 68 | }, options); 69 | 70 | // Perform the search 71 | return upload.findAndCountAll({ 72 | where: Extend(true, options.where, { 73 | filename: { $like: '%' + query.replace(/(%)/g, '\\$1') + '%' } 74 | }), 75 | limit: options.limit, 76 | offset: options.offset, 77 | order: [ 78 | ['createdAt', 'DESC'] 79 | ] 80 | }); 81 | } 82 | }, 83 | 84 | // Instance methods 85 | instanceMethods: { }, 86 | 87 | // Hooks 88 | hooks: { } 89 | }); 90 | 91 | return upload; 92 | 93 | }; 94 | -------------------------------------------------------------------------------- /source/modules/admin_menu.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Path = require('path'); 5 | 6 | const self = { 7 | 8 | // 9 | // Gets all admin menu items. 10 | // 11 | // Returns an array of menu item groups: [{ items: [] }, { items: [] }, ...] 12 | // 13 | get: (I18n, User, Settings) => { 14 | const MakeUrl = require(Path.join(__basedir, 'source/modules/make_url.js'))(Settings); 15 | let primary = []; 16 | let secondary = []; 17 | 18 | // Posts 19 | primary.push({ 20 | label: I18n.term('posts'), 21 | link: MakeUrl.admin('posts'), 22 | icon: 'fa fa-file-text' 23 | }); 24 | 25 | // Tags 26 | if(['owner', 'admin', 'editor'].includes(User.role)) { 27 | primary.push({ 28 | label: I18n.term('tags'), 29 | link: MakeUrl.admin('tags'), 30 | icon: 'fa fa-tag' 31 | }); 32 | } 33 | 34 | // Navigation 35 | if(['owner', 'admin'].includes(User.role)) { 36 | primary.push({ 37 | label: I18n.term('navigation'), 38 | link: MakeUrl.admin('navigation'), 39 | icon: 'fa fa-map' 40 | }); 41 | } 42 | 43 | // Users 44 | if(['owner', 'admin'].includes(User.role)) { 45 | primary.push({ 46 | label: I18n.term('users'), 47 | link: MakeUrl.admin('users'), 48 | icon: 'fa fa-user' 49 | }); 50 | } 51 | 52 | // Settings 53 | if(['owner', 'admin'].includes(User.role)) { 54 | primary.push({ 55 | label: I18n.term('settings'), 56 | link: MakeUrl.admin('settings'), 57 | icon: 'fa fa-gear' 58 | }); 59 | } 60 | 61 | // New 62 | secondary.push({ 63 | label: I18n.term('new_post'), 64 | link: MakeUrl.admin('posts/new'), 65 | icon: 'fa fa-plus' 66 | }); 67 | 68 | // Search 69 | secondary.push({ 70 | label: I18n.term('search'), 71 | link: '#locater', 72 | icon: 'fa fa-search', 73 | noSearch: true 74 | }); 75 | 76 | return [ 77 | { items: secondary }, 78 | { items: primary } 79 | ]; 80 | } 81 | 82 | }; 83 | 84 | module.exports = self; 85 | -------------------------------------------------------------------------------- /source/modules/email.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Nodemailer = require('nodemailer'); 5 | const Promise = require('bluebird'); 6 | 7 | const self = { 8 | 9 | // 10 | // Sends an email using the SMTP connection. 11 | // 12 | // options* (object) 13 | // - to* (object) 14 | // - email* (string) - The recipient's email address. 15 | // - name (string) - The recipient's name. 16 | // - subject* (string) - The subject of the message. 17 | // - message* (object) 18 | // - html* (string) - The HTML version of the message. 19 | // - text* (string) - The text version of the message. 20 | // - placeholders (object) - Key/value pairs of placeholders to substitute in the message. 21 | // 22 | // Returns a promise. 23 | // 24 | send: (options) => { 25 | return new Promise((resolve, reject) => { 26 | // Create the transporter 27 | const transporter = Nodemailer.createTransport({ 28 | host: process.env.SMTP_HOST, 29 | port: process.env.SMTP_PORT, 30 | auth: { 31 | user: process.env.SMTP_USERNAME, 32 | pass: process.env.SMTP_PASSWORD 33 | }, 34 | secure: process.env.SMTP_SECURE.toLowerCase() === 'true' 35 | }); 36 | 37 | // Set placeholders 38 | if(options.placeholders) { 39 | for(let key in options.placeholders) { 40 | if(options.message.text) { 41 | options.message.text = options.message.text.replace('{' + key + '}', options.placeholders[key]); 42 | } 43 | 44 | if(options.message.html) { 45 | options.message.html = options.message.html.replace('{' + key + '}', options.placeholders[key]); 46 | } 47 | } 48 | } 49 | 50 | // Send the email 51 | transporter.sendMail({ 52 | to: { 53 | name: options.to.name, 54 | address: options.to.email 55 | }, 56 | from: { 57 | name: process.env.SMTP_FROM_NAME, 58 | address: process.env.SMTP_FROM_EMAIL 59 | }, 60 | subject: options.subject, 61 | html: options.message.html, 62 | text: options.message.text 63 | }, (err) => { 64 | err ? reject(err) : resolve(); 65 | }); 66 | }); 67 | } 68 | 69 | }; 70 | 71 | module.exports = self; 72 | -------------------------------------------------------------------------------- /source/modules/includes/admin_menu.js: -------------------------------------------------------------------------------- 1 | // 2 | // Toggle the mobile menu 3 | // 4 | 5 | /* eslint-env browser, jquery */ 6 | $(() => { 7 | // Show/hide the mobile menu with animation 8 | function toggleMenu(on) { 9 | // Show/hide with animation 10 | $('.admin-menu-items') 11 | .addClass('transition') 12 | .toggleClass('on', on) 13 | .on('transitionend', function() { 14 | $(this).removeClass('transition'); 15 | }); 16 | 17 | $('.admin-menu-toggle i') 18 | .toggleClass('fa-navicon', !on) 19 | .toggleClass('fa-remove', on); 20 | 21 | // Watch for ESC 22 | if(on) { 23 | $(document).on('keydown.admin-menu', (event) => { 24 | if(event.keyCode === 27) { 25 | toggleMenu(false); 26 | } 27 | }); 28 | } else { 29 | $(document).off('.admin-menu'); 30 | } 31 | } 32 | 33 | // Toggle the mobile menu 34 | $('.admin-menu-toggle').on('click', (event) => { 35 | event.preventDefault(); 36 | toggleMenu(!$('.admin-menu-items').is('.on')); 37 | }); 38 | 39 | // Keep admin menu dropdown inside the viewport 40 | $('.admin-menu-user-dropdown').on('shown.bs.dropdown', function() { 41 | let dropdown = $(this).find('.dropdown-menu'); 42 | 43 | $(this) 44 | // Remove alignment class to check position 45 | .removeClass('dropup') 46 | // Assign alignment class if the menu is off-screen 47 | .toggleClass('dropup', $(dropdown).is(':off-bottom')); 48 | }); 49 | 50 | // Enable tooltips (except for touch devices since tooltips prevent taps) 51 | if(!('ontouchstart' in document.documentElement)) { 52 | $('.admin-menu-item').tooltip({ 53 | placement: 'right' 54 | }); 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /source/modules/includes/ajax_submit_defaults.js: -------------------------------------------------------------------------------- 1 | // 2 | // Custom ajaxSubmit defaults 3 | // 4 | 5 | /* eslint-env browser, jquery */ 6 | $(() => { 7 | $.ajaxSubmit.defaults.messageSuccessClasses = 'alert alert-success'; 8 | $.ajaxSubmit.defaults.messageErrorClasses = 'alert alert-warning'; 9 | }); 10 | -------------------------------------------------------------------------------- /source/modules/includes/alertable_defaults.js: -------------------------------------------------------------------------------- 1 | // 2 | // Custom alertable defaults 3 | // 4 | 5 | /* eslint-env browser, jquery */ 6 | $(() => { 7 | $.alertable.defaults.show = function() { 8 | var modal = this.modal; 9 | var overlay = this.overlay; 10 | 11 | function reposition() { 12 | var height = $(modal).outerHeight(); 13 | var winHeight = $(window).height(); 14 | var top = (winHeight * .45) - (height / 2); // slightly above halfway up 15 | 16 | $(modal).css('top', top + 'px'); 17 | } 18 | 19 | // Maintain vertical position on resize 20 | reposition(); 21 | $(window).on('resize.alertable', reposition); 22 | 23 | // Show it 24 | $(modal).add(overlay).stop(true, true).fadeIn(100); 25 | 26 | // Brief delay before focusing to let the transition show the modal 27 | setTimeout(() => { 28 | if($(modal).find('.alertable-prompt').length) { 29 | // Focus on first prompt input 30 | $(modal).find('.alertable-prompt :input:first').focus(); 31 | } else { 32 | // Focus on the submit button 33 | $(modal).find(':submit').focus(); 34 | } 35 | }, 10); 36 | }; 37 | 38 | $.alertable.defaults.hide = function() { 39 | $(window).off('.alertable'); 40 | $(this.modal).add(this.overlay).stop(true, true).fadeOut(100); 41 | }; 42 | }); 43 | -------------------------------------------------------------------------------- /source/modules/includes/dropdown_animations.js: -------------------------------------------------------------------------------- 1 | // 2 | // Dropdown animations 3 | // 4 | 5 | /* eslint-env browser, jquery */ 6 | $(() => { 7 | $(document) 8 | .on('show.bs.dropdown', '.dropdown', function() { 9 | $(this).find('.dropdown-menu').stop(true, true).fadeIn(100); 10 | }) 11 | .on('hide.bs.dropdown', '.dropdown', function() { 12 | $(this).find('.dropdown-menu').stop(true, true).fadeOut(100); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /source/modules/includes/html_classes.js: -------------------------------------------------------------------------------- 1 | // 2 | // Helper classes 3 | // 4 | 5 | /* eslint-env browser, jquery */ 6 | $(() => { 7 | // Platform classes on 8 | $('html') 9 | .toggleClass('ios', /iPad|iPhone|iPod/.test(navigator.platform)) 10 | .toggleClass('mac', navigator.appVersion.indexOf('Mac') > -1) 11 | .toggleClass('linux', navigator.appVersion.indexOf('Linux') > -1) 12 | .toggleClass('windows', navigator.appVersion.indexOf('Windows') > -1); 13 | 14 | // Remove preload class to prevent transitions (see _overrides.scss) 15 | $('body').removeClass('preload'); 16 | }); 17 | -------------------------------------------------------------------------------- /source/modules/includes/shortcuts.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, jquery */ 2 | $(() => { 3 | 'use strict'; 4 | 5 | let isAvailable = $('#locater').length > 0; 6 | 7 | // Hides the shortcuts screen 8 | function hide() { 9 | // Remove bindings 10 | $(document).off('.shortcuts'); 11 | $('#shortcuts').off('.shortcuts'); 12 | 13 | // Hide it 14 | $('html').removeClass('has-modal'); 15 | $('#shortcuts, #shortcuts-overlay').animateCSS('fadeOut', 100, function() { 16 | $(this).prop('hidden', true); 17 | }); 18 | } 19 | 20 | // Shows the shortcuts screen 21 | function show() { 22 | // Don't show if there's another modal showing 23 | if($('html').hasClass('has-modal')) return false; 24 | 25 | // Show it 26 | $('html').addClass('has-modal'); 27 | $('#shortcuts, #shortcuts-overlay') 28 | .prop('hidden', false) 29 | .css('opacity', 0) 30 | .animateCSS('fadeIn', 100, function() { 31 | $(this).css('opacity', 1); 32 | }); 33 | 34 | // Escape or enter closes it 35 | $(document).on('keydown.shortcuts', (event) => { 36 | if(event.keyCode === 27 || event.keyCode === 13) { 37 | event.preventDefault(); 38 | hide(); 39 | } 40 | }); 41 | 42 | // Close when the overlay is clicked. Note that this particular control has certain styles to 43 | // allow the window scroll when it's too tall, to we actually have to check for clicks on 44 | // #shortcuts (but not #shortcuts-body). 45 | $('#shortcuts').on('click.shortcuts', (event) => { 46 | if(!$(event.target).parents().addBack().is('#shortcuts-body')) { 47 | hide(); 48 | } 49 | }); 50 | } 51 | 52 | $(document) 53 | // Show with f1 54 | .on('keydown', (event) => { 55 | if(event.keyCode === 112) { 56 | event.preventDefault(); 57 | 58 | // Only show if shortcuts are available 59 | if(isAvailable) { 60 | show(); 61 | } 62 | } 63 | }) 64 | // Show when clicking on data-shortcuts="show" 65 | .on('click', '[data-shortcuts="show"]', (event) => { 66 | event.preventDefault(); 67 | show(); 68 | }) 69 | // Show when clicking on data-shortcuts="hide" 70 | .on('click', '[data-shortcuts="hide"]', (event) => { 71 | event.preventDefault(); 72 | hide(); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /source/modules/includes/stretch.js: -------------------------------------------------------------------------------- 1 | // 2 | // Stretch helpers (see source/styles/_stretch.scss) 3 | // 4 | 5 | /* eslint-env browser, jquery */ 6 | $(() => { 7 | function stretchDown() { 8 | var winHeight = $(window).height(); 9 | $('.stretch-down').each(function() { 10 | $(this).outerHeight(winHeight - $(this).offset().top); 11 | }); 12 | } 13 | $(window).on('resize.postleaf', stretchDown); 14 | stretchDown(); 15 | }); 16 | -------------------------------------------------------------------------------- /source/modules/includes/toggle_password.js: -------------------------------------------------------------------------------- 1 | // Show/hide password fields when [data-toggle-password=".input-selector"] 2 | 3 | /* eslint-env browser, jquery */ 4 | $(() => { 5 | $(document).on('click', '[data-toggle-password]', function() { 6 | let trigger = this; 7 | let icon = $(trigger).find('.fa-eye, .fa-eye-slash'); 8 | let target = $(trigger).attr('data-toggle-password'); 9 | let type = $(target).prop('type') === 'password' ? 'text' : 'password'; 10 | 11 | // Toggle the field type 12 | $(target).prop('type', type); 13 | 14 | // Toggle the icon 15 | $(icon) 16 | .toggleClass('fa-eye', type === 'password') 17 | .toggleClass('fa-eye-slash', type !== 'password'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /source/modules/includes/xhr_progress.js: -------------------------------------------------------------------------------- 1 | // 2 | // Add a progress callback for XHR uploads 3 | // 4 | 5 | /* eslint-env browser, jquery */ 6 | (function addXhrProgressEvent($) { 7 | var originalXhr = $.ajaxSettings.xhr; 8 | $.ajaxSetup({ 9 | progress: function() { }, 10 | xhr: function() { 11 | var xhr = originalXhr(); 12 | var req = this; 13 | 14 | if(xhr.upload) { 15 | if(typeof xhr.upload.addEventListener === 'function') { 16 | xhr.upload.addEventListener('progress', (evt) => { 17 | req.progress(evt); 18 | }, false); 19 | } 20 | } 21 | 22 | return xhr; 23 | } 24 | }); 25 | })(jQuery); 26 | -------------------------------------------------------------------------------- /source/modules/markdown.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const He = require('he'); 5 | const Marked = require('marked'); 6 | const Striptags = require('striptags'); 7 | const Trim = require('trim'); 8 | 9 | // Create a custom renderer 10 | let renderer = new Marked.Renderer(); 11 | 12 | // Convert code blocks to
 instead of 

13 | renderer.code = (code, lang) => {
14 |   let pre;
15 | 
16 |   pre = lang ? '
' : '
';
17 |   pre += He.encode(code, { useNamedReferences: true });
18 |   pre += '
'; 19 | 20 | return pre; 21 | }; 22 | 23 | const self = { 24 | 25 | // 26 | // Converts a markdown string to HTML. 27 | // 28 | // text* (string) - The markdown string to convert. 29 | // 30 | // Returns an HTML string. 31 | // 32 | toHtml: (text) => { 33 | text = typeof text === 'string' ? text : ''; 34 | 35 | return Marked(text, { 36 | renderer: renderer 37 | }); 38 | }, 39 | 40 | // 41 | // Converts a markdown string to plain text. 42 | // 43 | // text* (string) - The markdown string to convert. 44 | // 45 | toText: (text) => { 46 | text = typeof text === 'string' ? text : ''; 47 | 48 | return Trim(Striptags(He.decode(Marked(text, { 49 | sanitize: true 50 | })))); 51 | } 52 | 53 | }; 54 | 55 | module.exports = self; 56 | -------------------------------------------------------------------------------- /source/modules/metaphor_engine.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Extend = require('extend'); 5 | const Metaphor = require('metaphor'); 6 | const Url = require('url'); 7 | 8 | const self = { 9 | 10 | create: (options) => { 11 | // Merge options 12 | options = Extend({ 13 | // Use a custom preview template 14 | preview: (description, options, callback) => { 15 | let url = description.url; 16 | let parsed = Url.parse(url); 17 | let prettyUrl = parsed.hostname + (parsed.pathname || '').replace(/\/$/, ''); 18 | let siteName = description.site_name; 19 | let title = description.title || ''; 20 | let content = description.description || ''; 21 | let icon = description.icon ? description.icon.any : ''; 22 | let image = description.image; 23 | 24 | // Image can be an object or an array of objects 25 | if(image && image.url) { 26 | image = image.url; 27 | } else if(Array.isArray(image)) { 28 | image = image[0].url; 29 | } 30 | 31 | // Embed card template 32 | let html = ` 33 | 44 | `; 45 | 46 | return callback(html.replace(/\n\s+/g, '')); 47 | } 48 | }, options); 49 | 50 | return new Metaphor.Engine(options); 51 | } 52 | 53 | }; 54 | 55 | module.exports = self; 56 | -------------------------------------------------------------------------------- /source/modules/paginate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const self = { 4 | 5 | // 6 | // Generates pagination data. 7 | // 8 | // totalItems* (int) - The total number of items in the collection. 9 | // itemsPerPage* (int) - The number of items per page. 10 | // currentPage (int) - The current page (default 1). 11 | // urlCallback (function) - A callback function accepting a page argument that generates a URL. 12 | // 13 | get: (totalItems, itemsPerPage, currentPage, urlCallback) => { 14 | totalItems = parseInt(totalItems); 15 | itemsPerPage = parseInt(itemsPerPage) || 1; 16 | currentPage = parseInt(currentPage) || 1; 17 | 18 | let totalPages = Math.ceil(totalItems / itemsPerPage); 19 | let prevPage = currentPage > 1 ? currentPage - 1 : null; 20 | let nextPage = currentPage < totalPages ? currentPage + 1 : null; 21 | let prevPageUrl = urlCallback && prevPage ? urlCallback(prevPage) : null; 22 | let nextPageUrl = urlCallback && nextPage ? urlCallback(nextPage) : null; 23 | 24 | return { 25 | totalItems, 26 | itemsPerPage, 27 | currentPage, 28 | totalPages, 29 | nextPage, 30 | nextPageUrl, 31 | prevPage, 32 | prevPageUrl 33 | }; 34 | } 35 | 36 | }; 37 | 38 | module.exports = self; 39 | -------------------------------------------------------------------------------- /source/modules/signed_url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Crypto = require('crypto'); 5 | const Url = require('url'); 6 | 7 | // 8 | // Converts an object to a query string, sorted alphabetically by key. Sorting alphabetically 9 | // provides a bit of flexibility when generating URLs, as params don't need to be in a specific 10 | // order. It also let us work around the pre-ES2016 issue of objects not having a guarantted order 11 | // when we parse the query string into an object. 12 | // 13 | // obj (object) - The object to convert. 14 | // 15 | // Returns a string. 16 | // 17 | function objectToQueryString(obj) { 18 | let query = []; 19 | 20 | Object.keys(obj).forEach((key) => { 21 | query.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key])); 22 | }); 23 | 24 | return query.sort().join('&'); 25 | } 26 | 27 | const self = { 28 | 29 | // 30 | // Generates a key for use with a signed URL. 31 | // 32 | // hostname* (string) - The full URL to generate a key for. 33 | // secret* (string) - A cryptographically secure string. 34 | // 35 | // Returns a string. 36 | // 37 | generateKey: (url, secret) => { 38 | url = Url.parse(url, true); 39 | let query = objectToQueryString(url.query); 40 | 41 | return Crypto 42 | .createHash('sha256') 43 | .update(secret + url.hostname + url.pathname + query) 44 | .digest('hex'); 45 | }, 46 | 47 | // 48 | // Generates a key and appends it to the given URL. 49 | // 50 | // url* (string) - The URL to sign. 51 | // secret* (string) - A cryptographically secure string. 52 | // 53 | // Returns a string. 54 | // 55 | sign: (url, secret) => { 56 | let key = self.generateKey(url, secret); 57 | let signedUrl = Url.parse(url); 58 | 59 | // Append the key. Note that Url.format() builds the query string from `search`, not `query`. 60 | signedUrl.search += '&key=' + encodeURIComponent(key); 61 | 62 | return Url.format(signedUrl); 63 | }, 64 | 65 | // 66 | // Verifies a signed URL. 67 | // 68 | // url* (string) - The full URL to verify. 69 | // secret* (string) - The secret used to generate the signed URL. 70 | // 71 | // Returns a boolean. 72 | // 73 | verify: (url, secret) => { 74 | url = Url.parse(url, true); 75 | let query = url.query; 76 | let key = url.query.key; 77 | 78 | // Remove key from query 79 | delete url.query.key; 80 | query = objectToQueryString(query); 81 | 82 | // Rebuild the URL and compare keys. Note that Url.format() builds the query string from `search`, not `query`. 83 | url.search = '?' + query; 84 | 85 | return key === self.generateKey(Url.format(url), secret); 86 | } 87 | 88 | }; 89 | 90 | module.exports = self; 91 | -------------------------------------------------------------------------------- /source/modules/slug.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Node modules 4 | const Slugify = require('slugify'); 5 | 6 | // 7 | // Generates a slug. 8 | // 9 | // string* (string) - The string to convert. 10 | // 11 | // Returns a string; 12 | // 13 | module.exports = (string) => { 14 | if(typeof string !== 'string') return ''; 15 | 16 | // Convert Unicode characters to Latin equivalents 17 | return Slugify(string) 18 | .toLowerCase() 19 | // Convert spaces and underscores to dashes 20 | .replace(/(\s|_)/g, '-') 21 | // Remove unsafe characters 22 | .replace(/[^a-z0-9-]/g, '') 23 | // Remove duplicate dashes 24 | .replace(/-+/g, '-') 25 | // Remove starting and ending dashes 26 | .replace(/(^-|-$)/g, ''); 27 | }; 28 | -------------------------------------------------------------------------------- /source/scripts/edit_tag.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, jquery */ 2 | 'use strict'; 3 | 4 | const Marked = require('marked'); 5 | const NProgress = require('nprogress'); 6 | 7 | $(() => { 8 | 9 | function getState() { 10 | return $('#tag-form').serialize(); 11 | } 12 | 13 | function enforceSlugSyntax() { 14 | $('#slug').val(Postleaf.Slug($('#slug').val())); 15 | } 16 | 17 | function updatePreview() { 18 | let slug = $('#slug').val(); 19 | let name = $.trim($('#name').val()); 20 | let description = $(Marked($.trim($('#description').val()))).text(); 21 | let metaTitle = $.trim($('#meta-title').val()); 22 | let metaDescription = $.trim($('#meta-description').val()); 23 | 24 | $('.search-engine-preview .slug').text(slug); 25 | $('.search-engine-preview-title').text(metaTitle || name); 26 | $('.search-engine-preview-description').text(metaDescription || description); 27 | } 28 | 29 | let changesSaved = $('#tag-form').attr('data-changes-saved'); 30 | let cleanState = getState(); 31 | let createAction = $('#tag-form').attr('data-create-action'); 32 | let saveConfirmation = $('#tag-form').attr('data-save-confirmation'); 33 | let tagCreated = $('#tag-form').attr('data-tag-created'); 34 | let tagId = $('#tag-form').attr('data-tag-id'); 35 | let updateAction = $('#tag-form').attr('data-update-action'); 36 | 37 | // Guess slug when name changes 38 | $('#name').on('change paste', function() { 39 | if(!$('#slug').val()) { 40 | $('#slug').val(Postleaf.Slug(this.value)); 41 | } 42 | }); 43 | 44 | // Enforce slug syntax 45 | $('#slug').on('change paste', enforceSlugSyntax); 46 | 47 | // Enable image controls 48 | $('.image-control') 49 | .imageControl() 50 | .on('uploadProgress.imageControl', (event, percent) => NProgress.set(percent * .9)) 51 | .on('uploadComplete.imageControl', () => NProgress.done(false)); 52 | 53 | // Update preview 54 | $('#name, #description, #slug, #meta-title, #meta-description').on('change keyup paste', updatePreview); 55 | updatePreview(); 56 | 57 | // Handle the form 58 | $('#tag-form').ajaxSubmit({ 59 | url: () => tagId ? updateAction.replace(':id', tagId) : createAction, 60 | method: () => tagId ? 'PUT' : 'POST', 61 | before: NProgress.start, 62 | after: NProgress.done, 63 | error: (res) => { 64 | // Show error message 65 | if(res.message) { 66 | $.announce.warning(res.message); 67 | } 68 | }, 69 | success: (res) => { 70 | if(res.tag) { 71 | // Show a success message 72 | let message = tagId ? changesSaved : tagCreated.replace(':name', res.tag.name); 73 | $.announce.success(message); 74 | 75 | // Set tag ID for future saves 76 | tagId = res.tag.id; 77 | 78 | // Update clean state 79 | cleanState = getState(); 80 | } 81 | } 82 | }); 83 | 84 | // Save button 85 | $('[data-save]').on('click', () => $('#tag-form').submit()); 86 | 87 | // Update hash on tab change 88 | $('#sidebar').find('[data-toggle="tab"]').on('show.bs.tab', function() { 89 | let href = this.href; 90 | 91 | // Remove hash for the first tab (initial state) 92 | if($(this).index() === 0) href = href.split('#')[0]; 93 | 94 | window.history.replaceState({}, '', href); 95 | }); 96 | 97 | // Set tab on page load 98 | if(location.hash) { 99 | $('#sidebar').find('[data-toggle="tab"][href="' + location.hash + '"]').each(function() { 100 | $(this).tab('show'); 101 | }); 102 | } 103 | 104 | // Watch for unsaved changes 105 | window.onbeforeunload = () => { 106 | if(getState() !== cleanState) { 107 | return saveConfirmation; 108 | } 109 | }; 110 | }); 111 | -------------------------------------------------------------------------------- /source/scripts/edit_user.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, jquery */ 2 | 'use strict'; 3 | 4 | const NProgress = require('nprogress'); 5 | 6 | $(() => { 7 | 8 | function getState() { 9 | return $('#user-form').serialize(); 10 | } 11 | 12 | function enforceSlugSyntax() { 13 | $('#username').val(Postleaf.Slug($('#username').val())); 14 | } 15 | 16 | let changesSaved = $('#user-form').attr('data-changes-saved'); 17 | let cleanState = getState(); 18 | let createAction = $('#user-form').attr('data-create-action'); 19 | let saveConfirmation = $('#user-form').attr('data-save-confirmation'); 20 | let userCreated = $('#user-form').attr('data-user-created'); 21 | let userId = $('#user-form').attr('data-user-id'); 22 | let updateAction = $('#user-form').attr('data-update-action'); 23 | 24 | // Guess username when name changes 25 | $('#name').on('change paste', function() { 26 | if(!$('#username').val()) { 27 | $('#username').val(Postleaf.Slug(this.value)); 28 | } 29 | }); 30 | 31 | // Enforce slug syntax 32 | $('#username').on('change paste', enforceSlugSyntax); 33 | 34 | // Enable image controls 35 | $('.image-control').each(function() { 36 | $(this) 37 | .imageControl() 38 | .on('uploadProgress.imageControl', (event, percent) => NProgress.set(percent * .9)) 39 | .on('uploadComplete.imageControl', () => NProgress.done(false)); 40 | }); 41 | 42 | // Handle the form 43 | $('#user-form').ajaxSubmit({ 44 | url: () => userId ? updateAction.replace(':id', userId) : createAction, 45 | method: () => userId ? 'PUT' : 'POST', 46 | before: NProgress.start, 47 | after: NProgress.done, 48 | error: (res) => { 49 | // Show error message 50 | if(res.message) { 51 | $.announce.warning(res.message); 52 | } 53 | }, 54 | success: (res) => { 55 | if(res.user) { 56 | // Show a success message 57 | let message = userId ? changesSaved : userCreated.replace(':name', res.user.name); 58 | $.announce.success(message); 59 | 60 | // Set user ID for future saves 61 | userId = res.user.id; 62 | 63 | // Update clean state 64 | cleanState = getState(); 65 | } 66 | } 67 | }); 68 | 69 | // Save button 70 | $('[data-save]').on('click', () => $('#user-form').submit()); 71 | 72 | // Update hash on tab change 73 | $('#sidebar').find('[data-toggle="tab"]').on('show.bs.tab', function() { 74 | let href = this.href; 75 | 76 | // Remove hash for the first tab (initial state) 77 | if($(this).index() === 0) href = href.split('#')[0]; 78 | 79 | window.history.replaceState({}, '', href); 80 | }); 81 | 82 | // Set tab on page load 83 | if(location.hash) { 84 | $('#sidebar').find('[data-toggle="tab"][href="' + location.hash + '"]').each(function() { 85 | $(this).tab('show'); 86 | }); 87 | } 88 | 89 | // Watch for unsaved changes 90 | window.onbeforeunload = () => { 91 | if(getState() !== cleanState) { 92 | return saveConfirmation; 93 | } 94 | }; 95 | }); 96 | -------------------------------------------------------------------------------- /source/scripts/install.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, jquery */ 2 | 'use strict'; 3 | 4 | const NProgress = require('nprogress'); 5 | 6 | $(() => { 7 | 8 | function enforceSlugSyntax() { 9 | $('#username').val(Postleaf.Slug($('#username').val())); 10 | } 11 | 12 | // Guess username when name changes 13 | $('#name').on('change paste', function() { 14 | if(!$('#username').val()) { 15 | $('#username').val(Postleaf.Slug(this.value)); 16 | } 17 | }); 18 | 19 | let redirect = $('#install-form').attr('data-redirect'); 20 | 21 | // Enforce slug syntax 22 | $('#username').on('change paste', enforceSlugSyntax); 23 | 24 | // Handle the form 25 | $('#install-form').ajaxSubmit({ 26 | before: NProgress.start, 27 | after: NProgress.done, 28 | error: (res) => { 29 | // Show error message 30 | if(res.message) { 31 | $.announce.warning(res.message); 32 | } 33 | }, 34 | success: () => location.href = redirect 35 | }); 36 | 37 | }); 38 | -------------------------------------------------------------------------------- /source/scripts/lib.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, jquery */ 2 | 'use strict'; 3 | 4 | // Globals 5 | window.jQuery = window.$ = require('jquery'); 6 | window.Tether = require('tether'); 7 | window.Postleaf = { 8 | FileManager: require('../modules/file_manager.js'), 9 | Slug: require('../modules/slug.js') 10 | }; 11 | 12 | // Bootstrap 13 | require('bootstrap'); 14 | 15 | // jQuery plugins 16 | require('@claviska/jquery-ajax-submit/jquery.ajaxSubmit.min.js'); 17 | require('@claviska/jquery-alertable/jquery.alertable.min.js'); 18 | require('@claviska/jquery-animate-css/jquery.animateCSS.min.js'); 19 | require('@claviska/jquery-announce/jquery.announce.min.js'); 20 | require('@claviska/jquery-offscreen/jquery.offscreen.js'); 21 | require('@claviska/jquery-selectable/jquery.selectable.min.js'); 22 | require('typeahead.js/dist/typeahead.jquery.min.js'); 23 | require('selectize/dist/js/standalone/selectize.min.js'); 24 | 25 | // Includes 26 | require('../modules/includes/admin_menu.js'); 27 | require('../modules/includes/ajax_submit_defaults.js'); 28 | require('../modules/includes/alertable_defaults.js'); 29 | require('../modules/includes/dropdown_animations.js'); 30 | require('../modules/includes/html_classes.js'); 31 | require('../modules/includes/image_control.js'); 32 | require('../modules/includes/locater.js'); 33 | require('../modules/includes/panel.js'); 34 | require('../modules/includes/shortcuts.js'); 35 | require('../modules/includes/stretch.js'); 36 | require('../modules/includes/toggle_password.js'); 37 | require('../modules/includes/xhr_progress.js'); 38 | -------------------------------------------------------------------------------- /source/scripts/login.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, jquery */ 2 | 'use strict'; 3 | 4 | const NProgress = require('nprogress'); 5 | 6 | $(() => { 7 | 8 | let redirect = $('#login-form').attr('data-redirect'); 9 | 10 | // Handle the form 11 | $('#login-form').ajaxSubmit({ 12 | before: NProgress.start, 13 | after: NProgress.done, 14 | error: () => $('#login-form').animateCSS('shake'), 15 | success: () => location.href = redirect 16 | }); 17 | 18 | }); 19 | -------------------------------------------------------------------------------- /source/scripts/quick_post.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, jquery */ 2 | 'use strict'; 3 | 4 | const NProgress = require('nprogress'); 5 | 6 | $(() => { 7 | 8 | let redirect = $('#quick-post-form').attr('data-redirect'); 9 | 10 | // Save for later 11 | $('[data-save], [data-publish]').on('click', function() { 12 | // Set the appropriate status 13 | let status = $(this).is('[data-save]') ? 'draft' : 'published'; 14 | $('#status').val(status); 15 | 16 | // Submit the form 17 | $('#quick-post-form').submit(); 18 | }); 19 | 20 | // Generate a slug when the title changes 21 | $('#title').on('change', function() { 22 | $('#slug').val(Postleaf.Slug($(this).val())); 23 | }); 24 | 25 | // Remember template preference 26 | $('#template') 27 | .on('change', function() { 28 | // Store the last selected template 29 | localStorage.setItem('quickPostTemplate', $(this).val()); 30 | }) 31 | .find('option').each(function() { 32 | // Restore the last selected template if there's a match 33 | if(this.value === localStorage.getItem('quickPostTemplate')) { 34 | $('#template').val(this.value); 35 | } 36 | }); 37 | 38 | // Handle the form 39 | $('#quick-post-form').ajaxSubmit({ 40 | before: () => { 41 | // Require title before submitting 42 | if($('#title').val() === '') { 43 | $('#quick-post-form').animateCSS('shake'); 44 | return false; 45 | } 46 | 47 | NProgress.start(); 48 | }, 49 | after: NProgress.done, 50 | error: (res) => { 51 | $('#quick-post-form').animateCSS('shake'); 52 | 53 | if(res.message) { 54 | $.announce.warning(res.message); 55 | } 56 | }, 57 | success: () => location.href = redirect 58 | }); 59 | 60 | // Submit on cmd + enter 61 | $('#quick-post-form :input').on('keydown', (event) => { 62 | if(event.keyCode === 13 && (event.metaKey || event.ctrlKey)) { 63 | $('#quick-post-form').submit(); 64 | } 65 | }); 66 | 67 | }); 68 | -------------------------------------------------------------------------------- /source/scripts/recover_password.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, jquery */ 2 | 'use strict'; 3 | 4 | const NProgress = require('nprogress'); 5 | 6 | $(() => { 7 | 8 | let redirect = $('#recover-form').attr('data-redirect'); 9 | 10 | // Handle the form 11 | $('#recover-form').ajaxSubmit({ 12 | before: NProgress.start, 13 | after: NProgress.done, 14 | error: (res) => { 15 | // Shake on error 16 | $('#recover-form').animateCSS('shake'); 17 | 18 | // Show error message 19 | if(res.message) { 20 | $.announce.warning(res.message); 21 | } 22 | }, 23 | success: function(res) { 24 | // Disable the form 25 | $(this).ajaxSubmit('disable', true); 26 | 27 | // Show success message 28 | if(res.message) { 29 | $.announce 30 | .success({ 31 | duration: 5000, 32 | message: res.message 33 | }) 34 | .then(() => location.href = redirect); 35 | } 36 | } 37 | }); 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /source/scripts/reset_password.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser, jquery */ 2 | 'use strict'; 3 | 4 | const NProgress = require('nprogress'); 5 | 6 | $(() => { 7 | 8 | let redirect = $('#reset-form').attr('data-redirect'); 9 | 10 | // Handle the form 11 | $('#reset-form').ajaxSubmit({ 12 | method: 'POST', 13 | before: NProgress.start, 14 | after: NProgress.done, 15 | error: (res) => { 16 | if(res.message) { 17 | $.announce.warning(res.message); 18 | } 19 | }, 20 | success: function(res) { 21 | $(this).ajaxSubmit('disable'); 22 | $.announce 23 | .success(res.message) 24 | .then(() => location.href = redirect); 25 | } 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /source/scripts/tinymce.js: -------------------------------------------------------------------------------- 1 | // 2 | // This file bundles up TinyMCE and other scripts required by the editor. 3 | // 4 | 5 | /* eslint-env browser, jquery */ 6 | 'use strict'; 7 | 8 | global.TinyMCE = require('tinymce/tinymce'); 9 | 10 | // Default theme is required :-\ 11 | require('tinymce/themes/modern/theme'); 12 | 13 | // Plugins 14 | require('tinymce/plugins/lists'); 15 | require('tinymce/plugins/paste'); 16 | require('tinymce/plugins/textpattern'); 17 | require('tinymce/plugins/wordcount'); 18 | -------------------------------------------------------------------------------- /source/styles/edit_post.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | 3 | .main-container { 4 | padding: 0; 5 | } 6 | 7 | .dropdown-menu .h2 .fa { transform: scale(1.4); } 8 | .dropdown-menu .h3 .fa { transform: scale(1.2); } 9 | .dropdown-menu .h4 .fa { transform: scale(1.0); } 10 | 11 | #editor-frame { 12 | position: relative; 13 | width: 100%; 14 | height: 100%; 15 | border: none; 16 | background: white; 17 | display: block; 18 | overflow: auto; 19 | transition: opacity .25s; 20 | } 21 | 22 | #status-bar { 23 | position: fixed; 24 | right: 1rem; 25 | bottom: 1rem; 26 | 27 | > div { 28 | vertical-align: middle; 29 | min-height: 1.7rem; 30 | display: inline-block; 31 | 32 | &:not(:first-child) { 33 | margin-left: .5rem; 34 | } 35 | } 36 | } 37 | 38 | #word-count { 39 | .word-count-none, 40 | .word-count-one, 41 | .word-count-many { 42 | background: rgba($postleaf-black, .8); 43 | font-size: .9rem; 44 | color: white; 45 | padding: .25rem .5rem; 46 | border-radius: .25rem; 47 | pointer-events: none; 48 | user-select: none; 49 | } 50 | } 51 | 52 | #zen-mode-theme { 53 | button { 54 | padding: 0; 55 | transform: rotate(0); 56 | transition: .4s transform; 57 | display: block; 58 | 59 | &[data-zen-theme="night"] { 60 | transform: rotate(180deg); 61 | } 62 | } 63 | 64 | img { 65 | width: 1.5em; 66 | height: 1.5em; 67 | display: block; 68 | } 69 | } 70 | 71 | #dropzone { 72 | position: fixed; 73 | z-index: 9999; 74 | top: 0; 75 | right: 0; 76 | bottom: 0; 77 | left: 0; 78 | background: rgba($postleaf-black, .9); 79 | padding: 3rem; 80 | 81 | .dropzone-target { 82 | width: 100%; 83 | font-size: 2rem; 84 | font-weight: 700; 85 | color: white; 86 | border: solid .5rem white; 87 | border-radius: $border-radius-large; 88 | text-align: center; 89 | transition: .2s transform, .2s color, .2s background-color, .2s border-color; 90 | padding: 1rem; 91 | display: table; 92 | 93 | &.active { 94 | color: white; 95 | background-color: $brand-primary; 96 | border-color: $brand-primary; 97 | } 98 | 99 | &:first-child { 100 | height: calc(30% - 3rem); 101 | margin-bottom: 3rem; 102 | } 103 | 104 | &:last-child { 105 | height: 70%; 106 | } 107 | 108 | .dropzone-text { 109 | display: table-cell; 110 | vertical-align: middle; 111 | } 112 | } 113 | } 114 | 115 | // Fixes an issue in iOS where hitting enter and various other commands (e.g. cmd+b) cause the 116 | // editor to scroll to the top of the page. Last tested on iOS 10.3.2. 117 | .ios { 118 | .stretch-down { 119 | overflow: visible; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /source/styles/edit_tag.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "partials/box"; 3 | 4 | $sidebar-width: 12rem; 5 | 6 | #sidebar { 7 | width: $sidebar-width; 8 | float: left; 9 | } 10 | 11 | #tag-form { 12 | margin-left: $sidebar-width + 2rem; 13 | 14 | h3 { 15 | margin-bottom: 1rem; 16 | 17 | &:not(:first-child) { 18 | margin-top: 4rem; 19 | } 20 | } 21 | } 22 | 23 | @include media-breakpoint-down(sm) { 24 | #sidebar { 25 | width: 100%; 26 | float: none; 27 | margin-bottom: 1rem; 28 | } 29 | 30 | #tag-form { 31 | margin: 0; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /source/styles/edit_user.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "partials/box"; 3 | 4 | $sidebar-width: 12rem; 5 | 6 | #sidebar { 7 | width: $sidebar-width; 8 | float: left; 9 | } 10 | 11 | #user-form { 12 | margin-left: $sidebar-width + 2rem; 13 | 14 | h3 { 15 | margin-bottom: 1rem; 16 | &:not(:first-child) { 17 | margin-top: 4rem; 18 | } 19 | } 20 | } 21 | 22 | @include media-breakpoint-down(sm) { 23 | #sidebar { 24 | width: 100%; 25 | float: none; 26 | margin-bottom: 1rem; 27 | } 28 | 29 | #user-form { 30 | margin: 0; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /source/styles/editor.scss: -------------------------------------------------------------------------------- 1 | @import 'partials/variables'; 2 | 3 | body { 4 | // Prevent iPhone from rendering huge text when the page is scaled way down 5 | -webkit-text-size-adjust: none; 6 | } 7 | 8 | // Show not-allowed cursor on all links 9 | a[href] { 10 | cursor: not-allowed; 11 | } 12 | 13 | // Hide the ghost container when dragging embeds 14 | .mce-drag-container { 15 | display: none !important; 16 | } 17 | 18 | // Inline boundaries for code and links 19 | a[data-mce-selected], 20 | code[data-mce-selected] { 21 | outline: dotted 1px currentColor; 22 | } 23 | 24 | // Image selections 25 | img[data-mce-selected] { 26 | outline: none !important; 27 | } 28 | 29 | // HR selections 30 | hr[data-mce-selected] { 31 | outline: none !important; 32 | } 33 | 34 | // Content regions 35 | [data-postleaf-region] { 36 | // Don't disappear when empty 37 | min-width: 1rem; 38 | min-height: 1rem; 39 | 40 | // Disable focus ring 41 | outline: none; 42 | 43 | a[href] { 44 | cursor: text; 45 | } 46 | } 47 | 48 | // Embeds 49 | [data-embed] { 50 | position: relative !important; 51 | margin-bottom: 1rem; 52 | 53 | // Create an invisible overlay on top of embedded elements to prevent interaction 54 | &::after { 55 | position: absolute; 56 | top: 0; 57 | left: 0; 58 | width: 100%; 59 | height: 100%; 60 | content: ''; 61 | background: $brand-primary; 62 | opacity: 0; 63 | cursor: text; 64 | } 65 | 66 | // Highlight the embed when selected 67 | &[data-mce-selected] { 68 | outline: solid 2px $brand-primary; 69 | outline-offset: .25rem; 70 | } 71 | } 72 | 73 | // Figures 74 | figure.image { 75 | &[data-mce-selected] img { 76 | outline: solid 2px $brand-primary; 77 | outline-offset: .25rem; 78 | } 79 | 80 | figcaption:focus { 81 | outline: none; 82 | } 83 | } 84 | 85 | // TinyMCE appends floating panels to the DOM for toolbars and other UI components. In some cases, 86 | // they can cause the browser to scroll unexpectedly to the bottom of the page. Hiding the panels 87 | // prevents this since we're not using TinyMCE's UI components anyway. 88 | .mce-floatpanel, 89 | .mce-panel { 90 | display: none !important; 91 | } 92 | 93 | // When a contentEditable="false" element is selected in Chrome, an mce-offscreen-selection appears 94 | // below the post content, causing the browser to scroll unexpectedly. Simply hiding the element 95 | // seems to fix it without any side effects. This only happens after toggling Zen Mode or reloading 96 | // the post preview, so it probably has something to do with the way we hot swap the title and 97 | // content elements. 98 | .mce-offscreen-selection { 99 | display: none !important; 100 | } 101 | -------------------------------------------------------------------------------- /source/styles/install.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "partials/box"; 3 | 4 | .logo { 5 | width: 15rem; 6 | margin: 2rem auto; 7 | display: block; 8 | } 9 | 10 | .install .box { 11 | max-width: 28rem; 12 | margin: 0 auto; 13 | } 14 | -------------------------------------------------------------------------------- /source/styles/lib.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | 3 | // Font Awesome 4 | @import "font-awesome/scss/font-awesome"; 5 | 6 | // Bootstrap 7 | @import "bootstrap/scss/custom"; 8 | @import "bootstrap/scss/variables"; 9 | @import "bootstrap/scss/mixins"; 10 | @import "bootstrap/scss/normalize"; 11 | @import "bootstrap/scss/print"; 12 | @import "bootstrap/scss/reboot"; 13 | @import "bootstrap/scss/type"; 14 | @import "bootstrap/scss/images"; 15 | @import "bootstrap/scss/code"; 16 | @import "bootstrap/scss/grid"; 17 | @import "bootstrap/scss/tables"; 18 | @import "bootstrap/scss/forms"; 19 | @import "bootstrap/scss/buttons"; 20 | // @import "bootstrap/scss/transitions"; 21 | @import "bootstrap/scss/dropdown"; 22 | @import "bootstrap/scss/button-group"; 23 | @import "bootstrap/scss/input-group"; 24 | @import "bootstrap/scss/custom-forms"; 25 | @import "bootstrap/scss/nav"; 26 | // @import "bootstrap/scss/navbar"; 27 | @import "bootstrap/scss/card"; 28 | // @import "bootstrap/scss/breadcrumb"; 29 | // @import "bootstrap/scss/pagination"; 30 | @import "bootstrap/scss/badge"; 31 | // @import "bootstrap/scss/jumbotron"; 32 | @import "bootstrap/scss/alert"; 33 | // @import "bootstrap/scss/progress"; 34 | // @import "bootstrap/scss/media"; 35 | // @import "bootstrap/scss/list-group"; 36 | // @import "bootstrap/scss/responsive-embed"; 37 | // @import "bootstrap/scss/close"; 38 | // @import "bootstrap/scss/modal"; 39 | @import "bootstrap/scss/tooltip"; 40 | // @import "bootstrap/scss/popover"; 41 | // @import "bootstrap/scss/carousel"; 42 | @import "bootstrap/scss/utilities"; 43 | 44 | // Third party libs 45 | @import "animate.css/animate.min"; 46 | 47 | // Custom partials 48 | @import "partials/admin_menu"; 49 | @import "partials/admin_toolbar"; 50 | @import "partials/alertable"; 51 | @import "partials/announce"; 52 | @import "partials/box"; 53 | @import "partials/card_cover"; 54 | @import "partials/empty_state"; 55 | @import "partials/image_control"; 56 | @import "partials/locater"; 57 | @import "partials/main_container"; 58 | @import "partials/nprogress"; 59 | @import "partials/panel"; 60 | @import "partials/revisions_table"; 61 | @import "partials/search_engine_preview"; 62 | @import "partials/selectize"; 63 | @import "partials/shortcuts"; 64 | @import "partials/stretch"; 65 | @import "partials/typeahead"; 66 | @import "partials/file_manager"; 67 | 68 | // Custom overrides 69 | @import "partials/overrides"; 70 | -------------------------------------------------------------------------------- /source/styles/login.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "partials/box"; 3 | 4 | body { 5 | padding: 10vh 0; 6 | } 7 | 8 | .logo { 9 | width: 15rem; 10 | margin: 2rem auto; 11 | display: block; 12 | } 13 | 14 | .login .box { 15 | max-width: 24rem; 16 | margin: 0 auto; 17 | } 18 | 19 | .meta { 20 | text-align: center; 21 | max-width: 24rem; 22 | margin: 1.5rem auto 0 auto; 23 | } 24 | 25 | @media screen and (max-height: 750px) { 26 | body { 27 | padding: 0; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /source/styles/navigation.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "partials/box"; 3 | 4 | .sortable-ghost { 5 | opacity: 0; 6 | } 7 | 8 | .nav-item { 9 | position: relative; 10 | width: 100%; 11 | background: white; 12 | box-shadow: 0 .1rem .1rem rgba($postleaf-black, .05); 13 | margin-bottom: 1px; 14 | display: table; 15 | cursor: move; 16 | padding-left: 1.5rem; 17 | 18 | &:first-child { 19 | border-top-left-radius: $border-radius; 20 | border-top-right-radius: $border-radius; 21 | } 22 | 23 | &:last-child { 24 | border-bottom-left-radius: $border-radius; 25 | border-bottom-right-radius: $border-radius; 26 | } 27 | 28 | // Grip 29 | &::before { 30 | position: absolute; 31 | top: 0; 32 | left: 0; 33 | bottom: 0; 34 | width: 2rem; 35 | content: ''; 36 | background-image: str-replace(url('data:image/svg+xml;charset=utf8,'), '#', '%23'); 37 | background-position: 1rem 50%; 38 | background-repeat: no-repeat; 39 | pointer-events: none; 40 | } 41 | 42 | .nav-item-field { 43 | width: calc(50% - 1.5rem); 44 | padding: 1rem; 45 | display: table-cell; 46 | 47 | &:first-child { 48 | padding-right: .5rem; 49 | } 50 | 51 | &:nth-child(2) { 52 | padding-left: .5rem; 53 | } 54 | } 55 | 56 | .nav-item-remove { 57 | width: 3rem; 58 | display: table-cell; 59 | vertical-align: middle; 60 | text-align: center; 61 | padding-right: 1rem; 62 | } 63 | } 64 | 65 | .nav-create { 66 | text-align: center; 67 | margin: 2rem 0; 68 | } 69 | 70 | @include media-breakpoint-down(xs) { 71 | 72 | .nav-item { 73 | position: relative; 74 | padding-left: 1.5rem; 75 | padding-right: 3rem; 76 | display: block; 77 | 78 | .nav-item-field { 79 | width: auto; 80 | display: block; 81 | 82 | &:first-child { 83 | padding: 1rem 1rem .25rem 1rem; 84 | } 85 | 86 | &:nth-child(2) { 87 | padding: .25rem 1rem 1rem 1rem; 88 | } 89 | } 90 | 91 | .nav-item-remove { 92 | position: absolute; 93 | top: 50%; 94 | right: .5rem; 95 | margin-top: $input-height / -2; 96 | } 97 | 98 | } 99 | 100 | } 101 | 102 | .empty-state { 103 | height: 20vh; 104 | } 105 | -------------------------------------------------------------------------------- /source/styles/partials/_admin_toolbar.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Page Toolbar 3 | // 4 | // Draws the toolbar that appears at the top of each admin panel page. 5 | // 6 | // Example: 7 | // 8 | //
9 | //
10 | //

Page Title

11 | //
12 | //
13 | // ... 14 | //
15 | //
16 | // 17 | // Notes: 18 | // 19 | // - A toolbar can have one or multiple admin-toolbar-group elements. 20 | // - You can force groups to collapse on XS screens using the admin-toolbar-collapse-xs modifier. 21 | // - You can use the disabled class on .admin-toolbar to disable interaction dynamically without 22 | // changing the state of each individual button. 23 | // 24 | 25 | $admin-toolbar-padding-x: 1rem; 26 | $admin-toolbar-padding-y: 1rem; 27 | 28 | .admin-toolbar { 29 | position: relative; 30 | background: white; 31 | border-bottom: solid 1px darken($body-bg, 10%); 32 | line-height: 1.5; 33 | display: table; 34 | width: 100%; 35 | user-select: none; 36 | 37 | // Disable toolbar buttons by appenbuttons while the toolbar is in a loading state 38 | &.disabled { 39 | pointer-events: none; 40 | 41 | // Make buttons appear disabled 42 | .btn { 43 | opacity: .5 44 | } 45 | } 46 | 47 | .admin-toolbar-title { 48 | font-size: 1.4rem; 49 | margin-bottom: 0; 50 | 51 | i { 52 | color: $text-muted; 53 | margin-left: .5rem; 54 | margin-right: .5rem; 55 | } 56 | 57 | img { 58 | max-height: 1.6em; 59 | max-width: 1.6em; 60 | border-radius: $border-radius; 61 | vertical-align: middle; 62 | } 63 | } 64 | 65 | .admin-toolbar-group { 66 | padding: $admin-toolbar-padding-y $admin-toolbar-padding-x; 67 | margin: 0; 68 | vertical-align: middle; 69 | display: table-cell; 70 | } 71 | 72 | .admin-toolbar-nowrap { 73 | white-space: nowrap; 74 | } 75 | 76 | .btn-group:not(:last-child) { 77 | margin-right: 1rem; 78 | } 79 | 80 | // Reduce gap between grouped dropdown buttons 81 | .dropdown > [data-toggle="dropdown"] { 82 | padding-right: .5rem; 83 | } 84 | } 85 | 86 | @include media-breakpoint-down(sm) { 87 | 88 | .admin-toolbar { 89 | .admin-toolbar-title { 90 | font-size: 1.2rem; 91 | } 92 | } 93 | } 94 | 95 | @include media-breakpoint-down(xs) { 96 | 97 | .admin-toolbar { 98 | .admin-toolbar-title { 99 | font-size: 1rem; 100 | } 101 | 102 | .admin-toolbar-group { 103 | padding: ($admin-toolbar-padding-y / 2) ($admin-toolbar-padding-x / 2); 104 | } 105 | } 106 | 107 | .admin-toolbar-collapse-xs { 108 | display: block; 109 | 110 | .admin-toolbar-group { 111 | max-width: none; 112 | padding-bottom: 0; 113 | display: block; 114 | 115 | .btn { 116 | padding-left: .75rem; 117 | padding-right: .75rem; 118 | } 119 | } 120 | 121 | .admin-toolbar-group:last-child { 122 | padding-bottom: $admin-toolbar-padding-y / 2; 123 | } 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /source/styles/partials/_alertable.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Alertable 3 | // 4 | // Custom styles for the jQuery Alertable plugin. 5 | // 6 | 7 | .alertable { 8 | position: fixed; 9 | z-index: 9999; 10 | // top: vertically centered by script 11 | left: calc(50% - 14rem); 12 | width: 28rem; 13 | background: white; 14 | border-radius: $border-radius; 15 | box-shadow: 0 .25rem 2.5rem rgba(black, .2); 16 | padding: 2rem; 17 | margin: 0 auto; 18 | } 19 | 20 | .alertable-overlay { 21 | position: fixed; 22 | z-index: 9998; 23 | top: 0; 24 | right: 0; 25 | bottom: 0; 26 | left: 0; 27 | background: rgba(black, .3); 28 | } 29 | 30 | .alertable-message { 31 | margin-bottom: 2rem; 32 | } 33 | 34 | .alertable-prompt { 35 | margin-bottom: 2rem; 36 | } 37 | 38 | .alertable-buttons { 39 | text-align: right; 40 | } 41 | 42 | .alertable button { 43 | margin-left: .25rem; 44 | } 45 | 46 | .alertable-ok { 47 | @extend .btn, .btn-primary; 48 | } 49 | 50 | .alertable-cancel { 51 | @extend .btn, .btn-link; 52 | } 53 | 54 | @include media-breakpoint-down(xs) { 55 | .alertable { 56 | left: 4%; 57 | width: 92%; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /source/styles/partials/_announce.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Announce 3 | // 4 | // Custom styles for the jQuery Announce plugin. 5 | // 6 | 7 | .announce { 8 | position: fixed; 9 | z-index: 9999; 10 | top: .5rem; 11 | right: .5rem; 12 | max-width: 30rem; 13 | border-radius: $border-radius; 14 | padding: 1rem; 15 | user-select: none; 16 | 17 | &.announce-danger { 18 | color: white; 19 | background-color: rgba($brand-danger, .9); 20 | } 21 | 22 | &.announce-info { 23 | color: white; 24 | background-color: rgba($postleaf-black, .9); 25 | } 26 | 27 | &.announce-success { 28 | color: white; 29 | background-color: rgba($brand-success, .9); 30 | } 31 | 32 | &.announce-warning { 33 | color: white; 34 | background-color: rgba($brand-warning, .9); 35 | } 36 | } 37 | 38 | @media screen and(max-width: 30rem) { 39 | .announce { 40 | width: 100%; 41 | border-radius: 0; 42 | top: 0; 43 | right: 0; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /source/styles/partials/_box.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Box 3 | // 4 | // A simple, styled box component. 5 | // 6 | // Example: 7 | // 8 | //
...
9 | // 10 | 11 | .box { 12 | background: white; 13 | border-radius: .25rem; 14 | box-shadow: 0 .1rem .1rem rgba($postleaf-black, .05); 15 | padding: 3rem; 16 | margin-bottom: 2rem; 17 | 18 | > :last-child { 19 | margin-bottom: 0; 20 | } 21 | } 22 | 23 | @include media-breakpoint-down(sm) { 24 | 25 | .box { 26 | padding: 2rem; 27 | } 28 | 29 | } 30 | 31 | @include media-breakpoint-down(xs) { 32 | 33 | .box { 34 | padding: 1.5rem; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /source/styles/partials/_card_cover.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Card Cover 3 | // 4 | // Makes regular Bootstrap cards cover photo-capable. 5 | // 6 | // Example: 7 | // 8 | //
10 | //
11 | //
12 | //
13 | // ... 14 | //
15 | // 16 | // 17 | // Notes: 18 | // - Add an avatar inside the card-cover-header with:
19 | // 20 | $card-cover-height: 10rem; 21 | $card-cover-avatar-size: 9rem; 22 | 23 | .card-cover { 24 | position: relative; 25 | 26 | .card-cover-header { 27 | position: relative; 28 | height: $card-cover-height; 29 | overflow: hidden; 30 | 31 | + .card-block { 32 | border-top: solid 1px $card-border-color; 33 | } 34 | } 35 | 36 | .card-cover-image { 37 | height: 100%; 38 | background-color: $body-bg; 39 | background-position: center; 40 | background-size: cover; 41 | background-repeat: no-repeat; 42 | border-top-left-radius: $border-radius; 43 | border-top-right-radius: $border-radius; 44 | 45 | // Image placeholder 46 | &::before { 47 | position: absolute; 48 | top: 0; 49 | left: 0; 50 | width: 100%; 51 | content: '\f06c'; 52 | line-height: $card-cover-height; 53 | font-size: $card-cover-height / 2; 54 | font-family: FontAwesome; 55 | color: #8aaabb; 56 | text-align: center; 57 | opacity: .25; 58 | } 59 | 60 | // Hide placeholder when a background image is set 61 | &[style]:not([style=""])::before { 62 | display: none; 63 | } 64 | } 65 | 66 | // Avatar 67 | .card-cover-avatar { 68 | img { 69 | position: absolute; 70 | top: ($card-cover-height - $card-cover-avatar-size) / 2; 71 | left: calc(50% - #{$card-cover-avatar-size / 2}); 72 | width: $card-cover-avatar-size; 73 | height: $card-cover-avatar-size; 74 | border-radius: 50%; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /source/styles/partials/_empty_state.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Empty State 3 | // 4 | // A basic empty state container. 5 | // 6 | // Example: 7 | // 8 | //
9 | //
10 | // No items found 11 | //
12 | //
13 | // 14 | // Notes: 15 | // 16 | // - Uses the current element's text size by default 17 | // - Add the empty-state-xs|sm|md|lg|xl modifiers to adjust the text size. 18 | // 19 | 20 | .empty-state { 21 | width: 100%; 22 | height: 100%; 23 | display: table; 24 | user-select: none; 25 | } 26 | 27 | .empty-state-message { 28 | color: $text-muted; 29 | text-align: center; 30 | vertical-align: middle; 31 | display: table-cell; 32 | } 33 | 34 | .empty-state-icon { 35 | font-size: 200%; 36 | display: block; 37 | } 38 | 39 | .empty-state-xl .empty-state-message { font-size: 3rem; } 40 | 41 | .empty-state-lg .empty-state-message { font-size: 2.5rem; } 42 | 43 | .empty-state-md .empty-state-message { font-size: 2rem; } 44 | 45 | .empty-state-sm .empty-state-message { font-size: 1.5rem; } 46 | 47 | .empty-state-xs .empty-state-message { font-size: 1rem; } 48 | -------------------------------------------------------------------------------- /source/styles/partials/_image_control.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Image Control 3 | // 4 | // Draws an control for working with images. Shows a placeholder when the control is empty, and 5 | // shows the image in cover mode when style="background-image" is set. 6 | // 7 | // Example: 8 | // 9 | //
10 | //
11 | //
12 | // 13 | // 14 | // 15 | //
16 | //
17 | //
18 | // 19 | // Notes: 20 | // 21 | // - There are xs, sm, lg, and xl variations. 22 | // - The `image-control-contain` modifier can be used to contain rather than cover the block. 23 | // 24 | 25 | $image-control-height-xs: 5rem; 26 | $image-control-height-sm: 10rem; 27 | $image-control-height-md: 15rem; 28 | $image-control-height-lg: 20rem; 29 | $image-control-height-xl: 25rem; 30 | $image-control-icon-color: #8aaabb; 31 | 32 | .image-control { 33 | position: relative; 34 | height: $image-control-height-md; 35 | border-radius: .25rem; 36 | background-color: $body-bg; 37 | background-position: center; 38 | background-size: cover; 39 | background-repeat: no-repeat; 40 | margin-bottom: .5rem; 41 | 42 | // Modifier to contain rather than cover 43 | &.image-control-contain { 44 | background-size: contain; 45 | } 46 | 47 | // Image placeholder 48 | &::before { 49 | position: absolute; 50 | top: 0; 51 | left: 0; 52 | width: 100%; 53 | content: '\f03e'; 54 | font-family: FontAwesome; 55 | color: $image-control-icon-color; 56 | font-size: 6rem; 57 | line-height: $image-control-height-md; 58 | text-align: center; 59 | opacity: .25; 60 | } 61 | 62 | // Dropzone 63 | &.image-control-dragging { 64 | &::after { 65 | position: absolute; 66 | top: 0; 67 | left: 0; 68 | width: 100%; 69 | height: 100%; 70 | content: ''; 71 | border: solid .5rem $brand-primary; 72 | border-radius: $border-radius; 73 | } 74 | } 75 | 76 | // Hide placeholder when a background image is set 77 | &[style]:not([style=""])::before { 78 | display: none; 79 | } 80 | 81 | .image-control-controls { 82 | position: absolute; 83 | right: .5rem; 84 | bottom: .5rem; 85 | } 86 | 87 | // XS variation 88 | &.image-control-xs { 89 | height: 5rem; 90 | 91 | &::before { 92 | font-size: 2rem; 93 | line-height: $image-control-height-xs; 94 | } 95 | } 96 | 97 | // Small variation 98 | &.image-control-sm { 99 | height: $image-control-height-sm; 100 | 101 | &::before { 102 | font-size: 4rem; 103 | line-height: $image-control-height-sm; 104 | } 105 | } 106 | 107 | // Large variation 108 | &.image-control-lg { 109 | height: $image-control-height-lg; 110 | 111 | &::before { 112 | font-size: 8rem; 113 | line-height: $image-control-height-lg; 114 | } 115 | } 116 | 117 | // XL variation 118 | &.image-control-xl { 119 | height: $image-control-height-xl; 120 | 121 | &::before { 122 | font-size: 10rem; 123 | line-height: $image-control-height-xl; 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /source/styles/partials/_locater.scss: -------------------------------------------------------------------------------- 1 | $locater-width: 35rem; 2 | $locater-width-xs: 90%; 3 | 4 | #locater-overlay { 5 | position: fixed; 6 | z-index: 900; 7 | top: 0; 8 | right: 0; 9 | bottom: 0; 10 | left: 0; 11 | background: rgba($postleaf-black, .3); 12 | } 13 | 14 | #locater { 15 | position: fixed; 16 | z-index: 901; 17 | top: 2rem; 18 | left: 50%; 19 | margin-left: -$locater-width / 2; 20 | width: $locater-width; 21 | background: white; 22 | border-radius: $border-radius; 23 | box-shadow: 0 1rem 2.5rem rgba($postleaf-black, .2); 24 | 25 | .input-group { 26 | padding: .5rem; 27 | } 28 | 29 | input[type="search"] { 30 | border: none; 31 | padding-left: .5rem; 32 | padding-right: .5rem; 33 | } 34 | 35 | .input-group-addon { 36 | position: relative; 37 | border: none; 38 | background: transparent; 39 | color: $text-muted; 40 | font-size: 1.6rem; 41 | } 42 | 43 | // Loading state 44 | &.loading { 45 | // Hide the search icon 46 | .input-group-addon { 47 | color: transparent; 48 | } 49 | 50 | // Animated spinner 51 | .input-group-addon::after { 52 | position: absolute; 53 | top: calc(50% - .75rem); 54 | right: calc(50% - .75rem); 55 | width: 1.5rem; 56 | height: 1.5rem; 57 | content: ''; 58 | border: solid 3px transparent; 59 | border-top-color: $text-muted; 60 | border-left-color: $text-muted; 61 | border-radius: 50%; 62 | animation: main-container-spinner 500ms linear infinite; 63 | } 64 | } 65 | 66 | } 67 | 68 | #locater-results { 69 | position: relative; 70 | z-index: 902; 71 | max-height: 50vh; 72 | border-top: solid 1px $input-border-color; 73 | border-bottom-right-radius: $border-radius; 74 | border-bottom-left-radius: $border-radius; 75 | overflow-y: auto; 76 | 77 | &:empty { 78 | display: none; 79 | } 80 | } 81 | 82 | .locater-result { 83 | @include text-truncate; 84 | position: relative; 85 | color: $body-color; 86 | display: block; 87 | padding: .5rem .5rem .5rem 4rem; 88 | 89 | .locater-result-icon, 90 | .locater-result-image { 91 | position: absolute; 92 | top: calc(50% - 1.25rem); 93 | left: .75rem; 94 | width: 2.5rem; 95 | height: 2.5rem; 96 | color: $text-muted; 97 | text-align: center; 98 | border-radius: $border-radius; 99 | } 100 | 101 | .locater-result-icon { 102 | top: calc(50% - 1rem); 103 | font-size: 2rem; 104 | } 105 | 106 | .locater-result-description { 107 | color: $text-muted; 108 | font-size: .8rem; 109 | } 110 | 111 | &:hover, 112 | &:focus { 113 | color: inherit; 114 | background-color: $body-bg; 115 | } 116 | 117 | &.active { 118 | background-color: $postleaf-blue; 119 | color: white; 120 | text-decoration: none; 121 | .locater-result-icon, 122 | .locater-result-description { 123 | color: white; 124 | } 125 | } 126 | } 127 | 128 | @include media-breakpoint-down(xs) { 129 | #locater { 130 | width: $locater-width-xs; 131 | margin-left: -$locater-width-xs / 2; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /source/styles/partials/_main_container.scss: -------------------------------------------------------------------------------- 1 | $main-container-padding: 2rem; 2 | $main-container-padding-xs: 1rem; 3 | 4 | @keyframes main-container-spinner { 5 | 0% { transform: rotate(0deg); } 6 | 100% { transform: rotate(360deg); } 7 | } 8 | 9 | .main-container, 10 | .main-container-fluid { 11 | @extend .container-fluid; 12 | position: relative; 13 | padding: $main-container-padding; 14 | 15 | // Loading state 16 | &.loading { 17 | // Animated spinner 18 | &::after { 19 | position: absolute; 20 | top: calc(50% - 1.5rem); 21 | right: calc(50% - 1.5rem); 22 | width: 3rem; 23 | height: 3rem; 24 | content: ''; 25 | border: solid 4px transparent; 26 | border-top-color: $brand-primary; 27 | border-left-color: $brand-primary; 28 | border-radius: 50%; 29 | animation: main-container-spinner 500ms linear infinite; 30 | } 31 | } 32 | } 33 | 34 | @include media-breakpoint-down(xs) { 35 | .main-container, 36 | .main-container-fluid { 37 | padding: $main-container-padding-xs; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /source/styles/partials/_nprogress.scss: -------------------------------------------------------------------------------- 1 | // 2 | // NProgress 3 | // 4 | // Custom styles for the NProgress plugin. 5 | // 6 | 7 | #nprogress { 8 | pointer-events: none; 9 | } 10 | 11 | #nprogress .bar { 12 | background: $brand-primary; 13 | position: fixed; 14 | z-index: 1031; 15 | top: 0; 16 | left: 0; 17 | width: 100%; 18 | height: 3px; 19 | } 20 | 21 | #nprogress .spinner { 22 | display: block; 23 | position: fixed; 24 | z-index: 1031; 25 | bottom: 1rem; 26 | right: 1rem; 27 | } 28 | 29 | #nprogress .spinner-icon { 30 | width: 1.5rem; 31 | height: 1.5rem; 32 | box-sizing: border-box; 33 | border: solid 2px transparent; 34 | border-top-color: $brand-primary; 35 | border-left-color: $brand-primary; 36 | border-radius: 50%; 37 | animation: nprogress-spinner 500ms linear infinite; 38 | } 39 | 40 | .nprogress-custom-parent { 41 | overflow: hidden; 42 | position: relative; 43 | } 44 | 45 | .nprogress-custom-parent #nprogress .spinner, 46 | .nprogress-custom-parent #nprogress .bar { 47 | position: absolute; 48 | } 49 | 50 | @-webkit-keyframes nprogress-spinner { 51 | 0% { -webkit-transform: rotate(0deg); } 52 | 100% { -webkit-transform: rotate(360deg); } 53 | } 54 | @keyframes nprogress-spinner { 55 | 0% { transform: rotate(0deg); } 56 | 100% { transform: rotate(360deg); } 57 | } 58 | -------------------------------------------------------------------------------- /source/styles/partials/_panel.scss: -------------------------------------------------------------------------------- 1 | // 2 | // A simple panel implementation. 3 | // 4 | // Example: 5 | // 6 | //
7 | //
8 | //

My Panel

9 | //
10 | // 11 | // 14 | // 15 | //
16 | // ... 17 | //
18 | // 19 | //
20 | // ... 21 | //
22 | // 23 | //
24 | // 25 | // Notes: 26 | // - Use the panel-left and panel-right modifiers to adjust the panel's position. 27 | // - Any element with the data-panel-hide attribute will hide the panel on click. 28 | // 29 | .panel { 30 | position: fixed; 31 | z-index: 500; 32 | top: 0; 33 | bottom: 0; 34 | width: 30rem; 35 | background: white; 36 | box-shadow: -.5rem 0 5rem rgba(black, .05); 37 | overflow-y: auto; 38 | -webkit-overflow-scrolling: touch; 39 | visibility: hidden; 40 | transition: .2s all; 41 | user-select: none; 42 | 43 | &.panel-right { 44 | right: 0; 45 | left: auto; 46 | border-left: solid 1px darken($body-bg, 10%); 47 | transform: translate3d(100%, 0, 0); 48 | } 49 | 50 | &.panel-left { 51 | right: auto; 52 | left: 0; 53 | border-right: solid 1px darken($body-bg, 10%); 54 | transform: translate3d(-100%, 0, 0); 55 | } 56 | 57 | &.active { 58 | visibility: visible; 59 | transform: translate3d(0, 0, 0); 60 | transition-delay: 0s; 61 | } 62 | 63 | .close { 64 | position: absolute; 65 | top: 1rem; 66 | right: 1rem; 67 | font-size: 1.2rem; 68 | color: $text-muted; 69 | transition: .2s color; 70 | 71 | &:hover { 72 | color: darken($text-muted, 10%); 73 | } 74 | } 75 | 76 | .panel-header { 77 | padding: 2rem 2rem 0 2rem; 78 | margin-top: -.5rem; 79 | 80 | h1, 81 | h2, 82 | h3 { 83 | font-weight: 300; 84 | margin-bottom: 2rem; 85 | } 86 | } 87 | 88 | .panel-body { 89 | padding: 0 2rem; 90 | } 91 | 92 | .panel-footer { 93 | padding: 0 2rem 2rem 2rem; 94 | margin-top: 2rem; 95 | } 96 | 97 | .nav { 98 | margin-bottom: 2rem; 99 | } 100 | } 101 | 102 | @include media-breakpoint-down(xs) { 103 | 104 | .panel { 105 | width: 100%; 106 | padding: 1rem; 107 | 108 | .close { 109 | top: .5rem; 110 | right: .5rem; 111 | } 112 | 113 | .panel-header { 114 | padding: .5rem 0 0 0; 115 | } 116 | 117 | .panel-body { 118 | padding: 0; 119 | } 120 | 121 | .panel-footer { 122 | padding: 0 0 1rem 0; 123 | } 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /source/styles/partials/_revisions_table.scss: -------------------------------------------------------------------------------- 1 | .revisions-table { 2 | 3 | tr:first-child td { 4 | border-top: none; 5 | } 6 | 7 | td { 8 | vertical-align: middle; 9 | } 10 | 11 | td:not(:first-child) { 12 | text-align: right; 13 | } 14 | 15 | .revisions-table-controls { 16 | white-space: nowrap; 17 | } 18 | 19 | .revisions-table-date { 20 | margin-bottom: .25rem; 21 | } 22 | 23 | .revisions-table-author { 24 | font-size: .9rem; 25 | color: $text-muted; 26 | } 27 | 28 | .revisions-table-avatar { 29 | width: 1.5em; 30 | height: auto; 31 | vertical-align: bottom; 32 | border-radius: $border-radius; 33 | margin-right: .25rem; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /source/styles/partials/_search_engine_preview.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Search Engine Preview 3 | // 4 | // Draws a search engine preview with title, URL, and description. 5 | // 6 | // Example: 7 | // 8 | //
9 | //
10 | //
11 | // {@url type="tag" slug="" absolute="true"/} 12 | //
13 | //
14 | //
15 | // 16 | .search-engine-preview { 17 | border: solid .5rem $body-bg; 18 | border-radius: $border-radius-lg; 19 | padding: 2rem; 20 | 21 | .search-engine-preview-title { 22 | font-size: 1.5rem; 23 | color: $postleaf-blue; 24 | margin-bottom: .25rem; 25 | } 26 | 27 | .search-engine-preview-url { 28 | @include text-truncate; 29 | font-size: 1rem; 30 | color: $postleaf-green; 31 | } 32 | 33 | .search-engine-preview-description { 34 | @include text-truncate; 35 | font-size: 1rem; 36 | color: $body-color; 37 | margin-top: .25rem; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /source/styles/partials/_selectize.scss: -------------------------------------------------------------------------------- 1 | // The main control 2 | .selectize-control { 3 | position: relative; 4 | 5 | // Make the form control transparent so it just wraps the selectize input 6 | &.form-control { 7 | padding: 0; 8 | height: auto; 9 | border: none; 10 | background: none; 11 | border-radius: 0; 12 | } 13 | } 14 | 15 | // Input 16 | .selectize-input { 17 | // Simulate a form control's appearance 18 | @extend .form-control; 19 | height: auto; 20 | cursor: text; 21 | min-height: $input-height; 22 | 23 | &.has-items { 24 | padding: .25rem .5rem; 25 | } 26 | 27 | &.dropdown-active { 28 | border-radius: $border-radius; 29 | } 30 | 31 | &.dropdown-active::before { 32 | display: none; 33 | } 34 | 35 | &.focus { 36 | border-color: $input-border-focus; 37 | } 38 | 39 | &.disabled { 40 | background: white; 41 | opacity: .5; 42 | cursor: default !important; 43 | 44 | > .item, 45 | > .item.active { 46 | background: white; 47 | border: 0 solid transparent; 48 | } 49 | } 50 | 51 | &::after { 52 | content: ' '; 53 | display: block; 54 | clear: left; 55 | } 56 | 57 | > * { 58 | vertical-align: baseline; 59 | display: inline-block; 60 | } 61 | 62 | > input { 63 | display: inline-block !important; 64 | padding: 0 !important; 65 | min-height: 0 !important; 66 | max-height: none !important; 67 | max-width: 100% !important; 68 | margin: 0 !important; 69 | text-indent: 0 !important; 70 | border: 0 none !important; 71 | background: none !important; 72 | line-height: inherit !important; 73 | box-shadow: none !important; 74 | } 75 | 76 | > input::-ms-clear { 77 | display: none; 78 | } 79 | 80 | > input:focus { 81 | outline: none !important; 82 | } 83 | 84 | > .item { 85 | color: $input-color; 86 | background: darken($body-bg, 2.5%); 87 | border: 0 solid rgba(0, 0, 0, 0); 88 | border-radius: $border-radius; 89 | padding: .15rem .25rem; 90 | margin: .1rem .25rem .1rem 0; 91 | cursor: pointer; 92 | } 93 | 94 | > .item.active { 95 | background: $brand-primary; 96 | color: white; 97 | border: 0 solid rgba(0, 0, 0, 0); 98 | } 99 | } 100 | 101 | // Input when a parent element has an error state 102 | .has-error .selectize-input { 103 | border-color: $brand-warning; 104 | 105 | &:focus { 106 | border-color: $brand-warning; 107 | } 108 | 109 | &.has-items { 110 | padding-left: 9px; 111 | padding-right: 9px; 112 | } 113 | } 114 | 115 | // Dropdown 116 | .selectize-dropdown { 117 | position: absolute; 118 | z-index: 1000; 119 | height: auto; 120 | background: $dropdown-bg; 121 | border: 1px solid $input-border-color; 122 | border-radius: $input-border-radius; 123 | box-shadow: $dropdown-box-shadow; 124 | padding: 0; 125 | margin: 0; 126 | 127 | [data-selectable] { 128 | cursor: pointer; 129 | overflow: hidden; 130 | padding: .25rem .75rem; 131 | 132 | .highlight { 133 | background: rgba(255, 237, 40, 0.4); 134 | } 135 | } 136 | 137 | .active { 138 | background-color: $link-color; 139 | color: white; 140 | } 141 | 142 | .selectize-dropdown-content { 143 | overflow-y: auto; 144 | overflow-x: hidden; 145 | max-height: 12rem; 146 | padding: .25rem 0; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /source/styles/partials/_shortcuts.scss: -------------------------------------------------------------------------------- 1 | $shortcuts-width: 35rem; 2 | $shortcuts-width-xs: 90%; 3 | 4 | #shortcuts-overlay { 5 | position: fixed; 6 | z-index: 900; 7 | top: 0; 8 | right: 0; 9 | bottom: 0; 10 | left: 0; 11 | background: rgba($postleaf-black, .3); 12 | } 13 | 14 | #shortcuts { 15 | position: fixed; 16 | z-index: 901; 17 | top: 0; 18 | right: 0; 19 | bottom: 0; 20 | left: 0; 21 | overflow-y: auto; 22 | -webkit-overflow-scrolling: touch; 23 | } 24 | 25 | #shortcuts-body { 26 | position: absolute; 27 | top: 0; 28 | left: 50%; 29 | margin-left: -$shortcuts-width / 2; 30 | width: $shortcuts-width; 31 | background: white; 32 | border-radius: $border-radius; 33 | box-shadow: 0 1rem 2.5rem rgba($postleaf-black, .2); 34 | padding: 2rem; 35 | margin-top: 2rem; 36 | margin-bottom: 2rem; 37 | 38 | .close { 39 | position: absolute; 40 | top: 1rem; 41 | right: 1rem; 42 | font-size: 1.2rem; 43 | color: $text-muted; 44 | } 45 | 46 | h2 { 47 | text-align: center; 48 | margin-bottom: 1rem; 49 | } 50 | 51 | .table { 52 | caption { 53 | font-size: .9rem; 54 | caption-side: top; 55 | text-align: center; 56 | } 57 | 58 | td { 59 | border-top: none; 60 | padding: .5rem; 61 | 62 | &:first-child { 63 | width: 50%; 64 | text-align: right; 65 | } 66 | } 67 | } 68 | } 69 | 70 | @include media-breakpoint-down(xs) { 71 | #shortcuts-body { 72 | width: $shortcuts-width-xs; 73 | margin-left: -$shortcuts-width-xs / 2; 74 | 75 | h2 { 76 | font-size: 1.5rem; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /source/styles/partials/_stretch.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Stretch 3 | // 4 | // A component for a simple stretch container that gets stretched down to the bottom of the 5 | // viewport using a script (see lib.js). 6 | // 7 | 8 | .stretch-down { 9 | overflow-y: auto; 10 | -webkit-overflow-scrolling: touch; 11 | } 12 | -------------------------------------------------------------------------------- /source/styles/partials/_typeahead.scss: -------------------------------------------------------------------------------- 1 | .twitter-typeahead { 2 | width: 100%; 3 | cursor: default; 4 | 5 | .tt-input { 6 | width: 100%; 7 | } 8 | 9 | .tt-menu { 10 | width: 100%; 11 | border: solid 1px $dropdown-border-color; 12 | background: $dropdown-bg; 13 | box-shadow: $dropdown-box-shadow; 14 | border-radius: $border-radius; 15 | padding: .25rem 0; 16 | } 17 | 18 | .tt-suggestion { 19 | padding: .25rem .75rem; 20 | white-space: nowrap; 21 | overflow: hidden; 22 | cursor: pointer; 23 | 24 | .fa { 25 | color: $text-muted; 26 | margin-right: .25rem; 27 | } 28 | 29 | &.tt-cursor { 30 | background-color: lighten($text-muted, 30%); 31 | } 32 | 33 | &:hover { 34 | background-color: $dropdown-link-hover-bg; 35 | color: $dropdown-link-hover-color; 36 | .fa, 37 | strong { 38 | color: inherit; 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /source/styles/posts.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | 3 | .main-container { 4 | padding: 0; 5 | 6 | // Height needs to be 100% for all the preview frame's containers 7 | > .row { 8 | height: 100%; 9 | margin-left: 0; 10 | margin-right: 0; 11 | 12 | > [class^="col-"] { 13 | height: 100%; 14 | padding: 0; 15 | } 16 | } 17 | } 18 | 19 | #posts { 20 | height: 100%; 21 | border-right: solid 1px darken($body-bg, 10%); 22 | overflow-y: auto; 23 | -webkit-overflow-scrolling: touch; 24 | } 25 | 26 | #empty { 27 | border-right: solid 1px darken($body-bg, 10%); 28 | } 29 | 30 | #none-selected, 31 | #many-selected { 32 | height: 100%; 33 | } 34 | 35 | @keyframes preview-spinner { 36 | 0% { transform: rotate(0deg); } 37 | 100% { transform: rotate(360deg); } 38 | } 39 | 40 | #preview { 41 | position: relative; 42 | height: 100%; 43 | overflow: hidden; 44 | user-select: none; 45 | padding: 2rem; 46 | 47 | // Preview spinner 48 | &.loading::after { 49 | position: absolute; 50 | top: calc(50% - 1.5rem); 51 | right: calc(50% - 1.5rem); 52 | width: 3rem; 53 | height: 3rem; 54 | content: ''; 55 | border: solid 4px transparent; 56 | border-top-color: $brand-primary; 57 | border-left-color: $brand-primary; 58 | border-radius: 50%; 59 | animation: preview-spinner 500ms linear infinite; 60 | } 61 | 62 | &.loading #preview-frame { 63 | display: none; 64 | } 65 | 66 | #preview-wrap { 67 | position: relative; 68 | width: 200%; 69 | height: 200%; 70 | transform: scale(.5); 71 | transform-origin: 0 0; 72 | box-shadow: 0 .2rem .2rem rgba($postleaf-black, .05); 73 | transition: .1s box-shadow; 74 | 75 | &:hover { 76 | box-shadow: 0 0 0 .5rem $link-color; 77 | } 78 | } 79 | 80 | #preview-frame { 81 | width: 100%; 82 | height: 100%; 83 | border: none; 84 | background: white; 85 | overflow-y: auto; 86 | display: block; 87 | -webkit-overflow-scrolling: touch; 88 | } 89 | } 90 | 91 | @include media-breakpoint-down(lg) { 92 | #preview { 93 | padding: 1rem; 94 | } 95 | } 96 | 97 | @include media-breakpoint-down(md) { 98 | #preview { 99 | padding: 0; 100 | } 101 | } 102 | 103 | // Post items 104 | .post-item { 105 | @include clearfix; 106 | color: inherit; 107 | background: white; 108 | box-shadow: 0 .1rem .1rem rgba($postleaf-black, .05); 109 | border-left: solid 0 $link-color; 110 | margin-bottom: 1px; 111 | padding: 1.25rem 1.5rem; 112 | display: block; 113 | cursor: pointer; 114 | user-select: none; 115 | transition: .1s border, .1s padding; 116 | 117 | &.selected { 118 | border-left-width: .5rem; 119 | padding-right: 1rem; 120 | } 121 | 122 | .post-item-title { 123 | font-size: 1.1rem; 124 | line-height: 1.4; 125 | margin-bottom: .5rem; 126 | } 127 | 128 | .post-item-details { 129 | font-size: .9rem; 130 | color: $text-muted; 131 | } 132 | 133 | .post-item-avatar { 134 | width: 1.5em; 135 | height: auto; 136 | vertical-align: bottom; 137 | border-radius: $border-radius; 138 | margin-right: .25rem; 139 | } 140 | 141 | .post-item-meta { 142 | float: right; 143 | } 144 | } 145 | 146 | // Dropdown filter 147 | #post-filter { 148 | 149 | &.active { 150 | .dropdown-toggle i { 151 | color: $link-color; 152 | } 153 | } 154 | 155 | .dropdown-item { 156 | padding: .1rem 1rem; 157 | 158 | &:hover { 159 | background: transparent; 160 | } 161 | 162 | label { 163 | display: block; 164 | margin: 0; 165 | } 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /source/styles/quick_post.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | 3 | .quick-post { 4 | // Header 5 | .quick-post-header { 6 | background: white; 7 | border-bottom: solid 1px lighten($input-border-color, 10%); 8 | padding: .5rem; 9 | 10 | // Title 11 | input[name="title"] { 12 | font-size: 1.2rem; 13 | border: none; 14 | border-top-left-radius: $border-radius; 15 | border-top-right-radius: $border-radius; 16 | border-bottom-left-radius: 0; 17 | border-bottom-right-radius: 0; 18 | } 19 | 20 | // Template 21 | select[name="template"] { 22 | color: $text-muted; 23 | font-size: .9rem; 24 | border-color: transparent; 25 | background-color: transparent; 26 | background-image: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='#{$text-muted}' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E"), "#", "%23"); 27 | cursor: pointer; 28 | } 29 | } 30 | 31 | // Body 32 | .quick-post-body { 33 | background: white; 34 | padding: .5rem; 35 | 36 | // Content 37 | textarea[name="content"] { 38 | border: none; 39 | border-radius: 0; 40 | resize: none; 41 | } 42 | } 43 | 44 | // Footer 45 | .quick-post-footer { 46 | background: white; 47 | border-bottom-left-radius: $border-radius; 48 | border-bottom-right-radius: $border-radius; 49 | text-align: right; 50 | padding: 1rem; 51 | margin-bottom: 1rem; 52 | } 53 | } 54 | 55 | @media screen and (min-height: 30rem) { 56 | 57 | .quick-post { 58 | padding-top: 10vh; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /source/styles/recover_password.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "login.scss"; 3 | 4 | .recover-password { 5 | // For now, we can use the same styles as the login page 6 | @extend .login; 7 | } 8 | -------------------------------------------------------------------------------- /source/styles/reset_password.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "login.scss"; 3 | 4 | .reset-password { 5 | // For now, we can use the same styles as the login page 6 | @extend .login; 7 | } 8 | -------------------------------------------------------------------------------- /source/styles/settings.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "partials/box"; 3 | 4 | $sidebar-width: 12rem; 5 | 6 | #sidebar { 7 | width: $sidebar-width; 8 | float: left; 9 | } 10 | 11 | #settings-form { 12 | margin-left: $sidebar-width + 2rem; 13 | 14 | h3 { 15 | margin-bottom: 1rem; 16 | &:not(:first-child) { 17 | margin-top: 4rem; 18 | } 19 | } 20 | } 21 | 22 | #about { 23 | // Postleaf logo 24 | h3:first-child { 25 | color: $text-muted; 26 | 27 | img { 28 | width: 15rem; 29 | vertical-align: top; 30 | } 31 | } 32 | } 33 | 34 | @include media-breakpoint-down(md) { 35 | #about { 36 | td:first-child { 37 | display: block; 38 | } 39 | 40 | td:last-child { 41 | border-top: none; 42 | display: block; 43 | } 44 | } 45 | } 46 | 47 | @include media-breakpoint-down(sm) { 48 | #sidebar { 49 | width: 100%; 50 | float: none; 51 | margin-bottom: 1rem; 52 | } 53 | 54 | #settings-form { 55 | margin: 0; 56 | } 57 | } 58 | 59 | // Make cards selectable 60 | #theme .card { 61 | cursor: pointer; 62 | user-select: none; 63 | margin-bottom: 2rem; 64 | } 65 | -------------------------------------------------------------------------------- /source/styles/tags.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "partials/box"; 3 | @import "partials/card_cover"; 4 | 5 | // Make cards selectable 6 | .card { 7 | cursor: pointer; 8 | user-select: none; 9 | margin-bottom: 2rem; 10 | 11 | .badge { 12 | margin-left: .5rem; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /source/styles/theme_toolbar.scss: -------------------------------------------------------------------------------- 1 | @import 'partials/variables'; 2 | 3 | $pl-toolbar-image-size: 28px; 4 | $pl-toolbar-padding: 8px; 5 | 6 | // Basic reset for toolbar 7 | .pl-toolbar, 8 | .pl-toolbar * { 9 | background: none; 10 | border: none; 11 | box-sizing: border-box; 12 | padding: 0; 13 | margin: 0; 14 | transition: none; 15 | } 16 | 17 | // Toolbar 18 | .pl-toolbar { 19 | position: fixed; 20 | z-index: 9999; 21 | bottom: 24px; 22 | left: 24px; 23 | background: $postleaf-black; 24 | border-radius: $border-radius; 25 | color: white; 26 | } 27 | 28 | // Toolbar items 29 | .pl-item { 30 | color: white; 31 | text-decoration: none; 32 | text-align: center; 33 | float: left; 34 | display: inline-block; 35 | cursor: pointer; 36 | transition: .1s transform; 37 | 38 | &:hover { 39 | transform: scale(1.1); 40 | } 41 | 42 | img { 43 | width: auto; 44 | height: $pl-toolbar-image-size + ($pl-toolbar-padding * 2); 45 | vertical-align: middle; 46 | padding: $pl-toolbar-padding; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /source/styles/users.scss: -------------------------------------------------------------------------------- 1 | @import "partials/variables"; 2 | @import "partials/box"; 3 | @import "partials/card_cover"; 4 | 5 | // Make cards selectable 6 | .card { 7 | cursor: pointer; 8 | user-select: none; 9 | margin-bottom: 2rem; 10 | 11 | .badge { 12 | margin-left: .5rem; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /source/styles/zen_mode.scss: -------------------------------------------------------------------------------- 1 | @import 'partials/variables'; 2 | @import 'bootstrap/scss/normalize'; 3 | @import 'bootstrap/scss/reboot'; 4 | @import 'bootstrap/scss/type'; 5 | @import 'bootstrap/scss/code'; 6 | 7 | body { 8 | // Day theme 9 | font-size: 20px; 10 | font-family: Lora, serif; 11 | font-weight: 300; 12 | background-color: white; 13 | color: rgba(black, .75); 14 | padding: 4rem 2rem; 15 | line-height: 1.8; 16 | transition: .4s background-color, .4s color; 17 | 18 | // Night theme 19 | &[data-theme="night"] { 20 | background-color: darken($postleaf-black, 2.5%); 21 | color: rgba(white, .75); 22 | } 23 | } 24 | 25 | // Hide outlines 26 | body [data-postleaf-region] { 27 | outline: none; 28 | } 29 | 30 | @media screen and (max-width: 600px) { 31 | body { 32 | font-size: 18px; 33 | padding: 2rem 1rem; 34 | } 35 | } 36 | 37 | .container { 38 | max-width: 40rem; 39 | margin: 0 auto; 40 | } 41 | 42 | .title { 43 | font-family: Lato, sans-serif; 44 | font-size: 3rem; 45 | margin: 0 0 3rem 0; 46 | } 47 | 48 | a, 49 | a:hover { 50 | color: inherit; 51 | text-decoration: underline; 52 | } 53 | 54 | em { 55 | font-style: italic; 56 | } 57 | 58 | strong { 59 | font-weight: 700; 60 | } 61 | 62 | h1, 63 | h2, 64 | h3, 65 | h4, 66 | h5, 67 | h6 { 68 | font-weight: 700; 69 | line-height: 1.2; 70 | } 71 | 72 | h2:not(:first-child), 73 | h3:not(:first-child), 74 | h4:not(:first-child) { 75 | margin-top: 3rem; 76 | } 77 | 78 | hr { 79 | border-top: solid .25rem $body-bg; 80 | margin: 4rem 0; 81 | } 82 | 83 | pre { 84 | background-color: $code-bg; 85 | color: $code-color; 86 | border-radius: $border-radius; 87 | padding: 1.5rem; 88 | } 89 | 90 | // Images 91 | img { 92 | max-width: 100%; 93 | } 94 | 95 | figure.image { 96 | display: block; 97 | } 98 | 99 | figure.image img { 100 | width: 100%; 101 | display: block; 102 | } 103 | 104 | // Full-width images 105 | figure.image:not(.align-left):not(.align-right):not(.align-center) { 106 | max-width: none; 107 | width: calc(100% + 4rem); 108 | margin-left: -2rem; 109 | } 110 | 111 | // Left-aligned images 112 | figure.image.align-left { 113 | width: 50%; 114 | float: left; 115 | margin-right: 1rem; 116 | margin-left: -2rem; 117 | } 118 | 119 | // Right-aligned images 120 | figure.image.align-right { 121 | width: 50%; 122 | float: right; 123 | margin-left: 1rem; 124 | margin-right: -2rem; 125 | } 126 | 127 | // Center-aligned images 128 | figure.image.align-center { 129 | width: 75%; 130 | margin-left: auto; 131 | margin-right: auto; 132 | } 133 | 134 | figure.image figcaption { 135 | font-family: Lato, sans-serif; 136 | font-size: 1.1rem; 137 | font-weight: 300; 138 | text-align: center; 139 | color: #999; 140 | margin-top: .25rem; 141 | } 142 | 143 | // Responsive embeds 144 | [data-embed] iframe { 145 | width: 100%; 146 | } 147 | 148 | // YouTube and Vimeo videos 149 | [data-embed-provider="Vimeo"], 150 | [data-embed-provider="YouTube"] { 151 | position: relative; 152 | padding-bottom: 56.25%; 153 | height: 0; 154 | overflow: hidden; 155 | width: 100%; 156 | margin-bottom: 1rem; 157 | } 158 | 159 | [data-embed-provider="Vimeo"] iframe, 160 | [data-embed-provider="YouTube"] iframe { 161 | position: absolute; 162 | top: 0; 163 | left: 0; 164 | width: 100%; 165 | height: 100%; 166 | } 167 | -------------------------------------------------------------------------------- /source/views/admin/install.dust: -------------------------------------------------------------------------------- 1 | {>"admin/layout"/} 2 | 3 | {! No nav for this page !} 4 | { 9 | {! Logo !} 10 | 11 | 12 |

13 | {@i18n term="lets_create_your_account"/} 14 |

15 | 16 | {! Install form !} 17 |
25 | {! Name !} 26 |
27 | 28 | 29 |
30 | 31 | {! Email !} 32 |
33 | 34 | 35 |
36 | 37 | {! Username !} 38 |
39 | 40 | 41 |

42 | {@i18n term="usernames_must_be_lowercase_and_can_only_contain"/} 43 |

44 |
45 | 46 | {! Password !} 47 |
48 | 49 |
50 | 51 | 52 | 55 | 56 |
57 |

58 | {@i18n term="passwords_need_to_be_at_least_eight_characters_long"/} 59 |

60 |
61 | 62 | {! Create my account !} 63 |
64 | 67 |
68 |
69 | 70 | {/body} 71 | 72 | {! No panels for this page !} 73 | { 2 | 3 | 4 | {meta.title} 5 | 6 | 7 | 8 | {! Meta description !} 9 | {?meta.description} 10 | 11 | {/meta.description} 12 | 13 | {! Robots !} 14 | {?meta.noIndex} 15 | 16 | {/meta.noIndex} 17 | 18 | 19 | 20 | 21 | {! Styles !} 22 | {#styles} 23 | 24 | {/styles} 25 | 26 | 27 | 28 | {! Default nav !} 29 | {+nav} 30 | {>"admin/partials/admin_menu"/} 31 | {/nav} 32 | 33 | {! Header !} 34 | {+header}{/header} 35 | 36 | {! Body !} 37 | {+body}{/body} 38 | 39 | {! Panels !} 40 | {+panels} 41 | {! Shortcuts !} 42 | {>"admin/partials/shortcuts"/} 43 | 44 | {! Locater !} 45 | {>"admin/partials/locater"/} 46 | 47 | {! File manager !} 48 | {>"admin/partials/file_manager"/} 49 | {/panels} 50 | 51 | 52 | {! Scripts !} 53 | {#scripts} 54 | 55 | {/scripts} 56 | 57 | 58 | -------------------------------------------------------------------------------- /source/views/admin/login.dust: -------------------------------------------------------------------------------- 1 | {>"admin/layout"/} 2 | 3 | {! No nav for this page !} 4 | { 9 | {! Logo !} 10 | 11 | 12 | {! Login form !} 13 |
21 | {! Username !} 22 |
23 | 24 | 25 |
26 | 27 | {! Password !} 28 |
29 | 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 |
39 | 40 | {! Login button !} 41 |
42 | 43 |
44 |
45 | 46 | {! Site meta !} 47 |

48 | {Settings.title} 49 |

50 | 51 | {/body} 52 | 53 | {! No panels for this page !} 54 | {"admin/layout"/} 2 | 3 | {! Header !} 4 | { 6 |
7 |

{meta.title}

8 |
9 |
10 | 11 | 12 | 13 |
14 | 15 | {/header} 16 | 17 | {! Body !} 18 | { 20 | 21 | {! Navigation items !} 22 | 66 | 67 | 68 | {/body} 69 | -------------------------------------------------------------------------------- /source/views/admin/partials/embed_panel.dust: -------------------------------------------------------------------------------- 1 | {! Embed panel !} 2 |
3 |
4 | {! Close button !} 5 | 8 | 9 | {! Panel header !} 10 |
11 |

{@i18n term="embed"/}

12 |
13 | 14 | {! Panel body !} 15 |
16 | {! Code !} 17 |
18 | 19 | {@i18n term="html"/} 20 | 21 | {@i18n term="paste_embed_code_or_a_url_here"/} 22 |
23 |
24 | 25 | {! Panel footer !} 26 |
27 | {! Delete !} 28 | 29 | 30 | {! Insert / cancel !} 31 | 32 | 33 |
34 |
35 |
36 | -------------------------------------------------------------------------------- /source/views/admin/partials/file_manager.dust: -------------------------------------------------------------------------------- 1 | 2 | 81 | -------------------------------------------------------------------------------- /source/views/admin/partials/file_manager_items.dust: -------------------------------------------------------------------------------- 1 | {#uploads} 2 | 3 |
22 |
23 |
24 | {! Icon !} 25 | 26 | {! Name !} 27 |
28 | {filename} 29 |
30 | {! Size !} 31 |
32 | {@formatBytes bytes=size/} 33 |
34 |
35 |
36 |
37 | 38 | {/uploads} 39 | -------------------------------------------------------------------------------- /source/views/admin/partials/link_panel.dust: -------------------------------------------------------------------------------- 1 | {! Link panel !} 2 | 62 | -------------------------------------------------------------------------------- /source/views/admin/partials/locater.dust: -------------------------------------------------------------------------------- 1 | {?User} 2 | 3 | 12 | {/User} 13 | -------------------------------------------------------------------------------- /source/views/admin/partials/locater_results.dust: -------------------------------------------------------------------------------- 1 | {#results} 2 | 3 | 4 | {! Image or icon !} 5 | {?image} 6 | {description} 7 | {:else} 8 | 9 | {/image} 10 | 11 | {! Title !} 12 |
13 | {title} 14 |
15 | 16 | {! Description !} 17 | {?description} 18 |
19 | {description} 20 |
21 | {/description} 22 |
23 | {/results} 24 | -------------------------------------------------------------------------------- /source/views/admin/partials/nav_item.dust: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /source/views/admin/partials/post_items.dust: -------------------------------------------------------------------------------- 1 | {#posts} 2 |
15 | {! Title !} 16 |

17 | {title} 18 |

19 | 20 | {! Details !} 21 |
22 | {! Avatar !} 23 | {?author.avatar} 24 | {author.name} 25 | {/author.avatar} 26 | 27 | {! Date !} 28 | 29 | {@date date=publishedAt relative="true"/} 30 | 31 | 32 | {! Post meta !} 33 |
34 | {! No index !} 35 | {?isPage} 36 | {@htmlPostBadge type="page"/} 37 | {/isPage} 38 | 39 | {! Featured !} 40 | {?isFeatured} 41 | {@htmlPostBadge type="featured"/} 42 | {/isFeatured} 43 | 44 | {! Sticky !} 45 | {?isSticky} 46 | {@htmlPostBadge type="sticky"/} 47 | {/isSticky} 48 | 49 | {! Status !} 50 | {@select key=status} 51 | {! Live / scheduled !} 52 | {@eq value="published"} 53 | {@postIsPublic} 54 | {@htmlPostBadge type="live"/} 55 | {:else} 56 | {@htmlPostBadge type="scheduled"/} 57 | {/postIsPublic} 58 | {/eq} 59 | 60 | {! Draft !} 61 | {@eq value="draft"} 62 | {@htmlPostBadge type="draft"/} 63 | {/eq} 64 | 65 | {! Pending !} 66 | {@eq value="pending"} 67 | {@htmlPostBadge type="pending"/} 68 | {/eq} 69 | 70 | {! Rejected !} 71 | {@eq value="rejected"} 72 | {@htmlPostBadge type="rejected"/} 73 | {/eq} 74 | {/select} 75 |
76 |
77 |
78 | {/posts} 79 | -------------------------------------------------------------------------------- /source/views/admin/partials/revisions_table.dust: -------------------------------------------------------------------------------- 1 | {! Revisions table !} 2 | 3 | 4 | {#revisions} 5 | 6 | 25 | 59 | 60 | {/revisions} 61 | 62 |
7 | {! Date !} 8 |
9 | {@date date=createdAt format="LLL"/} 10 | {@date date=createdAt format="LL"/} 11 |
12 | 13 | {! Author !} 14 |
15 | {?author.avatar} 16 | {author.name} 21 | {/author.avatar} 22 | {author.name} 23 |
24 |
26 |
27 | {! Open !} 28 | 36 | 37 | {! Revert !} 38 | 46 | 47 | {! Delete !} 48 | 57 |
58 |
63 | 64 | {! No revisions !} 65 |
66 |
67 |
68 | 69 |
70 | {@i18n term="no_revisions"/} 71 |
72 |
73 | -------------------------------------------------------------------------------- /source/views/admin/partials/shortcuts.dust: -------------------------------------------------------------------------------- 1 | 2 | 102 | -------------------------------------------------------------------------------- /source/views/admin/partials/tag_cards.dust: -------------------------------------------------------------------------------- 1 | {#tags} 2 |
3 |
10 |
11 |
15 |
16 |
17 |
18 | 19 | {@postCount tag=slug/} 20 | 21 |

{name}

22 |

23 | {@truncateWords text="{description|markdownToText}" chars="50"/} 24 |

25 |
26 |
27 |
28 | {/tags} 29 | -------------------------------------------------------------------------------- /source/views/admin/partials/theme_toolbar.dust: -------------------------------------------------------------------------------- 1 |
2 | {! Dashboard !} 3 | 4 | 5 | 6 | 7 | {! New post!} 8 | 9 | 10 | 11 | 12 | {! Only show edit buttons to owners/admins/editors !} 13 | {@in key=User.role value="owner,admin,editor"} 14 | {! Edit post !} 15 | {?post} 16 | 17 | 18 | 19 | {/post} 20 | 21 | {! Edit tag !} 22 | {?tag} 23 | 24 | 25 | 26 | {/tag} 27 | 28 | {! Edit author !} 29 | {?author} 30 | 31 | 32 | 33 | {/author} 34 | {/in} 35 |
36 | -------------------------------------------------------------------------------- /source/views/admin/partials/user_cards.dust: -------------------------------------------------------------------------------- 1 | {#users} 2 |
3 |
17 |
18 |
22 |
23 | {?avatar} 24 |
25 | {name} 26 |
27 | {/avatar} 28 |
29 |
30 | 31 | {@postCount author=username/} 32 | 33 |

{name}

34 |

35 | {@select key=role} 36 | {@eq value="owner"}{@i18n term="owner"/}{/eq} 37 | {@eq value="admin"}{@i18n term="administrator"/}{/eq} 38 | {@eq value="editor"}{@i18n term="editor"/}{/eq} 39 | {@eq value="contributor"}{@i18n term="contributor"/}{/eq} 40 | {/select} 41 |

42 |
43 |
44 |
45 | {/users} 46 | -------------------------------------------------------------------------------- /source/views/admin/quick_post.dust: -------------------------------------------------------------------------------- 1 | {>"admin/layout"/} 2 | 3 | {! No header for this page !} 4 | { 9 | 10 |
11 |
12 |
19 |
20 |
21 | {! Title !} 22 |
23 | 32 |
33 | 34 | {! Template !} 35 |
36 | {?templates} 37 | 45 | {/templates} 46 |
47 |
48 |
49 | 50 | {! Content !} 51 |
52 | 59 |
60 | 61 | 72 | 73 | {! Mobile tip !} 74 |

75 | {@i18n term="post_things_faster_by_adding_this_page_to_your_device"/} 76 |

77 | 78 | {! Hidden fields !} 79 | 80 | 81 | 82 | 83 | 84 |
85 |
86 |
87 | 88 | 89 | {/body} 90 | -------------------------------------------------------------------------------- /source/views/admin/recover_password.dust: -------------------------------------------------------------------------------- 1 | {>"admin/layout"/} 2 | 3 | {! No nav for this page !} 4 | { 9 | {! Logo !} 10 | 11 | 12 | {! Recover form !} 13 |
21 | {! Username !} 22 |
23 | 24 | 25 |
26 | 27 | {! Submit button !} 28 |
29 | 30 | {@i18n term="cancel"/} 31 |
32 |
33 | 34 | {! Site meta !} 35 |

36 | {Settings.title} 37 |

38 | 39 | {/body} 40 | 41 | {! No panels for this page !} 42 | {"admin/layout"/} 2 | 3 | {! No nav for this page !} 4 | { 9 | {! Logo !} 10 | 11 | 12 | {! Reset form !} 13 |
21 | {! New password !} 22 |
23 | 24 |
25 | 26 | 27 | 30 | 31 |
32 |
33 | 34 | {! Submit button !} 35 |
36 | 37 | 38 | 39 | {@i18n term="cancel"/} 40 |
41 |
42 | 43 | {! Site meta !} 44 |

45 | {Settings.title} 46 |

47 | 48 | {/body} 49 | 50 | {! No panels for this page !} 51 | { 2 | 3 | 4 | {! Channel info !} 5 | {Settings.title} 6 | 7 | http://example.com/ 8 | {Settings.tagline} 9 | {Settings.language} 10 | {@date format="ddd, DD MMM YYYY HH:mm:ss ZZ"/} 11 | {@date format="ddd, DD MMM YYYY HH:mm:ss ZZ"/} 12 | 13 | {! Loop through post items !} 14 | {#posts} 15 | 16 | {title} 17 | {@url type="post" slug=slug absolute="true"/} 18 | {@url type="post" slug=slug absolute="true"/} 19 | {@date date=publishedAt format="ddd, DD MMM YYYY HH:mm:ss ZZ"/} 20 | 23 | 24 | {/posts} 25 | 26 | 27 | -------------------------------------------------------------------------------- /source/views/admin/tags.dust: -------------------------------------------------------------------------------- 1 | {>"admin/layout"/} 2 | 3 | {! Header !} 4 | { 6 |
7 | 15 |
16 |
17 |
18 | {! Open !} 19 | 28 | 29 | {! Edit !} 30 | 39 | 40 | {! Delete !} 41 | 51 |
52 | 53 | {! New tag !} 54 | 55 | {@i18n term="new_tag"/} 56 | 57 |
58 | 59 | {/header} 60 | 61 | {! Body !} 62 | { 64 | 65 | {! Tags !} 66 |
67 | {>"admin/partials/tag_cards"/} 68 |
69 | 70 | {! Empty !} 71 | 76 | 77 | 78 | {/body} 79 | -------------------------------------------------------------------------------- /source/views/admin/users.dust: -------------------------------------------------------------------------------- 1 | {>"admin/layout"/} 2 | 3 | {! Header !} 4 | { 6 |
7 | 15 |
16 |
17 |
18 | {! Open !} 19 | 28 | 29 | {! Edit !} 30 | 39 | 40 | {! Delete !} 41 | 51 |
52 | 53 | {! New user !} 54 | 55 | {@i18n term="new_user"/} 56 | 57 |
58 | 59 | {/header} 60 | 61 | {! Body !} 62 | { 64 | 65 | {! Users !} 66 |
67 | {>"admin/partials/user_cards"/} 68 |
69 | 70 | {! Empty !} 71 | 76 | 77 | 78 | {/body} 79 | -------------------------------------------------------------------------------- /source/views/application_error.dust: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {title} 5 | 6 | 7 | 8 | 9 | 50 | 51 | 52 |
53 |
54 | Logo 55 |

{title}

56 |

{message}

57 |
58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /source/views/not_allowed.dust: -------------------------------------------------------------------------------- 1 | {>"application_error"/} 2 | -------------------------------------------------------------------------------- /source/views/not_found.dust: -------------------------------------------------------------------------------- 1 | {>"application_error"/} 2 | -------------------------------------------------------------------------------- /source/views/robots.dust: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: {@url type="admin"/} 3 | Sitemap: {@url path="sitemap.xml" absolute="true"/} 4 | -------------------------------------------------------------------------------- /source/views/sitemap.dust: -------------------------------------------------------------------------------- 1 | 2 | 3 | {! Homepage !} 4 | {#homepage} 5 | 6 | {@url absolute="true"/} 7 | {@date date=updatedAt format="YYYY-MM-DD[T]HH:mm:ss[Z]" timeZone="utc"/} 8 | weekly 9 | 1.0 10 | 11 | {/homepage} 12 | 13 | {! Pages !} 14 | {#pages} 15 | 16 | {@url type="post" slug=slug absolute="true"/} 17 | {@date date=updatedAt format="YYYY-MM-DD[T]HH:mm:ss[Z]" timeZone="utc"/} 18 | weekly 19 | 0.8 20 | 21 | {/pages} 22 | 23 | {! Posts !} 24 | {#posts} 25 | 26 | {@url type="post" slug=slug absolute="true"/} 27 | {@date date=updatedAt format="YYYY-MM-DD[T]HH:mm:ss[Z]" timeZone="utc"/} 28 | weekly 29 | 0.8 30 | 31 | {/posts} 32 | 33 | {! Authors !} 34 | {#authors} 35 | 36 | {@url type="author" username=username absolute="true"/} 37 | {@date date=updatedAt format="YYYY-MM-DD[T]HH:mm:ss[Z]" timeZone="utc"/} 38 | weekly 39 | 0.6 40 | 41 | {/authors} 42 | 43 | {! Tags !} 44 | {#tags} 45 | 46 | {@url type="tag" slug=slug absolute="true"/} 47 | {@date date=updatedAt format="YYYY-MM-DD[T]HH:mm:ss[Z]" timeZone="utc"/} 48 | weekly 49 | 0.6 50 | 51 | {/tags} 52 | 53 | -------------------------------------------------------------------------------- /source/views/zen_mode.dust: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {post.metaTitle} 6 | 7 | 8 | 9 | {@head/} 10 | 11 | 12 | {#post} 13 |
14 |

{@title editable="true"/}

15 |
{@content editable="true"/}
16 |
17 | {/post} 18 | 19 | {@foot/} 20 | 21 | 22 | --------------------------------------------------------------------------------