owncast/web/pages/admin/actions.tsx
Philipp a290770ac9
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
2023-02-14 09:08:54 -08:00

448 lines
12 KiB
TypeScript

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';
import { ExternalAction } from '../../interfaces/external-action';
import {
API_EXTERNAL_ACTIONS,
postConfigUpdateToAPI,
RESET_TIMEOUT,
} from '../../utils/config-constants';
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
import { ServerStatusContext } from '../../utils/server-status-context';
import { isValidUrl, DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/urls';
import { AdminLayout } from '../../components/layouts/AdminLayout';
const { Title, Paragraph } = Typography;
// Lazy loaded components
const DeleteOutlined = dynamic(() => import('@ant-design/icons/DeleteOutlined'), {
ssr: false,
});
const EditOutlined = dynamic(() => import('@ant-design/icons/EditOutlined'), {
ssr: false,
});
let resetTimer = null;
interface Props {
onCancel: () => void;
onOk: (
oldAction: ExternalAction | null,
oldActionIndex: number | null,
actionUrl: string,
actionHTML: string,
actionTitle: string,
actionDescription: string,
actionIcon: string,
actionColor: string,
openExternally: boolean,
) => 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<ActionType>('url');
const [actionUrl, setActionUrl] = useState('');
const [actionHTML, setActionHTML] = useState('');
const [actionTitle, setActionTitle] = useState('');
const [actionDescription, setActionDescription] = useState('');
const [actionIcon, setActionIcon] = useState('');
const [actionColor, setActionColor] = useState('');
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 || '');
setActionColor(action?.color || '');
setOpenExternally(action?.openExternally || false);
}, [action]);
function save() {
onOk(
action,
props.index,
// Save only one of the properties
actionType === 'html' ? '' : actionUrl,
actionType === 'html' ? actionHTML : '',
actionTitle,
actionDescription,
actionIcon,
actionColor,
openExternally,
);
setActionUrl('');
setActionHTML('');
setActionTitle('');
setActionDescription('');
setActionIcon('');
setActionColor('');
setOpenExternally(false);
}
function canSave(): Boolean {
if (actionType === 'html') {
return actionHTML !== '' && actionTitle !== '';
}
return isValidUrl(actionUrl, ['https:']) && actionTitle !== '';
}
const okButtonProps = {
disabled: !canSave(),
};
const onOpenExternallyChanged = checkbox => {
setOpenExternally(checkbox.target.checked);
};
const onActionHTMLChanged = (newActionHTML: string) => {
setActionHTML(newActionHTML);
};
return (
<Modal
destroyOnClose
title={action == null ? 'Create New Action' : 'Edit Action'}
open={open}
onOk={save}
onCancel={onCancel}
okButtonProps={okButtonProps}
>
<Form initialValues={action}>
Add the URL for the external action you want to present.{' '}
<strong>Only HTTPS URLs and embeds are supported.</strong>
<p>
<a
href="https://owncast.online/thirdparty/actions/"
target="_blank"
rel="noopener noreferrer"
>
Read more about external actions.
</a>
</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">
<Input
required
placeholder="https://myserver.com/action (required)"
onChange={input => setActionUrl(input.currentTarget.value.trim())}
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
/>
</Form.Item>
)}
<Form.Item name="title">
<Input
value={actionTitle}
required
placeholder="Your action title (required)"
onChange={input => setActionTitle(input.currentTarget.value)}
/>
</Form.Item>
<Form.Item name="description">
<Input
value={actionDescription}
placeholder="Optional description"
onChange={input => setActionDescription(input.currentTarget.value)}
/>
</Form.Item>
<Form.Item name="icon">
<Input
value={actionIcon}
placeholder="https://myserver.com/action/icon.png (optional)"
onChange={input => setActionIcon(input.currentTarget.value)}
/>
</Form.Item>
<div>
<Form.Item name="color" style={{ marginBottom: '0px' }}>
<Input
type="color"
value={actionColor}
onChange={input => setActionColor(input.currentTarget.value)}
/>
</Form.Item>
Optional background color of the action button.
</div>
{actionType === 'html' ? null : (
<Form.Item name="openExternally">
<Checkbox
checked={openExternally}
defaultChecked={openExternally}
onChange={onOpenExternallyChanged}
>
Open in a new tab instead of within your page.
</Checkbox>
</Form.Item>
)}
</Form>
</Modal>
);
};
const Actions = () => {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { externalActions } = serverConfig;
const [actions, setActions] = useState<ExternalAction[]>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [submitStatus, setSubmitStatus] = useState(null);
const [editAction, setEditAction] = useState<ExternalAction>(null);
const [editActionIndex, setEditActionIndex] = useState(-1);
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
useEffect(() => {
setActions(externalActions || []);
}, [externalActions]);
async function save(actionsData) {
await postConfigUpdateToAPI({
apiPath: API_EXTERNAL_ACTIONS,
data: { value: actionsData },
onSuccess: () => {
setFieldInConfigState({ fieldName: 'externalActions', value: actionsData, path: '' });
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
console.log(message);
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
}
async function handleDelete(action, index) {
const actionsData = [...actions];
actionsData.splice(index, 1);
try {
setActions(actionsData);
save(actionsData);
} catch (error) {
console.error(error);
}
}
async function handleSave(
oldAction: ExternalAction | null,
oldActionIndex: number,
url: string,
html: string,
title: string,
description: string,
icon: string,
color: string,
openExternally: boolean,
) {
try {
const actionsData = [...actions];
const newAction: ExternalAction = {
url,
html,
title,
description,
icon,
color,
openExternally,
};
// Replace old action if edited or append the new action
if (oldActionIndex >= 0) {
actionsData[oldActionIndex] = newAction;
} else {
actionsData.push(newAction);
}
setActions(actionsData);
await save(actionsData);
} catch (error) {
console.error(error);
}
}
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,
actionColor: string,
openExternally: boolean,
) => {
setIsModalOpen(false);
handleSave(
oldAction,
oldActionIndex,
actionUrl,
actionHTML,
actionTitle,
actionDescription,
actionIcon,
actionColor,
openExternally,
);
setEditAction(null);
setEditActionIndex(-1);
};
const handleModalCancelButton = () => {
setIsModalOpen(false);
};
const columns = [
{
title: '',
key: 'delete-edit',
render: (text, record, index) => (
<Space size="middle">
<Button onClick={() => handleDelete(record, index)} icon={<DeleteOutlined />} />
<Button onClick={() => handleEdit(record, index)} icon={<EditOutlined />} />
</Space>
),
},
{
title: 'Name',
dataIndex: 'title',
key: 'title',
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
},
{
title: 'URL / Embed',
key: 'url',
dataIndex: 'url',
render: (text, record) => (record.html ? 'HTML embed' : record.url),
},
{
title: 'Icon',
dataIndex: 'icon',
key: 'icon',
render: (url: string) => (url ? <img style={{ width: '2vw' }} src={url} alt="" /> : null),
},
{
title: 'Color',
dataIndex: 'color',
key: 'color',
render: (color: string) =>
color ? <div style={{ backgroundColor: color, height: '30px' }}>{color}</div> : null,
},
{
title: 'Opens',
key: 'openExternally',
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',
},
];
return (
<div>
<Title>External Actions</Title>
<Paragraph>
External action URLs are 3rd party UI you can display, embedded, into your Owncast page when
a user clicks on a button to launch your action.
</Paragraph>
<Paragraph>
Read more about how to use actions, with examples, at{' '}
<a
href="https://owncast.online/thirdparty/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
our documentation
</a>
.
</Paragraph>
<Table
rowKey={record => `${record.title}-${record.url}`}
columns={columns}
dataSource={actions}
pagination={false}
/>
<br />
<Button type="primary" onClick={showCreateModal}>
Create New Action
</Button>
<FormStatusIndicator status={submitStatus} />
<ActionModal
action={editAction}
index={editActionIndex}
open={isModalOpen}
onOk={handleModalSaveButton}
onCancel={handleModalCancelButton}
/>
</div>
);
};
Actions.getLayout = function getLayout(page: ReactElement) {
return <AdminLayout page={page} />;
};
export default Actions;