mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
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:
@@ -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}`;
|
||||
}
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
121
app/assets/javascripts/discourse/controllers/publish-page.js
Normal file
121
app/assets/javascripts/discourse/controllers/publish-page.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}`;
|
||||
})
|
||||
});
|
||||
@@ -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]));
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
32
app/assets/stylesheets/publish.scss
Normal file
32
app/assets/stylesheets/publish.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user