- 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
This commit is contained in:
gingervitis 2021-01-03 00:26:26 -08:00 committed by Gabe Kangas
parent c93aefa05d
commit c6e978f182
6 changed files with 402 additions and 114 deletions

View File

@ -0,0 +1,36 @@
# Config
TODO: explain how to use <Form> 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,
},
],
}
};
```

View File

@ -1,6 +1,8 @@
// DEFAULT VALUES // DEFAULT VALUES
import React from 'react'; import React from 'react';
import { CheckCircleFilled, ExclamationCircleFilled } from '@ant-design/icons'; 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_NAME = 'Owncast User';
export const DEFAULT_TITLE = 'Owncast Server'; export const DEFAULT_TITLE = 'Owncast Server';
@ -22,95 +24,95 @@ 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. // 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` // key is the input's `fieldName`
// the structure of this mirrors config data
export const TEXTFIELD_DEFAULTS = { export const TEXTFIELD_DEFAULTS = {
instanceDetails: {
// user name
name: { name: {
apiPath: '/name', apiPath: '/name',
defaultValue: DEFAULT_NAME, defaultValue: '',
maxLength: TEXT_MAXLENGTH, maxLength: TEXT_MAXLENGTH,
placeholder: DEFAULT_NAME, placeholder: 'username',
configPath: 'instanceDetails', label: 'User name',
label: 'Server name', tip: 'Who are you? What name do you want viewers to know you?',
tip: 'This is your name that shows up on things and stuff.', },
// like "goth land"
title: {
apiPath: '/servertitle',
defaultValue: '',
maxLength: TEXT_MAXLENGTH,
placeholder: 'Owncast site name',
label: 'Server Name',
tip: 'The name of your Owncast server',
},
streamTitle: {
apiPath: '/streamtitle',
defaultValue: '',
maxLength: TEXT_MAXLENGTH,
placeholder: 'Doing cool things...',
label: 'Stream Title',
tip: 'What is your stream about today?',
}, },
summary: { summary: {
apiPath: '/serversummary', apiPath: '/serversummary',
defaultValue: DEFAULT_NAME, defaultValue: '',
maxLength: TEXT_MAXLENGTH, maxLength: 500,
placeholder: DEFAULT_NAME, placeholder: 'Summary',
configPath: 'instanceDetails',
label: 'Summary', label: 'Summary',
tip: 'A brief blurb about what your stream is about.', 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.',
},
logo: { logo: {
apiPath: '/logo', apiPath: '/logo',
defaultValue: DEFAULT_NAME, defaultValue: '',
maxLength: TEXT_MAXLENGTH, maxLength: 255,
placeholder: DEFAULT_NAME, placeholder: '/img/mylogo.png',
configPath: 'instanceDetails', label: 'Logo',
label: 'Stream Title', tip: 'Path to your logo from website root',
tip: 'A brief blurb about what your stream is about.',
}, },
extraPageContent: { extraPageContent: {
apiPath: '/pagecontent', apiPath: '/pagecontent',
placeholder: '', placeholder: '',
configPath: 'instanceDetails', label: 'Extra page content',
label: 'Stream Key',
tip: 'Custom markup about yourself', 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.",
},
streamKey: {
apiPath: '/key',
defaultValue: DEFAULT_NAME,
maxLength: TEXT_MAXLENGTH,
placeholder: DEFAULT_NAME,
configPath: '',
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',
},
// //
tags: { tags: {
@ -118,9 +120,73 @@ export const TEXTFIELD_DEFAULTS = {
defaultValue: '', defaultValue: '',
maxLength: 24, maxLength: 24,
placeholder: 'Add a new tag', placeholder: 'Add a new tag',
configPath: 'instanceDetails',
label: '', label: '',
tip: '', tip: '',
},
},
streamKey: {
apiPath: '/key',
defaultValue: DEFAULT_NAME,
maxLength: TEXT_MAXLENGTH,
placeholder: 'abc123',
label: 'Stream Key',
tip: 'Secret stream key',
required: true,
},
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: 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.',
}
} }
} }

View File

@ -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 (
<div className="config-directory-details-form">
<Title level={3}>Owncast Directory Settings</Title>
<p>Would you like to appear in the <a href="https://directory.owncast.online" target="_blank" rel="noreferrer"><strong>Owncast Directory</strong></a>?</p>
<p><em>NOTE: You will need to have a URL specified in the <code>Instance URL</code> field to be able to use this.</em></p>
<div className="config-yp-container">
<Form
form={form}
layout="vertical"
>
<ToggleSwitch fieldName="enabled" configPath="yp" {...extraProps}/>
<ToggleSwitch fieldName="nsfw" configPath="instanceDetails" {...extraProps} />
</Form>
</div>
</div>
);
}

