├── .gitignore ├── README.md ├── assets ├── javascripts │ ├── discourse │ │ ├── components │ │ │ └── blog-post-header.js.es6 │ │ ├── connectors │ │ │ └── topic-above-post-stream │ │ │ │ └── blog-post-header-image.hbs │ │ ├── initializers │ │ │ └── extend-for-discourse-blog-post.js.es6 │ │ └── templates │ │ │ └── components │ │ │ └── blog-post-header.hbs │ └── lib │ │ └── discourse-markdown │ │ └── blog-post-whitelist.js.es6 └── stylesheets │ └── blog-post-styles.scss ├── config ├── locales │ ├── client.en.yml │ └── server.en.yml └── settings.yml └── plugin.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Note:** I'm no longer supporting this plugin. I think the [WP Discourse](https://github.com/discourse/wp-discourse) WordPress plugin is a better alternative for combining styled posts with a Discourse forum. If anyone would like to take over maintaining the Discourse Blog Post plugin, that would be great. 2 | 3 | ## Discourse Blog Post 4 | 5 | This plugin is for adding 'blog post' styles to the first post in a Discourse topic. If the post contains images, it places the first image 6 | in a full width header above the topic. It adds the css class `blog-post` to the topic's `body` tag, and the class `blog-post-content` to 7 | the cooked content of the post. 8 | 9 | The ability to create blog-posts on a forum is controlled by group membership. By default, only members 10 | of the `admins` and `moderators` groups are allowed to create blog-posts. To allow all users, trust level 11 | 1 and up, to be able to create blog posts, add the group 'trust_level_1' to the 'blog post allowed groups' setting. 12 | 13 | Blog posts must be enabled for a category before they can be created. This is done on the plugin's settings page. 14 | Enter the category names of the categories you want to use into the 'blog post allowed categories' setting. 15 | If a category is removed from the allowed categories list, blog posts that have already been created will remain. 16 | They can be converted to regular posts either by their creator or by a site admin. 17 | 18 | To create a blog-post in an enabled category, open the hidden ('...') post menu buttons underneath the first post 19 | in a topic and click on the 'book' icon. That will convert the post into a blog-post and turn the 'book' icon yellow. 20 | To convert a blog-post back into a regular post, click the 'book' icon again. 21 | 22 | After first creating a blog-post, you will need to either revisit or refresh the page for the header image to 23 | be hidden from the post content. Only the post creator will see the unhidden image. It's hidden from the post 24 | by adding the css class `blog-post-image` when the post content is rendered. 25 | 26 | The plugin adds some basic css rules for styling blog-posts. It allows you to use `` for 27 | creating large leading letters for paragraphs. 28 | 29 | To add further styling to blog posts on a forum, add styles to the `blog-post` class, for general page styles, 30 | or to the `blog-post-content` class for styling the content of the posts. For example, this will increase the font 31 | size to `20px` and set the `max-height` of the header image to `440px`. 32 | 33 | .blog-post-content { 34 | font-size: 20px; 35 | } 36 | 37 | .blog-post .blog-post-header-container { 38 | max-height: 440px; 39 | } 40 | 41 | The plugin's default styles can be disabled by deselecting the 'blog post use default styles' checkbox on the settings 42 | page. This could also be useful if you only wish to use the plugin's header-image function. 43 | 44 | ![alt tag](https://cloud.githubusercontent.com/assets/2975917/19752137/dfba541a-9baf-11e6-8b87-c55d6b6e4bc8.png) 45 | ![alt tag](https://cloud.githubusercontent.com/assets/2975917/19752147/f3783f12-9baf-11e6-9849-1d2450d6bef3.png) 46 | 47 | ### Installation 48 | 49 | Follow the [Install a Plugin](https://meta.discourse.org/t/install-a-plugin/19157) howto, using 50 | `git clone https://github.com/scossar/discourse-blog-post` as the plugin command. 51 | 52 | Once you've installed it, go to the plugin settings page to enable the plugin and configure which groups should be 53 | allowed to create blog posts and which categories they should be allowed to create them in. 54 | 55 | If you have any problems with this plugin, feel free to create an issue here. Any improvements to the plugin's css would be greatly appreciated. 56 | Pull requests are welcome. 57 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/blog-post-header.js.es6: -------------------------------------------------------------------------------- 1 | import {on} from 'ember-addons/ember-computed-decorators' 2 | import Scrolling from 'discourse/mixins/scrolling'; 3 | 4 | export default Ember.Component.extend(Scrolling, { 5 | tagName: 'div', 6 | classNames: 'discourse-blog-post-header', 7 | 8 | @on('didInsertElement', 'didReceiveAttrs') 9 | addBodyClass() { 10 | let bodyClasses = Discourse.SiteSettings.blog_post_use_default_styles ? 'blog-post blog-post-docked use-blog-post-styles' : 'blog-post blog-post-docked'; 11 | $('body').addClass(bodyClasses); 12 | 13 | this.bindScrolling({name: 'blog-post-header'}); 14 | }, 15 | 16 | @on('willDestroyElement') 17 | removeBodyClass() { 18 | $('body').removeClass('blog-post use-blog-post-styles blog-post-docked'); 19 | }, 20 | 21 | scrolled() { 22 | $('body').removeClass('blog-post-docked'); 23 | this.unbindScrolling('blog-post-header'); 24 | } 25 | }); -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/topic-above-post-stream/blog-post-header-image.hbs: -------------------------------------------------------------------------------- 1 | {{#if model.has_blog_post}} 2 | {{blog-post-header src=model.image_url}} 3 | {{/if}} -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/extend-for-discourse-blog-post.js.es6: -------------------------------------------------------------------------------- 1 | import {withPluginApi} from 'discourse/lib/plugin-api'; 2 | import {ajax} from 'discourse/lib/ajax'; 3 | import {popupAjaxError} from 'discourse/lib/ajax-error'; 4 | import TopicStatus from 'discourse/views/topic-status'; 5 | 6 | function markAsBlogPost(post) { 7 | const topic = post.topic; 8 | 9 | post.set('is_blog_post', true); 10 | topic.set('has_blog_post', true); 11 | 12 | ajax('/blog/mark_as_blog_post', { 13 | type: 'POST', 14 | data: {id: post.id} 15 | }).catch(popupAjaxError); 16 | } 17 | 18 | function unmarkAsBlogPost(post) { 19 | const topic = post.topic; 20 | 21 | post.set('is_blog_post', false); 22 | topic.set('has_blog_post', false); 23 | 24 | ajax('/blog/unmark_as_blog_post', { 25 | type: 'POST', 26 | data: {id: post.id} 27 | }).catch(popupAjaxError); 28 | } 29 | 30 | function addBlogImageClass($elem, helper) { 31 | if (helper) { 32 | const post = helper.getModel(); 33 | const isBlogPost = post.get('is_blog_post'); 34 | const imageUrl = post.get('image_url'); 35 | 36 | if (isBlogPost && imageUrl) { 37 | $elem.find('img').first().addClass('blog-post-image'); 38 | $elem.addClass('blog-post-content'); 39 | } else if (isBlogPost) { 40 | $elem.addClass('blog-post-content'); 41 | } 42 | } 43 | } 44 | 45 | function initializeWithApi(api) { 46 | api.includePostAttributes('is_blog_post', 'can_create_blog_post', 'allow_blog_posts_in_category', 'image_url'); 47 | 48 | api.addPostMenuButton('blogPost', attrs => { 49 | 50 | if (attrs.firstPost && attrs.can_create_blog_post) { 51 | if (attrs.is_blog_post) { 52 | 53 | return { 54 | action: 'unmarkAsBlogPost', 55 | icon: 'book', 56 | className: 'blog-post-icon', 57 | title: 'blog_post.convert_to_regular_post', 58 | position: 'second-last-hidden' 59 | } 60 | 61 | } else if (attrs.allow_blog_posts_in_category) { 62 | 63 | return { 64 | action: 'markAsBlogPost', 65 | icon: 'book', 66 | className: 'not-blog-post-icon', 67 | title: 'blog_post.convert_to_blog_post', 68 | position: 'second-last-hidden' 69 | } 70 | } 71 | } 72 | }); 73 | 74 | api.attachWidgetAction('post', 'markAsBlogPost', function () { 75 | const post = this.model; 76 | const current = post.get('topic.postStream.posts'); 77 | const topic = post.get('topic'); 78 | 79 | markAsBlogPost(post); 80 | 81 | current.forEach(p => this.appEvents.trigger('post-stream:refresh', {id: p.id})); 82 | }); 83 | 84 | api.attachWidgetAction('post', 'unmarkAsBlogPost', function () { 85 | const post = this.model; 86 | const current = post.get('topic.postStream.posts'); 87 | 88 | unmarkAsBlogPost(post); 89 | 90 | current.forEach(p => this.appEvents.trigger('post-stream:refresh', {id: p.id})); 91 | }); 92 | 93 | api.decorateCooked(addBlogImageClass); 94 | } 95 | 96 | export default { 97 | name: 'extend-for-discourse-blog-post', 98 | initialize() { 99 | 100 | TopicStatus.reopen({ 101 | statuses: function () { 102 | const results = this._super(); 103 | if (this.topic.has_blog_post) { 104 | results.push({ 105 | openTag: 'a href', 106 | closeTag: 'a', 107 | href: this.topic.get('url'), 108 | extraClasses: 'topic-status-blog-post', 109 | title: I18n.t('blog_post.has_blog_post'), 110 | icon: 'book', 111 | }); 112 | } 113 | return results; 114 | }.property() 115 | }); 116 | 117 | withPluginApi('0.1', initializeWithApi); 118 | } 119 | }; -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/components/blog-post-header.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if src}} 3 | 4 | {{/if}} 5 |
6 | -------------------------------------------------------------------------------- /assets/javascripts/lib/discourse-markdown/blog-post-whitelist.js.es6: -------------------------------------------------------------------------------- 1 | import { registerOption } from 'pretty-text/pretty-text'; 2 | 3 | registerOption((siteSettings, opts) => { 4 | opts.features['blog-post-whitelist'] = !!siteSettings.blog_post_enabled; 5 | }); 6 | 7 | export function setup(helper) { 8 | helper.whiteList([ 9 | 'span.large-letter' 10 | ]); 11 | } 12 | -------------------------------------------------------------------------------- /assets/stylesheets/blog-post-styles.scss: -------------------------------------------------------------------------------- 1 | button.widget-button.blog-post-icon i::before { 2 | color: gold; 3 | } 4 | 5 | .blog-post-header-container { 6 | overflow: hidden; 7 | } 8 | 9 | .blog-post-header-container img { 10 | width: 100%; 11 | height: auto; 12 | } 13 | 14 | .mobile-view .blog-post-header-container img { 15 | width: 130%; 16 | margin-left: 50%; 17 | -webkit-transform: translateX(-50%); 18 | transform: translateX(-50%); 19 | } 20 | 21 | .blog-post #main-outlet { 22 | padding-top: 62px; 23 | } 24 | 25 | .mobile-view .blog-post #main-outlet { 26 | padding-top: 60px; 27 | } 28 | 29 | .blog-post .blog-post-image { 30 | display: none; 31 | } 32 | 33 | // Mobile styles. 34 | .mobile-view .blog-post.use-blog-post-styles { 35 | #topic-title h1 { 36 | font-size: 2em; 37 | line-height: 1.2; 38 | margin-bottom: 4px; 39 | } 40 | 41 | // The blog post content. 42 | .blog-post-content { 43 | font-size: 1.15em; 44 | line-height: 1.3; 45 | } 46 | } 47 | 48 | // The default blog-post styles (used when the use_default_styles setting is enabled.) 49 | .blog-post.use-blog-post-styles { 50 | 51 | #topic-title h1 { 52 | font-size: 3em; 53 | } 54 | .title-wrapper .fa-pencil:before { 55 | font-size: 0.5em; 56 | } 57 | 58 | .topic-meta-data { 59 | font-size: small; 60 | } 61 | 62 | .topic-avatar { 63 | padding-top: 0; 64 | width: 45px; 65 | float: left; 66 | padding-right: 6px; 67 | } 68 | 69 | .topic-body { 70 | background-color: #f6f6f6; 71 | } 72 | 73 | .cooked { 74 | padding: 0.5em 1em; 75 | } 76 | 77 | .topic-avatar { 78 | border-top: none; 79 | } 80 | 81 | .topic-post { 82 | padding-bottom: 8px; 83 | } 84 | 85 | // The blog post (styles can be added to the blog-post content by targeting `.blog-post-content`.) 86 | .topic-post:first-child { 87 | font-size: 1.25em; 88 | line-height: 1.4; 89 | padding-bottom: 0; 90 | padding-top: 4px; 91 | 92 | .topic-avatar { 93 | border-top: 1px solid #e9e9e9; 94 | padding-top: 4px; 95 | } 96 | 97 | .topic-body { 98 | background-color: #fff; 99 | padding-top: 16px; 100 | } 101 | 102 | .cooked { 103 | padding: 0; 104 | } 105 | 106 | nav.post-controls .actions, 107 | .topic-map, 108 | .post-admin-menu { 109 | font-size: 14px; 110 | } 111 | 112 | .large-letter { 113 | // is a whitelisted tag 114 | font-size: 28px; 115 | line-height: 28px; 116 | letter-spacing: 1px; 117 | font-family: Georgia; 118 | } 119 | 120 | figure { 121 | margin: 0; 122 | } 123 | 124 | figcaption { 125 | margin-top: -16px; 126 | font-size: 0.8em; 127 | font-weight: bold; 128 | } 129 | 130 | blockquote { 131 | background: #f9f9f9; 132 | color: #777; 133 | border-left: 10px solid #ccc; 134 | padding: 0.5em 10px; 135 | quotes: "\201C" "\201D" "\2018" "\2019"; 136 | } 137 | blockquote:before { 138 | color: #ccc; 139 | content: open-quote; 140 | font-size: 4em; 141 | line-height: 0.1em; 142 | margin-right: 0.25em; 143 | vertical-align: -0.4em; 144 | } 145 | blockquote p { 146 | display: inline; 147 | } 148 | } 149 | } 150 | 151 | // Hide the timeline when first entering the topic. 152 | .blog-post.blog-post-docked .timeline-container.timeline-docked { 153 | display: none; 154 | } 155 | -------------------------------------------------------------------------------- /config/locales/client.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | js: 3 | blog_post: 4 | convert_to_blog_post: "Convert to blog post" 5 | convert_to_regular_post: "Convert to regular post" 6 | allow_blog_posts: "Allow blog posts for this categetory?" 7 | has_blog_post: "This topic is a blog post" 8 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | site_settings: 3 | blog_post_enabled: "Enable blog posts?" 4 | blog_post_allowed_groups: "Groups that are allowed to create blog posts." 5 | blog_post_allowed_categories: "Which categories should blog posts be allowed in?" 6 | blog_post_use_default_styles: "Use the default plugin styles for styling blog posts?" 7 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | blog_post_enabled: 3 | default: false 4 | client: true 5 | blog_post_allowed_groups: 6 | type: list 7 | default: "admins|moderators" 8 | client: true 9 | blog_post_allowed_categories: 10 | type: list 11 | default: "" 12 | client: true 13 | blog_post_use_default_styles: 14 | default: true 15 | client: true 16 | -------------------------------------------------------------------------------- /plugin.rb: -------------------------------------------------------------------------------- 1 | # name: discourse-blog-post 2 | # about: Style a Discourse post as a blog post 3 | # version: 0.2.2 4 | # authors: scossar 5 | # url: https://github.com/scossar/discourse-blog-post 6 | 7 | enabled_site_setting :blog_post_enabled 8 | 9 | register_asset 'stylesheets/blog-post-styles.scss' 10 | 11 | PLUGIN_NAME = 'discourse_blog_post'.freeze 12 | 13 | after_initialize do 14 | 15 | module ::DiscourseBlogPost 16 | class Engine < ::Rails::Engine 17 | engine_name PLUGIN_NAME 18 | isolate_namespace DiscourseBlogPost 19 | end 20 | end 21 | 22 | require_dependency 'application_controller' 23 | class DiscourseBlogPost::BlogPostController < ::ApplicationController 24 | 25 | def mark_as_blog_post 26 | post = Post.find(params[:id].to_i) 27 | 28 | post.custom_fields['is_blog_post'] = 'true' 29 | post.topic.custom_fields['blog_post_id'] = post.id 30 | 31 | post.save! 32 | post.topic.save! 33 | 34 | render json: success_json 35 | end 36 | 37 | def unmark_as_blog_post 38 | post = Post.find(params[:id].to_i) 39 | 40 | post.custom_fields['is_blog_post'] = nil 41 | post.topic.custom_fields['blog_post_id'] = nil 42 | 43 | post.save! 44 | post.topic.save! 45 | 46 | render json: success_json 47 | end 48 | end 49 | 50 | DiscourseBlogPost::Engine.routes.draw do 51 | post 'mark_as_blog_post' => 'blog_post#mark_as_blog_post' 52 | post 'unmark_as_blog_post' => 'blog_post#unmark_as_blog_post' 53 | end 54 | 55 | Discourse::Application.routes.append do 56 | mount ::DiscourseBlogPost::Engine, at: 'blog' 57 | end 58 | 59 | TopicView.add_post_custom_fields_whitelister do |user| 60 | ['is_blog_post'] 61 | end 62 | 63 | require_dependency 'topic_view_serializer' 64 | class ::TopicViewSerializer 65 | attributes :has_blog_post, :image_url 66 | 67 | def image_url 68 | object.image_url 69 | end 70 | 71 | def has_blog_post 72 | blog_post_id ? true : false 73 | end 74 | 75 | def blog_post_id 76 | id = object.topic.custom_fields['blog_post_id'] 77 | 78 | id && id.to_i rescue nil 79 | end 80 | end 81 | 82 | require_dependency 'post_serializer' 83 | class ::PostSerializer 84 | attributes :is_blog_post, :can_create_blog_post, :allow_blog_posts_in_category, :image_url 85 | 86 | def image_url 87 | topic = (topic_view && topic_view.topic) || object.topic 88 | topic.image_url 89 | end 90 | 91 | def is_blog_post 92 | post_custom_fields['is_blog_post'] == 'true' 93 | end 94 | 95 | def can_create_blog_post 96 | allowed_groups = SiteSetting.blog_post_allowed_groups.split('|') 97 | current_user = scope.current_user.present? ? scope.current_user : nil 98 | topic = (topic_view && topic_view.topic) || object.topic 99 | 100 | unless current_user && (current_user.id == topic.user_id || current_user.admin) 101 | return false 102 | end 103 | 104 | current_user.groups.each do |group| 105 | return true if allowed_groups.include?(group.name) 106 | end 107 | 108 | false 109 | end 110 | 111 | def allow_blog_posts_in_category 112 | allowed_categories = SiteSetting.blog_post_allowed_categories.split('|') 113 | topic = (topic_view && topic_view.topic) || object.topic 114 | 115 | # Private messages and banners don't have a category. 116 | if topic.category_id 117 | allowed_categories.include? topic.category.name 118 | else 119 | false 120 | end 121 | end 122 | end 123 | 124 | require_dependency 'topic_list_item_serializer' 125 | class ::TopicListItemSerializer 126 | attributes :has_blog_post 127 | 128 | def has_blog_post 129 | object.custom_fields['blog_post_id'] ? true : false 130 | end 131 | end 132 | 133 | TopicList.preloaded_custom_fields << 'blog_post_id' if TopicList.respond_to? :preloaded_custom_fields 134 | end 135 | --------------------------------------------------------------------------------