FEATURE: Support for publishing topics as pages (#9364)

If the feature is enabled, staff members can construct a URL and publish a
topic for others to browse without the regular Discourse chrome.

This is useful if you want to use Discourse like a CMS and publish
topics as articles, which can then be embedded into other systems.
This commit is contained in:
Robin Ward
2020-04-08 12:52:36 -04:00
committed by GitHub
parent b64b590cfb
commit e1f8014acd
38 changed files with 883 additions and 7 deletions

View File

@@ -0,0 +1,9 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
jsonMode: true,
pathFor(store, type, id) {
return `/pub/by-topic/${id}`;
}
});

View File

@@ -71,6 +71,27 @@ export default TextField.extend({
}
},
didReceiveAttrs() {
this._super(...arguments);
this._prevValue = this.value;
},
didUpdateAttrs() {
this._super(...arguments);
if (this._prevValue !== this.value) {
if (this.onChangeImmediate) {
next(() => this.onChangeImmediate(this.value));
}
if (this.onChange) {
debounce(this, this._debouncedChange, DEBOUNCE_MS);
}
}
},
_debouncedChange() {
next(() => this.onChange(this.value));
},
@discourseComputed("placeholderKey")
placeholder: {
get() {

View File

@@ -0,0 +1,121 @@
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { computed, action } from "@ember/object";
import { equal, not } from "@ember/object/computed";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
const States = {
initializing: "initializing",
checking: "checking",
valid: "valid",
invalid: "invalid",
saving: "saving",
new: "new",
existing: "existing",
unpublishing: "unpublishing",
unpublished: "unpublished"
};
const StateHelpers = {};
Object.keys(States).forEach(name => {
StateHelpers[name] = equal("state", name);
});
export default Controller.extend(ModalFunctionality, StateHelpers, {
state: null,
reason: null,
publishedPage: null,
disabled: not("valid"),
publishedPage: null,
showUrl: computed("state", function() {
return (
this.state === States.valid ||
this.state === States.saving ||
this.state === States.existing
);
}),
showUnpublish: computed("state", function() {
return this.state === States.existing || this.state === States.unpublishing;
}),
onShow() {
this.set("state", States.initializing);
this.store
.find("published_page", this.model.id)
.then(page => {
this.setProperties({ state: States.existing, publishedPage: page });
})
.catch(this.startNew);
},
@action
startCheckSlug() {
if (this.state === States.existing) {
return;
}
this.set("state", States.checking);
},
@action
checkSlug() {
if (this.state === States.existing) {
return;
}
return ajax("/pub/check-slug", {
data: { slug: this.publishedPage.slug }
}).then(result => {
if (result.valid_slug) {
this.set("state", States.valid);
} else {
this.setProperties({ state: States.invalid, reason: result.reason });
}
});
},
@action
unpublish() {
this.set("state", States.unpublishing);
return this.publishedPage
.destroyRecord()
.then(() => {
this.set("state", States.unpublished);
this.model.set("publishedPage", null);
})
.catch(result => {
this.set("state", States.existing);
popupAjaxError(result);
});
},
@action
publish() {
this.set("state", States.saving);
return this.publishedPage
.update({ slug: this.publishedPage.slug })
.then(() => {
this.set("state", States.existing);
this.model.set("publishedPage", this.publishedPage);
})
.catch(errResult => {
popupAjaxError(errResult);
this.set("state", States.existing);
});
},
@action
startNew() {
this.setProperties({
state: States.new,
publishedPage: this.store.createRecord("published_page", {
id: this.model.id,
slug: this.model.slug
})
});
this.checkSlug();
}
});

View File

@@ -76,7 +76,8 @@ export function transformBasicPost(post) {
replyCount: post.reply_count,
locked: post.locked,
userCustomFields: post.user_custom_fields,
readCount: post.readers_count
readCount: post.readers_count,
canPublishPage: false
};
_additionalAttributes.forEach(a => (postAtts[a] = post[a]));
@@ -118,6 +119,8 @@ export default function transformPost(
currentUser && (currentUser.id === post.user_id || currentUser.staff);
postAtts.canReplyAsNewTopic = details.can_reply_as_new_topic;
postAtts.canReviewTopic = !!details.can_review_topic;
postAtts.canPublishPage =
!!details.can_publish_page && post.post_number === 1;
postAtts.isWarning = topic.is_warning;
postAtts.links = post.get("internalLinks");
postAtts.replyDirectlyBelow =

View File

@@ -0,0 +1,8 @@
import RestModel from "discourse/models/rest";
import { computed } from "@ember/object";
export default RestModel.extend({
url: computed("slug", function() {
return `${Discourse.BaseUrl}/pub/${this.slug}`;
})
});

View File

@@ -545,6 +545,13 @@ const Topic = RestModel.extend({
this.details.updateFromJson(json.details);
keys.removeObjects(["details", "post_stream"]);
if (json.published_page) {
this.set(
"publishedPage",
this.store.createRecord("published-page", json.published_page)
);
}
}
keys.forEach(key => this.set(key, json[key]));
},

View File

@@ -89,6 +89,14 @@ const TopicRoute = DiscourseRoute.extend({
controller.setProperties({ flagTopic: true });
},
showPagePublish() {
const model = this.modelFor("topic");
showModal("publish-page", {
model,
title: "topic.publish_page.title"
});
},
showTopicStatusUpdate() {
const model = this.modelFor("topic");

View File

@@ -0,0 +1,62 @@
{{#d-modal-body}}
{{#if unpublished}}
<p>{{i18n "topic.publish_page.unpublished"}}</p>
{{else}}
{{#conditional-loading-spinner condition=initializing}}
<p class="publish-description">{{i18n "topic.publish_page.description"}}</p>
<form>
<label>{{i18n "topic.publish_page.slug"}}</label>
{{text-field value=publishedPage.slug onChange=(action "checkSlug") onChangeImmediate=(action "startCheckSlug") disabled=existing class="publish-slug"}}
</form>
<div class="publish-url">
{{conditional-loading-spinner condition=checking}}
{{#if existing}}
<div class='current-url'>
{{i18n "topic.publish_page.publish_url"}}
<div>
<a href={{publishedPage.url}} target="_blank" rel="noopener">{{publishedPage.url}}</a>
</div>
</div>
{{else}}
{{#if showUrl}}
<div class="valid-slug">
{{i18n "topic.publish_page.preview_url"}}
<div class='example-url'>{{publishedPage.url}}</div>
</div>
{{/if}}
{{#if invalid}}
{{i18n "topic.publish_page.invalid_slug"}} <span class="invalid-slug">{{reason}}.</span>
{{/if}}
{{/if}}
</div>
{{/conditional-loading-spinner}}
{{/if}}
{{/d-modal-body}}
<div class="modal-footer">
{{#if showUnpublish}}
{{d-button icon="times" label="close" action=(action "closeModal")}}
{{d-button
label="topic.publish_page.unpublish"
icon="trash"
class="btn-danger"
isLoading=unpublishing
action=(action "unpublish") }}
{{else if unpublished}}
{{d-button label="topic.publish_page.publishing_settings" action=(action "startNew")}}
{{else}}
{{d-button
label="topic.publish_page.publish"
class="btn-primary publish-page"
icon="file"
disabled=disabled
isLoading=saving
action=(action "publish") }}
{{/if}}
</div>

View File

@@ -85,6 +85,25 @@
{{topic-category topic=model class="topic-category"}}
{{/if}}
{{/topic-title}}
{{#if model.publishedPage}}
<div class='published-page'>
<div class="details">
{{i18n "topic.publish_page.topic_published"}}
<div>
<a href={{model.publishedPage.url}} target="_blank" rel="noopener">{{model.publishedPage.url}}</a>
</div>
</div>
<div class="controls">
{{d-button
icon="file"
label="topic.publish_page.publishing_settings"
action=(route-action "showPagePublish")
}}
</div>
</div>
{{/if}}
{{/if}}
<div class="container posts">
@@ -230,7 +249,8 @@
selectBelow=(action "selectBelow")
fillGapBefore=(action "fillGapBefore")
fillGapAfter=(action "fillGapAfter")
showInvite=(route-action "showInvite")}}
showInvite=(route-action "showInvite")
showPagePublish=(route-action "showPagePublish")}}
{{/unless}}
{{conditional-loading-spinner condition=model.postStream.loadingBelow}}

View File

@@ -120,6 +120,15 @@ export function buildManageButtons(attrs, currentUser, siteSettings) {
}
}
if (attrs.canPublishPage) {
contents.push({
icon: "file",
label: "post.controls.publish_page",
action: "showPagePublish",
className: "btn-default publish-page"
});
}
if (attrs.canManage) {
contents.push({
icon: "cog",

View File

@@ -684,6 +684,27 @@
}
}
.publish-page-modal .modal-body {
p.publish-description {
margin-top: 0;
}
input.publish-slug {
width: 100%;
}
.publish-url {
margin-bottom: 1em;
.example-url,
.invalid-slug {
font-weight: bold;
}
}
.publish-slug:disabled {
cursor: not-allowed;
}
}
.modal:not(.has-tabs) {
.modal-tab {
position: absolute;

View File

@@ -295,3 +295,14 @@ a.topic-featured-link {
}
}
}
.published-page {
display: flex;
justify-content: space-between;
padding-bottom: 1em;
max-width: calc(
#{$topic-body-width} + #{$topic-avatar-width} + #{$topic-body-width-padding *
2}
);
align-items: center;
}

View File

@@ -0,0 +1,32 @@
@import "common";
body {
background-color: $secondary;
color: $primary;
}
.published-page {
margin: 2em auto;
max-width: 800px;
h1 {
color: $header_primary;
}
.published-page-author {
margin-top: 1em;
margin-bottom: 2em;
display: flex;
.avatar {
margin-right: 1em;
}
.topic-created-at {
color: $primary-medium;
}
}
.published-page-body {
font-size: 1.25em;
}
}