View File

@ -11,19 +11,21 @@ const { Title } = Typography;
export default function EditInstanceTags() { export default function EditInstanceTags() {
const [newTagInput, setNewTagInput] = useState(''); const [newTagInput, setNewTagInput] = useState('');
const [submitStatus, setSubmitStatus] = useState(null); const [submitStatus, setSubmitStatus] = useState(null);
const [submitDetails, setSubmitDetails] = useState(''); const [submitStatusMessage, setSubmitStatusMessage] = useState('');
const serverStatusData = useContext(ServerStatusContext); const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setConfigField } = serverStatusData || {}; const { serverConfig, setConfigField } = serverStatusData || {};
const { instanceDetails } = serverConfig; const { instanceDetails } = serverConfig;
const { tags = [] } = instanceDetails; const { tags = [] } = instanceDetails;
const configPath = 'instanceDetails';
const { const {
apiPath, apiPath,
maxLength, maxLength,
placeholder, placeholder,
configPath, } = TEXTFIELD_DEFAULTS[configPath].tags || {};
} = TEXTFIELD_DEFAULTS.tags || {};
let resetTimer = null; let resetTimer = null;
@ -35,38 +37,34 @@ export default function EditInstanceTags() {
const resetStates = () => { const resetStates = () => {
setSubmitStatus(null); setSubmitStatus(null);
setSubmitDetails(''); setSubmitStatusMessage('');
resetTimer = null; resetTimer = null;
clearTimeout(resetTimer); clearTimeout(resetTimer);
} }
// posts all the tags at once as an array obj // posts all the tags at once as an array obj
const postUpdateToAPI = async (postValue: any) => { const postUpdateToAPI = async (postValue: any) => {
// const result = await fetchData(`${SERVER_CONFIG_UPDATE_URL}${apiPath}`, { const result = await fetchData(`${SERVER_CONFIG_UPDATE_URL}${apiPath}`, {
// data: { value: postValue }, data: { value: postValue },
// method: 'POST', method: 'POST',
// auth: true, auth: true,
// }); });
const result = {
success: true,
message: 'success yay'
}
if (result.success) { if (result.success) {
setConfigField({ fieldName: 'tags', value: postValue, path: configPath }); setConfigField({ fieldName: 'tags', value: postValue, path: configPath });
setSubmitStatus('success'); setSubmitStatus('success');
setSubmitDetails('Tags updated.'); setSubmitStatusMessage('Tags updated.');
setNewTagInput(''); setNewTagInput('');
} else { } else {
setSubmitStatus('error'); setSubmitStatus('error');
setSubmitDetails(result.message); setSubmitStatusMessage(result.message);
} }
resetTimer = setTimeout(resetStates, RESET_TIMEOUT); resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}; };
const handleInputChange = e => { const handleInputChange = e => {
if (submitDetails !== '') { if (submitStatusMessage !== '') {
setSubmitDetails(''); setSubmitStatusMessage('');
} }
setNewTagInput(e.target.value); setNewTagInput(e.target.value);
}; };
@ -76,11 +74,11 @@ export default function EditInstanceTags() {
resetStates(); resetStates();
const newTag = newTagInput.trim(); const newTag = newTagInput.trim();
if (newTag === '') { if (newTag === '') {
setSubmitDetails('Please enter a tag'); setSubmitStatusMessage('Please enter a tag');
return; return;
} }
if (tags.some(tag => tag.toLowerCase() === newTag.toLowerCase())) { if (tags.some(tag => tag.toLowerCase() === newTag.toLowerCase())) {
setSubmitDetails('This tag is already used!'); setSubmitStatusMessage('This tag is already used!');
return; return;
} }
@ -116,8 +114,8 @@ export default function EditInstanceTags() {
); );
})} })}
</div> </div>
<div className={`add-new-status ${submitStatus || ''}`}> <div className={`status-message ${submitStatus || ''}`}>
{newStatusIcon} {newStatusMessage} {submitDetails} {newStatusIcon} {newStatusMessage} {submitStatusMessage}
</div> </div>
<div className="add-new-tag-section"> <div className="add-new-tag-section">
<Input <Input
@ -130,7 +128,6 @@ export default function EditInstanceTags() {
placeholder={placeholder} placeholder={placeholder}
allowClear allowClear
/> />
</div> </div>
</div> </div>
); );

