From a148ababd69b02618e55240223c8e4caab5d79cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Trzci=C5=84ski?= Date: Tue, 28 Jan 2025 11:13:24 +0100 Subject: [PATCH] [#72191] simplify templates UI --- src/components/Modal.jsx | 57 -------- src/components/Settings.jsx | 15 +- src/components/TemplateManager.jsx | 213 ----------------------------- src/components/Templates.jsx | 140 +++++++++++++++++++ src/components/Topbar.jsx | 32 ++++- src/mystState.js | 3 +- 6 files changed, 171 insertions(+), 289 deletions(-) delete mode 100644 src/components/Modal.jsx delete mode 100644 src/components/TemplateManager.jsx create mode 100644 src/components/Templates.jsx diff --git a/src/components/Modal.jsx b/src/components/Modal.jsx deleted file mode 100644 index ae9b3c5..0000000 --- a/src/components/Modal.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import { styled } from "styled-components"; -import DefaultButton from "./Buttons"; - -const Container = styled.section` - z-index: 2; - display: flex; - flex-direction: column; - justify-content: center; - gap: 10px; - width: 450px; - padding: 20px; - right: 50%; - transform: translate(50%, 0); - top: 100%; - position: absolute; - background-color: var(--icon-bg); - border: 1px solid var(--icon-border); - border-radius: var(--border-radius); -`; - -const Heading = styled.h3` - color: var(--gray-900); -`; - -const ButtonContainer = styled.div` - display: flex; - align-items: center; - justify-content: space-between; -`; - -const ModalButton = styled(DefaultButton)` - padding: 0 10px; - margin-top: 0px; - - &:hover { - background-color: ${(props) => (props.$negative ? "var(--red-500)" : "var(--icon-main-active)")} !important; - border: 1px solid ${(props) => (props.$negative ? "var(--red-500)" : "var(--icon-main-active)")} !important; - } -`; - -const Modal = ({ changeDocumentTemplate, selectedTemplate, closeModal }) => { - return ( - - Are you sure you want to change the current template? - - changeDocumentTemplate(selectedTemplate)}> - ✓ Yes - - - x Cancel - - - - ); -}; - -export default Modal; diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index c7ad2b7..f717df3 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -4,17 +4,8 @@ import { MystState } from "../mystState"; import { Compartment } from "@codemirror/state"; import { useSignalEffect } from "@preact/signals"; -const Dropdown = styled.div` +const SettingsList = styled.div` width: 240px; - padding: 20px; - border-radius: var(--border-radius); - box-shadow: 4px 4px 10px var(--gray-600); - background: white; - display: none; - - &:hover { - display: block; - } h1 { font-size: 20px; @@ -106,7 +97,7 @@ const Settings = () => { }); return ( - +

Settings

    {userSettings.value.map((s) => ( @@ -116,7 +107,7 @@ const Settings = () => { ))}
-
+ ); }; diff --git a/src/components/TemplateManager.jsx b/src/components/TemplateManager.jsx deleted file mode 100644 index 9ce365f..0000000 --- a/src/components/TemplateManager.jsx +++ /dev/null @@ -1,213 +0,0 @@ -import { useState, useEffect, useContext } from "preact/hooks"; -import Modal from "./Modal"; -import DefaultButton from "./Buttons"; -import { css, styled } from "styled-components"; -import { TopbarButton } from "./Topbar"; -import { MystState } from "../mystState.js"; -import { useSignalEffect } from "@preact/signals"; - -const TemplateDropdownContent = styled.div` - display: none; - text-transform: uppercase; - white-space: nowrap; - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - color: var(--icon-color); - background-color: var(--icon-bg); - z-index: 20; - gap: 5px; - padding: 5px; -`; - -const TemplateIcon = () => ( - - - - - - -); - -const TemplateButton = styled(DefaultButton)` - color: ${(props) => (props.error ? "var(--red-500)" : "var(--icon-color)")}; - border: 1px solid ${(props) => (props.error ? "var(--red-500)" : "var(--icon-border)")}; - padding: 0 10px 0 10px; - margin-top: 0px; - - ${(props) => - props.error && - css` - cursor: default; - &:hover { - border: 1px solid var(--red-500) !important; - background-color: var(--icon-bg) !important; - } - `} -`; - -const Dropdown = styled.div` - position: relative; - &:hover { - div { - display: inline-flex; - flex-direction: column; - } - } -`; - -const ButtonTooltipFlex = styled.div` - display: flex; - flex-direction: row-reverse; - border: 1px solid var(--gray-900); - width: inherit; -`; - -const TemplatesList = styled.div` - position: absolute; - padding-top: 5px; -`; - -const validateTemplConfig = (templConfig) => { - const requiredFields = ["id", "templatetext"]; - for (const key in templConfig) { - for (let field of requiredFields) { - if (!templConfig[key][field]) templConfig[key].errorMessage = `Configuration of template ${key} is lacking '${field}'`; - } - - if (templConfig[key].errorMessage) console.error(templConfig[key].errorMessage); - } - - return templConfig; -}; - -const TemplateManager = () => { - const { options, editorView } = useContext(MystState); - const [template, setTemplate] = useState(""); - const [readyTemplates, setReadyTemplates] = useState({}); - const [selectedTemplate, setSelectedTemplate] = useState(null); - const [showModal, setShowModal] = useState(false); - const [showTooltip, setShowTooltip] = useState(false); - - const [generalErr, setGeneralErr] = useState({ - error: null, - message: null, - }); - - const checkResponseStatus = (response) => (response.ok ? response : Promise.reject(`Invalid HTTP response: ${response.status}`)); - - const changeDocumentTemplate = (template) => { - setTemplate(readyTemplates[template].templatetext); - editorView.value.dispatch({ changes: { from: 0, to: editorView.value.state.doc.length, insert: readyTemplates[template].templatetext } }); - setShowModal(false); - }; - - const getTemplateConfig = (url) => - fetch(url) - .then(checkResponseStatus) - .then((response) => - response.json().catch((error) => { - console.error(error); - setGeneralErr({ - error, - message: "Template configuration is not valid", - }); - }), - ) - .catch((error) => { - console.warn(error); - setGeneralErr({ error, message: "Template configuration not found" }); - }); - - const loadTemplateFromURL = (url) => - fetch(url) - .then(checkResponseStatus) - .then((response) => response.text()) - .catch((err) => { - console.error(err); - throw new Error("Could not fetch the template"); - }); - - const fillTemplatesWithFetchedData = async (templatesConfig) => { - if (!templatesConfig) { - return {}; - } - - for (const templateName in templatesConfig) { - const templateUrl = templatesConfig[templateName].templatetext; - await loadTemplateFromURL(templateUrl) - .then((templateText) => (templatesConfig[templateName].templatetext = templateText)) - .catch((err) => (templatesConfig[templateName].errorMessage ??= err.message)); - } - - return templatesConfig; - }; - - useSignalEffect(() => { - // reset all and fetch config - setTemplate(""); - setReadyTemplates({}); - setSelectedTemplate(null); - setShowModal(false); - setShowTooltip(false); - getTemplateConfig(options.templatelist.value).then(validateTemplConfig).then(fillTemplatesWithFetchedData).then(setReadyTemplates); - }); - - if (generalErr.error) { - return null; - } - - if (Object.keys(readyTemplates).length == 0) { - return ( - setShowTooltip(true)} - onMouseLeave={() => setShowTooltip(false)} - > - - - ); - } - - return ( - <> - {showModal && ( - { - setShowModal(false); - setSelectedTemplate(false); - }} - changeDocumentTemplate={changeDocumentTemplate} - /> - )} - - - - - - - {Object.keys(readyTemplates).map((key) => ( - { - if (readyTemplates[key].errorMessage) return; - setShowModal(true); - setSelectedTemplate(key); - }} - > - {readyTemplates[key].id} - - ))} - - - - - ); -}; - -export default TemplateManager; diff --git a/src/components/Templates.jsx b/src/components/Templates.jsx new file mode 100644 index 0000000..87c20fb --- /dev/null +++ b/src/components/Templates.jsx @@ -0,0 +1,140 @@ +import { useContext, useRef } from "preact/hooks"; +import { css, styled } from "styled-components"; +import { MystState } from "../mystState.js"; +import DefaultButton from "./Buttons.js"; +import { useSignal, useSignalEffect } from "@preact/signals"; + +const TemplatesList = styled.div` + h1 { + font-size: 20px; + margin: 0; + margin-bottom: 16px; + } + + .list { + display: flex; + flex-direction: column; + gap: 8px; + } +`; + +const TemplateButton = styled(DefaultButton)` + color: ${(props) => (props.error ? "var(--red-500)" : "var(--icon-color)")}; + border: 1px solid ${(props) => (props.error ? "var(--red-500)" : "var(--icon-border)")}; + + ${(props) => + props.error && + css` + &:hover { + border: 1px solid var(--red-500) !important; + background-color: var(--icon-bg) !important; + } + `} +`; + +const Modal = styled.dialog` + width: 450px; + padding: 20px; + background-color: var(--icon-bg); + border: 1px solid var(--icon-border); + border-radius: var(--border-radius); + margin: 0; + top: 60px; + left: 50%; + transform: translateX(-50%); + + .buttons { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; + } +`; + +async function fetchTemplates(templateListUrl) { + const res = await fetch(templateListUrl); + if (!res.ok) throw new Error(`${res.status} Failed to fetch templates`); + const templates = await res.json(); + for (const key in templates) { + if (templates[key].id == undefined) templates[key].error = "Missing id field"; + if (templates[key].templatetext == undefined) templates[key].error = "Missing templatetext field"; + if (templates[key].error) continue; + + try { + const res = await fetch(templates[key].templatetext); + if (!res.ok) throw new Error(`${res.status} Failed to fetch template text`); + templates[key].templatetext = await res.text(); + } catch (err) { + templates[key].error = err; + } + } + + return templates; +} + +const Templates = () => { + const { options, editorView } = useContext(MystState); + const templates = useSignal({}); + const selectedTemplate = useSignal(null); + const modalRef = useRef(null); + + useSignalEffect(() => { + if (!options.templatelist.value) { + options.includeButtons.value = options.includeButtons.peek().filter((b) => b.id != "templates"); + return; + } + + selectedTemplate.value = null; + fetchTemplates(options.templatelist.value) + .then((t) => (templates.value = t)) + .catch((err) => { + console.error(err); + options.includeButtons.value = options.includeButtons.peek().filter((b) => b.id != "templates"); + }); + }); + + useSignalEffect(() => { + if (selectedTemplate.value) modalRef.current?.showModal?.(); + else modalRef.current?.close?.(); + }); + + return ( + <> + +

Templates

+
+ {Object.values(templates.value).map((template) => ( + (selectedTemplate.value = template)} + > + {template.id} + + ))} +
+
+ +

+ Are you sure you want to apply the {selectedTemplate.value?.id} template? It will replace the current document and remove all comments. +

+
+ { + editorView.value.dispatch({ changes: { from: 0, to: editorView.value.state.doc.length, insert: selectedTemplate.value.templatetext } }); + selectedTemplate.value = null; + }} + > + Yes, apply + + (selectedTemplate.value = null)}>No, cancel +
+
+ + ); +}; + +export default Templates; diff --git a/src/components/Topbar.jsx b/src/components/Topbar.jsx index 2f32687..24829d5 100644 --- a/src/components/Topbar.jsx +++ b/src/components/Topbar.jsx @@ -5,7 +5,6 @@ import purify from "dompurify"; import DefaultButton from "./Buttons"; import ButtonGroup from "./ButtonGroup"; import Avatars from "./Avatars"; -import TemplateManager from "./TemplateManager"; import { MystState } from "../mystState"; import { useComputed, useSignal } from "@preact/signals"; @@ -59,10 +58,18 @@ const Topbar = styled.div` position: absolute; top: 50px; padding-top: 10px; + display: none; - &:hover > * { + &:hover { display: block; } + + .dropdown-content { + padding: 20px; + border-radius: var(--border-radius); + box-shadow: 4px 4px 10px var(--gray-600); + background: white; + } } @media print { @@ -98,7 +105,7 @@ export const TopbarButton = styled(DefaultButton)` background-color: ${(props) => (props.active ? "var(--icon-main-active)" : "var(--icon-bg)")}; width: 40px; - &:hover ~ .btn-dropdown > * { + &:hover ~ .btn-dropdown { display: block; } `; @@ -232,12 +239,22 @@ const TocIcon = () => ( ); +const TemplatesIcon = () => ( + + + + + + +); + const icons = { fullscreen: FullscreenIcon, "copy-html": CopyIcon, refresh: RefreshIcon, "print-to-pdf": PrintPDFIcon, settings: SettingsIcon, + templates: TemplatesIcon, }; export const EditorTopbar = ({ alert, users, buttons }) => { @@ -267,7 +284,7 @@ export const EditorTopbar = ({ alert, users, buttons }) => { }); const clickedId = useComputed(() => editorModeButtons.value.findIndex((b) => b.id[0].toUpperCase() + b.id.slice(1) === options.mode.value)); const buttonsLeft = useMemo(() => buttons.map((b) => ({ ...b, icon: b.icon || icons[b.id] })).filter((b) => b.icon), [buttons]); - const textButtons = useMemo(() => buttons.filter((b) => b.text && b.id !== "template-manager"), [buttons]); + const textButtons = useMemo(() => buttons.filter((b) => b.text), [buttons]); return ( @@ -278,10 +295,13 @@ export const EditorTopbar = ({ alert, users, buttons }) => { {typeof button.icon == "function" ? : } -
{button.dropdown?.()}
+ {button.dropdown && ( +
+
{button.dropdown()}
+
+ )} ))} - {buttons.find((b) => b.id === "template-manager") && options.templatelist.value && } {alert && {alert} } diff --git a/src/mystState.js b/src/mystState.js index 09684f2..74d43df 100644 --- a/src/mystState.js +++ b/src/mystState.js @@ -10,6 +10,7 @@ import { CollaborationClient } from "./collaboration"; import { CodeMirror as VimCM, vim } from "@replit/codemirror-vim"; import { collabClientFacet } from "./extensions"; import { TextManager } from "./text"; +import Templates from "./components/Templates"; export const predefinedButtons = { printToPdf: { @@ -17,7 +18,7 @@ export const predefinedButtons = { tooltip: "Print document as pdf", action: () => window.print(), }, - templateManager: { id: "template-manager" }, + templateManager: { id: "templates", tooltip: "Templates", dropdown: Templates }, copyHtml: { id: "copy-html", tooltip: "Copy document as HTML" }, fullscreen: { id: "fullscreen", tooltip: "Fullscreen" }, refresh: { id: "refresh", tooltip: "Refresh issue links" },