From c6e978f182b2a28e370c286f52b480c449dffa10 Mon Sep 17 00:00:00 2001 From: gingervitis Date: Sun, 3 Jan 2021 00:26:26 -0800 Subject: [PATCH] - start a README to document config admin later - update constants - add instanceUrl field to public details; if empty, then turn off yp.enabled. - edit YP/Directory settings; hide if instanceUrl is empty - update toggleswitch logic --- web/pages/components/config/README.md | 36 +++ web/pages/components/config/constants.tsx | 228 +++++++++++------- .../components/config/edit-directory.tsx | 60 +++++ .../config/{tags.tsx => edit-tags.tsx} | 41 ++-- .../components/config/form-textfield.tsx | 39 ++- .../components/config/form-toggleswitch.tsx | 112 +++++++++ 6 files changed, 402 insertions(+), 114 deletions(-) create mode 100644 web/pages/components/config/README.md create mode 100644 web/pages/components/config/edit-directory.tsx rename web/pages/components/config/{tags.tsx => edit-tags.tsx} (80%) create mode 100644 web/pages/components/config/form-toggleswitch.tsx diff --git a/web/pages/components/config/README.md b/web/pages/components/config/README.md new file mode 100644 index 000000000..6ff97f1f7 --- /dev/null +++ b/web/pages/components/config/README.md @@ -0,0 +1,36 @@ +# Config + + +TODO: explain how to use
and how the custom `form-xxxx` components work together. + + +## Misc notes +- `instanceDetails` needs to be filled out before `yp.enabled` can be turned on. + + + +## Config data structure (with default values) +``` +{ + streamKey: '', + instanceDetails: { + tags: [], + nsfw: false, + }, + yp: { + enabled: false, + instance: '', + }, + videoSettings: { + videoQualityVariants: [ + { + audioPassthrough: false, + videoPassthrough: false, + videoBitrate: 0, + audioBitrate: 0, + framerate: 0, + }, + ], + } +}; +``` \ No newline at end of file diff --git a/web/pages/components/config/constants.tsx b/web/pages/components/config/constants.tsx index b1bc93070..5dc54a834 100644 --- a/web/pages/components/config/constants.tsx +++ b/web/pages/components/config/constants.tsx @@ -1,6 +1,8 @@ // DEFAULT VALUES import React from 'react'; import { CheckCircleFilled, ExclamationCircleFilled } from '@ant-design/icons'; +import { fetchData, SERVER_CONFIG_UPDATE_URL } from '../../../utils/apis'; +import { ApiPostArgs } from '../../../types/config-section'; export const DEFAULT_NAME = 'Owncast User'; export const DEFAULT_TITLE = 'Owncast Server'; @@ -22,105 +24,169 @@ export const SUCCESS_STATES = { }; + +export async function postConfigUpdateToAPI(args: ApiPostArgs) { + const { + apiPath, + data, + onSuccess, + onError, + } = args; + const result = await fetchData(`${SERVER_CONFIG_UPDATE_URL}${apiPath}`, { + data, + method: 'POST', + auth: true, + }); + if (result.success && onSuccess) { + onSuccess(); + } else if (onError) { + onError(); + } +} + // Creating this so that it'll be easier to change values in one place, rather than looking for places to change it in a sea of JSX. // key is the input's `fieldName` - +// the structure of this mirrors config data export const TEXTFIELD_DEFAULTS = { - name: { - apiPath: '/name', - defaultValue: DEFAULT_NAME, - maxLength: TEXT_MAXLENGTH, - placeholder: DEFAULT_NAME, - configPath: 'instanceDetails', - label: 'Server name', - tip: 'This is your name that shows up on things and stuff.', - }, + instanceDetails: { - summary: { - apiPath: '/serversummary', - defaultValue: DEFAULT_NAME, - maxLength: TEXT_MAXLENGTH, - placeholder: DEFAULT_NAME, - configPath: 'instanceDetails', - label: 'Summary', - tip: 'A brief blurb about what your stream is about.', - }, - title: { - apiPath: '/servertitle', - defaultValue: DEFAULT_NAME, - maxLength: TEXT_MAXLENGTH, - placeholder: DEFAULT_NAME, - configPath: 'instanceDetails', - label: 'Server Title', - tip: 'A brief blurb about what your stream is about.', - }, - streamTitle: { - apiPath: '/streamtitle', - defaultValue: DEFAULT_NAME, - maxLength: TEXT_MAXLENGTH, - placeholder: DEFAULT_NAME, - configPath: 'instanceDetails', - label: 'Stream Title', - tip: 'The name of your stream today.', - }, + // user name + name: { + apiPath: '/name', + defaultValue: '', + maxLength: TEXT_MAXLENGTH, + placeholder: 'username', + label: 'User name', + tip: 'Who are you? What name do you want viewers to know you?', + }, - logo: { - apiPath: '/logo', - defaultValue: DEFAULT_NAME, - maxLength: TEXT_MAXLENGTH, - placeholder: DEFAULT_NAME, - configPath: 'instanceDetails', - label: 'Stream Title', - tip: 'A brief blurb about what your stream is about.', - }, + // like "goth land" + title: { + apiPath: '/servertitle', + defaultValue: '', + maxLength: TEXT_MAXLENGTH, + placeholder: 'Owncast site name', + label: 'Server Name', + tip: 'The name of your Owncast server', + }, - extraPageContent: { - apiPath: '/pagecontent', - placeholder: '', - configPath: 'instanceDetails', - label: 'Stream Key', - tip: 'Custom markup about yourself', - }, + streamTitle: { + apiPath: '/streamtitle', + defaultValue: '', + maxLength: TEXT_MAXLENGTH, + placeholder: 'Doing cool things...', + label: 'Stream Title', + tip: 'What is your stream about today?', + }, + + summary: { + apiPath: '/serversummary', + defaultValue: '', + maxLength: 500, + placeholder: 'Summary', + label: 'Summary', + tip: 'A brief blurb about what your stream is about.', + }, + + logo: { + apiPath: '/logo', + defaultValue: '', + maxLength: 255, + placeholder: '/img/mylogo.png', + label: 'Logo', + tip: 'Path to your logo from website root', + }, + + extraPageContent: { + apiPath: '/pagecontent', + placeholder: '', + label: 'Extra page content', + tip: 'Custom markup about yourself', + }, + + nsfw: { + apiPath: '/nsfw', + placeholder: '', + label: 'NSFW?', + tip: "Turn this ON if you plan to steam explicit or adult content. You may want to respectfully set this flag so that unexpecting eyes won't accidentally see it from the Directory.", + }, + + + // + tags: { + apiPath: '/tags', + defaultValue: '', + maxLength: 24, + placeholder: 'Add a new tag', + label: '', + tip: '', + }, + }, + streamKey: { apiPath: '/key', defaultValue: DEFAULT_NAME, maxLength: TEXT_MAXLENGTH, - placeholder: DEFAULT_NAME, - configPath: '', + placeholder: 'abc123', label: 'Stream Key', tip: 'Secret stream key', - }, - ffmpegPath: { - // apiPath: '/key', - defaultValue: DEFAULT_NAME, - maxLength: TEXT_MAXLENGTH, - placeholder: DEFAULT_NAME, - configPath: '', - label: 'FFmpeg Path', - tip: 'Absolute file path of the FFMPEG application on your server', - }, - webServerPort: { - apiPath: '/port', - defaultValue: '', - maxLength: 6, - placeholder: DEFAULT_NAME, - configPath: '', - label: 'Server port', - tip: 'What port are you serving Owncast from? Default is :8080', + required: true, }, - // - tags: { - apiPath: '/tags', + ffmpegPath: { + apiPath: '/ffmpegpath', + defaultValue: DEFAULT_NAME, + maxLength: TEXT_MAXLENGTH, + placeholder: '/usr/local/bin/ffmpeg', + label: 'FFmpeg Path', + tip: 'Absolute file path of the FFMPEG application on your server', + required: true, + }, + + webServerPort: { + apiPath: '/webserverport', defaultValue: '', - maxLength: 24, - placeholder: 'Add a new tag', - configPath: 'instanceDetails', - label: '', - tip: '', + maxLength: 6, + placeholder: '8080', + label: 'Owncast Server port', + tip: 'What port are you serving Owncast from? Default is :8080', + required: true, + }, + rtmpServerPort: { + apiPath: '/rtmpserverport', + defaultValue: '1935', + maxLength: 6, + placeholder: DEFAULT_NAME, + label: 'RTMP port', + tip: 'What port are you receiving RTMP?', + required: true, + }, + + s3: { + // tbd + }, + + // YP options + yp: { + instanceUrl: { + apiPath: '/serverurl', + defaultValue: 'https://owncast.mysite.com', + maxLength: 255, + placeholder: 'url', + label: 'Instance URL', + tip: 'Please provide the url to your Owncast site if you enable this Directory setting.', + }, + enabled: { + apiPath: '/directoryenabled', + defaultValue: false, + maxLength: 0, + placeholder: '', + label: 'Display in the Owncast Directory?', + tip: 'Turn this ON if you want to show up in the Owncast directory at https://directory.owncast.online.', + } } } diff --git a/web/pages/components/config/edit-directory.tsx b/web/pages/components/config/edit-directory.tsx new file mode 100644 index 000000000..c2e1d5c1a --- /dev/null +++ b/web/pages/components/config/edit-directory.tsx @@ -0,0 +1,60 @@ +// rename to "directory" +import React, { useContext, useEffect } from 'react'; +import { Typography, Form } from 'antd'; + +import ToggleSwitch from './form-toggleswitch'; + +import { ServerStatusContext } from '../../../utils/server-status-context'; + +const { Title } = Typography; + +export default function EditYPDetails() { + const [form] = Form.useForm(); + + const serverStatusData = useContext(ServerStatusContext); + const { serverConfig } = serverStatusData || {}; + + const { yp, instanceDetails } = serverConfig; + const { nsfw } = instanceDetails; + const { enabled, instanceUrl } = yp; + + const initialValues = { + ...yp, + enabled, + nsfw, + }; + + const hasInstanceUrl = instanceUrl !== ''; + + useEffect(() => { + form.setFieldsValue(initialValues); + }, [yp]); + + const extraProps = { + initialValues, + disabled: !hasInstanceUrl, + }; + + // TODO: DISABLE THIS SECTION UNTIL instanceURL is populated + return ( +
+ Owncast Directory Settings + +