View File

@ -46,23 +46,31 @@ export default function TextField(props: TextFieldProps) {
const { setConfigField } = serverStatusData || {}; const { setConfigField } = serverStatusData || {};
const { const {
configPath = '',
disabled = false,
fieldName, fieldName,
type,
initialValues = {},
handleResetValue, handleResetValue,
initialValues = {},
onSubmit,
type,
} = props; } = 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 <Form> which is doing a form.setFieldsValue() upstream.
const initialValue = initialValues[fieldName] || ''; const initialValue = initialValues[fieldName] || '';
// Get other static info we know about this field.
const defaultDetails = TEXTFIELD_DEFAULTS[configPath] || TEXTFIELD_DEFAULTS;
const { const {
apiPath = '', apiPath = '',
configPath = '',
maxLength = TEXT_MAXLENGTH, maxLength = TEXT_MAXLENGTH,
// placeholder = '', placeholder = '',
label = '', label = '',
tip = '', tip = '',
} = TEXTFIELD_DEFAULTS[fieldName] || {}; required = false,
} = defaultDetails[fieldName] || {};
// Clear out any validation states and messaging
const resetStates = () => { const resetStates = () => {
setSubmitStatus(''); setSubmitStatus('');
setHasChanged(false); setHasChanged(false);
@ -87,9 +95,10 @@ export default function TextField(props: TextFieldProps) {
resetTimer = setTimeout(resetStates, RESET_TIMEOUT); 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; const val = type === TEXTFIELD_TYPE_NUMBER ? e : e.target.value;
if (val === '' || val === initialValue) { if ((required && (val === '' || val === null)) || val === initialValue) {
setHasChanged(false); setHasChanged(false);
} else { } else {
resetStates(); 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 handleBlur = e => {
const val = e.target.value; const val = e.target.value;
if (val === '') { if (required && val === '') {
handleResetValue(fieldName); handleResetValue(fieldName);
} }
}; };
// how to get current value of input // how to get current value of input
const handleSubmit = () => { const handleSubmit = () => {
if (fieldValueForSubmit !== '' && fieldValueForSubmit !== initialValue) { if ((required && fieldValueForSubmit !== '') || fieldValueForSubmit !== initialValue) {
postUpdateToAPI(fieldValueForSubmit); 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 = {}; let fieldProps = {};
if (type === TEXTFIELD_TYPE_TEXTAREA) { if (type === TEXTFIELD_TYPE_TEXTAREA) {
Field = Input.TextArea; Field = Input.TextArea;
@ -142,14 +157,16 @@ export default function TextField(props: TextFieldProps) {
hasFeedback hasFeedback
validateStatus={submitStatus} validateStatus={submitStatus}
help={submitStatusMessage} help={submitStatusMessage}
required={required}
> >
<Field <Field
className="field" className="field"
allowClear allowClear
placeholder={initialValue} placeholder={placeholder}
maxLength={maxLength} maxLength={maxLength}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
disabled={disabled}
{...fieldProps} {...fieldProps}
/> />
</Form.Item> </Form.Item>

View File

@ -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<FormItemProps['validateStatus']>('');
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 ? (
<span className="info">
<Tooltip title={tip}>
<InfoCircleOutlined />
</Tooltip>
</span>
) : null;
return (
<div className="toggleswitch-container">
<div className="toggleswitch">
<Form.Item
name={fieldName}
validateStatus={submitStatus}
>
<Switch
className="switch"
loading={submitStatus === 'validating'}
onChange={handleChange}
checked={initialValue}
checkedChildren="ON"
unCheckedChildren="OFF"
disabled={disabled}
/>
</Form.Item>
<span className="label">{label}</span>
{tipComponent}
</div>
<div className={`status-message ${submitStatus || ''}`}>
{newStatusIcon} {newStatusMessage} {submitStatusMessage}
</div>
</div>
);
}