From a290770ac94f80cfba8a275ee211fd80ca62deee Mon Sep 17 00:00:00 2001 From: Philipp Date: Tue, 14 Feb 2023 18:08:54 +0100 Subject: [PATCH] Allow embedding HTML for external actions (#2693) * Admin UI: implement HTML embeds * Admin UI External Actions: set correct useHTML on edits * Admin UI: edit by index, not URL * External Actions: render HTML on stream frontend * Don't open embeds externally * Remove TODO comment * Add HTML as unique action key * Admin UI: Actions: use CodeMirror editor, dropdown --- models/externalAction.go | 2 + web/components/ui/Content/Content.tsx | 21 +++- web/components/ui/Modal/Modal.module.scss | 1 + web/interfaces/external-action.ts | 3 +- web/package-lock.json | 1 + web/package.json | 1 + web/pages/admin/actions.tsx | 129 ++++++++++++++++------ 7 files changed, 120 insertions(+), 38 deletions(-) diff --git a/models/externalAction.go b/models/externalAction.go index 5508e2096..b9e7969de 100644 --- a/models/externalAction.go +++ b/models/externalAction.go @@ -4,6 +4,8 @@ package models type ExternalAction struct { // URL is the URL to load. URL string `json:"url"` + // HTML is the HTML to embed into the modal. When this is set, OpenExternally and URL are ignored + HTML string `json:"html"` // Title is the name of this action, displayed in the modal. Title string `json:"title"` // Description is the description of this action. diff --git a/web/components/ui/Content/Content.tsx b/web/components/ui/Content/Content.tsx index 877054960..2290c9c5b 100644 --- a/web/components/ui/Content/Content.tsx +++ b/web/components/ui/Content/Content.tsx @@ -75,7 +75,7 @@ const OwncastPlayer = dynamic( ); const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay }) => { - const { title, description, url } = externalActionToDisplay; + const { title, description, url, html } = externalActionToDisplay; return ( setExternalActionToDisplay(null)} - /> + > + {html ? ( +
+ ) : null} + ); }; @@ -127,7 +139,8 @@ export const Content: FC = () => { const externalActionSelected = (action: ExternalAction) => { const { openExternally, url } = action; - if (openExternally) { + // apply openExternally only if we don't have an HTML embed + if (openExternally && url) { window.open(url, '_blank'); } else { setExternalActionToDisplay(action); @@ -136,7 +149,7 @@ export const Content: FC = () => { const externalActionButtons = externalActions.map(action => ( diff --git a/web/components/ui/Modal/Modal.module.scss b/web/components/ui/Modal/Modal.module.scss index 8a7213a69..2d29dae8a 100644 --- a/web/components/ui/Modal/Modal.module.scss +++ b/web/components/ui/Modal/Modal.module.scss @@ -11,3 +11,4 @@ background-color: var(--theme-color-components-modal-content-background); color: var(--theme-color-components-modal-content-text); } + diff --git a/web/interfaces/external-action.ts b/web/interfaces/external-action.ts index 538dd7e24..bdbdf26fb 100644 --- a/web/interfaces/external-action.ts +++ b/web/interfaces/external-action.ts @@ -2,7 +2,8 @@ export interface ExternalAction { title: string; description?: string; color?: string; - url: string; + url?: string; + html?: string; icon?: string; openExternally?: boolean; } diff --git a/web/package-lock.json b/web/package-lock.json index c96b9c212..bebd35237 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ant-design/icons": "4.8.0", "@codemirror/lang-css": "6.0.2", + "@codemirror/lang-html": "^6.4.2", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/lang-markdown": "6.0.5", "@codemirror/language-data": "6.1.0", diff --git a/web/package.json b/web/package.json index 6527d6fc5..fd356c358 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@ant-design/icons": "4.8.0", "@codemirror/lang-css": "6.0.2", + "@codemirror/lang-html": "^6.4.2", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/lang-markdown": "6.0.5", "@codemirror/language-data": "6.1.0", diff --git a/web/pages/admin/actions.tsx b/web/pages/admin/actions.tsx index 84539d34c..46e142795 100644 --- a/web/pages/admin/actions.tsx +++ b/web/pages/admin/actions.tsx @@ -1,5 +1,7 @@ -import { Button, Checkbox, Form, Input, Modal, Space, Table, Typography } from 'antd'; -import _ from 'lodash'; +import { Button, Checkbox, Form, Input, Modal, Select, Space, Table, Typography } from 'antd'; +import CodeMirror from '@uiw/react-codemirror'; +import { bbedit } from '@uiw/codemirror-theme-bbedit'; +import { html as codeMirrorHTML } from '@codemirror/lang-html'; import dynamic from 'next/dynamic'; import React, { ReactElement, useContext, useEffect, useState } from 'react'; import { FormStatusIndicator } from '../../components/admin/FormStatusIndicator'; @@ -33,7 +35,9 @@ interface Props { onCancel: () => void; onOk: ( oldAction: ExternalAction | null, + oldActionIndex: number | null, actionUrl: string, + actionHTML: string, actionTitle: string, actionDescription: string, actionIcon: string, @@ -42,12 +46,19 @@ interface Props { ) => void; open: boolean; action: ExternalAction | null; + index: number | null; } +// ActionType is only used here to save either only the URL or only the HTML. +type ActionType = 'url' | 'html'; + const ActionModal = (props: Props) => { const { onOk, onCancel, open, action } = props; + const [actionType, setActionType] = useState('url'); + const [actionUrl, setActionUrl] = useState(''); + const [actionHTML, setActionHTML] = useState(''); const [actionTitle, setActionTitle] = useState(''); const [actionDescription, setActionDescription] = useState(''); const [actionIcon, setActionIcon] = useState(''); @@ -55,7 +66,9 @@ const ActionModal = (props: Props) => { const [openExternally, setOpenExternally] = useState(false); useEffect(() => { + setActionType((action?.html?.length || 0) > 0 ? 'html' : 'url'); setActionUrl(action?.url || ''); + setActionHTML(action?.html || ''); setActionTitle(action?.title || ''); setActionDescription(action?.description || ''); setActionIcon(action?.icon || ''); @@ -66,7 +79,10 @@ const ActionModal = (props: Props) => { function save() { onOk( action, - actionUrl, + props.index, + // Save only one of the properties + actionType === 'html' ? '' : actionUrl, + actionType === 'html' ? actionHTML : '', actionTitle, actionDescription, actionIcon, @@ -74,6 +90,7 @@ const ActionModal = (props: Props) => { openExternally, ); setActionUrl(''); + setActionHTML(''); setActionTitle(''); setActionDescription(''); setActionIcon(''); @@ -82,6 +99,9 @@ const ActionModal = (props: Props) => { } function canSave(): Boolean { + if (actionType === 'html') { + return actionHTML !== '' && actionTitle !== ''; + } return isValidUrl(actionUrl, ['https:']) && actionTitle !== ''; } @@ -93,6 +113,10 @@ const ActionModal = (props: Props) => { setOpenExternally(checkbox.target.checked); }; + const onActionHTMLChanged = (newActionHTML: string) => { + setActionHTML(newActionHTML); + }; + return ( { >
Add the URL for the external action you want to present.{' '} - Only HTTPS urls are supported. + Only HTTPS URLs and embeds are supported.

{ Read more about external actions.

- - setActionUrl(input.currentTarget.value.trim())} - type="url" - pattern={DEFAULT_TEXTFIELD_URL_PATTERN} + + setActionUrl(input.currentTarget.value.trim())} + type="url" + pattern={DEFAULT_TEXTFIELD_URL_PATTERN} + /> + + )} { Optional background color of the action button.
- - - Open in a new tab instead of within your page. - - + {actionType === 'html' ? null : ( + + + Open in a new tab instead of within your page. + + + )}
); @@ -177,6 +227,7 @@ const Actions = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [submitStatus, setSubmitStatus] = useState(null); const [editAction, setEditAction] = useState(null); + const [editActionIndex, setEditActionIndex] = useState(-1); const resetStates = () => { setSubmitStatus(null); @@ -205,9 +256,8 @@ const Actions = () => { }); } - async function handleDelete(action) { + async function handleDelete(action, index) { const actionsData = [...actions]; - const index = actions.findIndex(item => item.url === action.url); actionsData.splice(index, 1); try { @@ -220,7 +270,9 @@ const Actions = () => { async function handleSave( oldAction: ExternalAction | null, + oldActionIndex: number, url: string, + html: string, title: string, description: string, icon: string, @@ -232,6 +284,7 @@ const Actions = () => { const newAction: ExternalAction = { url, + html, title, description, icon, @@ -240,9 +293,8 @@ const Actions = () => { }; // Replace old action if edited or append the new action - const index = oldAction ? actions.findIndex(item => _.isEqual(item, oldAction)) : -1; - if (index >= 0) { - actionsData[index] = newAction; + if (oldActionIndex >= 0) { + actionsData[oldActionIndex] = newAction; } else { actionsData.push(newAction); } @@ -254,19 +306,23 @@ const Actions = () => { } } - async function handleEdit(action: ExternalAction) { + async function handleEdit(action: ExternalAction, index) { + setEditActionIndex(index); setEditAction(action); setIsModalOpen(true); } const showCreateModal = () => { setEditAction(null); + setEditActionIndex(-1); setIsModalOpen(true); }; const handleModalSaveButton = ( oldAction: ExternalAction | null, + oldActionIndex: number, actionUrl: string, + actionHTML: string, actionTitle: string, actionDescription: string, actionIcon: string, @@ -276,7 +332,9 @@ const Actions = () => { setIsModalOpen(false); handleSave( oldAction, + oldActionIndex, actionUrl, + actionHTML, actionTitle, actionDescription, actionIcon, @@ -284,6 +342,7 @@ const Actions = () => { openExternally, ); setEditAction(null); + setEditActionIndex(-1); }; const handleModalCancelButton = () => { @@ -294,10 +353,10 @@ const Actions = () => { { title: '', key: 'delete-edit', - render: (text, record) => ( + render: (text, record, index) => ( -