mirror of
				https://github.com/owncast/owncast.git
				synced 2025-10-31 18:18:06 +08:00 
			
		
		
		
	Admin support for creating 3rd party external link actions (#72)
* WIP external actions * Add comment * Add support for external actions icons and colors
This commit is contained in:
		| @ -182,6 +182,9 @@ export default function MainLayout(props) { | ||||
|             <Menu.Item key="access-tokens"> | ||||
|               <Link href="/access-tokens">Access Tokens</Link> | ||||
|             </Menu.Item> | ||||
|             <Menu.Item key="actions"> | ||||
|               <Link href="/actions">External Actions</Link> | ||||
|             </Menu.Item> | ||||
|           </SubMenu> | ||||
|           <Menu.Item key="help" icon={<QuestionCircleOutlined />} title="Help"> | ||||
|             <Link href="/help">Help</Link> | ||||
|  | ||||
							
								
								
									
										276
									
								
								web/pages/actions.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										276
									
								
								web/pages/actions.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,276 @@ | ||||
| // comment | ||||
|  | ||||
| import React, { useState, useEffect, useContext } from 'react'; | ||||
| import { Table, Space, Button, Modal, Checkbox, Input, Typography } from 'antd'; | ||||
| import { ServerStatusContext } from '../utils/server-status-context'; | ||||
| import { DeleteOutlined } from '@ant-design/icons'; | ||||
| import isValidUrl from '../utils/urls'; | ||||
| import FormStatusIndicator from '../components/config/form-status-indicator'; | ||||
| import { | ||||
|   createInputStatus, | ||||
|   StatusState, | ||||
|   STATUS_ERROR, | ||||
|   STATUS_PROCESSING, | ||||
|   STATUS_SUCCESS, | ||||
| } from '../utils/input-statuses'; | ||||
|  | ||||
| import { | ||||
|   postConfigUpdateToAPI, | ||||
|   API_EXTERNAL_ACTIONS, | ||||
|   RESET_TIMEOUT, | ||||
| } from '../utils/config-constants'; | ||||
|  | ||||
| const { Title, Paragraph } = Typography; | ||||
| let resetTimer = null; | ||||
|  | ||||
| interface Props { | ||||
|   onCancel: () => void; | ||||
|   onOk: any; // todo: make better type | ||||
|   visible: boolean; | ||||
| } | ||||
|  | ||||
| function NewActionModal(props: Props) { | ||||
|   const { onOk, onCancel, visible } = props; | ||||
|  | ||||
|   const [actionUrl, setActionUrl] = useState(''); | ||||
|   const [actionTitle, setActionTitle] = useState(''); | ||||
|   const [actionDescription, setActionDescription] = useState(''); | ||||
|   const [actionIcon, setActionIcon] = useState(''); | ||||
|   const [actionColor, setActionColor] = useState(''); | ||||
|   const [openExternally, setOpenExternally] = useState(false); | ||||
|  | ||||
|   function save() { | ||||
|     onOk(actionUrl, actionTitle, actionDescription, actionIcon, actionColor, openExternally); | ||||
|   } | ||||
|  | ||||
|   const okButtonProps = { | ||||
|     disabled: !isValidUrl(actionUrl) || actionTitle === '', | ||||
|   }; | ||||
|  | ||||
|   const onOpenExternallyChanged = checkbox => { | ||||
|     setOpenExternally(checkbox.target.checked); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Modal | ||||
|       title="Create New Action" | ||||
|       visible={visible} | ||||
|       onOk={save} | ||||
|       onCancel={onCancel} | ||||
|       okButtonProps={okButtonProps} | ||||
|     > | ||||
|       <div> | ||||
|         <p> | ||||
|           <Input | ||||
|             value={actionUrl} | ||||
|             required | ||||
|             placeholder="https://myserver.com/action (required)" | ||||
|             onChange={input => setActionUrl(input.currentTarget.value)} | ||||
|           /> | ||||
|         </p> | ||||
|         <p> | ||||
|           <Input | ||||
|             value={actionTitle} | ||||
|             required | ||||
|             placeholder="Your action title (required)" | ||||
|             onChange={input => setActionTitle(input.currentTarget.value)} | ||||
|           /> | ||||
|         </p> | ||||
|  | ||||
|         <p> | ||||
|           <Input | ||||
|             value={actionDescription} | ||||
|             placeholder="Optional description" | ||||
|             onChange={input => setActionDescription(input.currentTarget.value)} | ||||
|           /> | ||||
|         </p> | ||||
|  | ||||
|         <p> | ||||
|           <Input | ||||
|             value={actionIcon} | ||||
|             placeholder="https://myserver.com/action/icon.png (optional)" | ||||
|             onChange={input => setActionIcon(input.currentTarget.value)} | ||||
|           /> | ||||
|         </p> | ||||
|  | ||||
|         <p> | ||||
|           <Input | ||||
|             type="color" | ||||
|             value={actionColor} | ||||
|             onChange={input => setActionColor(input.currentTarget.value)} | ||||
|           /> | ||||
|         </p> | ||||
|  | ||||
|         <Checkbox | ||||
|           checked={openExternally} | ||||
|           defaultChecked={openExternally} | ||||
|           onChange={onOpenExternallyChanged} | ||||
|         > | ||||
|           Open in a new tab instead of within your page. | ||||
|         </Checkbox> | ||||
|       </div> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default function Actions() { | ||||
|   const serverStatusData = useContext(ServerStatusContext); | ||||
|   const { serverConfig, setFieldInConfigState } = serverStatusData || {}; | ||||
|   const { externalActions } = serverConfig; | ||||
|   const [actions, setActions] = useState([]); | ||||
|   const [isModalVisible, setIsModalVisible] = useState(false); | ||||
|   const [submitStatus, setSubmitStatus] = useState(null); | ||||
|  | ||||
|   const resetStates = () => { | ||||
|     setSubmitStatus(null); | ||||
|     resetTimer = null; | ||||
|     clearTimeout(resetTimer); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setActions(externalActions || []); | ||||
|   }, [externalActions]); | ||||
|  | ||||
|   const columns = [ | ||||
|     { | ||||
|       title: '', | ||||
|       key: 'delete', | ||||
|       render: (text, record) => ( | ||||
|         <Space size="middle"> | ||||
|           <Button onClick={() => handleDelete(record)} icon={<DeleteOutlined />} /> | ||||
|         </Space> | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       title: 'Name', | ||||
|       dataIndex: 'title', | ||||
|       key: 'title', | ||||
|     }, | ||||
|     { | ||||
|       title: 'Description', | ||||
|       dataIndex: 'description', | ||||
|       key: 'description', | ||||
|     }, | ||||
|     { | ||||
|       title: 'URL', | ||||
|       dataIndex: 'url', | ||||
|       key: 'url', | ||||
|     }, | ||||
|     { | ||||
|       title: 'Icon', | ||||
|       dataIndex: 'icon', | ||||
|       key: 'icon', | ||||
|       render: (url: string) => { | ||||
|         return url ? <img src={url} /> : null; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       title: 'Color', | ||||
|       dataIndex: 'color', | ||||
|       key: 'color', | ||||
|       render: (color: string) => { | ||||
|         return color ? (<div style={{backgroundColor: color, height: '30px'}}>{color}</div>) : null; | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       title: 'Opens', | ||||
|       dataIndex: 'openExternally', | ||||
|       key: 'openExternally', | ||||
|       render: (openExternally: boolean) => { | ||||
|         return openExternally ? 'In a new tab' : 'In a modal'; | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   async function handleDelete(action) { | ||||
|     let actionsData = [...actions]; | ||||
|     const index = actions.findIndex(item => item.url === action.url); | ||||
|     actionsData.splice(index, 1); | ||||
|  | ||||
|     setActions(actionsData); | ||||
|     save(actionsData); | ||||
|     try { | ||||
|     } catch (error) { | ||||
|       console.error(error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function handleSave( | ||||
|     url: string, | ||||
|     title: string, | ||||
|     description: string, | ||||
|     icon: string, | ||||
|     color: string, | ||||
|     openExternally: boolean, | ||||
|   ) { | ||||
|     try { | ||||
|       let actionsData = [...actions]; | ||||
|       const updatedActions = actionsData.concat({ url, title, description, icon, color, openExternally }); | ||||
|       setActions(updatedActions); | ||||
|       await save(updatedActions); | ||||
|     } catch (error) { | ||||
|       console.error(error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   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); | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   const showCreateModal = () => { | ||||
|     setIsModalVisible(true); | ||||
|   }; | ||||
|  | ||||
|   const handleModalSaveButton = ( | ||||
|     actionUrl: string, | ||||
|     actionTitle: string, | ||||
|     actionDescription: string, | ||||
|     actionIcon: string, | ||||
|     actionColor: string, | ||||
|     openExternally: boolean, | ||||
|   ) => { | ||||
|     setIsModalVisible(false); | ||||
|     handleSave(actionUrl, actionTitle, actionDescription, actionIcon, actionColor, openExternally); | ||||
|   }; | ||||
|  | ||||
|   const handleModalCancelButton = () => { | ||||
|     setIsModalVisible(false); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <Title>External Actions</Title> | ||||
|       <Paragraph>Description goes here.</Paragraph> | ||||
|       <Paragraph> | ||||
|         Read more about how to use actions, with examples, at{' '} | ||||
|         <a href="https://owncast.online/thirdparty/?source=admin">our documentation</a>. | ||||
|       </Paragraph> | ||||
|  | ||||
|       <Table rowKey="id" columns={columns} dataSource={actions} pagination={false} /> | ||||
|       <br /> | ||||
|       <Button type="primary" onClick={showCreateModal}> | ||||
|         Create New Action | ||||
|       </Button> | ||||
|       <FormStatusIndicator status={submitStatus} /> | ||||
|  | ||||
|       <NewActionModal | ||||
|         visible={isModalVisible} | ||||
|         onOk={handleModalSaveButton} | ||||
|         onCancel={handleModalCancelButton} | ||||
|       /> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| @ -80,6 +80,13 @@ export interface S3Field { | ||||
|   servingEndpoint?: string; | ||||
| } | ||||
|  | ||||
| export interface ExternalAction { | ||||
|   title: string, | ||||
|   description: string; | ||||
|   url: string; | ||||
|   openExternally: boolean; | ||||
| } | ||||
|  | ||||
| export interface ConfigDetails { | ||||
|   ffmpegPath: string; | ||||
|   instanceDetails: ConfigInstanceDetailsFields; | ||||
| @ -90,4 +97,5 @@ export interface ConfigDetails { | ||||
|   yp: ConfigDirectoryFields; | ||||
|   videoSettings: VideoSettingsFields; | ||||
|   chatDisabled: boolean; | ||||
|   externalActions: ExternalAction[]; | ||||
| } | ||||
|  | ||||
| @ -60,9 +60,13 @@ export const DELETE_WEBHOOK = `${API_LOCATION}webhooks/delete`; | ||||
|  | ||||
| // Create a single webhook | ||||
| export const CREATE_WEBHOOK = `${API_LOCATION}webhooks/create`; | ||||
|  | ||||
| // hard coded social icons list | ||||
| export const SOCIAL_PLATFORMS_LIST = `${NEXT_PUBLIC_API_HOST}api/socialplatforms`; | ||||
|  | ||||
| // set external action links | ||||
| export const EXTERNAL_ACTIONS = `${API_LOCATION}api/externalactions` | ||||
|  | ||||
| export const API_YP_RESET = `${API_LOCATION}yp/reset`; | ||||
|  | ||||
| export const TEMP_UPDATER_API = LOGS_ALL; | ||||
|  | ||||
| @ -27,6 +27,7 @@ export const API_VIDEO_VARIANTS = '/video/streamoutputvariants'; | ||||
| export const API_WEB_PORT = '/webserverport'; | ||||
| export const API_YP_SWITCH = '/directoryenabled'; | ||||
| export const API_CHAT_DISABLE = '/chat/disable'; | ||||
| export const API_EXTERNAL_ACTIONS = '/externalactions' | ||||
|  | ||||
| export async function postConfigUpdateToAPI(args: ApiPostArgs) { | ||||
|   const { apiPath, data, onSuccess, onError } = args; | ||||
|  | ||||
| @ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| import { STATUS, fetchData, FETCH_INTERVAL, SERVER_CONFIG } from './apis'; | ||||
| import { ConfigDetails, UpdateArgs } from '../types/config-section'; | ||||
| import { ConfigDetails, UpdateArgs, ExternalAction } from '../types/config-section'; | ||||
| import { DEFAULT_VARIANT_STATE } from './config-constants'; | ||||
|  | ||||
| export const initialServerConfigState: ConfigDetails = { | ||||
| @ -44,6 +44,7 @@ export const initialServerConfigState: ConfigDetails = { | ||||
|     cpuUsageLevel: 3, | ||||
|     videoQualityVariants: [DEFAULT_VARIANT_STATE], | ||||
|   }, | ||||
|   externalActions: [], | ||||
| }; | ||||
|  | ||||
| const initialServerStatusState = { | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Gabe Kangas
					Gabe Kangas