diff --git a/extensions/publisher-command-center/app.py b/extensions/publisher-command-center/app.py index d78c05a7..cfd2cd2f 100644 --- a/extensions/publisher-command-center/app.py +++ b/extensions/publisher-command-center/app.py @@ -1,6 +1,6 @@ from http import client import asyncio -from fastapi import FastAPI, Header +from fastapi import FastAPI, Header, Body from fastapi.staticfiles import StaticFiles from posit import connect from posit.connect.errors import ClientError @@ -61,6 +61,29 @@ async def content( visitor = get_visitor_client(posit_connect_user_session_token) return visitor.content.get(content_id) +@app.patch("/api/content/{content_id}/lock") +async def lock_content( + content_id: str, + posit_connect_user_session_token: str = Header(None), +): + visitor = get_visitor_client(posit_connect_user_session_token) + content = visitor.content.get(content_id) + is_locked = content.locked + + content.update(locked=not is_locked) + return content + +@app.patch("/api/content/{content_id}/rename") +async def rename_content( + content_id: str, + title: str = Body(..., embed = True), + posit_connect_user_session_token: str = Header(None), +): + visitor = get_visitor_client(posit_connect_user_session_token) + content = visitor.content.get(content_id) + + content.update(title = title) + return content @app.get("/api/contents/{content_id}/processes") async def get_content_processes( diff --git a/extensions/publisher-command-center/scss/index.scss b/extensions/publisher-command-center/scss/index.scss index cca3d006..2cc39c78 100644 --- a/extensions/publisher-command-center/scss/index.scss +++ b/extensions/publisher-command-center/scss/index.scss @@ -39,6 +39,10 @@ $colors: ( color: $blue; } +.lock-loading { + color: $gray; +} + // Removes the white background of the modal .modal.show { background: none; diff --git a/extensions/publisher-command-center/src/components/ContentsComponent.js b/extensions/publisher-command-center/src/components/ContentsComponent.js index d803b8d1..d6541c75 100644 --- a/extensions/publisher-command-center/src/components/ContentsComponent.js +++ b/extensions/publisher-command-center/src/components/ContentsComponent.js @@ -2,7 +2,9 @@ import m from "mithril"; import { format } from "date-fns"; import Contents from "../models/Contents"; import Languages from "./Languages"; +import LockContentButton from "./LockContentButton"; import DeleteModal from "./DeleteModal"; +import RenameModal from "./RenameModal"; const ContentsComponent = { error: null, @@ -43,6 +45,8 @@ const ContentsComponent = { m("th", { scope: "col" }, "Date Added"), m("th", { scope: "col" }, ""), m("th", { scope: "col" }, ""), + m("th", { scope: "col" }, ""), + m("th", { scope: "col" }, ""), ]), ), m( @@ -72,6 +76,28 @@ const ContentsComponent = { "td", m("button", { class: "action-btn", + ariaLabel: `Rename ${title}`, + title: `Rename ${title}`, + "data-bs-toggle": "modal", + "data-bs-target": `#renameModal-${guid}`, + }, [ + m("i", { class: "fa-solid fa-pencil" }) + ]), + ), + m( + "td", + m(LockContentButton, { + contentId: guid, + contentTitle: title, + isLocked: content["locked"], + }), + ), + m( + "td", + m("button", { + class: "action-btn", + title: `Delete ${title}`, + ariaLabel: `Delete ${title}`, "data-bs-toggle": "modal", "data-bs-target": `#deleteModal-${guid}`, }, [ @@ -83,6 +109,8 @@ const ContentsComponent = { m("a", { class: "fa-solid fa-arrow-up-right-from-square", href: content["content_url"], + ariaLabel: `Open ${title} (opens in new tab)`, + title: `Open ${title}`, target: "_blank", onclick: (e) => e.stopPropagation(), }), @@ -91,6 +119,10 @@ const ContentsComponent = { contentId: guid, contentTitle: title, }), + m(RenameModal, { + contentId: guid, + contentTitle: title, + }), ], ); }), diff --git a/extensions/publisher-command-center/src/components/LockContentButton.js b/extensions/publisher-command-center/src/components/LockContentButton.js new file mode 100644 index 00000000..6422110b --- /dev/null +++ b/extensions/publisher-command-center/src/components/LockContentButton.js @@ -0,0 +1,52 @@ +import m from "mithril"; +import Contents from "../models/Contents"; + +const LockedContentButton = { + oninit: function () { + this.isLoading = false; + }, + + view: function(vnode) { + const labelMessage = vnode.attrs.isLocked ? + `Unlock ${vnode.attrs.contentTitle}` : + `Lock Content ${vnode.attrs.contentTitle}`; + + const iconClassName = () => { + if (this.isLoading) return "fa-spinner fa-spin lock-loading"; + + if (vnode.attrs.isLocked) { + return "fa-lock"; + } else { + return "fa-lock-open"; + } + }; + + return m("button", { + class: "action-btn", + ariaLabel: labelMessage, + title: labelMessage, + disabled: this.isLoading, + onclick: async () => { + if (this.isLoading) { return; } + + this.isLoading = true; + m.redraw(); + + try { + await Contents.lock(vnode.attrs.contentId) + } finally { + this.isLoading = false; + m.redraw(); + } + } + }, [ + m("i", { + class: `fa-solid ${iconClassName()}`, + + } + ) + ]) + } +}; + +export default LockedContentButton; diff --git a/extensions/publisher-command-center/src/components/RenameModal.js b/extensions/publisher-command-center/src/components/RenameModal.js new file mode 100644 index 00000000..fd0b159f --- /dev/null +++ b/extensions/publisher-command-center/src/components/RenameModal.js @@ -0,0 +1,93 @@ +import m from "mithril"; +import Contents from "../models/Contents"; +import { Modal } from "bootstrap"; + +const RenameModalForm = { + newName: "", + guid: "", + isValid: false, + onsubmit: function(e) { + if (RenameModalForm.isValid) { + e.preventDefault(); + Contents.rename(RenameModalForm.guid, RenameModalForm.newName); + + const modalEl = document.getElementById(`renameModal-${RenameModalForm.guid}`); + const modal = Modal.getInstance(modalEl); + modal.hide(); + + RenameModalForm.newName = ""; + } + }, + view: function(vnode) { + return m("form", { + onsubmit: (e) => { this.onsubmit(e); } + }, + [ + m("section", { class: "modal-body" }, [ + m("div", { class: "form-group" }, [ + m("label", { + for: "rename-content-input", + class: "mb-3", + }, "Enter new name for ", [ + m("span", { class: "fw-bold" }, `${vnode.attrs.contentTitle}`) + ]), + m("input", { + oninput: function(e) { + RenameModalForm.isValid = e.target.validity.valid; + RenameModalForm.guid = vnode.attrs.contentId; + RenameModalForm.newName = e.target.value; + }, + id: "rename-content-input", + type: "text", + class: "form-control", + required: true, + minlength: 3, + maxlength: 1024, + value: RenameModalForm.newName, + }), + ]) + ]), + m("div", { class: "modal-footer" }, [ + m( + "button", + { + type: "submit", + class: "btn btn-primary", + ariaLabel: "Rename Content", + }, + "Rename Content", + ), + ]), + ]) + }, +} + +const RenameModal = { + view: function(vnode) { + return m("div", { + class: "modal", + id: `renameModal-${vnode.attrs.contentId}`, + tabindex: "-1", + ariaHidden: true + }, [ + m("div", { class: "modal-dialog modal-dialog-centered" }, [ + m("div", { class: "modal-content" }, [ + m("div", { class: "modal-header"}, [ + m("h1", { class: "modal-title fs-6" }, "Rename Content"), + m("button", { + class: "btn-close", + ariaLabel: "Close modal", + "data-bs-dismiss": "modal" + }), + ]), + m(RenameModalForm, { + contentTitle: vnode.attrs.contentTitle, + contentId: vnode.attrs.contentId, + }) + ]), + ]), + ]) + }, +}; + +export default RenameModal; diff --git a/extensions/publisher-command-center/src/index.js b/extensions/publisher-command-center/src/index.js index f8fc06af..a0f5823c 100644 --- a/extensions/publisher-command-center/src/index.js +++ b/extensions/publisher-command-center/src/index.js @@ -1,6 +1,6 @@ import m from "mithril"; -import "bootstrap/dist/js/bootstrap.bundle.min.js"; +import * as bootstrap from "bootstrap"; import "@fortawesome/fontawesome-free/css/all.min.css"; import "../scss/index.scss"; diff --git a/extensions/publisher-command-center/src/models/Contents.js b/extensions/publisher-command-center/src/models/Contents.js index 64c3fb6d..2a5cb075 100644 --- a/extensions/publisher-command-center/src/models/Contents.js +++ b/extensions/publisher-command-center/src/models/Contents.js @@ -34,6 +34,29 @@ export default { this.data = this.data.filter((c) => c.guid !== guid); }, + lock: async function (guid) { + await m.request({ + method: "PATCH", + url: `api/content/${guid}/lock`, + }).then((response) => { + const targetContent = this.data.find((c) => c.guid === guid); + Object.assign(targetContent, response); + }); + }, + + rename: async function (guid, newName) { + await m.request({ + method: "PATCH", + url: `api/content/${guid}/rename`, + body: { + title: newName, + }, + }).then((response) => { + const targetContent = this.data.find((c) => c.guid === guid); + Object.assign(targetContent, response); + }); + }, + reset: function () { this.data = null; this._fetch = null;