You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
447 lines
12 KiB
447 lines
12 KiB
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/validators'; |
|
|
|
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(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: (_, record, index) => ( |
|
<Space size="middle"> |
|
<Button onClick={() => handleDelete(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: (_, 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;
|
|
|