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:
66
web/components/chart.tsx
Normal file
66
web/components/chart.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { LineChart } from 'react-chartkick';
|
||||
import 'chart.js';
|
||||
import format from 'date-fns/format';
|
||||
|
||||
interface TimedValue {
|
||||
time: Date;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface ChartProps {
|
||||
data?: TimedValue[];
|
||||
title?: string;
|
||||
color: string;
|
||||
unit: string;
|
||||
dataCollections?: any[];
|
||||
}
|
||||
|
||||
function createGraphDataset(dataArray) {
|
||||
const dataValues = {};
|
||||
dataArray.forEach(item => {
|
||||
const dateObject = new Date(item.time);
|
||||
const dateString = format(dateObject, 'p P');
|
||||
dataValues[dateString] = item.value;
|
||||
});
|
||||
return dataValues;
|
||||
}
|
||||
|
||||
export default function Chart({ data, title, color, unit, dataCollections }: ChartProps) {
|
||||
const renderData = [];
|
||||
|
||||
if (data && data.length > 0) {
|
||||
renderData.push({
|
||||
name: title,
|
||||
color,
|
||||
data: createGraphDataset(data),
|
||||
});
|
||||
}
|
||||
|
||||
dataCollections.forEach(collection => {
|
||||
renderData.push({
|
||||
name: collection.name,
|
||||
data: createGraphDataset(collection.data),
|
||||
color: collection.color,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="line-chart-container">
|
||||
<LineChart
|
||||
xtitle="Time"
|
||||
ytitle={title}
|
||||
suffix={unit}
|
||||
legend="bottom"
|
||||
color={color}
|
||||
data={renderData}
|
||||
download={title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Chart.defaultProps = {
|
||||
dataCollections: [],
|
||||
data: [],
|
||||
title: '',
|
||||
};
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
web/components/info-tip.tsx
Normal file
20
web/components/info-tip.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
interface InfoTipProps {
|
||||
tip: string | null;
|
||||
}
|
||||
|
||||
export default function InfoTip({ tip }: InfoTipProps) {
|
||||
if (tip === '' || tip === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="info-tip">
|
||||
<Tooltip title={tip}>
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
30
web/components/key-value-table.tsx
Normal file
30
web/components/key-value-table.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Table, Typography } from 'antd';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function KeyValueTable({ title, data }: KeyValueTableProps) {
|
||||
const columns = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Value',
|
||||
dataIndex: 'value',
|
||||
key: 'value',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={2}>{title}</Title>
|
||||
<Table pagination={false} columns={columns} dataSource={data} rowKey="name" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeyValueTableProps {
|
||||
title: string;
|
||||
data: any;
|
||||
}
|
||||
88
web/components/log-table.tsx
Normal file
88
web/components/log-table.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Table, Tag, Typography } from 'antd';
|
||||
import Linkify from 'react-linkify';
|
||||
import { SortOrder } from 'antd/lib/table/interface';
|
||||
import format from 'date-fns/format';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
function renderColumnLevel(text, entry) {
|
||||
let color = 'black';
|
||||
|
||||
if (entry.level === 'warning') {
|
||||
color = 'orange';
|
||||
} else if (entry.level === 'error') {
|
||||
color = 'red';
|
||||
}
|
||||
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
}
|
||||
|
||||
function renderMessage(text) {
|
||||
return <Linkify>{text}</Linkify>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
logs: object[];
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export default function LogTable({ logs, pageSize }: Props) {
|
||||
if (!logs?.length) {
|
||||
return null;
|
||||
}
|
||||
const columns = [
|
||||
{
|
||||
title: 'Level',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
filters: [
|
||||
{
|
||||
text: 'Info',
|
||||
value: 'info',
|
||||
},
|
||||
{
|
||||
text: 'Warning',
|
||||
value: 'warning',
|
||||
},
|
||||
{
|
||||
text: 'Error',
|
||||
value: 'Error',
|
||||
},
|
||||
],
|
||||
onFilter: (level, row) => row.level.indexOf(level) === 0,
|
||||
render: renderColumnLevel,
|
||||
},
|
||||
{
|
||||
title: 'Timestamp',
|
||||
dataIndex: 'time',
|
||||
key: 'time',
|
||||
render: timestamp => {
|
||||
const dateObject = new Date(timestamp);
|
||||
return format(dateObject, 'p P');
|
||||
},
|
||||
sorter: (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
defaultSortOrder: 'descend' as SortOrder,
|
||||
},
|
||||
{
|
||||
title: 'Message',
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
render: renderMessage,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="logs-section">
|
||||
<Title level={2}>Logs</Title>
|
||||
<Table
|
||||
size="middle"
|
||||
dataSource={logs}
|
||||
columns={columns}
|
||||
rowKey={row => row.time}
|
||||
pagination={{ pageSize: pageSize || 20 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
web/components/logo.tsx
Normal file
159
web/components/logo.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Logo() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 95.68623352050781 104.46271514892578"
|
||||
className="logo-svg"
|
||||
>
|
||||
<g transform="matrix(1 0 0 1 -37.08803939819336 -18.940391540527344)">
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<g transform="matrix(1.0445680396949917 0 0 1.0445679172996596 36.34559138380523 18.877718021903796)">
|
||||
<g transform="matrix(1 0 0 1 0 0)">
|
||||
<defs>
|
||||
<linearGradient
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
id="gradient120"
|
||||
gradientTransform="rotate(-90 .5 .5)"
|
||||
>
|
||||
<stop offset="0" stopColor="#1f2022" stopOpacity="1" />
|
||||
<stop offset="1" stopColor="#635e69" stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#gradient120)"
|
||||
d="M91.5 75.35Q93.05 71.15 91.65 67.7 90.35 64.5 86.65 62.3 83.2 60.3 78.3 59.4 73.85 58.6 68.6 58.7 63.55 58.85 58.8 59.8 54.25 60.75 50.8 62.2 47.4 63.65 45.5 65.35 43.6 67.15 43.5 69.05 43.35 71.3 45.8 73.9 48.05 76.3 52.1 78.6 56.15 80.9 61.05 82.55 66.3 84.3 71.4 84.8 74.7 85.1 77.55 84.9 80.65 84.6 83.3 83.6 86.15 82.5 88.15 80.55 90.4 78.4 91.5 75.35M70.6 67.5Q72.3 68.4 73.1 69.7 73.9 71.15 73.45 73 73.1 74.3 72.3 75.25 71.55 76.1 70.3 76.6 69.25 77.05 67.75 77.25 66.3 77.4 64.85 77.3 62.3 77.15 59.25 76.3 56.6 75.5 54.15 74.3 51.9 73.2 50.45 72 49.05 70.75 49.1 69.8 49.2 69 50.25 68.25 51.3 67.55 53.15 67 55 66.4 57.25 66.1 59.8 65.8 62.1 65.8 64.65 65.85 66.7 66.2 68.9 66.65 70.6 67.5Z"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1 0 0 1 0 0)">
|
||||
<defs>
|
||||
<linearGradient
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
id="gradient121"
|
||||
gradientTransform="rotate(-180 .5 .5)"
|
||||
>
|
||||
<stop offset="0" stopColor="#2087e2" stopOpacity="1" />
|
||||
<stop offset="1" stopColor="#b63fff" stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#gradient121)"
|
||||
d="M66.6 15.05Q66.4 9.65 63.9 6.05 61.25 2.1 56.1 0.65 54.95 0.3 53.65 0.15 52.5 0 51.3 0.1 50.2 0.1 49.1 0.35 48.15 0.55 47 1 43.3 2.45 40.3 6.1 37.5 9.4 35.5 14.3 33.75 18.45 32.7 23.4 31.7 28.05 31.35 32.85 31.05 37.2 31.3 41.2 31.6 45.15 32.4 48.35 34 54.9 37.3 56.4 37.6 56.55 37.9 56.65L39.2 56.85Q39.45 56.85 39.95 56.8 42.05 56.6 44.7 55.05 47.25 53.5 50.05 50.8 53.05 47.9 55.85 44.05 58.8 40.05 61.1 35.6 63.8 30.35 65.25 25.3 66.75 19.75 66.6 15.05M47.55 23.15Q48.05 23.25 48.4 23.4 52.45 24.8 52.55 29.85 52.6 34 50 39.4 47.85 43.9 44.85 47.3 42.05 50.5 40.15 50.7L39.9 50.75 39.45 50.7 39.2 50.6Q37.8 49.95 37.25 46.35 36.7 42.7 37.3 38 37.95 32.75 39.75 28.8 41.9 24.1 45.05 23.25 45.6 23.1 45.85 23.1 46.25 23.05 46.65 23.05 47.05 23.05 47.55 23.15Z"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1 0 0 1 0 0)">
|
||||
<defs>
|
||||
<linearGradient
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
id="gradient122"
|
||||
gradientTransform="rotate(-90 .5 .5)"
|
||||
>
|
||||
<stop offset="0" stopColor="#100f0f" stopOpacity="1" />
|
||||
<stop offset="1" stopColor="#49261F" stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#gradient122)"
|
||||
d="M2.7 33.6Q2.1 34.4 1.7 35.35 1.25 36.5 1.05 37.7 0 42.6 2.2 47.2 4 51 8 54.35 11.55 57.3 16 59.15 20.5 61 23.85 60.85 24.5 60.85 25.25 60.7 26 60.55 26.5 60.3 27 60.05 27.45 59.65 27.9 59.25 28.15 58.75 29.35 56.45 27.5 51.65 25.6 47 21.75 42.1 17.75 37 13.4 34.05 8.7 30.9 5.45 31.7 4.65 31.9 3.95 32.4 3.25 32.85 2.7 33.6M10.1 43.55Q10.35 43.1 10.6 42.85 10.85 42.6 11.2 42.4 11.6 42.25 11.9 42.2 13.5 41.9 15.95 43.6 18.15 45.05 20.35 47.7 22.35 50.1 23.55 52.4 24.7 54.75 24.25 55.7 24.15 55.9 24 56 23.85 56.2 23.65 56.25 23.55 56.35 23.25 56.4L22.7 56.5Q21.1 56.6 18.55 55.6 16.05 54.6 13.85 52.95 11.5 51.2 10.35 49.15 9.05 46.8 9.75 44.45 9.9 43.95 10.1 43.55Z"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1 0 0 1 0 0)">
|
||||
<defs>
|
||||
<linearGradient
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
id="gradient123"
|
||||
gradientTransform="rotate(-180 .5 .5)"
|
||||
>
|
||||
<stop offset="0" stopColor="#222020" stopOpacity="1" />
|
||||
<stop offset="1" stopColor="#49261F" stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#gradient123)"
|
||||
d="M34.95 74.2L34.75 74.2Q33.2 74.15 31.9 75.25 30.7 76.3 29.85 78.25 29.1 80 28.8 82.2 28.5 84.4 28.7 86.65 29.1 91.4 31.5 94.7 34.3 98.5 39.3 99.7L39.4 99.7 39.7 99.8 39.85 99.8Q45.3 100.85 47.15 97.75 48 96.3 48 94.05 47.95 91.9 47.2 89.35 46.45 86.75 45.1 84.15 43.75 81.5 42.05 79.35 40.25 77.1 38.45 75.75 36.55 74.35 34.95 74.2M33.55 80.4Q34.35 78.2 35.6 78.3L35.65 78.3Q36.9 78.45 38.6 80.9 40.3 83.35 41.15 86.05 42.1 89 41.55 90.75 40.9 92.6 38.35 92.25L38.3 92.25 38.25 92.2 38.1 92.2Q35.6 91.7 34.25 89.6 33.1 87.7 32.95 85 32.8 82.35 33.55 80.4Z"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(0.9999999999999999 0 0 1 0 5.684341886080802e-14)">
|
||||
<defs>
|
||||
<linearGradient
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
id="gradient124"
|
||||
gradientTransform="rotate(-180 .5 .5)"
|
||||
>
|
||||
{' '}
|
||||
<stop offset="0" stopColor="#1e1c1c" stopOpacity="1" />
|
||||
<stop offset="1" stopColor="#49261F" stopOpacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#gradient124)"
|
||||
d="M22.7 69.65Q22.25 69.3 21.6 69.05 20.95 68.8 20.25 68.7 19.6 68.55 18.85 68.5 16.7 68.45 14.65 69.15 12.65 69.8 11.4 71.1 10.15 72.5 10.2 74.2 10.25 76.05 11.95 78.2 12.4 78.75 13.05 79.4 13.55 79.9 14.2 80.3 14.7 80.6 15.3 80.85 16 81.1 16.4 81.1 18.2 81.35 19.9 80.35 21.55 79.4 22.75 77.65 24 75.85 24.3 73.95 24.6 71.85 23.55 70.5 23.15 70 22.7 69.65M21.7 71.7Q22.15 72.3 21.9 73.3 21.7 74.25 21 75.25 20.3 76.2 19.4 76.75 18.45 77.35 17.55 77.25L17 77.15Q16.7 77.05 16.45 76.85 16.25 76.75 15.9 76.45 15.7 76.25 15.4 75.9 14.5 74.75 14.7 73.8 14.8 72.95 15.75 72.3 16.6 71.7 17.8 71.4 19 71.1 20.1 71.15L20.65 71.2 21.1 71.3Q21.3 71.4 21.45 71.5L21.7 71.7Z"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1 0 0 1 0 0)">
|
||||
<defs>
|
||||
<linearGradient
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
id="gradient125"
|
||||
gradientTransform="rotate(-360 .5 .5)"
|
||||
>
|
||||
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0.5" />
|
||||
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#gradient125)"
|
||||
d="M52.6 19.25Q59.6 19.25 66.2 20.95 66.7 17.8 66.6 15.05 66.4 9.65 63.9 6.05 61.25 2.1 56.1 0.65 54.95 0.3 53.65 0.15 52.5 0 51.3 0.1 50.2 0.1 49.1 0.35 48.15 0.55 47 1 43.3 2.45 40.3 6.1 37.5 9.4 35.5 14.3 33.85 18.3 32.8 22.85 42.25 19.25 52.6 19.25Z"
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1 0 0 1 0 0)">
|
||||
<defs>
|
||||
<linearGradient
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
id="gradient126"
|
||||
gradientTransform="rotate(-360 .5 .5)"
|
||||
>
|
||||
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0.5" />
|
||||
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#gradient126)"
|
||||
d="M1.05 37.7Q0 42.6 2.2 47.2 2.95 48.8 4.05 50.25 7.55 41.65 14.4 34.75 14 34.45 13.4 34.05 8.7 30.9 5.45 31.7 4.65 31.9 3.95 32.4 3.25 32.85 2.7 33.6 2.1 34.4 1.7 35.35 1.25 36.5 1.05 37.7Z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(1.219512230276127 0 0 1.2195122143630526 32.82519274395008 88.56945194723018)">
|
||||
<path fill="#000000" fillOpacity="1" d="" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
229
web/components/main-layout.tsx
Normal file
229
web/components/main-layout.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Link from 'next/link';
|
||||
import Head from 'next/head';
|
||||
import { differenceInSeconds } from 'date-fns';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Layout, Menu, Popover, Alert } from 'antd';
|
||||
|
||||
import {
|
||||
SettingOutlined,
|
||||
HomeOutlined,
|
||||
LineChartOutlined,
|
||||
ToolOutlined,
|
||||
PlayCircleFilled,
|
||||
MinusSquareFilled,
|
||||
QuestionCircleOutlined,
|
||||
MessageOutlined,
|
||||
ExperimentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import classNames from 'classnames';
|
||||
import { upgradeVersionAvailable } from '../utils/apis';
|
||||
import { parseSecondsToDurationString } from '../utils/format';
|
||||
|
||||
import OwncastLogo from './logo';
|
||||
import { ServerStatusContext } from '../utils/server-status-context';
|
||||
import { AlertMessageContext } from '../utils/alert-message-context';
|
||||
|
||||
import TextFieldWithSubmit from './config/form-textfield-with-submit';
|
||||
import { TEXTFIELD_PROPS_STREAM_TITLE } from '../utils/config-constants';
|
||||
|
||||
import { UpdateArgs } from '../types/config-section';
|
||||
|
||||
let performedUpgradeCheck = false;
|
||||
|
||||
export default function MainLayout(props) {
|
||||
const { children } = props;
|
||||
|
||||
const context = useContext(ServerStatusContext);
|
||||
const { serverConfig, online, broadcaster, versionNumber } = context || {};
|
||||
const { instanceDetails } = serverConfig;
|
||||
|
||||
const [currentStreamTitle, setCurrentStreamTitle] = useState('');
|
||||
|
||||
const alertMessage = useContext(AlertMessageContext);
|
||||
|
||||
const router = useRouter();
|
||||
const { route } = router || {};
|
||||
|
||||
const { Header, Footer, Content, Sider } = Layout;
|
||||
const { SubMenu } = Menu;
|
||||
|
||||
const [upgradeVersion, setUpgradeVersion] = useState(null);
|
||||
const checkForUpgrade = async () => {
|
||||
try {
|
||||
const result = await upgradeVersionAvailable(versionNumber);
|
||||
setUpgradeVersion(result);
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!performedUpgradeCheck) {
|
||||
checkForUpgrade();
|
||||
performedUpgradeCheck = true;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentStreamTitle(instanceDetails.streamTitle);
|
||||
}, [instanceDetails]);
|
||||
|
||||
const handleStreamTitleChanged = ({ value }: UpdateArgs) => {
|
||||
setCurrentStreamTitle(value);
|
||||
};
|
||||
|
||||
const appClass = classNames({
|
||||
'app-container': true,
|
||||
online,
|
||||
});
|
||||
|
||||
const upgradeMenuItemStyle = upgradeVersion ? 'block' : 'none';
|
||||
const upgradeVersionString = upgradeVersion || '';
|
||||
|
||||
const clearAlertMessage = () => {
|
||||
alertMessage.setMessage(null);
|
||||
};
|
||||
|
||||
const headerAlertMessage = alertMessage.message ? (
|
||||
<Alert message={alertMessage.message} afterClose={clearAlertMessage} banner closable />
|
||||
) : null;
|
||||
|
||||
// status indicator items
|
||||
const streamDurationString = broadcaster
|
||||
? parseSecondsToDurationString(differenceInSeconds(new Date(), new Date(broadcaster.time)))
|
||||
: '';
|
||||
const currentThumbnail = online ? (
|
||||
<img src="/thumbnail.jpg" className="online-thumbnail" alt="current thumbnail" />
|
||||
) : null;
|
||||
const statusIcon = online ? <PlayCircleFilled /> : <MinusSquareFilled />;
|
||||
const statusMessage = online ? `Online ${streamDurationString}` : 'Offline';
|
||||
|
||||
const statusIndicator = (
|
||||
<div className="online-status-indicator">
|
||||
<span className="status-label">{statusMessage}</span>
|
||||
<span className="status-icon">{statusIcon}</span>
|
||||
</div>
|
||||
);
|
||||
const statusIndicatorWithThumb = online ? (
|
||||
<Popover content={currentThumbnail} title="Thumbnail" trigger="hover">
|
||||
{statusIndicator}
|
||||
</Popover>
|
||||
) : (
|
||||
statusIndicator
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout className={appClass}>
|
||||
<Head>
|
||||
<title>Owncast Admin</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png" />
|
||||
</Head>
|
||||
|
||||
<Sider width={240} className="side-nav">
|
||||
<Menu
|
||||
theme="dark"
|
||||
defaultSelectedKeys={[route.substring(1) || 'home']}
|
||||
defaultOpenKeys={['current-stream-menu', 'utilities-menu', 'configuration']}
|
||||
mode="inline"
|
||||
>
|
||||
<h1 className="owncast-title">
|
||||
<span className="logo-container">
|
||||
<OwncastLogo />
|
||||
</span>
|
||||
<span className="title-label">Owncast Admin</span>
|
||||
</h1>
|
||||
<Menu.Item key="home" icon={<HomeOutlined />}>
|
||||
<Link href="/">Home</Link>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item key="viewer-info" icon={<LineChartOutlined />} title="Current stream">
|
||||
<Link href="/viewer-info">Viewers</Link>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item key="chat" icon={<MessageOutlined />} title="Chat utilities">
|
||||
<Link href="/chat">Chat</Link>
|
||||
</Menu.Item>
|
||||
|
||||
<SubMenu key="configuration" title="Configuration" icon={<SettingOutlined />}>
|
||||
<Menu.Item key="config-public-details">
|
||||
<Link href="/config-public-details">General</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="config-social-items">
|
||||
<Link href="/config-social-items">Social Links</Link>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item key="config-page-content">
|
||||
<Link href="/config-page-content">Page Content</Link>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item key="config-server-details">
|
||||
<Link href="/config-server-details">Server Setup</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="config-video">
|
||||
<Link href="/config-video">Video Configuration</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="config-storage">
|
||||
<Link href="/config-storage">Storage</Link>
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
|
||||
<SubMenu key="utilities-menu" icon={<ToolOutlined />} title="Utilities">
|
||||
<Menu.Item key="hardware-info">
|
||||
<Link href="/hardware-info">Hardware</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="logs">
|
||||
<Link href="/logs">Logs</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="upgrade" style={{ display: upgradeMenuItemStyle }}>
|
||||
<Link href="/upgrade">
|
||||
<a>Upgrade to v{upgradeVersionString}</a>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
<SubMenu key="integrations-menu" icon={<ExperimentOutlined />} title="Integrations">
|
||||
<Menu.Item key="webhooks">
|
||||
<Link href="/webhooks">Webhooks</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="access-tokens">
|
||||
<Link href="/access-tokens">Access Tokens</Link>
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
<Menu.Item key="help" icon={<QuestionCircleOutlined />} title="Help">
|
||||
<Link href="/help">Help</Link>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Sider>
|
||||
|
||||
<Layout className="layout-main">
|
||||
<Header className="layout-header">
|
||||
<div className="global-stream-title-container">
|
||||
<TextFieldWithSubmit
|
||||
fieldName="streamTitle"
|
||||
{...TEXTFIELD_PROPS_STREAM_TITLE}
|
||||
placeholder="What you're streaming right now"
|
||||
value={currentStreamTitle}
|
||||
initialValue={instanceDetails.streamTitle}
|
||||
onChange={handleStreamTitleChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statusIndicatorWithThumb}
|
||||
</Header>
|
||||
|
||||
{headerAlertMessage}
|
||||
|
||||
<Content className="main-content-container">{children}</Content>
|
||||
|
||||
<Footer className="footer-container">
|
||||
<a href="https://owncast.online/">About Owncast v{versionNumber}</a>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
MainLayout.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
};
|
||||
94
web/components/message-visiblity-toggle.tsx
Normal file
94
web/components/message-visiblity-toggle.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// Custom component for AntDesign Button that makes an api call, then displays a confirmation icon upon
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import {
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
CheckCircleFilled,
|
||||
ExclamationCircleFilled,
|
||||
} from '@ant-design/icons';
|
||||
import { fetchData, UPDATE_CHAT_MESSGAE_VIZ } from '../utils/apis';
|
||||
import { MessageType } from '../types/chat';
|
||||
import { OUTCOME_TIMEOUT } from '../pages/chat';
|
||||
import { isEmptyObject } from '../utils/format';
|
||||
|
||||
interface MessageToggleProps {
|
||||
isVisible: boolean;
|
||||
message: MessageType;
|
||||
setMessage: (message: MessageType) => void;
|
||||
}
|
||||
|
||||
export default function MessageVisiblityToggle({
|
||||
isVisible,
|
||||
message,
|
||||
setMessage,
|
||||
}: MessageToggleProps) {
|
||||
if (!message || isEmptyObject(message)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let outcomeTimeout = null;
|
||||
const [outcome, setOutcome] = useState(0);
|
||||
|
||||
const { id: messageId } = message || {};
|
||||
|
||||
const resetOutcome = () => {
|
||||
outcomeTimeout = setTimeout(() => {
|
||||
setOutcome(0);
|
||||
}, OUTCOME_TIMEOUT);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(outcomeTimeout);
|
||||
};
|
||||
});
|
||||
|
||||
const updateChatMessage = async () => {
|
||||
clearTimeout(outcomeTimeout);
|
||||
setOutcome(0);
|
||||
const result = await fetchData(UPDATE_CHAT_MESSGAE_VIZ, {
|
||||
auth: true,
|
||||
method: 'POST',
|
||||
data: {
|
||||
visible: !isVisible,
|
||||
idArray: [messageId],
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success && result.message === 'changed') {
|
||||
setMessage({ ...message, visible: !isVisible });
|
||||
setOutcome(1);
|
||||
} else {
|
||||
setMessage({ ...message, visible: isVisible });
|
||||
setOutcome(-1);
|
||||
}
|
||||
resetOutcome();
|
||||
};
|
||||
|
||||
let outcomeIcon = <CheckCircleFilled style={{ color: 'transparent' }} />;
|
||||
if (outcome) {
|
||||
outcomeIcon =
|
||||
outcome > 0 ? (
|
||||
<CheckCircleFilled style={{ color: 'var(--ant-success)' }} />
|
||||
) : (
|
||||
<ExclamationCircleFilled style={{ color: 'var(--ant-warning)' }} />
|
||||
);
|
||||
}
|
||||
|
||||
const toolTipMessage = `Click to ${isVisible ? 'hide' : 'show'} this message`;
|
||||
return (
|
||||
<div className={`toggle-switch ${isVisible ? '' : 'hidden'}`}>
|
||||
<span className="outcome-icon">{outcomeIcon}</span>
|
||||
<Tooltip title={toolTipMessage} placement="topRight">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="small"
|
||||
type="text"
|
||||
icon={isVisible ? <EyeOutlined /> : <EyeInvisibleOutlined />}
|
||||
onClick={updateChatMessage}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
web/components/statistic.tsx
Normal file
71
web/components/statistic.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Typography, Statistic, Card, Progress } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface StatisticItemProps {
|
||||
title?: string;
|
||||
value?: any;
|
||||
prefix?: JSX.Element;
|
||||
color?: string;
|
||||
progress?: boolean;
|
||||
centered?: boolean;
|
||||
formatter?: any;
|
||||
}
|
||||
const defaultProps = {
|
||||
title: '',
|
||||
value: 0,
|
||||
prefix: null,
|
||||
color: '',
|
||||
progress: false,
|
||||
centered: false,
|
||||
formatter: null,
|
||||
};
|
||||
|
||||
function ProgressView({ title, value, prefix, color }: StatisticItemProps) {
|
||||
const endColor = value > 90 ? 'red' : color;
|
||||
const content = (
|
||||
<div>
|
||||
{prefix}
|
||||
<div>
|
||||
<Text type="secondary">{title}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">{value}%</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Progress
|
||||
type="dashboard"
|
||||
percent={value}
|
||||
width={120}
|
||||
strokeColor={{
|
||||
'0%': color,
|
||||
'90%': endColor,
|
||||
}}
|
||||
format={percent => content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
ProgressView.defaultProps = defaultProps;
|
||||
|
||||
function StatisticView({ title, value, prefix, formatter }: StatisticItemProps) {
|
||||
return <Statistic title={title} value={value} prefix={prefix} formatter={formatter} />;
|
||||
}
|
||||
StatisticView.defaultProps = defaultProps;
|
||||
|
||||
export default function StatisticItem(props: StatisticItemProps) {
|
||||
const { progress, centered } = props;
|
||||
const View = progress ? ProgressView : StatisticView;
|
||||
|
||||
const style = centered ? { display: 'flex', alignItems: 'center', justifyContent: 'center' } : {};
|
||||
|
||||
return (
|
||||
<Card type="inner">
|
||||
<div style={style}>
|
||||
<View {...props} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
StatisticItem.defaultProps = defaultProps;
|
||||
Reference in New Issue
Block a user