0

- 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
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.',
}
}
}

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() {
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() {
);
})}
</div>
<div className={`add-new-status ${submitStatus || ''}`}>
{newStatusIcon} {newStatusMessage} {submitDetails}
<div className={`status-message ${submitStatus || ''}`}>
{newStatusIcon} {newStatusMessage} {submitStatusMessage}
</div>
<div className="add-new-tag-section">
<Input
@ -130,7 +128,6 @@ export default function EditInstanceTags() {
placeholder={placeholder}
allowClear
/>
</div>
</div>
);

View File

@ -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 <Form> 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}
>
<Field
className="field"
allowClear
placeholder={initialValue}
placeholder={placeholder}
maxLength={maxLength}
onChange={handleChange}
onBlur={handleBlur}
disabled={disabled}
{...fieldProps}
/>
</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>
);
}