move components folder and fix build errors (#18)
* move components folder and fix build errors Fixes https://github.com/owncast/owncast/issues/689 * Prettified Code! Co-authored-by: nebunez <nebunez@users.noreply.github.com>
This commit is contained in:
127
web/components/config/README.md
Normal file
127
web/components/config/README.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# About the Config editing section
|
||||
|
||||
An adventure with React, React Hooks and Ant Design forms.
|
||||
|
||||
## General data flow in this React app
|
||||
|
||||
### First things to note
|
||||
- When the Admin app loads, the `ServerStatusContext` (in addition to checking server `/status` on a timer) makes a call to the `/serverconfig` API to get your config details. This data will be stored as **`serverConfig`** in app state, and _provided_ to the app via `useContext` hook.
|
||||
|
||||
- The `serverConfig` in state is be the central source of data that pre-populates the forms.
|
||||
|
||||
- The `ServerStatusContext` also provides a method for components to update the serverConfig state, called `setFieldInConfigState()`.
|
||||
|
||||
- After you have updated a config value in a form field, and successfully submitted it through its endpoint, you should call `setFieldInConfigState` to update the global state with the new value.
|
||||
|
||||
- Each top field of the serverConfig has its own API update endpoint.
|
||||
|
||||
### Form Flow
|
||||
Each form input (or group of inputs) you make, you should
|
||||
1. Get the field values that you want out of `serverConfig` from ServerStatusContext with `useContext`.
|
||||
2. Next we'll have to put these field values of interest into a `useState` in each grouping. This will help you edit the form.
|
||||
3. Because ths config data is populated asynchronously, Use a `useEffect` to check when that data has arrived before putting it into state.
|
||||
4. You will be using the state's value to populate the `defaultValue` and the `value` props of each Ant input component (`Input`, `Toggle`, `Switch`, `Select`, `Slider` are currently used).
|
||||
5. When an `onChange` event fires for each type of input component, you will update the local state of each page with the changed value.
|
||||
6. Depending on the form, an `onChange` of the input component, or a subsequent `onClick` of a submit button will take the value from local state and POST the field's API.
|
||||
7. `onSuccess` of the post, you should update the global app state with the new value.
|
||||
|
||||
There are also a variety of other local states to manage the display of error/success messaging.
|
||||
|
||||
## Notes about `form-textfield` and `form-togglefield`
|
||||
- The text field is intentionally designed to make it difficult for the user to submit bad data.
|
||||
- If you make a change on a field, a Submit buttton will show up that you have to click to update. That will be the only way you can update it.
|
||||
- If you clear out a field that is marked as Required, then exit/blur the field, it will repopulate with its original value.
|
||||
|
||||
- Both of these elements are specifically meant to be used with updating `serverConfig` fields, since each field requires its own endpoint.
|
||||
|
||||
- Give these fields a bunch of props, and they will display labelling, some helpful UI around tips, validation messaging, as well as submit the update for you.
|
||||
|
||||
- (currently undergoing re-styling and TS cleanup)
|
||||
|
||||
- NOTE: you don't have to use these components. Some form groups may require a customized UX flow where you're better off using the Ant components straight up.
|
||||
|
||||
|
||||
## Using Ant's `<Form>` with `form-textfield`.
|
||||
UPDATE: No more `<Form>` use!
|
||||
|
||||
~~You may see that a couple of pages (currently **Public Details** and **Server Details** page), is mainly a grouping of similar Text fields.~~
|
||||
|
||||
~~These are utilizing the `<Form>` component, and these calls:~~
|
||||
~~- `const [form] = Form.useForm();`~~
|
||||
~~- `form.setFieldsValue(initialValues);`~~
|
||||
|
||||
~~It seems that a `<Form>` requires its child inputs to be in a `<Form.Item>`, to help manage overall validation on the form before submission.~~
|
||||
|
||||
~~The `form-textfield` component was created to be used with this Form. It wraps with a `<Form.Item>`, which I believe handles the local state change updates of the value.~~
|
||||
|
||||
## Current Refactoring:
|
||||
~~While `Form` + `Form.Item` provides many useful UI features that I'd like to utilize, it's turning out to be too restricting for our uses cases.~~
|
||||
|
||||
~~I am refactoring `form-textfield` so that it does not rely on `<Form.Item>`. But it will require some extra handling and styling of things like error states and success messaging.~~
|
||||
|
||||
### UI things
|
||||
I'm in the middle of refactoring somes tyles and layout, and regorganizing some CSS. See TODO list below.
|
||||
|
||||
|
||||
---
|
||||
## Potential Optimizations
|
||||
|
||||
- There might be some patterns that could be overly engineered!
|
||||
|
||||
- There are also a few patterns across all the form groups that repeat quite a bit. Perhaps these patterns could be consolidated into a custom hook that could handle all the steps.
|
||||
|
||||
|
||||
|
||||
## Current `serverConfig` data structure (with default values)
|
||||
Each of these fields has its own end point for updating.
|
||||
```
|
||||
{
|
||||
streamKey: '',
|
||||
instanceDetails: {
|
||||
extraPageContent: '',
|
||||
logo: '',
|
||||
name: '',
|
||||
nsfw: false,
|
||||
socialHandles: [],
|
||||
streamTitle: '',
|
||||
summary: '',
|
||||
tags: [],
|
||||
title: '',
|
||||
},
|
||||
ffmpegPath: '',
|
||||
rtmpServerPort: '',
|
||||
webServerPort: '',
|
||||
s3: {},
|
||||
yp: {
|
||||
enabled: false,
|
||||
instanceUrl: '',
|
||||
},
|
||||
videoSettings: {
|
||||
latencyLevel: 4,
|
||||
videoQualityVariants: [],
|
||||
}
|
||||
};
|
||||
|
||||
// `yp.instanceUrl` needs to be filled out before `yp.enabled` can be turned on.
|
||||
```
|
||||
|
||||
|
||||
## Ginger's TODO list:
|
||||
|
||||
- cleanup
|
||||
- more consitent constants
|
||||
- cleanup types
|
||||
- cleanup style sheets..? make style module for each config page? (but what about ant deisgn overrides?)
|
||||
- redesign
|
||||
- label+form layout - put them into a table, table of rows?, includes responsive to stacked layout
|
||||
- change Encoder preset into slider
|
||||
- page headers - diff color?
|
||||
- fix social handles icon in table
|
||||
- things could use smaller font?
|
||||
- Design, color ideas
|
||||
|
||||
https://uxcandy.co/demo/label_pro/preview/demo_2/pages/forms/form-elements.html
|
||||
|
||||
https://www.bootstrapdash.com/demo/corona/jquery/template/modern-vertical/pages/forms/basic_elements.html
|
||||
- maybe convert common form pattern to custom hook?
|
||||
|
||||
62
web/components/config/cpu-usage.tsx
Normal file
62
web/components/config/cpu-usage.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { Typography, Slider } from 'antd';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const SLIDER_MARKS = {
|
||||
1: 'lowest',
|
||||
2: '',
|
||||
3: '',
|
||||
4: '',
|
||||
5: 'highest',
|
||||
};
|
||||
|
||||
const TOOLTIPS = {
|
||||
1: 'lowest',
|
||||
2: 'low',
|
||||
3: 'medium',
|
||||
4: 'high',
|
||||
5: 'highest',
|
||||
};
|
||||
|
||||
export default function CPUUsageSelector({ defaultValue, onChange }) {
|
||||
const [selectedOption, setSelectedOption] = useState(null);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
const { videoSettings } = serverConfig || {};
|
||||
|
||||
if (!videoSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOption(defaultValue);
|
||||
}, [videoSettings]);
|
||||
|
||||
const handleChange = value => {
|
||||
setSelectedOption(value);
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="config-video-segements-conatiner">
|
||||
<Title level={3}>CPU Usage</Title>
|
||||
<p>There are trade-offs when considering CPU usage blah blah more wording here.</p>
|
||||
<br />
|
||||
<br />
|
||||
<div className="segment-slider-container">
|
||||
<Slider
|
||||
tipFormatter={value => TOOLTIPS[value]}
|
||||
onChange={handleChange}
|
||||
min={1}
|
||||
max={Object.keys(SLIDER_MARKS).length}
|
||||
marks={SLIDER_MARKS}
|
||||
defaultValue={selectedOption}
|
||||
value={selectedOption}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
web/components/config/edit-directory.tsx
Normal file
69
web/components/config/edit-directory.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// Note: references to "yp" in the app are likely related to Owncast Directory
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import ToggleSwitch from './form-toggleswitch-with-submit';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { FIELD_PROPS_NSFW, FIELD_PROPS_YP } from '../../utils/config-constants';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function EditYPDetails() {
|
||||
const [formDataValues, setFormDataValues] = useState(null);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
|
||||
const { yp, instanceDetails } = serverConfig;
|
||||
const { nsfw } = instanceDetails;
|
||||
const { enabled, instanceUrl } = yp;
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
...yp,
|
||||
enabled,
|
||||
nsfw,
|
||||
});
|
||||
}, [yp, instanceDetails]);
|
||||
|
||||
const hasInstanceUrl = instanceUrl !== '';
|
||||
if (!formDataValues) {
|
||||
return null;
|
||||
}
|
||||
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 style={{ backgroundColor: 'black', fontSize: '.75rem', padding: '5px' }}>
|
||||
<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">
|
||||
<ToggleSwitch
|
||||
fieldName="enabled"
|
||||
{...FIELD_PROPS_YP}
|
||||
checked={formDataValues.enabled}
|
||||
disabled={!hasInstanceUrl}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
fieldName="nsfw"
|
||||
{...FIELD_PROPS_NSFW}
|
||||
checked={formDataValues.nsfw}
|
||||
disabled={!hasInstanceUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
web/components/config/edit-instance-details.tsx
Normal file
94
web/components/config/edit-instance-details.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import TextFieldWithSubmit, {
|
||||
TEXTFIELD_TYPE_TEXTAREA,
|
||||
TEXTFIELD_TYPE_URL,
|
||||
} from './form-textfield-with-submit';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
TEXTFIELD_PROPS_INSTANCE_URL,
|
||||
TEXTFIELD_PROPS_SERVER_NAME,
|
||||
TEXTFIELD_PROPS_SERVER_SUMMARY,
|
||||
TEXTFIELD_PROPS_LOGO,
|
||||
API_YP_SWITCH,
|
||||
} from '../../utils/config-constants';
|
||||
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
|
||||
export default function EditInstanceDetails() {
|
||||
const [formDataValues, setFormDataValues] = useState(null);
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
|
||||
const { instanceDetails, yp } = serverConfig;
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
...instanceDetails,
|
||||
...yp,
|
||||
});
|
||||
}, [instanceDetails, yp]);
|
||||
|
||||
if (!formDataValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if instanceUrl is empty, we should also turn OFF the `enabled` field of directory.
|
||||
const handleSubmitInstanceUrl = () => {
|
||||
if (formDataValues.instanceUrl === '') {
|
||||
if (yp.enabled === true) {
|
||||
postConfigUpdateToAPI({
|
||||
apiPath: API_YP_SWITCH,
|
||||
data: { value: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`publicDetailsContainer`}>
|
||||
<div className={`textFieldsSection`}>
|
||||
<TextFieldWithSubmit
|
||||
fieldName="instanceUrl"
|
||||
{...TEXTFIELD_PROPS_INSTANCE_URL}
|
||||
value={formDataValues.instanceUrl}
|
||||
initialValue={yp.instanceUrl}
|
||||
type={TEXTFIELD_TYPE_URL}
|
||||
onChange={handleFieldChange}
|
||||
onSubmit={handleSubmitInstanceUrl}
|
||||
/>
|
||||
|
||||
<TextFieldWithSubmit
|
||||
fieldName="name"
|
||||
{...TEXTFIELD_PROPS_SERVER_NAME}
|
||||
value={formDataValues.name}
|
||||
initialValue={instanceDetails.name}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<TextFieldWithSubmit
|
||||
fieldName="summary"
|
||||
{...TEXTFIELD_PROPS_SERVER_SUMMARY}
|
||||
type={TEXTFIELD_TYPE_TEXTAREA}
|
||||
value={formDataValues.summary}
|
||||
initialValue={instanceDetails.summary}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<TextFieldWithSubmit
|
||||
fieldName="logo"
|
||||
{...TEXTFIELD_PROPS_LOGO}
|
||||
value={formDataValues.logo}
|
||||
initialValue={instanceDetails.logo}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
web/components/config/edit-server-details.tsx
Normal file
140
web/components/config/edit-server-details.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { CopyOutlined, RedoOutlined } from '@ant-design/icons';
|
||||
|
||||
import { TEXTFIELD_TYPE_NUMBER, TEXTFIELD_TYPE_PASSWORD } from './form-textfield';
|
||||
import TextFieldWithSubmit from './form-textfield-with-submit';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { AlertMessageContext } from '../../utils/alert-message-context';
|
||||
|
||||
import {
|
||||
TEXTFIELD_PROPS_FFMPEG,
|
||||
TEXTFIELD_PROPS_RTMP_PORT,
|
||||
TEXTFIELD_PROPS_STREAM_KEY,
|
||||
TEXTFIELD_PROPS_WEB_PORT,
|
||||
} from '../../utils/config-constants';
|
||||
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
|
||||
export default function EditInstanceDetails() {
|
||||
const [formDataValues, setFormDataValues] = useState(null);
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { setMessage } = useContext(AlertMessageContext);
|
||||
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
|
||||
const { streamKey, ffmpegPath, rtmpServerPort, webServerPort } = serverConfig;
|
||||
|
||||
const [copyIsVisible, setCopyVisible] = useState(false);
|
||||
|
||||
const COPY_TOOLTIP_TIMEOUT = 3000;
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
streamKey,
|
||||
ffmpegPath,
|
||||
rtmpServerPort,
|
||||
webServerPort,
|
||||
});
|
||||
}, [serverConfig]);
|
||||
|
||||
if (!formDataValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const showConfigurationRestartMessage = () => {
|
||||
setMessage('Updating server settings requires a restart of your Owncast server.');
|
||||
};
|
||||
|
||||
const showStreamKeyChangeMessage = () => {
|
||||
setMessage(
|
||||
'Changing your stream key will log you out of the admin and block you from streaming until you change the key in your broadcasting software.',
|
||||
);
|
||||
};
|
||||
|
||||
const showFfmpegChangeMessage = () => {
|
||||
if (serverStatusData.online) {
|
||||
setMessage('The updated ffmpeg path will be used when starting your next live stream.');
|
||||
}
|
||||
};
|
||||
|
||||
function generateStreamKey() {
|
||||
let key = '';
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
key += Math.random().toString(36).substring(2);
|
||||
}
|
||||
|
||||
handleFieldChange({ fieldName: 'streamKey', value: key });
|
||||
}
|
||||
|
||||
function copyStreamKey() {
|
||||
navigator.clipboard.writeText(formDataValues.streamKey).then(() => {
|
||||
setCopyVisible(true);
|
||||
setTimeout(() => setCopyVisible(false), COPY_TOOLTIP_TIMEOUT);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="edit-public-details-container">
|
||||
<div className="field-container field-streamkey-container">
|
||||
<div className="left-side">
|
||||
<TextFieldWithSubmit
|
||||
fieldName="streamKey"
|
||||
{...TEXTFIELD_PROPS_STREAM_KEY}
|
||||
value={formDataValues.streamKey}
|
||||
initialValue={streamKey}
|
||||
type={TEXTFIELD_TYPE_PASSWORD}
|
||||
onChange={handleFieldChange}
|
||||
onSubmit={showStreamKeyChangeMessage}
|
||||
/>
|
||||
<div className="streamkey-actions">
|
||||
<Tooltip title="Generate a stream key">
|
||||
<Button icon={<RedoOutlined />} size="small" onClick={generateStreamKey} />
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
className="copy-tooltip"
|
||||
title={copyIsVisible ? 'Copied!' : 'Copy to clipboard'}
|
||||
>
|
||||
<Button icon={<CopyOutlined />} size="small" onClick={copyStreamKey} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TextFieldWithSubmit
|
||||
fieldName="ffmpegPath"
|
||||
{...TEXTFIELD_PROPS_FFMPEG}
|
||||
value={formDataValues.ffmpegPath}
|
||||
initialValue={ffmpegPath}
|
||||
onChange={handleFieldChange}
|
||||
onSubmit={showFfmpegChangeMessage}
|
||||
/>
|
||||
<TextFieldWithSubmit
|
||||
fieldName="webServerPort"
|
||||
{...TEXTFIELD_PROPS_WEB_PORT}
|
||||
value={formDataValues.webServerPort}
|
||||
initialValue={webServerPort}
|
||||
type={TEXTFIELD_TYPE_NUMBER}
|
||||
onChange={handleFieldChange}
|
||||
onSubmit={showConfigurationRestartMessage}
|
||||
/>
|
||||
<TextFieldWithSubmit
|
||||
fieldName="rtmpServerPort"
|
||||
{...TEXTFIELD_PROPS_RTMP_PORT}
|
||||
value={formDataValues.rtmpServerPort}
|
||||
initialValue={rtmpServerPort}
|
||||
type={TEXTFIELD_TYPE_NUMBER}
|
||||
onChange={handleFieldChange}
|
||||
onSubmit={showConfigurationRestartMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
292
web/components/config/edit-social-links.tsx
Normal file
292
web/components/config/edit-social-links.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Typography, Table, Button, Modal, Input } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import SocialDropdown from './social-icons-dropdown';
|
||||
import { fetchData, NEXT_PUBLIC_API_HOST, SOCIAL_PLATFORMS_LIST } from '../../utils/apis';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import {
|
||||
API_SOCIAL_HANDLES,
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
DEFAULT_SOCIAL_HANDLE,
|
||||
OTHER_SOCIAL_HANDLE_OPTION,
|
||||
} from '../../utils/config-constants';
|
||||
import { SocialHandle, UpdateArgs } from '../../types/config-section';
|
||||
import { isValidUrl } from '../../utils/urls';
|
||||
import TextField from './form-textfield';
|
||||
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
|
||||
import FormStatusIndicator from './form-status-indicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function EditSocialLinks() {
|
||||
const [availableIconsList, setAvailableIconsList] = useState([]);
|
||||
const [currentSocialHandles, setCurrentSocialHandles] = useState([]);
|
||||
|
||||
const [displayModal, setDisplayModal] = useState(false);
|
||||
const [displayOther, setDisplayOther] = useState(false);
|
||||
const [modalProcessing, setModalProcessing] = useState(false);
|
||||
const [editId, setEditId] = useState(-1);
|
||||
|
||||
// current data inside modal
|
||||
const [modalDataState, setModalDataState] = useState(DEFAULT_SOCIAL_HANDLE);
|
||||
|
||||
const [submitStatus, setSubmitStatus] = useState(null);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { socialHandles: initialSocialHandles } = instanceDetails;
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
const PLACEHOLDERS = {
|
||||
mastodon: 'https://mastodon.social/@username',
|
||||
twitter: 'https://twitter.com/username',
|
||||
};
|
||||
|
||||
const getAvailableIcons = async () => {
|
||||
try {
|
||||
const result = await fetchData(SOCIAL_PLATFORMS_LIST, { auth: false });
|
||||
const list = Object.keys(result).map(item => ({
|
||||
key: item,
|
||||
...result[item],
|
||||
}));
|
||||
setAvailableIconsList(list);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
const selectedOther =
|
||||
modalDataState.platform !== '' &&
|
||||
!availableIconsList.find(item => item.key === modalDataState.platform);
|
||||
|
||||
useEffect(() => {
|
||||
getAvailableIcons();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceDetails.socialHandles) {
|
||||
setCurrentSocialHandles(initialSocialHandles);
|
||||
}
|
||||
}, [instanceDetails]);
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
const resetModal = () => {
|
||||
setDisplayModal(false);
|
||||
setEditId(-1);
|
||||
setDisplayOther(false);
|
||||
setModalProcessing(false);
|
||||
setModalDataState({ ...DEFAULT_SOCIAL_HANDLE });
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
resetModal();
|
||||
};
|
||||
|
||||
const updateModalState = (fieldName: string, value: string) => {
|
||||
setModalDataState({
|
||||
...modalDataState,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
const handleDropdownSelect = (value: string) => {
|
||||
if (value === OTHER_SOCIAL_HANDLE_OPTION) {
|
||||
setDisplayOther(true);
|
||||
updateModalState('platform', '');
|
||||
} else {
|
||||
setDisplayOther(false);
|
||||
updateModalState('platform', value);
|
||||
}
|
||||
};
|
||||
const handleOtherNameChange = event => {
|
||||
const { value } = event.target;
|
||||
updateModalState('platform', value);
|
||||
};
|
||||
|
||||
const handleUrlChange = ({ value }: UpdateArgs) => {
|
||||
updateModalState('url', value);
|
||||
};
|
||||
|
||||
// posts all the variants at once as an array obj
|
||||
const postUpdateToAPI = async (postValue: any) => {
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: API_SOCIAL_HANDLES,
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'socialHandles',
|
||||
value: postValue,
|
||||
path: 'instanceDetails',
|
||||
});
|
||||
|
||||
// close modal
|
||||
setModalProcessing(false);
|
||||
handleModalCancel();
|
||||
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
|
||||
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// on Ok, send all of dataState to api
|
||||
// show loading
|
||||
// close modal when api is done
|
||||
const handleModalOk = () => {
|
||||
setModalProcessing(true);
|
||||
const postData = currentSocialHandles.length ? [...currentSocialHandles] : [];
|
||||
if (editId === -1) {
|
||||
postData.push(modalDataState);
|
||||
} else {
|
||||
postData.splice(editId, 1, modalDataState);
|
||||
}
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (index: number) => {
|
||||
const postData = [...currentSocialHandles];
|
||||
postData.splice(index, 1);
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const socialHandlesColumns: ColumnsType<SocialHandle> = [
|
||||
{
|
||||
title: '#',
|
||||
dataIndex: 'key',
|
||||
key: 'key',
|
||||
},
|
||||
{
|
||||
title: 'Platform',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
render: (platform: string) => {
|
||||
const platformInfo = availableIconsList.find(item => item.key === platform);
|
||||
if (!platformInfo) {
|
||||
return platform;
|
||||
}
|
||||
const { icon, platform: platformName } = platformInfo;
|
||||
return (
|
||||
<>
|
||||
<span className="option-icon">
|
||||
<img src={`${NEXT_PUBLIC_API_HOST}${icon}`} alt="" className="option-icon" />
|
||||
</span>
|
||||
<span className="option-label">{platformName}</span>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Url Link',
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: 'edit',
|
||||
render: (data, record, index) => {
|
||||
return (
|
||||
<span className="actions">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditId(index);
|
||||
setModalDataState({ ...currentSocialHandles[index] });
|
||||
setDisplayModal(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
className="delete-button"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleDeleteItem(index)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const okButtonProps = {
|
||||
disabled: !isValidUrl(modalDataState.url),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="social-links-edit-container">
|
||||
<p>Add all your social media handles and links to your other profiles here.</p>
|
||||
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
|
||||
<Table
|
||||
className="dataTable"
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey={record => record.url}
|
||||
columns={socialHandlesColumns}
|
||||
dataSource={currentSocialHandles}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Edit Social Handle"
|
||||
visible={displayModal}
|
||||
onOk={handleModalOk}
|
||||
onCancel={handleModalCancel}
|
||||
confirmLoading={modalProcessing}
|
||||
okButtonProps={okButtonProps}
|
||||
>
|
||||
<SocialDropdown
|
||||
iconList={availableIconsList}
|
||||
selectedOption={selectedOther ? OTHER_SOCIAL_HANDLE_OPTION : modalDataState.platform}
|
||||
onSelected={handleDropdownSelect}
|
||||
/>
|
||||
{displayOther ? (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Other"
|
||||
defaultValue={modalDataState.platform}
|
||||
onChange={handleOtherNameChange}
|
||||
/>
|
||||
<br />
|
||||
</>
|
||||
) : null}
|
||||
<br />
|
||||
<TextField
|
||||
fieldName="social-url"
|
||||
label="URL"
|
||||
placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
|
||||
value={modalDataState.url}
|
||||
onChange={handleUrlChange}
|
||||
/>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</Modal>
|
||||
<br />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
resetModal();
|
||||
setDisplayModal(true);
|
||||
}}
|
||||
>
|
||||
Add a new social link
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
web/components/config/edit-storage.tsx
Normal file
221
web/components/config/edit-storage.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Switch, Button, Collapse } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { AlertMessageContext } from '../../utils/alert-message-context';
|
||||
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
API_S3_INFO,
|
||||
RESET_TIMEOUT,
|
||||
S3_TEXT_FIELDS_INFO,
|
||||
} from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import TextField from './form-textfield';
|
||||
import FormStatusIndicator from './form-status-indicator';
|
||||
import { isValidUrl } from '../../utils/urls';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
// we could probably add more detailed checks here
|
||||
// `currentValues` is what's currently in the global store and in the db
|
||||
function checkSaveable(formValues: any, currentValues: any) {
|
||||
const { endpoint, accessKey, secret, bucket, region, enabled, servingEndpoint, acl } = formValues;
|
||||
// if fields are filled out and different from what's in store, then return true
|
||||
if (enabled) {
|
||||
if (!!endpoint && isValidUrl(endpoint) && !!accessKey && !!secret && !!bucket && !!region) {
|
||||
if (
|
||||
enabled !== currentValues.enabled ||
|
||||
endpoint !== currentValues.endpoint ||
|
||||
accessKey !== currentValues.accessKey ||
|
||||
secret !== currentValues.secret ||
|
||||
region !== currentValues.region ||
|
||||
(!!currentValues.servingEndpoint && servingEndpoint !== currentValues.servingEndpoint) ||
|
||||
(!!currentValues.acl && acl !== currentValues.acl)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (enabled !== currentValues.enabled) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function EditStorage() {
|
||||
const [formDataValues, setFormDataValues] = useState(null);
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
const [shouldDisplayForm, setShouldDisplayForm] = useState(false);
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const { setMessage: setAlertMessage } = useContext(AlertMessageContext);
|
||||
|
||||
const { s3 } = serverConfig;
|
||||
const {
|
||||
accessKey = '',
|
||||
acl = '',
|
||||
bucket = '',
|
||||
enabled = false,
|
||||
endpoint = '',
|
||||
region = '',
|
||||
secret = '',
|
||||
servingEndpoint = '',
|
||||
} = s3;
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
accessKey,
|
||||
acl,
|
||||
bucket,
|
||||
enabled,
|
||||
endpoint,
|
||||
region,
|
||||
secret,
|
||||
servingEndpoint,
|
||||
});
|
||||
setShouldDisplayForm(enabled);
|
||||
}, [s3]);
|
||||
|
||||
if (!formDataValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let resetTimer = null;
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
// update individual values in state
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
// posts the whole state
|
||||
const handleSave = async () => {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
const postValue = formDataValues;
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: API_S3_INFO,
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({ fieldName: 's3', value: postValue, path: '' });
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
setAlertMessage(
|
||||
'Changing your storage configuration will take place the next time you start a new stream.',
|
||||
);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// toggle switch.
|
||||
const handleSwitchChange = (storageEnabled: boolean) => {
|
||||
setShouldDisplayForm(storageEnabled);
|
||||
handleFieldChange({ fieldName: 'enabled', value: storageEnabled });
|
||||
};
|
||||
|
||||
const containerClass = classNames({
|
||||
'edit-storage-container': true,
|
||||
enabled: shouldDisplayForm,
|
||||
});
|
||||
|
||||
const isSaveable = checkSaveable(formDataValues, s3);
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className="enable-switch">
|
||||
<Switch
|
||||
checked={formDataValues.enabled}
|
||||
defaultChecked={formDataValues.enabled}
|
||||
onChange={handleSwitchChange}
|
||||
checkedChildren="ON"
|
||||
unCheckedChildren="OFF"
|
||||
/>{' '}
|
||||
Enabled
|
||||
</div>
|
||||
|
||||
<div className="form-fields">
|
||||
<div className="field-container">
|
||||
<TextField
|
||||
{...S3_TEXT_FIELDS_INFO.endpoint}
|
||||
value={formDataValues.endpoint}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-container">
|
||||
<TextField
|
||||
{...S3_TEXT_FIELDS_INFO.accessKey}
|
||||
value={formDataValues.accessKey}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-container">
|
||||
<TextField
|
||||
{...S3_TEXT_FIELDS_INFO.secret}
|
||||
value={formDataValues.secret}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-container">
|
||||
<TextField
|
||||
{...S3_TEXT_FIELDS_INFO.bucket}
|
||||
value={formDataValues.bucket}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-container">
|
||||
<TextField
|
||||
{...S3_TEXT_FIELDS_INFO.region}
|
||||
value={formDataValues.region}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Collapse className="advanced-section">
|
||||
<Panel header="Optional Settings" key="1">
|
||||
<div className="field-container">
|
||||
<TextField
|
||||
{...S3_TEXT_FIELDS_INFO.acl}
|
||||
value={formDataValues.acl}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-container">
|
||||
<TextField
|
||||
{...S3_TEXT_FIELDS_INFO.servingEndpoint}
|
||||
value={formDataValues.servingEndpoint}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<div className="button-container">
|
||||
<Button type="primary" onClick={handleSave} disabled={!isSaveable}>
|
||||
Save
|
||||
</Button>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
web/components/config/edit-tags.tsx
Normal file
133
web/components/config/edit-tags.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { Typography, Tag } from 'antd';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import {
|
||||
FIELD_PROPS_TAGS,
|
||||
RESET_TIMEOUT,
|
||||
postConfigUpdateToAPI,
|
||||
} from '../../utils/config-constants';
|
||||
import TextField from './form-textfield';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
STATUS_WARNING,
|
||||
} from '../../utils/input-statuses';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function EditInstanceTags() {
|
||||
const [newTagInput, setNewTagInput] = useState<string>('');
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { tags = [] } = instanceDetails;
|
||||
|
||||
const { apiPath, maxLength, placeholder, configPath } = FIELD_PROPS_TAGS;
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
// posts all the tags at once as an array obj
|
||||
const postUpdateToAPI = async (postValue: any) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath,
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({ fieldName: 'tags', value: postValue, path: configPath });
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Tags updated.'));
|
||||
setNewTagInput('');
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = ({ value }: UpdateArgs) => {
|
||||
if (!submitStatus) {
|
||||
setSubmitStatus(null);
|
||||
}
|
||||
setNewTagInput(value);
|
||||
};
|
||||
|
||||
// send to api and do stuff
|
||||
const handleSubmitNewTag = () => {
|
||||
resetStates();
|
||||
const newTag = newTagInput.trim();
|
||||
if (newTag === '') {
|
||||
setSubmitStatus(createInputStatus(STATUS_WARNING, 'Please enter a tag'));
|
||||
return;
|
||||
}
|
||||
if (tags.some(tag => tag.toLowerCase() === newTag.toLowerCase())) {
|
||||
setSubmitStatus(createInputStatus(STATUS_WARNING, 'This tag is already used!'));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTags = [...tags, newTag];
|
||||
postUpdateToAPI(updatedTags);
|
||||
};
|
||||
|
||||
const handleDeleteTag = index => {
|
||||
resetStates();
|
||||
const updatedTags = [...tags];
|
||||
updatedTags.splice(index, 1);
|
||||
postUpdateToAPI(updatedTags);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tag-editor-container">
|
||||
<Title level={3}>Add Tags</Title>
|
||||
<p>This is a great way to categorize your Owncast server on the Directory!</p>
|
||||
|
||||
<div className="tag-current-tags">
|
||||
{tags.map((tag, index) => {
|
||||
const handleClose = () => {
|
||||
handleDeleteTag(index);
|
||||
};
|
||||
return (
|
||||
<Tag closable onClose={handleClose} key={`tag-${tag}-${index}`}>
|
||||
{tag}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="add-new-tag-section">
|
||||
<TextField
|
||||
fieldName="tag-input"
|
||||
value={newTagInput}
|
||||
className="new-tag-input"
|
||||
onChange={handleInputChange}
|
||||
onPressEnter={handleSubmitNewTag}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
status={submitStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
web/components/config/form-status-indicator.tsx
Normal file
22
web/components/config/form-status-indicator.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { StatusState } from '../../utils/input-statuses';
|
||||
|
||||
interface FormStatusIndicatorProps {
|
||||
status: StatusState;
|
||||
}
|
||||
export default function FormStatusIndicator({ status }: FormStatusIndicatorProps) {
|
||||
const { type, icon, message } = status || {};
|
||||
const classes = classNames({
|
||||
'status-container': true,
|
||||
[`status-${type}`]: type,
|
||||
empty: !message,
|
||||
});
|
||||
return (
|
||||
<div className={classes}>
|
||||
{icon ? <span className="status-icon">{icon}</span> : null}
|
||||
{message ? <span className="status-message">{message}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
web/components/config/form-textfield-with-submit.tsx
Normal file
148
web/components/config/form-textfield-with-submit.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constants';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import TextField, { TextFieldProps } from './form-textfield';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import FormStatusIndicator from './form-status-indicator';
|
||||
|
||||
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 const TEXTFIELD_TYPE_URL = 'url';
|
||||
|
||||
interface TextFieldWithSubmitProps extends TextFieldProps {
|
||||
apiPath: string;
|
||||
configPath?: string;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export default function TextFieldWithSubmit(props: TextFieldWithSubmitProps) {
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
const {
|
||||
apiPath,
|
||||
configPath = '',
|
||||
initialValue,
|
||||
...textFieldProps // rest of props
|
||||
} = props;
|
||||
|
||||
const { fieldName, required, tip, status, value, onChange, onSubmit } = textFieldProps;
|
||||
|
||||
// Clear out any validation states and messaging
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
setHasChanged(false);
|
||||
clearTimeout(resetTimer);
|
||||
resetTimer = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Add native validity checks here, somehow
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
|
||||
// const hasValidity = (type !== TEXTFIELD_TYPE_NUMBER && e.target.validity.valid) || type === TEXTFIELD_TYPE_NUMBER ;
|
||||
if ((required && (value === '' || value === null)) || value === initialValue) {
|
||||
setHasChanged(false);
|
||||
} else {
|
||||
// show submit button
|
||||
resetStates();
|
||||
setHasChanged(true);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 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 = ({ fieldName: changedFieldName, value: changedValue }: UpdateArgs) => {
|
||||
if (onChange) {
|
||||
onChange({ fieldName: changedFieldName, value: changedValue });
|
||||
}
|
||||
};
|
||||
|
||||
// if you blur a required field with an empty value, restore its original value in state (parent's state), if an onChange from parent is available.
|
||||
const handleBlur = ({ value: changedValue }: UpdateArgs) => {
|
||||
if (onChange && required && changedValue === '') {
|
||||
onChange({ fieldName, value: initialValue });
|
||||
}
|
||||
};
|
||||
|
||||
// how to get current value of input
|
||||
const handleSubmit = async () => {
|
||||
if ((required && value !== '') || value !== initialValue) {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath,
|
||||
data: { value },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({ fieldName, value, path: configPath });
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
|
||||
},
|
||||
});
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
|
||||
// if an extra onSubmit handler was sent in as a prop, let's run that too.
|
||||
if (onSubmit) {
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const textfieldContainerClass = classNames({
|
||||
'textfield-with-submit-container': true,
|
||||
submittable: hasChanged,
|
||||
});
|
||||
return (
|
||||
<div className={textfieldContainerClass}>
|
||||
<div className="textfield-component">
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
onSubmit={null}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="textfield-container lower-container">
|
||||
<p className="label-spacer" />
|
||||
<div className="lower-content">
|
||||
<div className="field-tip">{tip}</div>
|
||||
<FormStatusIndicator status={status || submitStatus} />
|
||||
<div className="update-button-container">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="submit-button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasChanged}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TextFieldWithSubmit.defaultProps = {
|
||||
configPath: '',
|
||||
initialValue: '',
|
||||
};
|
||||
170
web/components/config/form-textfield.tsx
Normal file
170
web/components/config/form-textfield.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Input, InputNumber } from 'antd';
|
||||
import { FieldUpdaterFunc } from '../../types/config-section';
|
||||
// import InfoTip from '../info-tip';
|
||||
import { StatusState } from '../../utils/input-statuses';
|
||||
import FormStatusIndicator from './form-status-indicator';
|
||||
|
||||
export const TEXTFIELD_TYPE_TEXT = 'default';
|
||||
export const TEXTFIELD_TYPE_PASSWORD = 'password'; // Input.Password
|
||||
export const TEXTFIELD_TYPE_NUMBER = 'numeric'; // InputNumber
|
||||
export const TEXTFIELD_TYPE_TEXTAREA = 'textarea'; // Input.TextArea
|
||||
export const TEXTFIELD_TYPE_URL = 'url';
|
||||
|
||||
export interface TextFieldProps {
|
||||
fieldName: string;
|
||||
|
||||
onSubmit?: () => void;
|
||||
onPressEnter?: () => void;
|
||||
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
status?: StatusState;
|
||||
tip?: string;
|
||||
type?: string;
|
||||
value?: string | number;
|
||||
onBlur?: FieldUpdaterFunc;
|
||||
onChange?: FieldUpdaterFunc;
|
||||
}
|
||||
|
||||
export default function TextField(props: TextFieldProps) {
|
||||
const {
|
||||
className,
|
||||
disabled,
|
||||
fieldName,
|
||||
label,
|
||||
maxLength,
|
||||
onBlur,
|
||||
onChange,
|
||||
onPressEnter,
|
||||
placeholder,
|
||||
required,
|
||||
status,
|
||||
tip,
|
||||
type,
|
||||
value,
|
||||
} = props;
|
||||
|
||||
// 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 an extra onChange handler was sent in as a prop, let's run that too.
|
||||
if (onChange) {
|
||||
onChange({ fieldName, value: val });
|
||||
}
|
||||
};
|
||||
|
||||
// if you blur a required field with an empty value, restore its original value in state (parent's state), if an onChange from parent is available.
|
||||
const handleBlur = (e: any) => {
|
||||
const val = e.target.value;
|
||||
if (onBlur) {
|
||||
onBlur({ value: val });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressEnter = () => {
|
||||
if (onPressEnter) {
|
||||
onPressEnter();
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
fieldProps = {
|
||||
autoSize: true,
|
||||
};
|
||||
} else if (type === TEXTFIELD_TYPE_PASSWORD) {
|
||||
Field = Input.Password;
|
||||
fieldProps = {
|
||||
visibilityToggle: true,
|
||||
};
|
||||
} else if (type === TEXTFIELD_TYPE_NUMBER) {
|
||||
Field = InputNumber;
|
||||
fieldProps = {
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 10 ** maxLength - 1,
|
||||
};
|
||||
} else if (type === TEXTFIELD_TYPE_URL) {
|
||||
fieldProps = {
|
||||
type: 'url',
|
||||
};
|
||||
}
|
||||
|
||||
const fieldId = `field-${fieldName}`;
|
||||
|
||||
const { type: statusType } = status || {};
|
||||
|
||||
const containerClass = classNames({
|
||||
'textfield-container': true,
|
||||
[`type-${type}`]: true,
|
||||
required,
|
||||
[`status-${statusType}`]: status,
|
||||
});
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
{label ? (
|
||||
<div className="label-side">
|
||||
<label htmlFor={fieldId} className="textfield-label">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="input-side">
|
||||
<div className="input-group">
|
||||
<Field
|
||||
id={fieldId}
|
||||
className={`field ${className} ${fieldId}`}
|
||||
{...fieldProps}
|
||||
allowClear
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onPressEnter={handlePressEnter}
|
||||
disabled={disabled}
|
||||
value={value as number | (readonly string[] & number)}
|
||||
/>
|
||||
</div>
|
||||
<FormStatusIndicator status={status} />
|
||||
<p className="field-tip">
|
||||
{tip}
|
||||
{/* <InfoTip tip={tip} /> */}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TextField.defaultProps = {
|
||||
className: '',
|
||||
// configPath: '',
|
||||
disabled: false,
|
||||
// initialValue: '',
|
||||
label: '',
|
||||
maxLength: 255,
|
||||
|
||||
placeholder: '',
|
||||
required: false,
|
||||
status: null,
|
||||
tip: '',
|
||||
type: TEXTFIELD_TYPE_TEXT,
|
||||
value: '',
|
||||
onSubmit: () => {},
|
||||
onBlur: () => {},
|
||||
onChange: () => {},
|
||||
onPressEnter: () => {},
|
||||
};
|
||||
90
web/components/config/form-toggleswitch-with-submit.tsx
Normal file
90
web/components/config/form-toggleswitch-with-submit.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import FormStatusIndicator from './form-status-indicator';
|
||||
|
||||
import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constants';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import InfoTip from '../info-tip';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
apiPath: string;
|
||||
fieldName: string;
|
||||
|
||||
checked?: boolean;
|
||||
configPath?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
export default function ToggleSwitch(props: ToggleSwitchProps) {
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const { apiPath, checked, configPath = '', disabled = false, fieldName, label, tip } = props;
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
clearTimeout(resetTimer);
|
||||
resetTimer = null;
|
||||
};
|
||||
|
||||
const handleChange = async (isChecked: boolean) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath,
|
||||
data: { value: isChecked },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({ fieldName, value: isChecked, path: configPath });
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
|
||||
},
|
||||
});
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
};
|
||||
|
||||
const loading = submitStatus !== null && submitStatus.type === STATUS_PROCESSING;
|
||||
return (
|
||||
<div className="toggleswitch-container">
|
||||
<div className="toggleswitch">
|
||||
<Switch
|
||||
className={`switch field-${fieldName}`}
|
||||
loading={loading}
|
||||
onChange={handleChange}
|
||||
defaultChecked={checked}
|
||||
checked={checked}
|
||||
checkedChildren="ON"
|
||||
unCheckedChildren="OFF"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="label">
|
||||
{label} <InfoTip tip={tip} />
|
||||
</span>
|
||||
</div>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToggleSwitch.defaultProps = {
|
||||
checked: false,
|
||||
configPath: '',
|
||||
disabled: false,
|
||||
label: '',
|
||||
tip: '',
|
||||
};
|
||||
61
web/components/config/social-icons-dropdown.tsx
Normal file
61
web/components/config/social-icons-dropdown.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { SocialHandleDropdownItem } from '../../types/config-section';
|
||||
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
|
||||
import { OTHER_SOCIAL_HANDLE_OPTION } from '../../utils/config-constants';
|
||||
|
||||
interface DropdownProps {
|
||||
iconList: SocialHandleDropdownItem[];
|
||||
selectedOption: string;
|
||||
onSelected: any;
|
||||
}
|
||||
|
||||
export default function SocialDropdown({ iconList, selectedOption, onSelected }: DropdownProps) {
|
||||
const handleSelected = (value: string) => {
|
||||
if (onSelected) {
|
||||
onSelected(value);
|
||||
}
|
||||
};
|
||||
const inititalSelected = selectedOption === '' ? null : selectedOption;
|
||||
return (
|
||||
<div className="social-dropdown-container">
|
||||
<p className="">
|
||||
If you are looking for a platform name not on this list, please select Other and type in
|
||||
your own name. A logo will not be provided.
|
||||
</p>
|
||||
<p className="">
|
||||
If you DO have a logo, drop it in to the <code>/webroot/img/platformicons</code> directory
|
||||
and update the <code>/socialHandle.go</code> list. Then restart the server and it will show
|
||||
up in the list.
|
||||
</p>
|
||||
|
||||
<Select
|
||||
style={{ width: 240 }}
|
||||
className="social-dropdown"
|
||||
placeholder="Social platform..."
|
||||
defaultValue={inititalSelected}
|
||||
value={inititalSelected}
|
||||
onSelect={handleSelected}
|
||||
>
|
||||
{iconList.map(item => {
|
||||
const { platform, icon, key } = item;
|
||||
return (
|
||||
<Select.Option className="social-option" key={`platform-${key}`} value={key}>
|
||||
<span className="option-icon">
|
||||
<img src={`${NEXT_PUBLIC_API_HOST}${icon}`} alt="" className="option-icon" />
|
||||
</span>
|
||||
<span className="option-label">{platform}</span>
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
<Select.Option
|
||||
className="social-option"
|
||||
key={`platform-${OTHER_SOCIAL_HANDLE_OPTION}`}
|
||||
value={OTHER_SOCIAL_HANDLE_OPTION}
|
||||
>
|
||||
Other...
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
web/components/config/video-latency.tsx
Normal file
139
web/components/config/video-latency.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { Typography, Slider } from 'antd';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { AlertMessageContext } from '../../utils/alert-message-context';
|
||||
import {
|
||||
API_VIDEO_SEGMENTS,
|
||||
RESET_TIMEOUT,
|
||||
postConfigUpdateToAPI,
|
||||
} from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import FormStatusIndicator from './form-status-indicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const SLIDER_MARKS = {
|
||||
1: 'Low',
|
||||
2: '',
|
||||
3: '',
|
||||
4: '',
|
||||
5: '',
|
||||
6: 'High',
|
||||
};
|
||||
|
||||
const SLIDER_COMMENTS = {
|
||||
1: 'Lowest latency, lowest error tolerance',
|
||||
2: 'Low latency, low error tolerance',
|
||||
3: 'Lower latency, lower error tolerance',
|
||||
4: 'Medium latency, medium error tolerance (Default)',
|
||||
5: 'Higher latency, higher error tolerance',
|
||||
6: 'Highest latency, highest error tolerance',
|
||||
};
|
||||
|
||||
interface SegmentToolTipProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
function SegmentToolTip({ value }: SegmentToolTipProps) {
|
||||
return <span className="segment-tip">{value}</span>;
|
||||
}
|
||||
|
||||
export default function VideoLatency() {
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
// const [submitStatus, setSubmitStatus] = useState(null);
|
||||
// const [submitStatusMessage, setSubmitStatusMessage] = useState('');
|
||||
const [selectedOption, setSelectedOption] = useState(null);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { setMessage } = useContext(AlertMessageContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const { videoSettings } = serverConfig || {};
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
if (!videoSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOption(videoSettings.latencyLevel);
|
||||
}, [videoSettings]);
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
// setSubmitStatusMessage('');
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
// posts all the variants at once as an array obj
|
||||
const postUpdateToAPI = async (postValue: any) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: API_VIDEO_SEGMENTS,
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'latencyLevel',
|
||||
value: postValue,
|
||||
path: 'videoSettings',
|
||||
});
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Latency buffer level updated.'));
|
||||
|
||||
// setSubmitStatus('success');
|
||||
// setSubmitStatusMessage('Variants updated.');
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
if (serverStatusData.online) {
|
||||
setMessage(
|
||||
'Your latency buffer setting will take effect the next time you begin a live stream.',
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
|
||||
// setSubmitStatus('error');
|
||||
// setSubmitStatusMessage(message);
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = value => {
|
||||
postUpdateToAPI(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="config-video-segements-conatiner">
|
||||
<Title level={3}>Latency Buffer</Title>
|
||||
<p>
|
||||
While it's natural to want to keep your latency as low as possible, you may experience
|
||||
reduced error tolerance and stability in some environments the lower you go.
|
||||
</p>
|
||||
For interactive live streams you may want to experiment with a lower latency, for
|
||||
non-interactive broadcasts you may want to increase it.{' '}
|
||||
<a href="https://owncast.online/docs/encoding#latency-buffer">Read to learn more.</a>
|
||||
<p></p>
|
||||
<div className="segment-slider-container">
|
||||
<Slider
|
||||
tipFormatter={value => <SegmentToolTip value={SLIDER_COMMENTS[value]} />}
|
||||
onChange={handleChange}
|
||||
min={1}
|
||||
max={6}
|
||||
marks={SLIDER_MARKS}
|
||||
defaultValue={selectedOption}
|
||||
value={selectedOption}
|
||||
/>
|
||||
</div>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
web/components/config/video-variant-form.tsx
Normal file
187
web/components/config/video-variant-form.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
// This content populates the video variant modal, which is spawned from the variants table.
|
||||
import React from 'react';
|
||||
import { Slider, Switch, Collapse } from 'antd';
|
||||
import { FieldUpdaterFunc, VideoVariant } from '../../types/config-section';
|
||||
import { DEFAULT_VARIANT_STATE } from '../../utils/config-constants';
|
||||
import InfoTip from '../info-tip';
|
||||
import CPUUsageSelector from './cpu-usage';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
const VIDEO_VARIANT_DEFAULTS = {
|
||||
framerate: {
|
||||
min: 10,
|
||||
max: 90,
|
||||
defaultValue: 24,
|
||||
unit: 'fps',
|
||||
incrementBy: 1,
|
||||
tip: 'You prob wont need to touch this unless youre a hardcore gamer and need all the bitties',
|
||||
},
|
||||
videoBitrate: {
|
||||
min: 600,
|
||||
max: 6000,
|
||||
defaultValue: 1200,
|
||||
unit: 'kbps',
|
||||
incrementBy: 100,
|
||||
tip: 'This is importatnt yo',
|
||||
},
|
||||
audioBitrate: {
|
||||
min: 600,
|
||||
max: 1200,
|
||||
defaultValue: 800,
|
||||
unit: 'kbps',
|
||||
incrementBy: 100,
|
||||
tip: 'nothing to see here',
|
||||
},
|
||||
videoPassthrough: {
|
||||
tip: 'If No is selected, then you should set your desired Video Bitrate.',
|
||||
},
|
||||
audioPassthrough: {
|
||||
tip: 'If No is selected, then you should set your desired Audio Bitrate.',
|
||||
},
|
||||
};
|
||||
|
||||
interface VideoVariantFormProps {
|
||||
dataState: VideoVariant;
|
||||
onUpdateField: FieldUpdaterFunc;
|
||||
}
|
||||
|
||||
export default function VideoVariantForm({
|
||||
dataState = DEFAULT_VARIANT_STATE,
|
||||
onUpdateField,
|
||||
}: VideoVariantFormProps) {
|
||||
const handleFramerateChange = (value: number) => {
|
||||
onUpdateField({ fieldName: 'framerate', value });
|
||||
};
|
||||
const handleVideoBitrateChange = (value: number) => {
|
||||
onUpdateField({ fieldName: 'videoBitrate', value });
|
||||
};
|
||||
const handleVideoPassChange = (value: boolean) => {
|
||||
onUpdateField({ fieldName: 'videoPassthrough', value });
|
||||
};
|
||||
const handleVideoCpuUsageLevelChange = (value: number) => {
|
||||
onUpdateField({ fieldName: 'cpuUsageLevel', value });
|
||||
};
|
||||
|
||||
const framerateDefaults = VIDEO_VARIANT_DEFAULTS.framerate;
|
||||
const framerateMin = framerateDefaults.min;
|
||||
const framerateMax = framerateDefaults.max;
|
||||
const framerateUnit = framerateDefaults.unit;
|
||||
|
||||
const videoBitrateDefaults = VIDEO_VARIANT_DEFAULTS.videoBitrate;
|
||||
const videoBRMin = videoBitrateDefaults.min;
|
||||
const videoBRMax = videoBitrateDefaults.max;
|
||||
const videoBRUnit = videoBitrateDefaults.unit;
|
||||
|
||||
const selectedVideoBRnote = `Selected: ${dataState.videoBitrate}${videoBRUnit} - it sucks`;
|
||||
const selectedFramerateNote = `Selected: ${dataState.framerate}${framerateUnit} - whoa there`;
|
||||
const selectedPresetNote = '';
|
||||
|
||||
return (
|
||||
<div className="config-variant-form">
|
||||
<div className="section-intro">
|
||||
Say a thing here about how this all works. Read more{' '}
|
||||
<a href="https://owncast.online/docs/configuration/">here</a>.
|
||||
<br />
|
||||
<br />
|
||||
</div>
|
||||
|
||||
{/* ENCODER PRESET FIELD */}
|
||||
<div className="field">
|
||||
<div className="form-component">
|
||||
<CPUUsageSelector
|
||||
defaultValue={dataState.cpuUsageLevel}
|
||||
onChange={handleVideoCpuUsageLevelChange}
|
||||
/>
|
||||
{selectedPresetNote ? (
|
||||
<span className="selected-value-note">{selectedPresetNote}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIDEO PASSTHROUGH FIELD */}
|
||||
<div style={{ display: 'none' }}>
|
||||
<div className="field">
|
||||
<p className="label">
|
||||
<InfoTip tip={VIDEO_VARIANT_DEFAULTS.videoPassthrough.tip} />
|
||||
Use Video Passthrough?
|
||||
</p>
|
||||
<div className="form-component">
|
||||
<Switch
|
||||
defaultChecked={dataState.videoPassthrough}
|
||||
checked={dataState.videoPassthrough}
|
||||
onChange={handleVideoPassChange}
|
||||
checkedChildren="Yes"
|
||||
unCheckedChildren="No"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIDEO BITRATE FIELD */}
|
||||
<div className={`field ${dataState.videoPassthrough ? 'disabled' : ''}`}>
|
||||
<p className="label">
|
||||
<InfoTip tip={VIDEO_VARIANT_DEFAULTS.videoBitrate.tip} />
|
||||
Video Bitrate:
|
||||
</p>
|
||||
<div className="form-component">
|
||||
<Slider
|
||||
tipFormatter={value => `${value} ${videoBRUnit}`}
|
||||
disabled={dataState.videoPassthrough === true}
|
||||
defaultValue={dataState.videoBitrate}
|
||||
value={dataState.videoBitrate}
|
||||
onChange={handleVideoBitrateChange}
|
||||
step={videoBitrateDefaults.incrementBy}
|
||||
min={videoBRMin}
|
||||
max={videoBRMax}
|
||||
marks={{
|
||||
[videoBRMin]: `${videoBRMin} ${videoBRUnit}`,
|
||||
[videoBRMax]: `${videoBRMax} ${videoBRUnit}`,
|
||||
}}
|
||||
/>
|
||||
{selectedVideoBRnote ? (
|
||||
<span className="selected-value-note">{selectedVideoBRnote}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<Collapse>
|
||||
<Panel header="Advanced Settings" key="1">
|
||||
<div className="section-intro">Touch if you dare.</div>
|
||||
|
||||
{/* FRAME RATE FIELD */}
|
||||
<div className="field">
|
||||
<p className="label">
|
||||
<InfoTip tip={VIDEO_VARIANT_DEFAULTS.framerate.tip} />
|
||||
Frame rate:
|
||||
</p>
|
||||
<div className="form-component">
|
||||
<Slider
|
||||
// tooltipVisible
|
||||
tipFormatter={value => `${value} ${framerateUnit}`}
|
||||
defaultValue={dataState.framerate}
|
||||
value={dataState.framerate}
|
||||
onChange={handleFramerateChange}
|
||||
step={framerateDefaults.incrementBy}
|
||||
min={framerateMin}
|
||||
max={framerateMax}
|
||||
marks={{
|
||||
[framerateMin]: `${framerateMin} ${framerateUnit}`,
|
||||
[framerateMax]: `${framerateMax} ${framerateUnit}`,
|
||||
}}
|
||||
/>
|
||||
{selectedFramerateNote ? (
|
||||
<span className="selected-value-note">{selectedFramerateNote}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
web/components/config/video-variants-table.tsx
Normal file
228
web/components/config/video-variants-table.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
// Updating a variant will post ALL the variants in an array as an update to the API.
|
||||
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { Typography, Table, Modal, Button } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { AlertMessageContext } from '../../utils/alert-message-context';
|
||||
import { UpdateArgs, VideoVariant } from '../../types/config-section';
|
||||
|
||||
import VideoVariantForm from './video-variant-form';
|
||||
import {
|
||||
API_VIDEO_VARIANTS,
|
||||
DEFAULT_VARIANT_STATE,
|
||||
SUCCESS_STATES,
|
||||
RESET_TIMEOUT,
|
||||
postConfigUpdateToAPI,
|
||||
} from '../../utils/config-constants';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const CPU_USAGE_LEVEL_MAP = {
|
||||
1: 'lowest',
|
||||
2: 'low',
|
||||
3: 'medium',
|
||||
4: 'high',
|
||||
5: 'highest',
|
||||
};
|
||||
|
||||
export default function CurrentVariantsTable() {
|
||||
const [displayModal, setDisplayModal] = useState(false);
|
||||
const [modalProcessing, setModalProcessing] = useState(false);
|
||||
const [editId, setEditId] = useState(0);
|
||||
const { setMessage } = useContext(AlertMessageContext);
|
||||
|
||||
// current data inside modal
|
||||
const [modalDataState, setModalDataState] = useState(DEFAULT_VARIANT_STATE);
|
||||
|
||||
const [submitStatus, setSubmitStatus] = useState(null);
|
||||
const [submitStatusMessage, setSubmitStatusMessage] = useState('');
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const { videoSettings } = serverConfig || {};
|
||||
const { videoQualityVariants } = videoSettings || {};
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
if (!videoSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
setSubmitStatusMessage('');
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
setDisplayModal(false);
|
||||
setEditId(-1);
|
||||
setModalDataState(DEFAULT_VARIANT_STATE);
|
||||
};
|
||||
|
||||
// posts all the variants at once as an array obj
|
||||
const postUpdateToAPI = async (postValue: any) => {
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: API_VIDEO_VARIANTS,
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'videoQualityVariants',
|
||||
value: postValue,
|
||||
path: 'videoSettings',
|
||||
});
|
||||
|
||||
// close modal
|
||||
setModalProcessing(false);
|
||||
handleModalCancel();
|
||||
|
||||
setSubmitStatus('success');
|
||||
setSubmitStatusMessage('Variants updated.');
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
|
||||
if (serverStatusData.online) {
|
||||
setMessage(
|
||||
'Updating your video configuration will take effect the next time you begin a new stream.',
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus('error');
|
||||
setSubmitStatusMessage(message);
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// on Ok, send all of dataState to api
|
||||
// show loading
|
||||
// close modal when api is done
|
||||
const handleModalOk = () => {
|
||||
setModalProcessing(true);
|
||||
|
||||
const postData = [...videoQualityVariants];
|
||||
if (editId === -1) {
|
||||
postData.push(modalDataState);
|
||||
} else {
|
||||
postData.splice(editId, 1, modalDataState);
|
||||
}
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const handleDeleteVariant = index => {
|
||||
const postData = [...videoQualityVariants];
|
||||
postData.splice(index, 1);
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const handleUpdateField = ({ fieldName, value }: UpdateArgs) => {
|
||||
setModalDataState({
|
||||
...modalDataState,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const { icon: newStatusIcon = null, message: newStatusMessage = '' } =
|
||||
SUCCESS_STATES[submitStatus] || {};
|
||||
|
||||
const videoQualityColumns: ColumnsType<VideoVariant> = [
|
||||
{
|
||||
title: 'Video bitrate',
|
||||
dataIndex: 'videoBitrate',
|
||||
key: 'videoBitrate',
|
||||
render: (bitrate: number) => (!bitrate ? 'Same as source' : `${bitrate} kbps`),
|
||||
},
|
||||
|
||||
{
|
||||
title: 'CPU Usage',
|
||||
dataIndex: 'cpuUsageLevel',
|
||||
key: 'cpuUsageLevel',
|
||||
render: (level: string) => (!level ? 'n/a' : CPU_USAGE_LEVEL_MAP[level]),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: 'edit',
|
||||
render: (data: VideoVariant) => {
|
||||
const index = data.key - 1;
|
||||
return (
|
||||
<span className="actions">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditId(index);
|
||||
setModalDataState(videoQualityVariants[index]);
|
||||
setDisplayModal(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
className="delete-button"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
disabled={videoQualityVariants.length === 1}
|
||||
onClick={() => {
|
||||
handleDeleteVariant(index);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const statusMessage = (
|
||||
<div className={`status-message ${submitStatus || ''}`}>
|
||||
{newStatusIcon} {newStatusMessage} {submitStatusMessage}
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoQualityVariantData = videoQualityVariants.map((variant, index) => ({
|
||||
key: index + 1,
|
||||
...variant,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={3}>Stream output</Title>
|
||||
|
||||
{statusMessage}
|
||||
|
||||
<Table
|
||||
className="variants-table"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={videoQualityColumns}
|
||||
dataSource={videoQualityVariantData}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Edit Video Variant Details"
|
||||
visible={displayModal}
|
||||
onOk={handleModalOk}
|
||||
onCancel={handleModalCancel}
|
||||
confirmLoading={modalProcessing}
|
||||
>
|
||||
<VideoVariantForm dataState={{ ...modalDataState }} onUpdateField={handleUpdateField} />
|
||||
|
||||
{statusMessage}
|
||||
</Modal>
|
||||
<br />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setEditId(-1);
|
||||
setModalDataState(DEFAULT_VARIANT_STATE);
|
||||
setDisplayModal(true);
|
||||
}}
|
||||
>
|
||||
Add a new variant
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user