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:
@@ -23,7 +23,7 @@ import { AppProps } from 'next/app';
|
||||
import ServerStatusProvider from '../utils/server-status-context';
|
||||
import AlertMessageProvider from '../utils/alert-message-context';
|
||||
|
||||
import MainLayout from './components/main-layout';
|
||||
import MainLayout from '../components/main-layout';
|
||||
|
||||
function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Table, Typography, Tooltip, Button } from "antd";
|
||||
import { CheckCircleFilled, ExclamationCircleFilled } from "@ant-design/icons";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Typography, Tooltip, Button } from 'antd';
|
||||
import { CheckCircleFilled, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import classNames from 'classnames';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import format from 'date-fns/format'
|
||||
import format from 'date-fns/format';
|
||||
|
||||
import { CHAT_HISTORY, fetchData, FETCH_INTERVAL, UPDATE_CHAT_MESSGAE_VIZ } from "../utils/apis";
|
||||
import { CHAT_HISTORY, fetchData, FETCH_INTERVAL, UPDATE_CHAT_MESSGAE_VIZ } from '../utils/apis';
|
||||
import { MessageType } from '../types/chat';
|
||||
import { isEmptyObject } from "../utils/format";
|
||||
import MessageVisiblityToggle from "./components/message-visiblity-toggle";
|
||||
import { isEmptyObject } from '../utils/format';
|
||||
import MessageVisiblityToggle from '../components/message-visiblity-toggle';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function Chat() {
|
||||
setMessages(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("==== error", error);
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function Chat() {
|
||||
}, []);
|
||||
|
||||
const nameFilters = createUserNameFilters(messages);
|
||||
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedKeys: string[]) => {
|
||||
@@ -81,10 +81,10 @@ export default function Chat() {
|
||||
},
|
||||
};
|
||||
|
||||
const updateMessage = message => {
|
||||
const messageIndex = messages.findIndex(m => m.id === message.id);
|
||||
messages.splice(messageIndex, 1, message)
|
||||
setMessages([...messages]);
|
||||
const updateMessage = message => {
|
||||
const messageIndex = messages.findIndex(m => m.id === message.id);
|
||||
messages.splice(messageIndex, 1, message);
|
||||
setMessages([...messages]);
|
||||
};
|
||||
|
||||
const resetBulkOutcome = () => {
|
||||
@@ -93,7 +93,7 @@ export default function Chat() {
|
||||
setBulkAction('');
|
||||
}, OUTCOME_TIMEOUT);
|
||||
};
|
||||
const handleSubmitBulk = async (bulkVisibility) => {
|
||||
const handleSubmitBulk = async bulkVisibility => {
|
||||
setBulkProcessing(true);
|
||||
const result = await fetchData(UPDATE_CHAT_MESSGAE_VIZ, {
|
||||
auth: true,
|
||||
@@ -104,7 +104,7 @@ export default function Chat() {
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success && result.message === "changed") {
|
||||
if (result.success && result.message === 'changed') {
|
||||
setBulkOutcome(<CheckCircleFilled />);
|
||||
resetBulkOutcome();
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function Chat() {
|
||||
const updatedList = [...messages];
|
||||
selectedRowKeys.map(key => {
|
||||
const messageIndex = updatedList.findIndex(m => m.id === key);
|
||||
const newMessage = {...messages[messageIndex], visible: bulkVisibility };
|
||||
const newMessage = { ...messages[messageIndex], visible: bulkVisibility };
|
||||
updatedList.splice(messageIndex, 1, newMessage);
|
||||
return null;
|
||||
});
|
||||
@@ -123,15 +123,15 @@ export default function Chat() {
|
||||
resetBulkOutcome();
|
||||
}
|
||||
setBulkProcessing(false);
|
||||
}
|
||||
};
|
||||
const handleSubmitBulkShow = () => {
|
||||
setBulkAction('show');
|
||||
handleSubmitBulk(true);
|
||||
}
|
||||
};
|
||||
const handleSubmitBulkHide = () => {
|
||||
setBulkAction('hide');
|
||||
handleSubmitBulk(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chatColumns: ColumnsType<MessageType> = [
|
||||
{
|
||||
@@ -140,7 +140,7 @@ export default function Chat() {
|
||||
key: 'timestamp',
|
||||
className: 'timestamp-col',
|
||||
defaultSortOrder: 'descend',
|
||||
render: (timestamp) => {
|
||||
render: timestamp => {
|
||||
const dateObject = new Date(timestamp);
|
||||
return format(dateObject, 'PP pp');
|
||||
},
|
||||
@@ -155,7 +155,7 @@ export default function Chat() {
|
||||
filters: nameFilters,
|
||||
onFilter: (value, record) => record.author === value,
|
||||
sorter: (a, b) => a.author.localeCompare(b.author),
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
ellipsis: true,
|
||||
render: author => (
|
||||
<Tooltip placement="topLeft" title={author}>
|
||||
@@ -176,21 +176,20 @@ export default function Chat() {
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: body }}
|
||||
/>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'visible',
|
||||
key: 'visible',
|
||||
className: 'toggle-col',
|
||||
filters: [{ text: 'Visible messages', value: true }, { text: 'Hidden messages', value: false }],
|
||||
filters: [
|
||||
{ text: 'Visible messages', value: true },
|
||||
{ text: 'Hidden messages', value: false },
|
||||
],
|
||||
onFilter: (value, record) => record.visible === value,
|
||||
render: (visible, record) => (
|
||||
<MessageVisiblityToggle
|
||||
isVisible={visible}
|
||||
message={record}
|
||||
setMessage={updateMessage}
|
||||
/>
|
||||
<MessageVisiblityToggle isVisible={visible} message={record} setMessage={updateMessage} />
|
||||
),
|
||||
width: 30,
|
||||
},
|
||||
@@ -200,7 +199,7 @@ export default function Chat() {
|
||||
'bulk-editor': true,
|
||||
active: selectedRowKeys.length,
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<div className="chat-messages">
|
||||
<Title level={2}>Chat Messages</Title>
|
||||
@@ -236,14 +235,14 @@ export default function Chat() {
|
||||
<Table
|
||||
size="small"
|
||||
className="messages-table"
|
||||
pagination={{ pageSize: 100 }}
|
||||
pagination={{ pageSize: 100 }}
|
||||
scroll={{ y: 540 }}
|
||||
rowClassName={record => !record.visible ? 'hidden' : ''}
|
||||
rowClassName={record => (!record.visible ? 'hidden' : '')}
|
||||
dataSource={messages}
|
||||
columns={chatColumns}
|
||||
rowKey={(row) => row.id}
|
||||
rowKey={row => row.id}
|
||||
rowSelection={rowSelection}
|
||||
/>
|
||||
</div>)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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: '',
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
# 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?
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
// DEFAULT VALUES
|
||||
import React from 'react';
|
||||
import { CheckCircleFilled, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { fetchData, SERVER_CONFIG_UPDATE_URL } from '../../../utils/apis';
|
||||
import { ApiPostArgs, VideoVariant, SocialHandle } from '../../../types/config-section';
|
||||
|
||||
export const TEXT_MAXLENGTH = 255;
|
||||
|
||||
export const RESET_TIMEOUT = 3000;
|
||||
|
||||
export const SUCCESS_STATES = {
|
||||
success: {
|
||||
icon: <CheckCircleFilled style={{ color: 'green' }} />,
|
||||
message: 'Success!',
|
||||
},
|
||||
error: {
|
||||
icon: <ExclamationCircleFilled style={{ color: 'red' }} />,
|
||||
message: 'An error occurred.',
|
||||
},
|
||||
};
|
||||
|
||||
// CONFIG API ENDPOINTS
|
||||
export const API_CUSTOM_CONTENT = '/pagecontent';
|
||||
export const API_FFMPEG = '/ffmpegpath';
|
||||
export const API_INSTANCE_URL = '/serverurl';
|
||||
export const API_LOGO = '/logo';
|
||||
export const API_NSFW_SWITCH = '/nsfw';
|
||||
export const API_RTMP_PORT = '/rtmpserverport';
|
||||
export const API_S3_INFO = '/s3';
|
||||
export const API_SERVER_SUMMARY = '/serversummary';
|
||||
export const API_SERVER_NAME = '/name';
|
||||
export const API_SOCIAL_HANDLES = '/socialhandles';
|
||||
export const API_STREAM_KEY = '/key';
|
||||
export const API_STREAM_TITLE = '/streamtitle';
|
||||
export const API_TAGS = '/tags';
|
||||
export const API_USERNAME = '/name';
|
||||
export const API_VIDEO_SEGMENTS = '/video/streamlatencylevel';
|
||||
export const API_VIDEO_VARIANTS = '/video/streamoutputvariants';
|
||||
export const API_WEB_PORT = '/webserverport';
|
||||
export const API_YP_SWITCH = '/directoryenabled';
|
||||
|
||||
export async function postConfigUpdateToAPI(args: ApiPostArgs) {
|
||||
const { apiPath, data, onSuccess, onError } = args;
|
||||
const result = await fetchData(`${SERVER_CONFIG_UPDATE_URL}${apiPath}`, {
|
||||
data,
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
});
|
||||
if (result.success && onSuccess) {
|
||||
onSuccess(result.message);
|
||||
} else if (onError) {
|
||||
onError(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Some default props to help build out a TextField
|
||||
export const TEXTFIELD_PROPS_SERVER_NAME = {
|
||||
apiPath: API_SERVER_NAME,
|
||||
maxLength: TEXT_MAXLENGTH,
|
||||
placeholder: 'Owncast site name', // like "gothland"
|
||||
label: 'Name',
|
||||
tip: 'The name of your Owncast server',
|
||||
};
|
||||
export const TEXTFIELD_PROPS_STREAM_TITLE = {
|
||||
apiPath: API_STREAM_TITLE,
|
||||
maxLength: 100,
|
||||
placeholder: 'Doing cool things...',
|
||||
label: 'Stream Title',
|
||||
tip: 'What is your stream about today?',
|
||||
};
|
||||
export const TEXTFIELD_PROPS_SERVER_SUMMARY = {
|
||||
apiPath: API_SERVER_SUMMARY,
|
||||
maxLength: 500,
|
||||
placeholder: '',
|
||||
label: 'About',
|
||||
tip: 'A brief blurb about you, your server, or what your stream is about.',
|
||||
};
|
||||
export const TEXTFIELD_PROPS_LOGO = {
|
||||
apiPath: API_LOGO,
|
||||
maxLength: 255,
|
||||
placeholder: '/img/mylogo.png',
|
||||
label: 'Logo',
|
||||
tip:
|
||||
'Path to your logo from website root. We recommend that you use a square image that is at least 256x256. (upload functionality coming soon)',
|
||||
};
|
||||
export const TEXTFIELD_PROPS_STREAM_KEY = {
|
||||
apiPath: API_STREAM_KEY,
|
||||
configPath: '',
|
||||
maxLength: TEXT_MAXLENGTH,
|
||||
placeholder: 'abc123',
|
||||
label: 'Stream Key',
|
||||
tip: 'Save this key somewhere safe, you will need it to stream or login to the admin dashboard!',
|
||||
required: true,
|
||||
};
|
||||
export const TEXTFIELD_PROPS_FFMPEG = {
|
||||
apiPath: API_FFMPEG,
|
||||
configPath: '',
|
||||
maxLength: TEXT_MAXLENGTH,
|
||||
placeholder: '/usr/local/bin/ffmpeg',
|
||||
label: 'FFmpeg Path',
|
||||
tip: 'Absolute file path of the FFMPEG application on your server',
|
||||
required: true,
|
||||
};
|
||||
export const TEXTFIELD_PROPS_WEB_PORT = {
|
||||
apiPath: API_WEB_PORT,
|
||||
configPath: '',
|
||||
maxLength: 6,
|
||||
placeholder: '8080',
|
||||
label: 'Owncast port',
|
||||
tip: 'What port is your Owncast web server listening? Default is 8080',
|
||||
required: true,
|
||||
};
|
||||
export const TEXTFIELD_PROPS_RTMP_PORT = {
|
||||
apiPath: API_RTMP_PORT,
|
||||
configPath: '',
|
||||
maxLength: 6,
|
||||
placeholder: '1935',
|
||||
label: 'RTMP port',
|
||||
tip: 'What port should accept inbound broadcasts? Default is 1935',
|
||||
required: true,
|
||||
};
|
||||
export const TEXTFIELD_PROPS_INSTANCE_URL = {
|
||||
apiPath: API_INSTANCE_URL,
|
||||
configPath: 'yp',
|
||||
maxLength: 255,
|
||||
placeholder: 'https://owncast.mysite.com',
|
||||
label: 'Instance URL',
|
||||
tip: 'The full url to your Owncast server.',
|
||||
};
|
||||
// MISC FIELDS
|
||||
export const FIELD_PROPS_TAGS = {
|
||||
apiPath: API_TAGS,
|
||||
configPath: 'instanceDetails',
|
||||
maxLength: 24,
|
||||
placeholder: 'Add a new tag',
|
||||
required: true,
|
||||
label: '',
|
||||
tip: '',
|
||||
};
|
||||
|
||||
export const FIELD_PROPS_CUSTOM_CONTENT = {
|
||||
apiPath: API_CUSTOM_CONTENT,
|
||||
configPath: 'instanceDetails',
|
||||
placeholder: '',
|
||||
label: 'Extra page content',
|
||||
tip: 'Custom markup about yourself',
|
||||
};
|
||||
export const FIELD_PROPS_NSFW = {
|
||||
apiPath: API_NSFW_SWITCH,
|
||||
configPath: 'instanceDetails',
|
||||
label: 'NSFW?',
|
||||
tip:
|
||||
"Turn this ON if you plan to steam explicit or adult content. You may want to respectfully set this flag so that unexpecting eyes won't accidentally see it from the Directory.",
|
||||
};
|
||||
|
||||
export const FIELD_PROPS_YP = {
|
||||
apiPath: API_YP_SWITCH,
|
||||
configPath: 'yp',
|
||||
label: 'Display in the Owncast Directory?',
|
||||
tip:
|
||||
'Turn this ON if you want to show up in the Owncast directory at https://directory.owncast.online.',
|
||||
};
|
||||
|
||||
export const DEFAULT_VARIANT_STATE: VideoVariant = {
|
||||
framerate: 24,
|
||||
videoPassthrough: false,
|
||||
videoBitrate: 800,
|
||||
audioPassthrough: true, // if false, then CAN set audiobitrate
|
||||
audioBitrate: 0,
|
||||
cpuUsageLevel: 3,
|
||||
};
|
||||
|
||||
export const DEFAULT_SOCIAL_HANDLE: SocialHandle = {
|
||||
url: '',
|
||||
platform: '',
|
||||
};
|
||||
|
||||
export const OTHER_SOCIAL_HANDLE_OPTION = 'OTHER_SOCIAL_HANDLE_OPTION';
|
||||
|
||||
export const TEXTFIELD_PROPS_S3_COMMON = {
|
||||
maxLength: 255,
|
||||
};
|
||||
|
||||
export const S3_TEXT_FIELDS_INFO = {
|
||||
accessKey: {
|
||||
fieldName: 'accessKey',
|
||||
label: 'Access Key',
|
||||
maxLength: 255,
|
||||
placeholder: 'access key 123',
|
||||
tip: '',
|
||||
},
|
||||
acl: {
|
||||
fieldName: 'acl',
|
||||
label: 'ACL',
|
||||
maxLength: 255,
|
||||
placeholder: '',
|
||||
tip: 'Optional specific access control value to add to your content. Generally not required.',
|
||||
},
|
||||
bucket: {
|
||||
fieldName: 'bucket',
|
||||
label: 'Bucket',
|
||||
maxLength: 255,
|
||||
placeholder: 'bucket 123',
|
||||
tip: 'Create a new bucket for each Owncast instance you may be running.',
|
||||
},
|
||||
endpoint: {
|
||||
fieldName: 'endpoint',
|
||||
label: 'Endpoint',
|
||||
maxLength: 255,
|
||||
placeholder: 'https://your.s3.provider.endpoint.com',
|
||||
tip: 'The full URL endpoint your storage provider gave you.',
|
||||
},
|
||||
region: {
|
||||
fieldName: 'region',
|
||||
label: 'Region',
|
||||
maxLength: 255,
|
||||
placeholder: 'region 123',
|
||||
tip: '',
|
||||
},
|
||||
secret: {
|
||||
fieldName: 'secret',
|
||||
label: 'Secret key',
|
||||
maxLength: 255,
|
||||
placeholder: 'your secret key',
|
||||
tip: '',
|
||||
},
|
||||
servingEndpoint: {
|
||||
fieldName: 'servingEndpoint',
|
||||
label: 'Serving Endpoint',
|
||||
maxLength: 255,
|
||||
placeholder: 'http://cdn.ss3.provider.endpoint.com',
|
||||
tip:
|
||||
'Optional URL that content should be accessed from instead of the default. Used with CDNs and specific storage providers. Generally not required.',
|
||||
},
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// 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 './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>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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 './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>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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 './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>
|
||||
);
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
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 './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>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
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 './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>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/* 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 './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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { RESET_TIMEOUT, postConfigUpdateToAPI } from './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: '',
|
||||
};
|
||||
@@ -1,174 +0,0 @@
|
||||
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,
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.target.value.length > maxLength - 1) e.preventDefault();
|
||||
return false;
|
||||
},
|
||||
};
|
||||
} 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}
|
||||
/>
|
||||
</div>
|
||||
<FormStatusIndicator status={status} />
|
||||
<p className="field-tip">
|
||||
{tip}
|
||||
{/* <InfoTip tip={tip} /> */}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TextField.defaultProps = {
|
||||
className: '',
|
||||
// configPath: '',
|
||||
disabled: false,
|
||||
// initialValue: '',
|
||||
label: '',
|
||||
maxLength: null,
|
||||
|
||||
placeholder: '',
|
||||
required: false,
|
||||
status: null,
|
||||
tip: '',
|
||||
type: TEXTFIELD_TYPE_TEXT,
|
||||
value: '',
|
||||
onSubmit: () => {},
|
||||
onBlur: () => {},
|
||||
onChange: () => {},
|
||||
onPressEnter: () => {},
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
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 './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: '',
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
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 './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>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
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 './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>
|
||||
);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
// 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 './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>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
// 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 './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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
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 './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,
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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 '../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>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
API_CUSTOM_CONTENT,
|
||||
} from './components/config/constants';
|
||||
} from '../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
STATUS_SUCCESS,
|
||||
} from '../utils/input-statuses';
|
||||
import 'react-markdown-editor-lite/lib/index.css';
|
||||
import FormStatusIndicator from './components/config/form-status-indicator';
|
||||
import FormStatusIndicator from '../components/config/form-status-indicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -87,7 +87,8 @@ export default function PageContentEditor() {
|
||||
<Title level={2}>Page Content</Title>
|
||||
|
||||
<p>
|
||||
Edit the content of your page by using simple <a href="https://www.markdownguide.org/basic-syntax/">Markdown syntax</a>.
|
||||
Edit the content of your page by using simple{' '}
|
||||
<a href="https://www.markdownguide.org/basic-syntax/">Markdown syntax</a>.
|
||||
</p>
|
||||
|
||||
<MdEditor
|
||||
@@ -100,7 +101,7 @@ export default function PageContentEditor() {
|
||||
markdownClass: 'markdown-editor-pane',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
<div className="page-content-actions">
|
||||
{hasChanged ? (
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
@@ -108,7 +109,6 @@ export default function PageContentEditor() {
|
||||
</Button>
|
||||
) : null}
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import Link from 'next/link';
|
||||
|
||||
import EditInstanceDetails from './components/config/edit-instance-details';
|
||||
import EditDirectoryDetails from './components/config/edit-directory';
|
||||
import EditInstanceTags from './components/config/edit-tags';
|
||||
import EditInstanceDetails from '../components/config/edit-instance-details';
|
||||
import EditDirectoryDetails from '../components/config/edit-directory';
|
||||
import EditInstanceTags from '../components/config/edit-tags';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -13,7 +13,8 @@ export default function PublicFacingDetails() {
|
||||
<>
|
||||
<Title level={2}>General Settings</Title>
|
||||
<p>
|
||||
The following are displayed on your site to describe your stream and its content. <a href="https://owncast.online/docs/website/">Learn more.</a>
|
||||
The following are displayed on your site to describe your stream and its content.{' '}
|
||||
<a href="https://owncast.online/docs/website/">Learn more.</a>
|
||||
</p>
|
||||
<div className="edit-public-details-container">
|
||||
<EditInstanceDetails />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import EditServerDetails from './components/config/edit-server-details';
|
||||
import EditServerDetails from '../components/config/edit-server-details';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -9,7 +9,8 @@ export default function ConfigServerDetails() {
|
||||
<div className="config-server-details-form">
|
||||
<Title level={2}>Server Settings</Title>
|
||||
<p>
|
||||
You should change your stream key from the default and keep it safe. For most people it's likely the other settings will not need to be changed.
|
||||
You should change your stream key from the default and keep it safe. For most people it's
|
||||
likely the other settings will not need to be changed.
|
||||
</p>
|
||||
<div className="config-server-details-container">
|
||||
<EditServerDetails />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import EditSocialLinks from './components/config/edit-social-links';
|
||||
import EditSocialLinks from '../components/config/edit-social-links';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import EditStorage from './components/config/edit-storage';
|
||||
import EditStorage from '../components/config/edit-storage';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -9,10 +9,14 @@ export default function ConfigStorageInfo() {
|
||||
<>
|
||||
<Title level={2}>Storage</Title>
|
||||
<p>
|
||||
Owncast supports optionally using external storage providers to distribute your video. Learn more about this by visiting our <a href="https://owncast.online/docs/storage/">Storage Documentation</a>.
|
||||
Owncast supports optionally using external storage providers to distribute your video. Learn
|
||||
more about this by visiting our{' '}
|
||||
<a href="https://owncast.online/docs/storage/">Storage Documentation</a>.
|
||||
</p>
|
||||
<p>
|
||||
Configuring this incorrectly will likely cause your video to be unplayable. Double check the documentation for your storage provider on how to configure the bucket you created for Owncast.
|
||||
Configuring this incorrectly will likely cause your video to be unplayable. Double check the
|
||||
documentation for your storage provider on how to configure the bucket you created for
|
||||
Owncast.
|
||||
</p>
|
||||
<EditStorage />
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import VideoVariantsTable from './components/config/video-variants-table';
|
||||
import VideoLatency from './components/config/video-latency';
|
||||
import VideoVariantsTable from '../components/config/video-variants-table';
|
||||
import VideoLatency from '../components/config/video-latency';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -11,18 +11,20 @@ export default function ConfigVideoSettings() {
|
||||
<div className="config-video-variants">
|
||||
<Title level={2}>Video configuration</Title>
|
||||
<p>
|
||||
Before changing your video configuration <a href="https://owncast.online/docs/encoding">visit the video documentation</a>{' '}
|
||||
to learn how it impacts your stream performance.
|
||||
Before changing your video configuration{' '}
|
||||
<a href="https://owncast.online/docs/encoding">visit the video documentation</a> to learn
|
||||
how it impacts your stream performance.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<VideoVariantsTable />
|
||||
</p>
|
||||
<br/><hr/><br/>
|
||||
<p>
|
||||
<VideoLatency />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<VideoVariantsTable />
|
||||
</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<p>
|
||||
<VideoLatency />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { BulbOutlined, LaptopOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { Row } from "antd";
|
||||
import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { Row } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fetchData, FETCH_INTERVAL, HARDWARE_STATS } from '../utils/apis';
|
||||
import Chart from './components/chart';
|
||||
import StatisticItem from "./components/statistic";
|
||||
import Chart from '../components/chart';
|
||||
import StatisticItem from '../components/statistic';
|
||||
|
||||
interface TimedValue {
|
||||
time: Date,
|
||||
value: Number
|
||||
time: Date;
|
||||
value: Number;
|
||||
}
|
||||
|
||||
export default function HardwareInfo() {
|
||||
@@ -15,92 +15,88 @@ export default function HardwareInfo() {
|
||||
cpu: Array<TimedValue>(),
|
||||
memory: Array<TimedValue>(),
|
||||
disk: Array<TimedValue>(),
|
||||
message: "",
|
||||
message: '',
|
||||
});
|
||||
|
||||
const getHardwareStatus = async () => {
|
||||
try {
|
||||
const result = await fetchData(HARDWARE_STATS);
|
||||
setHardwareStatus({ ...result });
|
||||
|
||||
} catch (error) {
|
||||
setHardwareStatus({ ...hardwareStatus, message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let getStatusIntervalId = null;
|
||||
|
||||
getHardwareStatus();
|
||||
getStatusIntervalId = setInterval(getHardwareStatus, FETCH_INTERVAL); // runs every 1 min.
|
||||
|
||||
// returned function will be called on component unmount
|
||||
|
||||
// returned function will be called on component unmount
|
||||
return () => {
|
||||
clearInterval(getStatusIntervalId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
if (!hardwareStatus.cpu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentCPUUsage = hardwareStatus.cpu[hardwareStatus.cpu.length - 1]?.value;
|
||||
const currentRamUsage =
|
||||
hardwareStatus.memory[hardwareStatus.memory.length - 1]?.value;
|
||||
const currentDiskUsage =
|
||||
hardwareStatus.disk[hardwareStatus.disk.length - 1]?.value;
|
||||
|
||||
const series = [
|
||||
{
|
||||
name: "CPU",
|
||||
color: "#B63FFF",
|
||||
data: hardwareStatus.cpu,
|
||||
},
|
||||
{
|
||||
name: "Memory",
|
||||
color: "#2087E2",
|
||||
data: hardwareStatus.memory,
|
||||
},
|
||||
{
|
||||
name: "Disk",
|
||||
color: "#FF7700",
|
||||
data: hardwareStatus.disk,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Row gutter={[16, 16]} justify="space-around">
|
||||
<StatisticItem
|
||||
title={series[0].name}
|
||||
value={`${currentCPUUsage}`}
|
||||
prefix={<LaptopOutlined style={{color: series[0].color }}/>}
|
||||
color={series[0].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
<StatisticItem
|
||||
title={series[1].name}
|
||||
value={`${currentRamUsage}`}
|
||||
prefix={<BulbOutlined style={{color: series[1].color }} />}
|
||||
color={series[1].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
<StatisticItem
|
||||
title={series[2].name}
|
||||
value={`${currentDiskUsage}`}
|
||||
prefix={<SaveOutlined style={{color: series[2].color }} />}
|
||||
color={series[2].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
</Row>
|
||||
const currentRamUsage = hardwareStatus.memory[hardwareStatus.memory.length - 1]?.value;
|
||||
const currentDiskUsage = hardwareStatus.disk[hardwareStatus.disk.length - 1]?.value;
|
||||
|
||||
<Chart title="% used" dataCollections={series} color="#FF7700" unit="%" />
|
||||
</div>
|
||||
const series = [
|
||||
{
|
||||
name: 'CPU',
|
||||
color: '#B63FFF',
|
||||
data: hardwareStatus.cpu,
|
||||
},
|
||||
{
|
||||
name: 'Memory',
|
||||
color: '#2087E2',
|
||||
data: hardwareStatus.memory,
|
||||
},
|
||||
{
|
||||
name: 'Disk',
|
||||
color: '#FF7700',
|
||||
data: hardwareStatus.disk,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<Row gutter={[16, 16]} justify="space-around">
|
||||
<StatisticItem
|
||||
title={series[0].name}
|
||||
value={`${currentCPUUsage}`}
|
||||
prefix={<LaptopOutlined style={{ color: series[0].color }} />}
|
||||
color={series[0].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
<StatisticItem
|
||||
title={series[1].name}
|
||||
value={`${currentRamUsage}`}
|
||||
prefix={<BulbOutlined style={{ color: series[1].color }} />}
|
||||
color={series[1].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
<StatisticItem
|
||||
title={series[2].name}
|
||||
value={`${currentDiskUsage}`}
|
||||
prefix={<SaveOutlined style={{ color: series[2].color }} />}
|
||||
color={series[2].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Chart title="% used" dataCollections={series} color="#FF7700" unit="%" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,29 +7,29 @@ Will display an overview with the following datasources:
|
||||
TODO: Link each overview value to the sub-page that focuses on it.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import { Skeleton, Card, Statistic } from "antd";
|
||||
import { UserOutlined, ClockCircleOutlined } from "@ant-design/icons";
|
||||
import { formatDistanceToNow, formatRelative } from "date-fns";
|
||||
import { ServerStatusContext } from "../utils/server-status-context";
|
||||
import StatisticItem from "./components/statistic"
|
||||
import LogTable from "./components/log-table";
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Skeleton, Card, Statistic } from 'antd';
|
||||
import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { formatDistanceToNow, formatRelative } from 'date-fns';
|
||||
import { ServerStatusContext } from '../utils/server-status-context';
|
||||
import StatisticItem from '../components/statistic';
|
||||
import LogTable from '../components/log-table';
|
||||
import Offline from './offline-notice';
|
||||
|
||||
import {
|
||||
LOGS_WARN,
|
||||
fetchData,
|
||||
FETCH_INTERVAL,
|
||||
} from "../utils/apis";
|
||||
import { formatIPAddress, isEmptyObject } from "../utils/format";
|
||||
import { UpdateArgs } from "../types/config-section";
|
||||
import { LOGS_WARN, fetchData, FETCH_INTERVAL } from '../utils/apis';
|
||||
import { formatIPAddress, isEmptyObject } from '../utils/format';
|
||||
import { UpdateArgs } from '../types/config-section';
|
||||
|
||||
function streamDetailsFormatter(streamDetails) {
|
||||
return (
|
||||
<ul className="statistics-list">
|
||||
<li>{streamDetails.videoCodec || 'Unknown'} @ {streamDetails.videoBitrate || 'Unknown'} kbps</li>
|
||||
<li>
|
||||
{streamDetails.videoCodec || 'Unknown'} @ {streamDetails.videoBitrate || 'Unknown'} kbps
|
||||
</li>
|
||||
<li>{streamDetails.framerate || 'Unknown'} fps</li>
|
||||
<li>{streamDetails.width} x {streamDetails.height}</li>
|
||||
<li>
|
||||
{streamDetails.width} x {streamDetails.height}
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export default function Home() {
|
||||
const { broadcaster, serverConfig: configData } = serverStatusData || {};
|
||||
const { remoteAddr, streamDetails } = broadcaster || {};
|
||||
|
||||
const encoder = streamDetails?.encoder || "Unknown encoder";
|
||||
const encoder = streamDetails?.encoder || 'Unknown encoder';
|
||||
|
||||
const [logsData, setLogs] = useState([]);
|
||||
const getLogs = async () => {
|
||||
@@ -47,22 +47,22 @@ export default function Home() {
|
||||
const result = await fetchData(LOGS_WARN);
|
||||
setLogs(result);
|
||||
} catch (error) {
|
||||
console.log("==== error", error);
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
const getMoreStats = () => {
|
||||
getLogs();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getMoreStats();
|
||||
|
||||
let intervalId = null;
|
||||
intervalId = setInterval(getMoreStats, FETCH_INTERVAL);
|
||||
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isEmptyObject(configData) || isEmptyObject(serverStatusData)) {
|
||||
@@ -80,40 +80,38 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// map out settings
|
||||
const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map((setting, index) => {
|
||||
const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
|
||||
const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map(
|
||||
(setting, index) => {
|
||||
const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
|
||||
|
||||
const audioSetting = audioPassthrough
|
||||
? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps`
|
||||
: `${audioBitrate || 'Unknown'} kbps`;
|
||||
const audioSetting = audioPassthrough
|
||||
? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps`
|
||||
: `${audioBitrate || 'Unknown'} kbps`;
|
||||
|
||||
const videoSetting = videoPassthrough
|
||||
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${streamDetails.width} x ${streamDetails.height}`
|
||||
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
|
||||
const videoSetting = videoPassthrough
|
||||
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${
|
||||
streamDetails.width
|
||||
} x ${streamDetails.height}`
|
||||
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
|
||||
|
||||
let settingTitle = 'Outbound Stream Details';
|
||||
settingTitle = (videoQualitySettings?.length > 1) ?
|
||||
`${settingTitle} ${index + 1}` : settingTitle;
|
||||
return (
|
||||
<Card title={settingTitle} type="inner" key={`${settingTitle}${index}`}>
|
||||
<StatisticItem
|
||||
title="Outbound Video Stream"
|
||||
value={videoSetting}
|
||||
prefix={null}
|
||||
/>
|
||||
<StatisticItem
|
||||
title="Outbound Audio Stream"
|
||||
value={audioSetting}
|
||||
prefix={null}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
let settingTitle = 'Outbound Stream Details';
|
||||
settingTitle =
|
||||
videoQualitySettings?.length > 1 ? `${settingTitle} ${index + 1}` : settingTitle;
|
||||
return (
|
||||
<Card title={settingTitle} type="inner" key={`${settingTitle}${index}`}>
|
||||
<StatisticItem title="Outbound Video Stream" value={videoSetting} prefix={null} />
|
||||
<StatisticItem title="Outbound Audio Stream" value={audioSetting} prefix={null} />
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// inbound
|
||||
const { viewerCount, sessionPeakViewerCount } = serverStatusData;
|
||||
|
||||
const streamAudioDetailString = `${streamDetails.audioCodec}, ${streamDetails.audioBitrate || 'Unknown'} kbps`;
|
||||
const streamAudioDetailString = `${streamDetails.audioCodec}, ${
|
||||
streamDetails.audioBitrate || 'Unknown'
|
||||
} kbps`;
|
||||
|
||||
const broadcastDate = new Date(broadcaster.time);
|
||||
|
||||
@@ -123,18 +121,11 @@ export default function Home() {
|
||||
<div className="section online-status-section">
|
||||
<Card title="Stream is online" type="inner">
|
||||
<Statistic
|
||||
title={`Stream started ${formatRelative(
|
||||
broadcastDate,
|
||||
Date.now()
|
||||
)}`}
|
||||
title={`Stream started ${formatRelative(broadcastDate, Date.now())}`}
|
||||
value={formatDistanceToNow(broadcastDate)}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
/>
|
||||
<Statistic
|
||||
title="Viewers"
|
||||
value={viewerCount}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
<Statistic title="Viewers" value={viewerCount} prefix={<UserOutlined />} />
|
||||
<Statistic
|
||||
title="Peak viewer count"
|
||||
value={sessionPeakViewerCount}
|
||||
@@ -144,10 +135,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<div className="section stream-details-section">
|
||||
|
||||
<div className="details outbound-details">
|
||||
{videoQualitySettings}
|
||||
</div>
|
||||
<div className="details outbound-details">{videoQualitySettings}</div>
|
||||
|
||||
<div className="details other-details">
|
||||
<Card title="Inbound Stream Details" type="inner">
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import LogTable from "./components/log-table";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import LogTable from '../components/log-table';
|
||||
|
||||
import {
|
||||
LOGS_ALL,
|
||||
fetchData,
|
||||
} from "../utils/apis";
|
||||
import { LOGS_ALL, fetchData } from '../utils/apis';
|
||||
|
||||
const FETCH_INTERVAL = 5 * 1000; // 5 sec
|
||||
|
||||
@@ -16,7 +13,7 @@ export default function Logs() {
|
||||
const result = await fetchData(LOGS_ALL);
|
||||
setLogs(result);
|
||||
} catch (error) {
|
||||
console.log("==== error", error);
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,6 +30,5 @@ export default function Logs() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <LogTable logs={logs} pageSize={20}/>;
|
||||
return <LogTable logs={logs} pageSize={20} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
BookTwoTone,
|
||||
PlaySquareTwoTone,
|
||||
} from '@ant-design/icons';
|
||||
import OwncastLogo from './components/logo';
|
||||
import LogTable from './components/log-table';
|
||||
import OwncastLogo from '../components/logo';
|
||||
import LogTable from '../components/log-table';
|
||||
|
||||
const { Meta } = Card;
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Table, Row } from 'antd';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import { SortOrder } from 'antd/lib/table/interface';
|
||||
import Chart from './components/chart';
|
||||
import StatisticItem from './components/statistic';
|
||||
import Chart from '../components/chart';
|
||||
import StatisticItem from '../components/statistic';
|
||||
|
||||
import { ServerStatusContext } from '../utils/server-status-context';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user