mirror of
https://github.com/owncast/owncast.git
synced 2024-11-27 17:59:21 +03:00
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
This commit is contained in:
parent
c372c9b36e
commit
a290770ac9
7 changed files with 120 additions and 38 deletions
|
@ -4,6 +4,8 @@ package models
|
||||||
type ExternalAction struct {
|
type ExternalAction struct {
|
||||||
// URL is the URL to load.
|
// URL is the URL to load.
|
||||||
URL string `json:"url"`
|
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 is the name of this action, displayed in the modal.
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
// Description is the description of this action.
|
// Description is the description of this action.
|
||||||
|
|
|
@ -75,7 +75,7 @@ const OwncastPlayer = dynamic(
|
||||||
);
|
);
|
||||||
|
|
||||||
const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay }) => {
|
const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay }) => {
|
||||||
const { title, description, url } = externalActionToDisplay;
|
const { title, description, url, html } = externalActionToDisplay;
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={description || title}
|
title={description || title}
|
||||||
|
@ -83,7 +83,19 @@ const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay })
|
||||||
open={!!externalActionToDisplay}
|
open={!!externalActionToDisplay}
|
||||||
height="80vh"
|
height="80vh"
|
||||||
handleCancel={() => setExternalActionToDisplay(null)}
|
handleCancel={() => setExternalActionToDisplay(null)}
|
||||||
|
>
|
||||||
|
{html ? (
|
||||||
|
<div
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -127,7 +139,8 @@ export const Content: FC = () => {
|
||||||
|
|
||||||
const externalActionSelected = (action: ExternalAction) => {
|
const externalActionSelected = (action: ExternalAction) => {
|
||||||
const { openExternally, url } = action;
|
const { openExternally, url } = action;
|
||||||
if (openExternally) {
|
// apply openExternally only if we don't have an HTML embed
|
||||||
|
if (openExternally && url) {
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
} else {
|
} else {
|
||||||
setExternalActionToDisplay(action);
|
setExternalActionToDisplay(action);
|
||||||
|
@ -136,7 +149,7 @@ export const Content: FC = () => {
|
||||||
|
|
||||||
const externalActionButtons = externalActions.map(action => (
|
const externalActionButtons = externalActions.map(action => (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
key={action.url}
|
key={action.url || action.html}
|
||||||
action={action}
|
action={action}
|
||||||
externalActionSelected={externalActionSelected}
|
externalActionSelected={externalActionSelected}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -11,3 +11,4 @@
|
||||||
background-color: var(--theme-color-components-modal-content-background);
|
background-color: var(--theme-color-components-modal-content-background);
|
||||||
color: var(--theme-color-components-modal-content-text);
|
color: var(--theme-color-components-modal-content-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,8 @@ export interface ExternalAction {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
url: string;
|
url?: string;
|
||||||
|
html?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
openExternally?: boolean;
|
openExternally?: boolean;
|
||||||
}
|
}
|
||||||
|
|
1
web/package-lock.json
generated
1
web/package-lock.json
generated
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "4.8.0",
|
"@ant-design/icons": "4.8.0",
|
||||||
"@codemirror/lang-css": "6.0.2",
|
"@codemirror/lang-css": "6.0.2",
|
||||||
|
"@codemirror/lang-html": "^6.4.2",
|
||||||
"@codemirror/lang-javascript": "^6.1.2",
|
"@codemirror/lang-javascript": "^6.1.2",
|
||||||
"@codemirror/lang-markdown": "6.0.5",
|
"@codemirror/lang-markdown": "6.0.5",
|
||||||
"@codemirror/language-data": "6.1.0",
|
"@codemirror/language-data": "6.1.0",
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "4.8.0",
|
"@ant-design/icons": "4.8.0",
|
||||||
"@codemirror/lang-css": "6.0.2",
|
"@codemirror/lang-css": "6.0.2",
|
||||||
|
"@codemirror/lang-html": "^6.4.2",
|
||||||
"@codemirror/lang-javascript": "^6.1.2",
|
"@codemirror/lang-javascript": "^6.1.2",
|
||||||
"@codemirror/lang-markdown": "6.0.5",
|
"@codemirror/lang-markdown": "6.0.5",
|
||||||
"@codemirror/language-data": "6.1.0",
|
"@codemirror/language-data": "6.1.0",
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { Button, Checkbox, Form, Input, Modal, Space, Table, Typography } from 'antd';
|
import { Button, Checkbox, Form, Input, Modal, Select, Space, Table, Typography } from 'antd';
|
||||||
import _ from 'lodash';
|
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 dynamic from 'next/dynamic';
|
||||||
import React, { ReactElement, useContext, useEffect, useState } from 'react';
|
import React, { ReactElement, useContext, useEffect, useState } from 'react';
|
||||||
import { FormStatusIndicator } from '../../components/admin/FormStatusIndicator';
|
import { FormStatusIndicator } from '../../components/admin/FormStatusIndicator';
|
||||||
|
@ -33,7 +35,9 @@ interface Props {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onOk: (
|
onOk: (
|
||||||
oldAction: ExternalAction | null,
|
oldAction: ExternalAction | null,
|
||||||
|
oldActionIndex: number | null,
|
||||||
actionUrl: string,
|
actionUrl: string,
|
||||||
|
actionHTML: string,
|
||||||
actionTitle: string,
|
actionTitle: string,
|
||||||
actionDescription: string,
|
actionDescription: string,
|
||||||
actionIcon: string,
|
actionIcon: string,
|
||||||
|
@ -42,12 +46,19 @@ interface Props {
|
||||||
) => void;
|
) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
action: ExternalAction | null;
|
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 ActionModal = (props: Props) => {
|
||||||
const { onOk, onCancel, open, action } = props;
|
const { onOk, onCancel, open, action } = props;
|
||||||
|
|
||||||
|
const [actionType, setActionType] = useState<ActionType>('url');
|
||||||
|
|
||||||
const [actionUrl, setActionUrl] = useState('');
|
const [actionUrl, setActionUrl] = useState('');
|
||||||
|
const [actionHTML, setActionHTML] = useState('');
|
||||||
const [actionTitle, setActionTitle] = useState('');
|
const [actionTitle, setActionTitle] = useState('');
|
||||||
const [actionDescription, setActionDescription] = useState('');
|
const [actionDescription, setActionDescription] = useState('');
|
||||||
const [actionIcon, setActionIcon] = useState('');
|
const [actionIcon, setActionIcon] = useState('');
|
||||||
|
@ -55,7 +66,9 @@ const ActionModal = (props: Props) => {
|
||||||
const [openExternally, setOpenExternally] = useState(false);
|
const [openExternally, setOpenExternally] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setActionType((action?.html?.length || 0) > 0 ? 'html' : 'url');
|
||||||
setActionUrl(action?.url || '');
|
setActionUrl(action?.url || '');
|
||||||
|
setActionHTML(action?.html || '');
|
||||||
setActionTitle(action?.title || '');
|
setActionTitle(action?.title || '');
|
||||||
setActionDescription(action?.description || '');
|
setActionDescription(action?.description || '');
|
||||||
setActionIcon(action?.icon || '');
|
setActionIcon(action?.icon || '');
|
||||||
|
@ -66,7 +79,10 @@ const ActionModal = (props: Props) => {
|
||||||
function save() {
|
function save() {
|
||||||
onOk(
|
onOk(
|
||||||
action,
|
action,
|
||||||
actionUrl,
|
props.index,
|
||||||
|
// Save only one of the properties
|
||||||
|
actionType === 'html' ? '' : actionUrl,
|
||||||
|
actionType === 'html' ? actionHTML : '',
|
||||||
actionTitle,
|
actionTitle,
|
||||||
actionDescription,
|
actionDescription,
|
||||||
actionIcon,
|
actionIcon,
|
||||||
|
@ -74,6 +90,7 @@ const ActionModal = (props: Props) => {
|
||||||
openExternally,
|
openExternally,
|
||||||
);
|
);
|
||||||
setActionUrl('');
|
setActionUrl('');
|
||||||
|
setActionHTML('');
|
||||||
setActionTitle('');
|
setActionTitle('');
|
||||||
setActionDescription('');
|
setActionDescription('');
|
||||||
setActionIcon('');
|
setActionIcon('');
|
||||||
|
@ -82,6 +99,9 @@ const ActionModal = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function canSave(): Boolean {
|
function canSave(): Boolean {
|
||||||
|
if (actionType === 'html') {
|
||||||
|
return actionHTML !== '' && actionTitle !== '';
|
||||||
|
}
|
||||||
return isValidUrl(actionUrl, ['https:']) && actionTitle !== '';
|
return isValidUrl(actionUrl, ['https:']) && actionTitle !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,6 +113,10 @@ const ActionModal = (props: Props) => {
|
||||||
setOpenExternally(checkbox.target.checked);
|
setOpenExternally(checkbox.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onActionHTMLChanged = (newActionHTML: string) => {
|
||||||
|
setActionHTML(newActionHTML);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
|
@ -104,7 +128,7 @@ const ActionModal = (props: Props) => {
|
||||||
>
|
>
|
||||||
<Form initialValues={action}>
|
<Form initialValues={action}>
|
||||||
Add the URL for the external action you want to present.{' '}
|
Add the URL for the external action you want to present.{' '}
|
||||||
<strong>Only HTTPS urls are supported.</strong>
|
<strong>Only HTTPS URLs and embeds are supported.</strong>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
href="https://owncast.online/thirdparty/actions/"
|
href="https://owncast.online/thirdparty/actions/"
|
||||||
|
@ -114,6 +138,29 @@ const ActionModal = (props: Props) => {
|
||||||
Read more about external actions.
|
Read more about external actions.
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
<Form.Item>
|
||||||
|
<Select
|
||||||
|
value={actionType}
|
||||||
|
onChange={setActionType}
|
||||||
|
placeholder="Select an action type"
|
||||||
|
options={[
|
||||||
|
{ label: 'Link or embed an URL', value: 'url' },
|
||||||
|
{ label: 'Custom HTML', value: 'html' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{actionType === 'html' ? (
|
||||||
|
<Form.Item name="html">
|
||||||
|
<CodeMirror
|
||||||
|
value={actionHTML}
|
||||||
|
placeholder="HTML embed code (required)"
|
||||||
|
theme={bbedit}
|
||||||
|
height="200px"
|
||||||
|
extensions={[codeMirrorHTML()]}
|
||||||
|
onChange={onActionHTMLChanged}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
<Form.Item name="url">
|
<Form.Item name="url">
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
|
@ -123,6 +170,7 @@ const ActionModal = (props: Props) => {
|
||||||
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
|
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
)}
|
||||||
<Form.Item name="title">
|
<Form.Item name="title">
|
||||||
<Input
|
<Input
|
||||||
value={actionTitle}
|
value={actionTitle}
|
||||||
|
@ -155,6 +203,7 @@ const ActionModal = (props: Props) => {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
Optional background color of the action button.
|
Optional background color of the action button.
|
||||||
</div>
|
</div>
|
||||||
|
{actionType === 'html' ? null : (
|
||||||
<Form.Item name="openExternally">
|
<Form.Item name="openExternally">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={openExternally}
|
checked={openExternally}
|
||||||
|
@ -164,6 +213,7 @@ const ActionModal = (props: Props) => {
|
||||||
Open in a new tab instead of within your page.
|
Open in a new tab instead of within your page.
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
@ -177,6 +227,7 @@ const Actions = () => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [submitStatus, setSubmitStatus] = useState(null);
|
const [submitStatus, setSubmitStatus] = useState(null);
|
||||||
const [editAction, setEditAction] = useState<ExternalAction>(null);
|
const [editAction, setEditAction] = useState<ExternalAction>(null);
|
||||||
|
const [editActionIndex, setEditActionIndex] = useState(-1);
|
||||||
|
|
||||||
const resetStates = () => {
|
const resetStates = () => {
|
||||||
setSubmitStatus(null);
|
setSubmitStatus(null);
|
||||||
|
@ -205,9 +256,8 @@ const Actions = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(action) {
|
async function handleDelete(action, index) {
|
||||||
const actionsData = [...actions];
|
const actionsData = [...actions];
|
||||||
const index = actions.findIndex(item => item.url === action.url);
|
|
||||||
actionsData.splice(index, 1);
|
actionsData.splice(index, 1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -220,7 +270,9 @@ const Actions = () => {
|
||||||
|
|
||||||
async function handleSave(
|
async function handleSave(
|
||||||
oldAction: ExternalAction | null,
|
oldAction: ExternalAction | null,
|
||||||
|
oldActionIndex: number,
|
||||||
url: string,
|
url: string,
|
||||||
|
html: string,
|
||||||
title: string,
|
title: string,
|
||||||
description: string,
|
description: string,
|
||||||
icon: string,
|
icon: string,
|
||||||
|
@ -232,6 +284,7 @@ const Actions = () => {
|
||||||
|
|
||||||
const newAction: ExternalAction = {
|
const newAction: ExternalAction = {
|
||||||
url,
|
url,
|
||||||
|
html,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
|
@ -240,9 +293,8 @@ const Actions = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Replace old action if edited or append the new action
|
// Replace old action if edited or append the new action
|
||||||
const index = oldAction ? actions.findIndex(item => _.isEqual(item, oldAction)) : -1;
|
if (oldActionIndex >= 0) {
|
||||||
if (index >= 0) {
|
actionsData[oldActionIndex] = newAction;
|
||||||
actionsData[index] = newAction;
|
|
||||||
} else {
|
} else {
|
||||||
actionsData.push(newAction);
|
actionsData.push(newAction);
|
||||||
}
|
}
|
||||||
|
@ -254,19 +306,23 @@ const Actions = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEdit(action: ExternalAction) {
|
async function handleEdit(action: ExternalAction, index) {
|
||||||
|
setEditActionIndex(index);
|
||||||
setEditAction(action);
|
setEditAction(action);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCreateModal = () => {
|
const showCreateModal = () => {
|
||||||
setEditAction(null);
|
setEditAction(null);
|
||||||
|
setEditActionIndex(-1);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModalSaveButton = (
|
const handleModalSaveButton = (
|
||||||
oldAction: ExternalAction | null,
|
oldAction: ExternalAction | null,
|
||||||
|
oldActionIndex: number,
|
||||||
actionUrl: string,
|
actionUrl: string,
|
||||||
|
actionHTML: string,
|
||||||
actionTitle: string,
|
actionTitle: string,
|
||||||
actionDescription: string,
|
actionDescription: string,
|
||||||
actionIcon: string,
|
actionIcon: string,
|
||||||
|
@ -276,7 +332,9 @@ const Actions = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
handleSave(
|
handleSave(
|
||||||
oldAction,
|
oldAction,
|
||||||
|
oldActionIndex,
|
||||||
actionUrl,
|
actionUrl,
|
||||||
|
actionHTML,
|
||||||
actionTitle,
|
actionTitle,
|
||||||
actionDescription,
|
actionDescription,
|
||||||
actionIcon,
|
actionIcon,
|
||||||
|
@ -284,6 +342,7 @@ const Actions = () => {
|
||||||
openExternally,
|
openExternally,
|
||||||
);
|
);
|
||||||
setEditAction(null);
|
setEditAction(null);
|
||||||
|
setEditActionIndex(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModalCancelButton = () => {
|
const handleModalCancelButton = () => {
|
||||||
|
@ -294,10 +353,10 @@ const Actions = () => {
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
key: 'delete-edit',
|
key: 'delete-edit',
|
||||||
render: (text, record) => (
|
render: (text, record, index) => (
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
<Button onClick={() => handleDelete(record)} icon={<DeleteOutlined />} />
|
<Button onClick={() => handleDelete(record, index)} icon={<DeleteOutlined />} />
|
||||||
<Button onClick={() => handleEdit(record)} icon={<EditOutlined />} />
|
<Button onClick={() => handleEdit(record, index)} icon={<EditOutlined />} />
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
@ -312,9 +371,10 @@ const Actions = () => {
|
||||||
key: 'description',
|
key: 'description',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'URL',
|
title: 'URL / Embed',
|
||||||
dataIndex: 'url',
|
|
||||||
key: 'url',
|
key: 'url',
|
||||||
|
dataIndex: 'url',
|
||||||
|
render: (text, record) => (record.html ? 'HTML embed' : record.url),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Icon',
|
title: 'Icon',
|
||||||
|
@ -331,9 +391,11 @@ const Actions = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Opens',
|
title: 'Opens',
|
||||||
dataIndex: 'openExternally',
|
|
||||||
key: 'openExternally',
|
key: 'openExternally',
|
||||||
render: (openExternally: boolean) => (openExternally ? 'In a new tab' : 'In a modal'),
|
dataIndex: 'openExternally',
|
||||||
|
// Note: embeds will alway open in the same tab / in a modal
|
||||||
|
render: (openExternally: boolean, record) =>
|
||||||
|
!openExternally || record.html ? 'In the same tab' : 'In a new tab',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -370,6 +432,7 @@ const Actions = () => {
|
||||||
|
|
||||||
<ActionModal
|
<ActionModal
|
||||||
action={editAction}
|
action={editAction}
|
||||||
|
index={editActionIndex}
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onOk={handleModalSaveButton}
|
onOk={handleModalSaveButton}
|
||||||
onCancel={handleModalCancelButton}
|
onCancel={handleModalCancelButton}
|
||||||
|
|
Loading…
Reference in a new issue