Would you like to appear in the Owncast Directory?

+ +

NOTE: You will need to have a URL specified in the Instance URL field to be able to use this.

+ +
+ + + + +
+
+ ); +} + + diff --git a/web/pages/components/config/tags.tsx b/web/pages/components/config/edit-tags.tsx similarity index 80% rename from web/pages/components/config/tags.tsx rename to web/pages/components/config/edit-tags.tsx index fe97be1cf..1b56f541c 100644 --- a/web/pages/components/config/tags.tsx +++ b/web/pages/components/config/edit-tags.tsx @@ -11,19 +11,21 @@ const { Title } = Typography; export default function EditInstanceTags() { const [newTagInput, setNewTagInput] = useState(''); const [submitStatus, setSubmitStatus] = useState(null); - const [submitDetails, setSubmitDetails] = useState(''); + const [submitStatusMessage, setSubmitStatusMessage] = useState(''); const serverStatusData = useContext(ServerStatusContext); const { serverConfig, setConfigField } = serverStatusData || {}; const { instanceDetails } = serverConfig; const { tags = [] } = instanceDetails; + const configPath = 'instanceDetails'; + const { apiPath, maxLength, placeholder, - configPath, - } = TEXTFIELD_DEFAULTS.tags || {}; + } = TEXTFIELD_DEFAULTS[configPath].tags || {}; + let resetTimer = null; @@ -35,38 +37,34 @@ export default function EditInstanceTags() { const resetStates = () => { setSubmitStatus(null); - setSubmitDetails(''); + setSubmitStatusMessage(''); resetTimer = null; clearTimeout(resetTimer); } // posts all the tags at once as an array obj const postUpdateToAPI = async (postValue: any) => { - // const result = await fetchData(`${SERVER_CONFIG_UPDATE_URL}${apiPath}`, { - // data: { value: postValue }, - // method: 'POST', - // auth: true, - // }); + const result = await fetchData(`${SERVER_CONFIG_UPDATE_URL}${apiPath}`, { + data: { value: postValue }, + method: 'POST', + auth: true, + }); - const result = { - success: true, - message: 'success yay' - } if (result.success) { setConfigField({ fieldName: 'tags', value: postValue, path: configPath }); setSubmitStatus('success'); - setSubmitDetails('Tags updated.'); + setSubmitStatusMessage('Tags updated.'); setNewTagInput(''); } else { setSubmitStatus('error'); - setSubmitDetails(result.message); + setSubmitStatusMessage(result.message); } resetTimer = setTimeout(resetStates, RESET_TIMEOUT); }; const handleInputChange = e => { - if (submitDetails !== '') { - setSubmitDetails(''); + if (submitStatusMessage !== '') { + setSubmitStatusMessage(''); } setNewTagInput(e.target.value); }; @@ -76,11 +74,11 @@ export default function EditInstanceTags() { resetStates(); const newTag = newTagInput.trim(); if (newTag === '') { - setSubmitDetails('Please enter a tag'); + setSubmitStatusMessage('Please enter a tag'); return; } if (tags.some(tag => tag.toLowerCase() === newTag.toLowerCase())) { - setSubmitDetails('This tag is already used!'); + setSubmitStatusMessage('This tag is already used!'); return; } @@ -116,8 +114,8 @@ export default function EditInstanceTags() { ); })} -
- {newStatusIcon} {newStatusMessage} {submitDetails} +
+ {newStatusIcon} {newStatusMessage} {submitStatusMessage}
-
); diff --git a/web/pages/components/config/form-textfield.tsx b/web/pages/components/config/form-textfield.tsx index 66d017e94..63d69347e 100644 --- a/web/pages/components/config/form-textfield.tsx +++ b/web/pages/components/config/form-textfield.tsx @@ -46,23 +46,31 @@ export default function TextField(props: TextFieldProps) { const { setConfigField } = serverStatusData || {}; const { + configPath = '', + disabled = false, fieldName, - type, - initialValues = {}, handleResetValue, + initialValues = {}, + onSubmit, + type, } = props; + // Keep track of what the initial value is + // Note: we're not using `initialValue` as a prop, because we expect this component to be controlled by a parent Ant
which is doing a form.setFieldsValue() upstream. const initialValue = initialValues[fieldName] || ''; + // Get other static info we know about this field. + const defaultDetails = TEXTFIELD_DEFAULTS[configPath] || TEXTFIELD_DEFAULTS; const { apiPath = '', - configPath = '', maxLength = TEXT_MAXLENGTH, - // placeholder = '', + placeholder = '', label = '', tip = '', - } = TEXTFIELD_DEFAULTS[fieldName] || {}; + required = false, + } = defaultDetails[fieldName] || {}; + // Clear out any validation states and messaging const resetStates = () => { setSubmitStatus(''); setHasChanged(false); @@ -87,9 +95,10 @@ export default function TextField(props: TextFieldProps) { resetTimer = setTimeout(resetStates, RESET_TIMEOUT); }; - const handleChange = e => { + // if field is required but value is empty, or equals initial value, then don't show submit/update button. otherwise clear out any result messaging and display button. + const handleChange = (e: any) => { const val = type === TEXTFIELD_TYPE_NUMBER ? e : e.target.value; - if (val === '' || val === initialValue) { + if ((required && (val === '' || val === null)) || val === initialValue) { setHasChanged(false); } else { resetStates(); @@ -98,21 +107,27 @@ export default function TextField(props: TextFieldProps) { } }; + // if you blur a required field with an empty value, restore its original value const handleBlur = e => { const val = e.target.value; - if (val === '') { + if (required && val === '') { handleResetValue(fieldName); } }; // how to get current value of input const handleSubmit = () => { - if (fieldValueForSubmit !== '' && fieldValueForSubmit !== initialValue) { + if ((required && fieldValueForSubmit !== '') || fieldValueForSubmit !== initialValue) { postUpdateToAPI(fieldValueForSubmit); + // if an extra onSubmit handler was sent in as a prop, let's run that too. + if (onSubmit) { + onSubmit(); + } } } - let Field = Input; + // display the appropriate Ant text field + let Field = Input as typeof Input | typeof InputNumber | typeof Input.TextArea | typeof Input.Password; let fieldProps = {}; if (type === TEXTFIELD_TYPE_TEXTAREA) { Field = Input.TextArea; @@ -142,14 +157,16 @@ export default function TextField(props: TextFieldProps) { hasFeedback validateStatus={submitStatus} help={submitStatusMessage} + required={required} > diff --git a/web/pages/components/config/form-toggleswitch.tsx b/web/pages/components/config/form-toggleswitch.tsx new file mode 100644 index 000000000..b9f79a998 --- /dev/null +++ b/web/pages/components/config/form-toggleswitch.tsx @@ -0,0 +1,112 @@ +import React, { useState, useContext } from 'react'; +import { Form, Switch, Tooltip } from 'antd'; +import { FormItemProps } from 'antd/es/form'; + +import { InfoCircleOutlined } from '@ant-design/icons'; + +import { TEXTFIELD_DEFAULTS, RESET_TIMEOUT, SUCCESS_STATES } from './constants'; + +import { ToggleSwitchProps } from '../../../types/config-section'; +import { fetchData, SERVER_CONFIG_UPDATE_URL } from '../../../utils/apis'; +import { ServerStatusContext } from '../../../utils/server-status-context'; + +export const TEXTFIELD_TYPE_TEXT = 'default'; +export const TEXTFIELD_TYPE_PASSWORD = 'password'; // Input.Password +export const TEXTFIELD_TYPE_NUMBER = 'numeric'; +export const TEXTFIELD_TYPE_TEXTAREA = 'textarea'; + + +export default function ToggleSwitch(props: ToggleSwitchProps) { + const [submitStatus, setSubmitStatus] = useState(''); + const [submitStatusMessage, setSubmitStatusMessage] = useState(''); + + let resetTimer = null; + + const serverStatusData = useContext(ServerStatusContext); + const { setConfigField } = serverStatusData || {}; + + const { + fieldName, + initialValues = {}, + configPath = '', + disabled = false, + } = props; + + const initialValue = initialValues[fieldName] || false; + + const defaultDetails = TEXTFIELD_DEFAULTS[configPath] || TEXTFIELD_DEFAULTS; + + const { + apiPath = '', + label = '', + tip = '', + } = defaultDetails[fieldName] || {}; + + const resetStates = () => { + setSubmitStatus(''); + clearTimeout(resetTimer); + resetTimer = null; + } + + const postUpdateToAPI = async (postValue: any) => { + setSubmitStatus('validating'); + const result = await fetchData(`${SERVER_CONFIG_UPDATE_URL}${apiPath}`, { + data: { value: postValue }, + method: 'POST', + auth: true, + }); + + if (result.success) { + setConfigField({ fieldName, value: postValue, path: configPath }); + setSubmitStatus('success'); + } else { + setSubmitStatus('error'); + setSubmitStatusMessage(`There was an error: ${result.message}`); + } + resetTimer = setTimeout(resetStates, RESET_TIMEOUT); + }; + + const handleChange = checked => { + postUpdateToAPI(checked); + } + + const { + icon: newStatusIcon = null, + message: newStatusMessage = '', + } = SUCCESS_STATES[submitStatus] || {}; + + const tipComponent = tip ? ( + + + + + + ) : null; + + return ( +
+
+ + + + + {label} + {tipComponent} +
+
+ {newStatusIcon} {newStatusMessage} {submitStatusMessage} +
+
+ ); +}