0

Merge pull request #30 from owncast/0.0.6

0.0.6 -> master
This commit is contained in:
Gabe Kangas 2021-02-18 22:24:43 -08:00 committed by GitHub
commit 74c81dc461
78 changed files with 6332 additions and 1884 deletions

View File

@ -4,48 +4,49 @@ module.exports = {
es2021: true,
},
extends: [
"plugin:react/recommended",
"airbnb",
"prettier",
"prettier/@typescript-eslint",
"prettier/react",
'plugin:react/recommended',
'airbnb',
'prettier',
'prettier/@typescript-eslint',
'prettier/react',
],
parser: "@typescript-eslint/parser",
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: "module",
sourceType: 'module',
},
plugins: ["react", "@typescript-eslint"],
plugins: ['react', 'prettier', '@typescript-eslint'],
rules: {
"react/react-in-jsx-scope": "off",
"react/jsx-filename-extension": [1, { extensions: [".js", ".jsx", ".tsx"] }],
"react/jsx-props-no-spreading": "off",
'prettier/prettier': 'error',
'react/react-in-jsx-scope': 'off',
'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx', '.tsx'] }],
'react/jsx-props-no-spreading': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'error',
'no-use-before-define': [0],
'@typescript-eslint/no-use-before-define': [1],
"import/extensions": [
"error",
"ignorePackages",
'import/extensions': [
'error',
'ignorePackages',
{
"js": "never",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
]
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
},
settings: {
"import/resolver": {
"node": {
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
};

View File

@ -20,4 +20,3 @@ jobs:
config-path: '.eslintrc.js'
ignore-path: '.eslintignore'
extensions: 'ts,tsx,js,jsx'
extra-args: '--max-warnings=0'

View File

@ -23,7 +23,7 @@ jobs:
uses: creyD/prettier_action@v3.1
with:
# This part is also where you can pass other options, for example:
prettier_options: --write **/*.{js,tsx,jsx}
prettier_options: --write **/*.{js,tsx,jsx,css,scss}
only_changed: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,4 +1,9 @@
{
"useTabs": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
"singleQuote": true,
"trailingComma": "all",
"jsxBracketSameLine": false,
"arrowParens": "avoid"
}

View File

@ -1,7 +1,6 @@
import { LineChart } from 'react-chartkick'
import { LineChart } from 'react-chartkick';
import 'chart.js';
import format from 'date-fns/format';
import styles from '../../styles/styles.module.scss';
interface TimedValue {
time: Date;
@ -9,11 +8,11 @@ interface TimedValue {
}
interface ChartProps {
data?: TimedValue[],
title?: string,
color: string,
unit: string,
dataCollections?: any[],
data?: TimedValue[];
title?: string;
color: string;
unit: string;
dataCollections?: any[];
}
function createGraphDataset(dataArray) {
@ -22,7 +21,7 @@ function createGraphDataset(dataArray) {
const dateObject = new Date(item.time);
const dateString = format(dateObject, 'p P');
dataValues[dateString] = item.value;
})
});
return dataValues;
}
@ -33,18 +32,20 @@ export default function Chart({ data, title, color, unit, dataCollections }: Cha
renderData.push({
name: title,
color,
data: createGraphDataset(data)
data: createGraphDataset(data),
});
}
dataCollections.forEach(collection => {
renderData.push(
{name: collection.name, data: createGraphDataset(collection.data), color: collection.color}
)
renderData.push({
name: collection.name,
data: createGraphDataset(collection.data),
color: collection.color,
});
});
return (
<div className={styles.lineChartContainer}>
<div className="line-chart-container">
<LineChart
xtitle="Time"
ytitle={title}

View File

@ -0,0 +1,66 @@
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 CPU usage - lowest quality video',
2: 'Low CPU usage - low quality video',
3: 'Medium CPU usage - average quality video',
4: 'High CPU usage - high quality video',
5: 'Highest CPU usage - higher quality video',
};
interface Props {
defaultValue: number;
onChange: (arg: number) => void;
}
export default function CPUUsageSelector({ defaultValue, onChange }: Props) {
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-cpu-container">
<Title level={3}>CPU Usage</Title>
<p className="description">
Reduce to improve server performance, or increase it to improve video quality.
</p>
<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}
/>
<p className="selected-value-note">{TOOLTIPS[selectedOption]}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
// 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';
import { ServerStatusContext } from '../../utils/server-status-context';
import { FIELD_PROPS_NSFW, FIELD_PROPS_YP } from '../../utils/config-constants';
const { Title } = Typography;
export default function EditYPDetails() {
const [formDataValues, setFormDataValues] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { yp, instanceDetails } = serverConfig;
const { nsfw } = instanceDetails;
const { enabled, instanceUrl } = yp;
useEffect(() => {
setFormDataValues({
...yp,
enabled,
nsfw,
});
}, [yp, instanceDetails]);
const hasInstanceUrl = instanceUrl !== '';
if (!formDataValues) {
return null;
}
return (
<div className="config-directory-details-form">
<Title level={3} className="section-title">
Owncast Directory Settings
</Title>
<p className="description">
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>
);
}

View File

@ -0,0 +1,136 @@
import React, { useState, useContext, useEffect } from 'react';
import { Typography } from 'antd';
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,
FIELD_PROPS_YP,
FIELD_PROPS_NSFW,
} from '../../utils/config-constants';
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
import { UpdateArgs } from '../../types/config-section';
import ToggleSwitch from './form-toggleswitch';
const { Title } = Typography;
export default function EditInstanceDetails() {
const [formDataValues, setFormDataValues] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { instanceDetails, yp } = serverConfig;
const { instanceUrl } = yp;
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,
});
};
const hasInstanceUrl = instanceUrl !== '';
return (
<div className="edit-general-settings">
<Title level={3} className="section-title">
Configure Instance Details
</Title>
<br />
<TextFieldWithSubmit
fieldName="name"
{...TEXTFIELD_PROPS_SERVER_NAME}
value={formDataValues.name}
initialValue={instanceDetails.name}
onChange={handleFieldChange}
/>
<TextFieldWithSubmit
fieldName="instanceUrl"
{...TEXTFIELD_PROPS_INSTANCE_URL}
value={formDataValues.instanceUrl}
initialValue={yp.instanceUrl}
type={TEXTFIELD_TYPE_URL}
onChange={handleFieldChange}
onSubmit={handleSubmitInstanceUrl}
/>
<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}
/>
{instanceDetails.logo && <img src={'/logo'} alt="uploaded logo" className="logo-preview" />}
<br />
<p className="description">
Increase your audience by appearing in the{' '}
<a href="https://directory.owncast.online" target="_blank" rel="noreferrer">
<strong>Owncast Directory</strong>
</a>. This is an external service run by the Owncast project. <a href="https://owncast.online/docs/directory/">Learn more</a>.
{!yp.instanceUrl && <div><br/>You must set your server URL above to enable the directory.</div>}
</p>
<div className="config-yp-container">
<ToggleSwitch
fieldName="enabled"
useSubmit
{...FIELD_PROPS_YP}
checked={formDataValues.enabled}
disabled={!hasInstanceUrl}
/>
<ToggleSwitch
fieldName="nsfw"
useSubmit
{...FIELD_PROPS_NSFW}
checked={formDataValues.nsfw}
disabled={!hasInstanceUrl}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,118 @@
// EDIT CUSTOM DETAILS ON YOUR PAGE
import React, { useState, useEffect, useContext } from 'react';
import { Typography, Button } from 'antd';
import dynamic from 'next/dynamic';
import MarkdownIt from 'markdown-it';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
postConfigUpdateToAPI,
RESET_TIMEOUT,
API_CUSTOM_CONTENT,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
import 'react-markdown-editor-lite/lib/index.css';
const mdParser = new MarkdownIt(/* Markdown-it options */);
const MdEditor = dynamic(() => import('react-markdown-editor-lite'), {
ssr: false,
});
const { Title } = Typography;
export default function EditPageContent() {
const [content, setContent] = useState('');
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [hasChanged, setHasChanged] = useState(false);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { extraPageContent: initialContent } = instanceDetails;
let resetTimer = null;
function handleEditorChange({ text }) {
setContent(text);
if (text !== initialContent && !hasChanged) {
setHasChanged(true);
} else if (text === initialContent && hasChanged) {
setHasChanged(false);
}
}
// Clear out any validation states and messaging
const resetStates = () => {
setSubmitStatus(null);
setHasChanged(false);
clearTimeout(resetTimer);
resetTimer = null;
};
// posts all the tags at once as an array obj
async function handleSave() {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath: API_CUSTOM_CONTENT,
data: { value: content },
onSuccess: (message: string) => {
setFieldInConfigState({
fieldName: 'extraPageContent',
value: content,
path: 'instanceDetails',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, message));
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
},
});
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
useEffect(() => {
setContent(initialContent);
}, [instanceDetails]);
return (
<div className="edit-page-content">
<Title level={3} className="section-title">
Custom Page Content
</Title>
<p className="description">
Edit the content of your page by using simple{' '}
<a href="https://www.markdownguide.org/basic-syntax/">Markdown syntax</a>.
</p>
<MdEditor
style={{ height: '30em' }}
value={content}
renderHTML={(c: string) => mdParser.render(c)}
onChange={handleEditorChange}
config={{
htmlClass: 'markdown-editor-preview-pane',
markdownClass: 'markdown-editor-pane',
}}
/>
<br />
<div className="page-content-actions">
{hasChanged && (
<Button type="primary" onClick={handleSave}>
Save
</Button>
)}
<FormStatusIndicator status={submitStatus} />
</div>
</div>
);
}

View File

@ -0,0 +1,151 @@
import React, { useState, useContext, useEffect } from 'react';
import { Button, Tooltip, Collapse } from 'antd';
import { CopyOutlined, RedoOutlined } from '@ant-design/icons';
import { TEXTFIELD_TYPE_NUMBER, TEXTFIELD_TYPE_PASSWORD } from './form-textfield';
import TextFieldWithSubmit from './form-textfield-with-submit';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import {
TEXTFIELD_PROPS_FFMPEG,
TEXTFIELD_PROPS_RTMP_PORT,
TEXTFIELD_PROPS_STREAM_KEY,
TEXTFIELD_PROPS_WEB_PORT,
} from '../../utils/config-constants';
import { UpdateArgs } from '../../types/config-section';
import ResetYP from './reset-yp';
const { Panel } = Collapse;
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, yp } = 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-server-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}
/>
{yp.enabled && (
<Collapse className="advanced-settings">
<Panel header="Advanced Settings" key="1">
<ResetYP />
</Panel>
</Collapse>
)}
</div>
);
}

View File

@ -0,0 +1,295 @@
import React, { useState, useContext, useEffect } from 'react';
import { Typography, Table, Button, Modal, Input } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { DeleteOutlined } from '@ant-design/icons';
import SocialDropdown from './social-icons-dropdown';
import { fetchData, NEXT_PUBLIC_API_HOST, SOCIAL_PLATFORMS_LIST } from '../../utils/apis';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
API_SOCIAL_HANDLES,
postConfigUpdateToAPI,
RESET_TIMEOUT,
DEFAULT_SOCIAL_HANDLE,
OTHER_SOCIAL_HANDLE_OPTION,
} from '../../utils/config-constants';
import { SocialHandle, UpdateArgs } from '../../types/config-section';
import isValidUrl from '../../utils/urls';
import TextField from './form-textfield';
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
const { Title } = Typography;
export default function EditSocialLinks() {
const [availableIconsList, setAvailableIconsList] = useState([]);
const [currentSocialHandles, setCurrentSocialHandles] = useState([]);
const [displayModal, setDisplayModal] = useState(false);
const [displayOther, setDisplayOther] = useState(false);
const [modalProcessing, setModalProcessing] = useState(false);
const [editId, setEditId] = useState(-1);
// current data inside modal
const [modalDataState, setModalDataState] = useState(DEFAULT_SOCIAL_HANDLE);
const [submitStatus, setSubmitStatus] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { socialHandles: initialSocialHandles } = instanceDetails;
let resetTimer = null;
const PLACEHOLDERS = {
mastodon: 'https://mastodon.social/@username',
twitter: 'https://twitter.com/username',
};
const getAvailableIcons = async () => {
try {
const result = await fetchData(SOCIAL_PLATFORMS_LIST, { auth: false });
const list = Object.keys(result).map(item => ({
key: item,
...result[item],
}));
setAvailableIconsList(list);
} catch (error) {
console.log(error);
// do nothing
}
};
const selectedOther =
modalDataState.platform !== '' &&
!availableIconsList.find(item => item.key === modalDataState.platform);
useEffect(() => {
getAvailableIcons();
}, []);
useEffect(() => {
if (instanceDetails.socialHandles) {
setCurrentSocialHandles(initialSocialHandles);
}
}, [instanceDetails]);
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
const resetModal = () => {
setDisplayModal(false);
setEditId(-1);
setDisplayOther(false);
setModalProcessing(false);
setModalDataState({ ...DEFAULT_SOCIAL_HANDLE });
};
const handleModalCancel = () => {
resetModal();
};
const updateModalState = (fieldName: string, value: string) => {
setModalDataState({
...modalDataState,
[fieldName]: value,
});
};
const handleDropdownSelect = (value: string) => {
if (value === OTHER_SOCIAL_HANDLE_OPTION) {
setDisplayOther(true);
updateModalState('platform', '');
} else {
setDisplayOther(false);
updateModalState('platform', value);
}
};
const handleOtherNameChange = event => {
const { value } = event.target;
updateModalState('platform', value);
};
const handleUrlChange = ({ value }: UpdateArgs) => {
updateModalState('url', value);
};
// posts all the variants at once as an array obj
const postUpdateToAPI = async (postValue: any) => {
await postConfigUpdateToAPI({
apiPath: API_SOCIAL_HANDLES,
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'socialHandles',
value: postValue,
path: 'instanceDetails',
});
// close modal
setModalProcessing(false);
handleModalCancel();
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
// on Ok, send all of dataState to api
// show loading
// close modal when api is done
const handleModalOk = () => {
setModalProcessing(true);
const postData = currentSocialHandles.length ? [...currentSocialHandles] : [];
if (editId === -1) {
postData.push(modalDataState);
} else {
postData.splice(editId, 1, modalDataState);
}
postUpdateToAPI(postData);
};
const handleDeleteItem = (index: number) => {
const postData = [...currentSocialHandles];
postData.splice(index, 1);
postUpdateToAPI(postData);
};
const socialHandlesColumns: ColumnsType<SocialHandle> = [
{
title: 'Social Link',
dataIndex: '',
key: 'combo',
render: (data, record) => {
const { platform, url } = record;
const platformInfo = availableIconsList.find(item => item.key === platform);
if (!platformInfo) {
return platform;
}
const { icon, platform: platformName } = platformInfo;
return (
<div className="social-handle-cell">
<span className="option-icon">
<img src={`${icon}`} alt="" className="option-icon" />
</span>
<p className="option-label">
<strong>{platformName}</strong>
<span>{url}</span>
</p>
</div>
);
},
},
{
title: '',
dataIndex: '',
key: 'edit',
render: (data, record, index) => {
return (
<div className="actions">
<Button
size="small"
onClick={() => {
setEditId(index);
setModalDataState({ ...currentSocialHandles[index] });
setDisplayModal(true);
}}
>
Edit
</Button>
<Button
className="delete-button"
icon={<DeleteOutlined />}
size="small"
onClick={() => handleDeleteItem(index)}
/>
</div>
);
},
},
];
const okButtonProps = {
disabled: !isValidUrl(modalDataState.url),
};
const otherField = (
<div className="other-field-container formfield-container">
<div className="label-side" />
<div className="input-side">
<Input
placeholder="Other platform name"
defaultValue={modalDataState.platform}
onChange={handleOtherNameChange}
/>
</div>
</div>
);
return (
<div className="social-links-edit-container">
<Title level={3} className="section-title">
Your Social Handles
</Title>
<p className="description">
Add all your social media handles and links to your other profiles here.
</p>
<FormStatusIndicator status={submitStatus} />
<Table
className="social-handles-table"
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}
>
<div className="social-handle-modal-content">
<SocialDropdown
iconList={availableIconsList}
selectedOption={selectedOther ? OTHER_SOCIAL_HANDLE_OPTION : modalDataState.platform}
onSelected={handleDropdownSelect}
/>
{displayOther && otherField}
<br />
<TextField
fieldName="social-url"
label="URL"
placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
value={modalDataState.url}
onChange={handleUrlChange}
/>
<FormStatusIndicator status={submitStatus} />
</div>
</Modal>
<br />
<Button
type="primary"
onClick={() => {
resetModal();
setDisplayModal(true);
}}
>
Add a new social link
</Button>
</div>
);
}

View File

@ -0,0 +1,230 @@
import { Button, Collapse } from 'antd';
import classNames from 'classnames';
import React, { useContext, useState, useEffect } from 'react';
import { UpdateArgs } from '../../types/config-section';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import {
postConfigUpdateToAPI,
API_S3_INFO,
RESET_TIMEOUT,
S3_TEXT_FIELDS_INFO,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import TextField from './form-textfield';
import FormStatusIndicator from './form-status-indicator';
import isValidUrl from '../../utils/urls';
import ToggleSwitch from './form-toggleswitch';
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,
'form-module': true,
enabled: shouldDisplayForm,
});
const isSaveable = checkSaveable(formDataValues, s3);
return (
<div className={containerClass}>
<div className="enable-switch">
<ToggleSwitch
apiPath=""
fieldName="enabled"
label="Storage Enabled"
checked={formDataValues.enabled}
onChange={handleSwitchChange}
/>
{/* <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>
);
}

View File

@ -0,0 +1,139 @@
/* eslint-disable react/no-array-index-key */
import React, { useContext, useState, useEffect } from 'react';
import { Typography, Tag } from 'antd';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
FIELD_PROPS_TAGS,
RESET_TIMEOUT,
postConfigUpdateToAPI,
} from '../../utils/config-constants';
import TextField from './form-textfield';
import { UpdateArgs } from '../../types/config-section';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
STATUS_WARNING,
} from '../../utils/input-statuses';
const { Title } = Typography;
const TAG_COLOR = '#5a67d8';
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} className="section-title">
Add Tags
</Title>
<p className="description">
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} color={TAG_COLOR} 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>
);
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import classNames from 'classnames';
import { StatusState } from '../../utils/input-statuses';
interface FormStatusIndicatorProps {
status: StatusState;
}
export default function FormStatusIndicator({ status }: FormStatusIndicatorProps) {
const { type, icon, message } = status || {};
const classes = classNames({
'status-container': true,
[`status-${type}`]: type,
empty: !message,
});
return (
<div className={classes}>
{icon ? <span className="status-icon">{icon}</span> : null}
{message ? <span className="status-message">{message}</span> : null}
</div>
);
}

View File

@ -0,0 +1,148 @@
import React, { useEffect, useState, useContext } from 'react';
import { Button } from 'antd';
import classNames from 'classnames';
import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constants';
import { ServerStatusContext } from '../../utils/server-status-context';
import TextField, { TextFieldProps } from './form-textfield';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { UpdateArgs } from '../../types/config-section';
import FormStatusIndicator from './form-status-indicator';
export const TEXTFIELD_TYPE_TEXT = 'default';
export const TEXTFIELD_TYPE_PASSWORD = 'password'; // Input.Password
export const TEXTFIELD_TYPE_NUMBER = 'numeric';
export const TEXTFIELD_TYPE_TEXTAREA = 'textarea';
export const TEXTFIELD_TYPE_URL = 'url';
interface TextFieldWithSubmitProps extends TextFieldProps {
apiPath: string;
configPath?: string;
initialValue?: string;
}
export default function TextFieldWithSubmit(props: TextFieldWithSubmitProps) {
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [hasChanged, setHasChanged] = useState(false);
const serverStatusData = useContext(ServerStatusContext);
const { setFieldInConfigState } = serverStatusData || {};
let resetTimer = null;
const {
apiPath,
configPath = '',
initialValue,
...textFieldProps // rest of props
} = props;
const { fieldName, required, tip, status, value, onChange, onSubmit } = textFieldProps;
// Clear out any validation states and messaging
const resetStates = () => {
setSubmitStatus(null);
setHasChanged(false);
clearTimeout(resetTimer);
resetTimer = null;
};
useEffect(() => {
// TODO: Add native validity checks here, somehow
// https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
// const hasValidity = (type !== TEXTFIELD_TYPE_NUMBER && e.target.validity.valid) || type === TEXTFIELD_TYPE_NUMBER ;
if ((required && (value === '' || value === null)) || value === initialValue) {
setHasChanged(false);
} else {
// show submit button
resetStates();
setHasChanged(true);
}
}, [value]);
// if field is required but value is empty, or equals initial value, then don't show submit/update button. otherwise clear out any result messaging and display button.
const handleChange = ({ fieldName: changedFieldName, value: changedValue }: UpdateArgs) => {
if (onChange) {
onChange({ fieldName: changedFieldName, value: changedValue });
}
};
// if you blur a required field with an empty value, restore its original value in state (parent's state), if an onChange from parent is available.
const handleBlur = ({ value: changedValue }: UpdateArgs) => {
if (onChange && required && changedValue === '') {
onChange({ fieldName, value: initialValue });
}
};
// how to get current value of input
const handleSubmit = async () => {
if ((required && value !== '') || value !== initialValue) {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath,
data: { value },
onSuccess: () => {
setFieldInConfigState({ fieldName, value, path: configPath });
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
},
});
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
// if an extra onSubmit handler was sent in as a prop, let's run that too.
if (onSubmit) {
onSubmit();
}
}
};
const textfieldContainerClass = classNames({
'textfield-with-submit-container': true,
submittable: hasChanged,
});
return (
<div className={textfieldContainerClass}>
<div className="textfield-component">
<TextField
{...textFieldProps}
onSubmit={null}
onBlur={handleBlur}
onChange={handleChange}
/>
</div>
<div className="formfield-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: '',
};

View File

@ -0,0 +1,166 @@
import React from 'react';
import classNames from 'classnames';
import { Input, InputNumber } from 'antd';
import { FieldUpdaterFunc } from '../../types/config-section';
// import InfoTip from '../info-tip';
import { StatusState } from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
export const TEXTFIELD_TYPE_TEXT = 'default';
export const TEXTFIELD_TYPE_PASSWORD = 'password'; // Input.Password
export const TEXTFIELD_TYPE_NUMBER = 'numeric'; // InputNumber
export const TEXTFIELD_TYPE_TEXTAREA = 'textarea'; // Input.TextArea
export const TEXTFIELD_TYPE_URL = 'url';
export interface TextFieldProps {
fieldName: string;
onSubmit?: () => void;
onPressEnter?: () => void;
className?: string;
disabled?: boolean;
label?: string;
maxLength?: number;
placeholder?: string;
required?: boolean;
status?: StatusState;
tip?: string;
type?: string;
value?: string | number;
onBlur?: FieldUpdaterFunc;
onChange?: FieldUpdaterFunc;
}
export default function TextField(props: TextFieldProps) {
const {
className,
disabled,
fieldName,
label,
maxLength,
onBlur,
onChange,
onPressEnter,
placeholder,
required,
status,
tip,
type,
value,
} = props;
// if field is required but value is empty, or equals initial value, then don't show submit/update button. otherwise clear out any result messaging and display button.
const handleChange = (e: any) => {
const val = type === TEXTFIELD_TYPE_NUMBER ? e : e.target.value;
// if an extra onChange handler was sent in as a prop, let's run that too.
if (onChange) {
onChange({ fieldName, value: val });
}
};
// if you blur a required field with an empty value, restore its original value in state (parent's state), if an onChange from parent is available.
const handleBlur = (e: any) => {
const val = e.target.value;
if (onBlur) {
onBlur({ value: val });
}
};
const handlePressEnter = () => {
if (onPressEnter) {
onPressEnter();
}
};
// display the appropriate Ant text field
let Field = Input as
| typeof Input
| typeof InputNumber
| typeof Input.TextArea
| typeof Input.Password;
let fieldProps = {};
if (type === TEXTFIELD_TYPE_TEXTAREA) {
Field = Input.TextArea;
fieldProps = {
autoSize: true,
};
} else if (type === TEXTFIELD_TYPE_PASSWORD) {
Field = Input.Password;
fieldProps = {
visibilityToggle: true,
};
} else if (type === TEXTFIELD_TYPE_NUMBER) {
Field = InputNumber;
fieldProps = {
type: 'number',
min: 1,
max: 10 ** maxLength - 1,
};
} else if (type === TEXTFIELD_TYPE_URL) {
fieldProps = {
type: 'url',
};
}
const fieldId = `field-${fieldName}`;
const { type: statusType } = status || {};
const containerClass = classNames({
'formfield-container': true,
'textfield-container': true,
[`type-${type}`]: true,
required,
[`status-${statusType}`]: status,
});
return (
<div className={containerClass}>
{label ? (
<div className="label-side">
<label htmlFor={fieldId} className="formfield-label">
{label}
</label>
</div>
) : null}
<div className="input-side">
<div className="input-group">
<Field
id={fieldId}
className={`field ${className} ${fieldId}`}
{...fieldProps}
allowClear
placeholder={placeholder}
maxLength={maxLength}
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handlePressEnter}
disabled={disabled}
value={value as number | (readonly string[] & number)}
/>
</div>
<FormStatusIndicator status={status} />
<p className="field-tip">{tip}</p>
</div>
</div>
);
}
TextField.defaultProps = {
className: '',
disabled: false,
label: '',
maxLength: 255,
placeholder: '',
required: false,
status: null,
tip: '',
type: TEXTFIELD_TYPE_TEXT,
value: '',
onSubmit: () => {},
onBlur: () => {},
onChange: () => {},
onPressEnter: () => {},
};

View File

@ -0,0 +1,118 @@
// This is a wrapper for the Ant Switch component.
// This one is styled to match the form-textfield component.
// If `useSubmit` is true then it will automatically post to the config API onChange.
import React, { useState, useContext } from 'react';
import { Switch } from 'antd';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constants';
import { ServerStatusContext } from '../../utils/server-status-context';
interface ToggleSwitchProps {
fieldName: string;
apiPath?: string;
checked?: boolean;
configPath?: string;
disabled?: boolean;
label?: string;
tip?: string;
useSubmit?: boolean;
onChange?: (arg: boolean) => void;
}
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,
useSubmit,
onChange,
} = props;
const resetStates = () => {
setSubmitStatus(null);
clearTimeout(resetTimer);
resetTimer = null;
};
const handleChange = async (isChecked: boolean) => {
if (useSubmit) {
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);
}
if (onChange) {
onChange(isChecked);
}
};
const loading = submitStatus !== null && submitStatus.type === STATUS_PROCESSING;
return (
<div className="formfield-container toggleswitch-container">
{label && (
<div className="label-side">
<span className="formfield-label">{label}</span>
</div>
)}
<div className="input-side">
<div className="input-group">
<Switch
className={`switch field-${fieldName}`}
loading={loading}
onChange={handleChange}
defaultChecked={checked}
checked={checked}
checkedChildren="ON"
unCheckedChildren="OFF"
disabled={disabled}
/>
<FormStatusIndicator status={submitStatus} />
</div>
<p className="field-tip">{tip}</p>
</div>
</div>
);
}
ToggleSwitch.defaultProps = {
apiPath: '',
checked: false,
configPath: '',
disabled: false,
label: '',
tip: '',
useSubmit: false,
onChange: null,
};

View File

@ -0,0 +1,64 @@
import { Popconfirm, Button, Typography } from 'antd';
import { useContext, useState } from 'react';
import { AlertMessageContext } from '../../utils/alert-message-context';
import { API_YP_RESET, fetchData } from '../../utils/apis';
import { RESET_TIMEOUT } from '../../utils/config-constants';
import {
createInputStatus,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
export default function ResetYP() {
const { setMessage } = useContext(AlertMessageContext);
const [submitStatus, setSubmitStatus] = useState(null);
let resetTimer = null;
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
const resetDirectoryRegistration = async () => {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
try {
await fetchData(API_YP_RESET);
setMessage('');
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
} catch (error) {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${error}`));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
};
return (
<>
<Typography.Title level={3} className="section-title">
Reset Directory
</Typography.Title>
<p className="description">
If you are experiencing issues with your listing on the Owncast Directory and were asked to
&quot;reset&quot; your connection to the service, you can do that here. The next time you go
live it will try and re-register your server with the directory from scratch.
</p>
<Popconfirm
placement="topLeft"
title="Are you sure you want to reset your connection to the Owncast directory?"
onConfirm={resetDirectoryRegistration}
okText="Yes"
cancelText="No"
>
<Button type="primary">Reset Directory Connection</Button>
</Popconfirm>
<p>
<FormStatusIndicator status={submitStatus} />
</p>
</>
);
}

View File

@ -0,0 +1,63 @@
import React from 'react';
import { Select } from 'antd';
import { SocialHandleDropdownItem } from '../../types/config-section';
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
import { OTHER_SOCIAL_HANDLE_OPTION } from '../../utils/config-constants';
interface DropdownProps {
iconList: SocialHandleDropdownItem[];
selectedOption: string;
onSelected: any;
}
export default function SocialDropdown({ iconList, selectedOption, onSelected }: DropdownProps) {
const handleSelected = (value: string) => {
if (onSelected) {
onSelected(value);
}
};
const inititalSelected = selectedOption === '' ? null : selectedOption;
return (
<div className="social-dropdown-container">
<p className="description">
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>
<div className="formfield-container">
<div className="label-side">
<span className="formfield-label">Social Platform</span>
</div>
<div className="input-side">
<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={`${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>
</div>
</div>
);
}

View File

@ -0,0 +1,128 @@
import React, { useContext, useState, useEffect } from 'react';
import { Typography, Slider } from 'antd';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import {
API_VIDEO_SEGMENTS,
RESET_TIMEOUT,
postConfigUpdateToAPI,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
const { Title } = Typography;
const SLIDER_MARKS = {
1: 'Low',
2: '',
3: '',
4: '',
5: '',
6: 'High',
};
const SLIDER_COMMENTS = {
1: 'Lowest latency, lowest error tolerance',
2: 'Low latency, low error tolerance',
3: 'Lower latency, lower error tolerance',
4: 'Medium latency, medium error tolerance (Default)',
5: 'Higher latency, higher error tolerance',
6: 'Highest latency, highest error tolerance',
};
export default function VideoLatency() {
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
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);
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.'));
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));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
const handleChange = value => {
postUpdateToAPI(value);
};
return (
<div className="config-video-latency-container">
<Title level={3} className="section-title">
Latency Buffer
</Title>
<p className="description">
While it&apos;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>
<p className="description">
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>
<div className="segment-slider-container">
<Slider
tipFormatter={value => SLIDER_COMMENTS[value]}
onChange={handleChange}
min={1}
max={6}
marks={SLIDER_MARKS}
defaultValue={selectedOption}
value={selectedOption}
/>
<p className="selected-value-note">{SLIDER_COMMENTS[selectedOption]}</p>
<FormStatusIndicator status={submitStatus} />
</div>
</div>
);
}

View File

@ -0,0 +1,263 @@
// This content populates the video variant modal, which is spawned from the variants table.
import React from 'react';
import { Row, Col, Slider, Collapse, Typography } from 'antd';
import { FieldUpdaterFunc, VideoVariant, UpdateArgs } from '../../types/config-section';
import TextField from './form-textfield';
import { DEFAULT_VARIANT_STATE } from '../../utils/config-constants';
import CPUUsageSelector from './cpu-usage';
import ToggleSwitch from './form-toggleswitch';
const { Panel } = Collapse;
const VIDEO_VARIANT_DEFAULTS = {
framerate: {
min: 24,
max: 120,
defaultValue: 24,
unit: 'fps',
incrementBy: null,
tip:
'Reducing your framerate will decrease the amount of video that needs to be encoded and sent to your viewers, saving CPU and bandwidth at the expense of smoothness. A lower value is generally is fine for most content.',
},
videoBitrate: {
min: 600,
max: 6000,
defaultValue: 1200,
unit: 'kbps',
incrementBy: 100,
tip: 'The overall quality of your stream is generally impacted most by bitrate.',
},
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.',
},
scaledWidth: {
fieldName: 'scaledWidth',
label: 'Resized Width',
maxLength: 4,
placeholder: '1080',
tip: "Optionally resize this content's width.",
},
scaledHeight: {
fieldName: 'scaledHeight',
label: 'Resized Height',
maxLength: 4,
placeholder: '720',
tip: "Optionally resize this content's height.",
},
};
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 handleScaledWidthChanged = (args: UpdateArgs) => {
const value = Number(args.value);
// eslint-disable-next-line no-restricted-globals
if (isNaN(value)) {
return;
}
onUpdateField({ fieldName: 'scaledWidth', value: value || '' });
};
const handleScaledHeightChanged = (args: UpdateArgs) => {
const value = Number(args.value);
// eslint-disable-next-line no-restricted-globals
if (isNaN(value)) {
return;
}
onUpdateField({ fieldName: 'scaledHeight', value: value || '' });
};
const framerateDefaults = VIDEO_VARIANT_DEFAULTS.framerate;
const framerateMin = framerateDefaults.min;
const framerateMax = framerateDefaults.max;
const framerateUnit = framerateDefaults.unit;
const framerateMarks = {
[framerateMin]: `${framerateMin} ${framerateUnit}`,
30: '',
60: '',
90: '',
[framerateMax]: `${framerateMax} ${framerateUnit}`,
};
const videoBitrateDefaults = VIDEO_VARIANT_DEFAULTS.videoBitrate;
const videoBRMin = videoBitrateDefaults.min;
const videoBRMax = videoBitrateDefaults.max;
const videoBRUnit = videoBitrateDefaults.unit;
const videoBRMarks = {
[videoBRMin]: `${videoBRMin} ${videoBRUnit}`,
3000: 3000,
4500: 4500,
[videoBRMax]: `${videoBRMax} ${videoBRUnit}`,
};
const selectedVideoBRnote = () => {
let note = `${dataState.videoBitrate}${videoBRUnit}`;
if (dataState.videoBitrate < 2000) {
note = `${note} - Good for low bandwidth environments.`;
} else if (dataState.videoBitrate < 3500) {
note = `${note} - Good for most bandwidth environments.`;
} else {
note = `${note} - Good for high bandwidth environments.`;
}
return note;
};
const selectedFramerateNote = () => {
let note = `Selected: ${dataState.framerate}${framerateUnit}`;
switch (dataState.framerate) {
case 24:
note = `${note} - Good for film, presentations, music, low power/bandwidth servers.`;
break;
case 30:
note = `${note} - Good for slow/casual games, chat, general purpose.`;
break;
case 60:
note = `${note} - Good for fast/action games, sports, HD video.`;
break;
case 90:
note = `${note} - Good for newer fast games and hardware.`;
break;
case 120:
note = `${note} - Experimental, use at your own risk!`;
break;
default:
note = '';
}
return note;
};
return (
<div className="config-variant-form">
<p className="description">
<a href="https://owncast.online/docs/video">Learn more</a> about how each of these settings
can impact the performance of your server.
</p>
<Row gutter={16}>
<Col sm={24} md={12}>
{/* ENCODER PRESET FIELD */}
<div className="form-module cpu-usage-container">
<CPUUsageSelector
defaultValue={dataState.cpuUsageLevel}
onChange={handleVideoCpuUsageLevelChange}
/>
<p className="read-more-subtext">
<a href="https://owncast.online/docs/video/#cpu-usage">Read more about CPU usage.</a>
</p>
</div>
{/* VIDEO PASSTHROUGH FIELD - currently disabled */}
<div style={{ display: 'none' }} className="form-module">
<ToggleSwitch
label="Use Video Passthrough?"
fieldName="video-passthrough"
tip={VIDEO_VARIANT_DEFAULTS.videoPassthrough.tip}
checked={dataState.videoPassthrough}
onChange={handleVideoPassChange}
/>
</div>
</Col>
<Col sm={24} md={12}>
{/* VIDEO BITRATE FIELD */}
<div
className={`form-module bitrate-container ${
dataState.videoPassthrough ? 'disabled' : ''
}`}
>
<Typography.Title level={3}>Video Bitrate</Typography.Title>
<p className="description">{VIDEO_VARIANT_DEFAULTS.videoBitrate.tip}</p>
<div className="segment-slider-container">
<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={videoBRMarks}
/>
<p className="selected-value-note">{selectedVideoBRnote()}</p>
</div>
<p className="read-more-subtext">
<a href="https://owncast.online/docs/video/#bitrate">Read more about bitrates.</a>
</p>
</div>
</Col>
</Row>
<Collapse className="advanced-settings">
<Panel header="Advanced Settings" key="1">
<p className="description">
Resizing your content will take additional resources on your server. If you wish to
optionally resize your content for this stream output then you should either set the
width <strong>or</strong> the height to keep your aspect ratio.{' '}
<a href="https://owncast.online/docs/video/#resolution">Read more about resolutions.</a>
</p>
<TextField
type="number"
{...VIDEO_VARIANT_DEFAULTS.scaledWidth}
value={dataState.scaledWidth}
onChange={handleScaledWidthChanged}
/>
<TextField
type="number"
{...VIDEO_VARIANT_DEFAULTS.scaledHeight}
value={dataState.scaledHeight}
onChange={handleScaledHeightChanged}
/>
{/* FRAME RATE FIELD */}
<div className="form-module frame-rate">
<Typography.Title level={3}>Frame rate</Typography.Title>
<p className="description">{VIDEO_VARIANT_DEFAULTS.framerate.tip}</p>
<div className="segment-slider-container">
<Slider
tipFormatter={value => `${value} ${framerateUnit}`}
defaultValue={dataState.framerate}
value={dataState.framerate}
onChange={handleFramerateChange}
step={framerateDefaults.incrementBy}
min={framerateMin}
max={framerateMax}
marks={framerateMarks}
/>
<p className="selected-value-note">{selectedFramerateNote()}</p>
</div>
<p className="read-more-subtext">
<a href="https://owncast.online/docs/video/#framerate">Read more about framerates.</a>
</p>
</div>
</Panel>
</Collapse>
</div>
);
}

View File

@ -0,0 +1,226 @@
// 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,
RESET_TIMEOUT,
postConfigUpdateToAPI,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
const { Title } = Typography;
const 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<StatusState>(null);
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);
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) => {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath: API_VIDEO_VARIANTS,
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'videoQualityVariants',
value: postValue,
path: 'videoSettings',
});
// close modal
setModalProcessing(false);
handleModalCancel();
setSubmitStatus(createInputStatus(STATUS_SUCCESS, '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(createInputStatus(STATUS_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 = [...videoQualityVariants];
if (editId === -1) {
postData.push(modalDataState);
} else {
postData.splice(editId, 1, modalDataState);
}
postUpdateToAPI(postData);
};
const handleDeleteVariant = (index: number) => {
const postData = [...videoQualityVariants];
postData.splice(index, 1);
postUpdateToAPI(postData);
};
const handleUpdateField = ({ fieldName, value }: UpdateArgs) => {
setModalDataState({
...modalDataState,
[fieldName]: value,
});
};
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
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 videoQualityVariantData = videoQualityVariants.map((variant, index) => ({
key: index + 1,
...variant,
}));
return (
<>
<Title level={3} className="section-title">
Stream output
</Title>
<FormStatusIndicator status={submitStatus} />
<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}
width={900}
>
<VideoVariantForm dataState={{ ...modalDataState }} onUpdateField={handleUpdateField} />
<FormStatusIndicator status={submitStatus} />
</Modal>
<br />
<Button
type="primary"
onClick={() => {
setEditId(-1);
setModalDataState(DEFAULT_VARIANT_STATE);
setDisplayModal(true);
}}
>
Add a new variant
</Button>
</>
);
}

View File

@ -0,0 +1,20 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd';
interface InfoTipProps {
tip: string | null;
}
export default function InfoTip({ tip }: InfoTipProps) {
if (tip === '' || tip === null) {
return null;
}
return (
<span className="info-tip">
<Tooltip title={tip}>
<InfoCircleOutlined />
</Tooltip>
</span>
);
}

View File

@ -1,18 +1,18 @@
import { Table, Typography } from "antd";
import { Table, Typography } from 'antd';
const { Title } = Typography;
export default function KeyValueTable({ title, data }: KeyValueTableProps) {
const columns = [
{
title: "Name",
dataIndex: "name",
key: "name",
title: 'Name',
dataIndex: 'name',
key: 'name',
},
{
title: "Value",
dataIndex: "value",
key: "value",
title: 'Value',
dataIndex: 'value',
key: 'value',
},
];
@ -25,6 +25,6 @@ export default function KeyValueTable({ title, data }: KeyValueTableProps) {
}
interface KeyValueTableProps {
title: string,
data: any,
};
title: string;
data: any;
}

View File

@ -1,32 +1,30 @@
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'
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";
if (entry.level === 'warning') {
color = 'orange';
} else if (entry.level === 'error') {
color = "red";
color = 'red';
}
return <Tag color={color}>{text}</Tag>;
}
function renderMessage(text) {
return (
<Linkify>{text}</Linkify>
)
return <Linkify>{text}</Linkify>;
}
interface Props {
logs: object[],
pageSize: number
logs: object[];
pageSize: number;
}
export default function LogTable({ logs, pageSize }: Props) {
@ -35,57 +33,56 @@ export default function LogTable({ logs, pageSize }: Props) {
}
const columns = [
{
title: "Level",
dataIndex: "level",
key: "level",
title: 'Level',
dataIndex: 'level',
key: 'level',
filters: [
{
text: "Info",
value: "info",
text: 'Info',
value: 'info',
},
{
text: "Warning",
value: "warning",
text: 'Warning',
value: 'warning',
},
{
text: "Error",
value: "Error",
text: 'Error',
value: 'Error',
},
],
onFilter: (level, row) => row.level.indexOf(level) === 0,
render: renderColumnLevel,
},
{
title: "Timestamp",
dataIndex: "time",
key: "time",
render: (timestamp) => {
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,
sortDirections: ['descend', 'ascend'] as SortOrder[],
defaultSortOrder: 'descend' as SortOrder,
},
{
title: "Message",
dataIndex: "message",
key: "message",
title: 'Message',
dataIndex: 'message',
key: 'message',
render: renderMessage,
},
];
return (
<div className="logs-section">
<Title level={2}>Logs</Title>
<Title>Logs</Title>
<Table
size="middle"
dataSource={logs}
columns={columns}
rowKey={(row) => row.time}
rowKey={row => row.time}
pagination={{ pageSize: pageSize || 20 }}
/>
</div>
);
}

159
web/components/logo.tsx Normal file
View File

@ -0,0 +1,159 @@
import React from 'react';
export default function Logo() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 95.68623352050781 104.46271514892578"
className="logo-svg"
>
<g transform="matrix(1 0 0 1 -37.08803939819336 -18.940391540527344)">
<g>
<g>
<g>
<g transform="matrix(1.0445680396949917 0 0 1.0445679172996596 36.34559138380523 18.877718021903796)">
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient120"
gradientTransform="rotate(-90 .5 .5)"
>
<stop offset="0" stopColor="#1f2022" stopOpacity="1" />
<stop offset="1" stopColor="#635e69" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient120)"
d="M91.5 75.35Q93.05 71.15 91.65 67.7 90.35 64.5 86.65 62.3 83.2 60.3 78.3 59.4 73.85 58.6 68.6 58.7 63.55 58.85 58.8 59.8 54.25 60.75 50.8 62.2 47.4 63.65 45.5 65.35 43.6 67.15 43.5 69.05 43.35 71.3 45.8 73.9 48.05 76.3 52.1 78.6 56.15 80.9 61.05 82.55 66.3 84.3 71.4 84.8 74.7 85.1 77.55 84.9 80.65 84.6 83.3 83.6 86.15 82.5 88.15 80.55 90.4 78.4 91.5 75.35M70.6 67.5Q72.3 68.4 73.1 69.7 73.9 71.15 73.45 73 73.1 74.3 72.3 75.25 71.55 76.1 70.3 76.6 69.25 77.05 67.75 77.25 66.3 77.4 64.85 77.3 62.3 77.15 59.25 76.3 56.6 75.5 54.15 74.3 51.9 73.2 50.45 72 49.05 70.75 49.1 69.8 49.2 69 50.25 68.25 51.3 67.55 53.15 67 55 66.4 57.25 66.1 59.8 65.8 62.1 65.8 64.65 65.85 66.7 66.2 68.9 66.65 70.6 67.5Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient121"
gradientTransform="rotate(-180 .5 .5)"
>
<stop offset="0" stopColor="#2087e2" stopOpacity="1" />
<stop offset="1" stopColor="#b63fff" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient121)"
d="M66.6 15.05Q66.4 9.65 63.9 6.05 61.25 2.1 56.1 0.65 54.95 0.3 53.65 0.15 52.5 0 51.3 0.1 50.2 0.1 49.1 0.35 48.15 0.55 47 1 43.3 2.45 40.3 6.1 37.5 9.4 35.5 14.3 33.75 18.45 32.7 23.4 31.7 28.05 31.35 32.85 31.05 37.2 31.3 41.2 31.6 45.15 32.4 48.35 34 54.9 37.3 56.4 37.6 56.55 37.9 56.65L39.2 56.85Q39.45 56.85 39.95 56.8 42.05 56.6 44.7 55.05 47.25 53.5 50.05 50.8 53.05 47.9 55.85 44.05 58.8 40.05 61.1 35.6 63.8 30.35 65.25 25.3 66.75 19.75 66.6 15.05M47.55 23.15Q48.05 23.25 48.4 23.4 52.45 24.8 52.55 29.85 52.6 34 50 39.4 47.85 43.9 44.85 47.3 42.05 50.5 40.15 50.7L39.9 50.75 39.45 50.7 39.2 50.6Q37.8 49.95 37.25 46.35 36.7 42.7 37.3 38 37.95 32.75 39.75 28.8 41.9 24.1 45.05 23.25 45.6 23.1 45.85 23.1 46.25 23.05 46.65 23.05 47.05 23.05 47.55 23.15Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient122"
gradientTransform="rotate(-90 .5 .5)"
>
<stop offset="0" stopColor="#100f0f" stopOpacity="1" />
<stop offset="1" stopColor="#49261F" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient122)"
d="M2.7 33.6Q2.1 34.4 1.7 35.35 1.25 36.5 1.05 37.7 0 42.6 2.2 47.2 4 51 8 54.35 11.55 57.3 16 59.15 20.5 61 23.85 60.85 24.5 60.85 25.25 60.7 26 60.55 26.5 60.3 27 60.05 27.45 59.65 27.9 59.25 28.15 58.75 29.35 56.45 27.5 51.65 25.6 47 21.75 42.1 17.75 37 13.4 34.05 8.7 30.9 5.45 31.7 4.65 31.9 3.95 32.4 3.25 32.85 2.7 33.6M10.1 43.55Q10.35 43.1 10.6 42.85 10.85 42.6 11.2 42.4 11.6 42.25 11.9 42.2 13.5 41.9 15.95 43.6 18.15 45.05 20.35 47.7 22.35 50.1 23.55 52.4 24.7 54.75 24.25 55.7 24.15 55.9 24 56 23.85 56.2 23.65 56.25 23.55 56.35 23.25 56.4L22.7 56.5Q21.1 56.6 18.55 55.6 16.05 54.6 13.85 52.95 11.5 51.2 10.35 49.15 9.05 46.8 9.75 44.45 9.9 43.95 10.1 43.55Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient123"
gradientTransform="rotate(-180 .5 .5)"
>
<stop offset="0" stopColor="#222020" stopOpacity="1" />
<stop offset="1" stopColor="#49261F" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient123)"
d="M34.95 74.2L34.75 74.2Q33.2 74.15 31.9 75.25 30.7 76.3 29.85 78.25 29.1 80 28.8 82.2 28.5 84.4 28.7 86.65 29.1 91.4 31.5 94.7 34.3 98.5 39.3 99.7L39.4 99.7 39.7 99.8 39.85 99.8Q45.3 100.85 47.15 97.75 48 96.3 48 94.05 47.95 91.9 47.2 89.35 46.45 86.75 45.1 84.15 43.75 81.5 42.05 79.35 40.25 77.1 38.45 75.75 36.55 74.35 34.95 74.2M33.55 80.4Q34.35 78.2 35.6 78.3L35.65 78.3Q36.9 78.45 38.6 80.9 40.3 83.35 41.15 86.05 42.1 89 41.55 90.75 40.9 92.6 38.35 92.25L38.3 92.25 38.25 92.2 38.1 92.2Q35.6 91.7 34.25 89.6 33.1 87.7 32.95 85 32.8 82.35 33.55 80.4Z"
/>
</g>
<g transform="matrix(0.9999999999999999 0 0 1 0 5.684341886080802e-14)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient124"
gradientTransform="rotate(-180 .5 .5)"
>
{' '}
<stop offset="0" stopColor="#1e1c1c" stopOpacity="1" />
<stop offset="1" stopColor="#49261F" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient124)"
d="M22.7 69.65Q22.25 69.3 21.6 69.05 20.95 68.8 20.25 68.7 19.6 68.55 18.85 68.5 16.7 68.45 14.65 69.15 12.65 69.8 11.4 71.1 10.15 72.5 10.2 74.2 10.25 76.05 11.95 78.2 12.4 78.75 13.05 79.4 13.55 79.9 14.2 80.3 14.7 80.6 15.3 80.85 16 81.1 16.4 81.1 18.2 81.35 19.9 80.35 21.55 79.4 22.75 77.65 24 75.85 24.3 73.95 24.6 71.85 23.55 70.5 23.15 70 22.7 69.65M21.7 71.7Q22.15 72.3 21.9 73.3 21.7 74.25 21 75.25 20.3 76.2 19.4 76.75 18.45 77.35 17.55 77.25L17 77.15Q16.7 77.05 16.45 76.85 16.25 76.75 15.9 76.45 15.7 76.25 15.4 75.9 14.5 74.75 14.7 73.8 14.8 72.95 15.75 72.3 16.6 71.7 17.8 71.4 19 71.1 20.1 71.15L20.65 71.2 21.1 71.3Q21.3 71.4 21.45 71.5L21.7 71.7Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient125"
gradientTransform="rotate(-360 .5 .5)"
>
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0.5" />
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0.2" />
</linearGradient>
</defs>
<path
fill="url(#gradient125)"
d="M52.6 19.25Q59.6 19.25 66.2 20.95 66.7 17.8 66.6 15.05 66.4 9.65 63.9 6.05 61.25 2.1 56.1 0.65 54.95 0.3 53.65 0.15 52.5 0 51.3 0.1 50.2 0.1 49.1 0.35 48.15 0.55 47 1 43.3 2.45 40.3 6.1 37.5 9.4 35.5 14.3 33.85 18.3 32.8 22.85 42.25 19.25 52.6 19.25Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient126"
gradientTransform="rotate(-360 .5 .5)"
>
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0.5" />
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0.2" />
</linearGradient>
</defs>
<path
fill="url(#gradient126)"
d="M1.05 37.7Q0 42.6 2.2 47.2 2.95 48.8 4.05 50.25 7.55 41.65 14.4 34.75 14 34.45 13.4 34.05 8.7 30.9 5.45 31.7 4.65 31.9 3.95 32.4 3.25 32.85 2.7 33.6 2.1 34.4 1.7 35.35 1.25 36.5 1.05 37.7Z"
/>
</g>
</g>
</g>
<g transform="matrix(1.219512230276127 0 0 1.2195122143630526 32.82519274395008 88.56945194723018)">
<path fill="#000000" fillOpacity="1" d="" />
</g>
</g>
</g>
</g>
</svg>
);
}

View File

@ -0,0 +1,216 @@
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import Link from 'next/link';
import Head from 'next/head';
import { differenceInSeconds } from 'date-fns';
import { useRouter } from 'next/router';
import { Layout, Menu, Popover, Alert } from 'antd';
import {
SettingOutlined,
HomeOutlined,
LineChartOutlined,
ToolOutlined,
PlayCircleFilled,
MinusSquareFilled,
QuestionCircleOutlined,
MessageOutlined,
ExperimentOutlined,
} from '@ant-design/icons';
import classNames from 'classnames';
import { upgradeVersionAvailable } from '../utils/apis';
import { parseSecondsToDurationString } from '../utils/format';
import OwncastLogo from './logo';
import { ServerStatusContext } from '../utils/server-status-context';
import { AlertMessageContext } from '../utils/alert-message-context';
import TextFieldWithSubmit from './config/form-textfield-with-submit';
import { TEXTFIELD_PROPS_STREAM_TITLE } from '../utils/config-constants';
import { UpdateArgs } from '../types/config-section';
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('');
const checkForUpgrade = async () => {
try {
const result = await upgradeVersionAvailable(versionNumber);
setUpgradeVersion(result);
} catch (error) {
console.log('==== error', error);
}
};
useEffect(() => {
checkForUpgrade();
}, [versionNumber]);
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 upgradeMessage = `Upgrade to v${upgradeVersionString}`;
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
defaultSelectedKeys={[route.substring(1) || 'home']}
defaultOpenKeys={['current-stream-menu', 'utilities-menu', 'configuration']}
mode="inline"
className="menu-container"
>
<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-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">{upgradeMessage}</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 are you streaming 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,
};

View File

@ -1,20 +1,28 @@
// 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";
// Custom component for AntDesign Button that makes an api call, then displays a confirmation icon upon
import React, { useState, useEffect } from 'react';
import { Button, Tooltip } from 'antd';
import {
EyeOutlined,
EyeInvisibleOutlined,
CheckCircleFilled,
ExclamationCircleFilled,
} from '@ant-design/icons';
import { fetchData, UPDATE_CHAT_MESSGAE_VIZ } from '../utils/apis';
import { MessageType } from '../types/chat';
import { OUTCOME_TIMEOUT } from '../pages/chat';
import { isEmptyObject } from '../utils/format';
interface MessageToggleProps {
isVisible: boolean;
message: MessageType;
setMessage: (message: MessageType) => void,
};
setMessage: (message: MessageType) => void;
}
export default function MessageVisiblityToggle({ isVisible, message, setMessage }: MessageToggleProps) {
export default function MessageVisiblityToggle({
isVisible,
message,
setMessage,
}: MessageToggleProps) {
if (!message || isEmptyObject(message)) {
return null;
}
@ -25,16 +33,17 @@ export default function MessageVisiblityToggle({ isVisible, message, setMessage
const { id: messageId } = message || {};
const resetOutcome = () => {
outcomeTimeout = setTimeout(() => { setOutcome(0)}, OUTCOME_TIMEOUT);
outcomeTimeout = setTimeout(() => {
setOutcome(0);
}, OUTCOME_TIMEOUT);
};
useEffect(() => {
return () => {
clearTimeout(outcomeTimeout);
};
});
const updateChatMessage = async () => {
clearTimeout(outcomeTimeout);
setOutcome(0);
@ -47,7 +56,7 @@ export default function MessageVisiblityToggle({ isVisible, message, setMessage
},
});
if (result.success && result.message === "changed") {
if (result.success && result.message === 'changed') {
setMessage({ ...message, visible: !isVisible });
setOutcome(1);
} else {
@ -55,14 +64,16 @@ export default function MessageVisiblityToggle({ isVisible, message, setMessage
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)' }} />;
outcomeIcon =
outcome > 0 ? (
<CheckCircleFilled style={{ color: 'var(--ant-success)' }} />
) : (
<ExclamationCircleFilled style={{ color: 'var(--ant-warning)' }} />
);
}
const toolTipMessage = `Click to ${isVisible ? 'hide' : 'show'} this message`;
@ -74,10 +85,10 @@ export default function MessageVisiblityToggle({ isVisible, message, setMessage
shape="circle"
size="small"
type="text"
icon={ isVisible ? <EyeOutlined /> : <EyeInvisibleOutlined /> }
icon={isVisible ? <EyeOutlined /> : <EyeInvisibleOutlined />}
onClick={updateChatMessage}
/>
</Tooltip>
</div>
);
}
}

View File

@ -1,18 +1,21 @@
import { Typography, Statistic, Card, Progress} from "antd";
/* eslint-disable react/no-unused-prop-types */
// TODO: This component should be cleaned up and usage should be re-examined. The types should be reconsidered as well.
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,
};
title?: string;
value?: any;
prefix?: any;
color?: string;
progress?: boolean;
centered?: boolean;
formatter?: any;
}
const defaultProps = {
title: '',
title: '',
value: 0,
prefix: null,
color: '',
@ -21,16 +24,19 @@ const defaultProps = {
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>
{prefix}
<div>
<Text type="secondary">{title}</Text>
</div>
<div>
<Text type="secondary">{value}%</Text>
</div>
</div>
)
);
return (
<Progress
type="dashboard"
@ -40,21 +46,14 @@ function ProgressView({ title, value, prefix, color }: StatisticItemProps) {
'0%': color,
'90%': endColor,
}}
format={percent => content}
format={() => content}
/>
)
);
}
ProgressView.defaultProps = defaultProps;
function StatisticView({ title, value, prefix, formatter }: StatisticItemProps) {
return (
<Statistic
title={title}
value={value}
prefix={prefix}
formatter={formatter}
/>
);
return <Statistic title={title} value={value} prefix={prefix} formatter={formatter} />;
}
StatisticView.defaultProps = defaultProps;
@ -62,14 +61,14 @@ export default function StatisticItem(props: StatisticItemProps) {
const { progress, centered } = props;
const View = progress ? ProgressView : StatisticView;
const style = centered ? {display: 'flex', alignItems: 'center', justifyContent: 'center'} : {};
const style = centered ? { display: 'flex', alignItems: 'center', justifyContent: 'center' } : {};
return (
<Card type="inner">
<div style={style}>
<View {...props} />
</div>
</Card>
<Card type="inner">
<div style={style}>
<View {...props} />
</div>
</Card>
);
}
StatisticItem.defaultProps = defaultProps;

91
web/docs/README.md Normal file
View File

@ -0,0 +1,91 @@
# Tips for creating a new Admin form
### Layout
- Give your page or form a title. Feel free to use Ant Design's `<Title>` component.
- Give your form a description inside of a `<p className="description" />` tag.
- Use some Ant Design `Row` and `Col`'s to layout your forms if you want to spread them out into responsive columns. If you use an `<Row>`s, be sure to use `<Col>`s with them too!
- Use the `form-module` CSS class if you want to add a visual separation to a grouping of items.
### Form fields
- Feel free to use the pre-styled `<TextField>` text form field or the `<ToggleSwitch>` compnent, in a group of form fields together. These have been styled and laid out to match each other.
- `Slider`'s - If your form uses an Ant Slider component, follow this recommended markup of CSS classes to maintain a consistent look and feel to other Sliders in the app.
```
<div className="segment-slider-container">
<Slider ...props />
<p className="selected-value-note">{selected value}</p>
</div>
```
### Submit Statuses
- It would be nice to display indicators of success/warnings to let users know if something has been successfully updated on the server. It has a lot of steps (sorry, but it could probably be optimized), but it'll provide a consistent way to display messaging.
- See `reset-yp.tsx` for an example of using `submitStatus` with `useState()` and the `<FormStatusIndicator>` component to achieve this.
### Styling
- This admin site chooses to have a generally Dark color palette, but with colors that are different from Ant design's _dark_ stylesheet, so that style sheet is not included. This results in a very large `ant-overrides.scss` file to reset colors on frequently used Ant components in the system. If you find yourself a new Ant Component that has not yet been used in this app, feel free to add a reset style for that component to the overrides stylesheet.
- Take a look at `variables.scss` CSS file if you want to give some elements custom css colors.
---
---
# Creating Admin forms the Config section
First things first..
## General Config data flow in this React app
- 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.
## Suggested Config Form Flow
- *NOTE: Each top field of the serverConfig has its own API update endpoint.*
There many steps here, but they are highly suggested to ensure that Config values are updated and displayed properly throughout the entire admin form.
For 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.
- It is recommended that you use `form-textfield-with-submit` and `form-toggleswitch`(with `useSubmit=true`) Components to edit Config fields.
Examples of Config form groups where individual form fields submitting to the update API include:
- `edit-instance-details.tsx`
- `edit-server-details.tsx`
Examples of Config form groups where there is 1 submit button for the entire group include:
- `edit-storage.tsx`
---
#### Notes about `form-textfield-with-submit` and `form-togglefield` (with useSubmit=true)
- 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.

477
web/package-lock.json generated
View File

@ -4,100 +4,6 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@ampproject/toolbox-core": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/@ampproject/toolbox-core/-/toolbox-core-2.7.4.tgz",
"integrity": "sha512-qpBhcS4urB7IKc+jx2kksN7BuvvwCo7Y3IstapWo+EW+COY5EYAUwb2pil37v3TsaqHKgX//NloFP1SKzGZAnw==",
"requires": {
"cross-fetch": "3.0.6",
"lru-cache": "6.0.0"
}
},
"@ampproject/toolbox-optimizer": {
"version": "2.7.1-alpha.0",
"resolved": "https://registry.npmjs.org/@ampproject/toolbox-optimizer/-/toolbox-optimizer-2.7.1-alpha.0.tgz",
"integrity": "sha512-WGPZKVQvHgNYJk1XVJCCmY+NVGTGJtvn0OALDyiegN4FJWOcilQUhCIcjMkZN59u1flz/u+sEKccM5qsROqVyg==",
"requires": {
"@ampproject/toolbox-core": "^2.7.1-alpha.0",
"@ampproject/toolbox-runtime-version": "^2.7.1-alpha.0",
"@ampproject/toolbox-script-csp": "^2.5.4",
"@ampproject/toolbox-validator-rules": "^2.7.1-alpha.0",
"abort-controller": "3.0.0",
"cross-fetch": "3.0.6",
"cssnano-simple": "1.2.1",
"dom-serializer": "1.1.0",
"domhandler": "3.3.0",
"domutils": "2.4.2",
"htmlparser2": "5.0.1",
"https-proxy-agent": "5.0.0",
"lru-cache": "6.0.0",
"node-fetch": "2.6.1",
"normalize-html-whitespace": "1.0.0",
"postcss": "7.0.32",
"postcss-safe-parser": "4.0.2",
"terser": "5.5.1"
},
"dependencies": {
"cssnano-simple": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/cssnano-simple/-/cssnano-simple-1.2.1.tgz",
"integrity": "sha512-9vOyjw8Dj/T12kIOnXPZ5VnEIo6F3YMaIn0wqJXmn277R58cWpI3AvtdlCBtohX7VAUNYcyk2d0dKcXXkb5I6Q==",
"requires": {
"cssnano-preset-simple": "1.2.1",
"postcss": "^7.0.32"
}
},
"dom-serializer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.1.0.tgz",
"integrity": "sha512-ox7bvGXt2n+uLWtCRLybYx60IrOlWL/aCebWJk1T0d4m3y2tzf4U3ij9wBMUb6YJZpz06HCCYuyCDveE2xXmzQ==",
"requires": {
"domelementtype": "^2.0.1",
"domhandler": "^3.0.0",
"entities": "^2.0.0"
}
},
"domhandler": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz",
"integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==",
"requires": {
"domelementtype": "^2.0.1"
}
},
"postcss": {
"version": "7.0.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz",
"integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==",
"requires": {
"chalk": "^2.4.2",
"source-map": "^0.6.1",
"supports-color": "^6.1.0"
}
}
}
},
"@ampproject/toolbox-runtime-version": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/@ampproject/toolbox-runtime-version/-/toolbox-runtime-version-2.7.4.tgz",
"integrity": "sha512-SAdOUOERp42thVNWaBJlnFvFVvnacMVnz5z9LyUZHSnoL1EqrAW5Sz5jv+Ly+gkA8NYsEaUxAdSCBAzE9Uzb4Q==",
"requires": {
"@ampproject/toolbox-core": "2.7.4"
}
},
"@ampproject/toolbox-script-csp": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@ampproject/toolbox-script-csp/-/toolbox-script-csp-2.5.4.tgz",
"integrity": "sha512-+knTYetI5nWllRZ9wFcj7mYxelkiiFVRAAW/hl0ad8EnKHMH82tRlk40CapEnUHhp6Er5sCYkumQ8dngs3Q4zQ=="
},
"@ampproject/toolbox-validator-rules": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/@ampproject/toolbox-validator-rules/-/toolbox-validator-rules-2.7.4.tgz",
"integrity": "sha512-z3JRcpIZLLdVC9XVe7YTZuB3a/eR9s2SjElYB9AWRdyJyL5Jt7+pGNv4Uwh1uHVoBXsWEVQzNOWSNtrO3mSwZA==",
"requires": {
"cross-fetch": "3.0.6"
}
},
"@ant-design/colors": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-4.0.5.tgz",
@ -257,19 +163,19 @@
"integrity": "sha512-CAEbWH7OIur6jEOzaai83jq3FmKmv4PmX1JYfs9IrYcGEVI/lyL1EXJGCj7eFVJ0bg5QR8LMxBlEtA+xKiLpFw=="
},
"@next/env": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-10.0.6.tgz",
"integrity": "sha512-++zgrcSL9SprjWKBbO3YuVj/8CTmxJl+zLErW/Kbr2VCT0u12SrBcMKvD236lEGs5/qUgeBfU3Dvmr6MASWrtA=="
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-10.0.7.tgz",
"integrity": "sha512-/vnz2SL/mk3Tei58WfRtVnvz5xHmAqcBmZL5sTBEy1CZG6OtZGNx0qAFCjtVkeJ5m1Bh4Ut+WFh/RF333wx8Sg=="
},
"@next/polyfill-module": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@next/polyfill-module/-/polyfill-module-10.0.6.tgz",
"integrity": "sha512-Sk3HYFxiI3AyIhw7Nnc5un//duCthAP2XtPb4N1SamymOU2QSb8I1zkcsxVIlZRvftdXikQQgra6Ck2zfJRxpA=="
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/@next/polyfill-module/-/polyfill-module-10.0.7.tgz",
"integrity": "sha512-HxqzRpoSgmZP0kRIWwH+e0SgtAXqJ0VkYtwWcsQFED8+xF4Eqn+7Twyp4uE6hutC8gr8IFSFqH+DEYhRtg1ltQ=="
},
"@next/react-dev-overlay": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@next/react-dev-overlay/-/react-dev-overlay-10.0.6.tgz",
"integrity": "sha512-KbxpyVT6gr1KZ7JoFDKGNM0hK7CxPkIC14j/gYgR6qSOhxGs3GEIBScJRXfKD7LNPMgVgQtaJlBYlEJ7aQu1xg==",
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/@next/react-dev-overlay/-/react-dev-overlay-10.0.7.tgz",
"integrity": "sha512-yq71MDHVqN2N+IqOvZDiFsMpQrBcymrdpTx1ShhAADX7cWQvW4dhcIir4BbfrS10vS1LLz/3a8uKZkGdNoJj3w==",
"requires": {
"@babel/code-frame": "7.12.11",
"anser": "1.4.9",
@ -321,34 +227,13 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"source-map": {
"version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
"integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
"requires": {
"whatwg-url": "^7.0.0"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"@next/react-refresh-utils": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@next/react-refresh-utils/-/react-refresh-utils-10.0.6.tgz",
"integrity": "sha512-4BF+8PyzDcYpumQJ22yBUjVP/CL2KLPM+3K3ZQb61HvmIptB/t+jFnH2ew8S7ORQNu/caVQC6wP5wAfOtpJNsg=="
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/@next/react-refresh-utils/-/react-refresh-utils-10.0.7.tgz",
"integrity": "sha512-d/71vtQglv6m7sh4W1O9drc2hYti7UnAdEXfBLZAS354g2S80lvCRGIhbDrMx4w0rpShoxBIZboE2++LihAESg=="
},
"@nodelib/fs.scandir": {
"version": "2.1.3",
@ -404,6 +289,18 @@
"integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw==",
"dev": true
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
"dev": true
},
"@types/highlight.js": {
"version": "9.12.4",
"resolved": "https://registry.npmjs.org/@types/highlight.js/-/highlight.js-9.12.4.tgz",
"integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==",
"dev": true
},
"@types/json-schema": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz",
@ -416,6 +313,23 @@
"integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
"dev": true
},
"@types/linkify-it": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.0.tgz",
"integrity": "sha512-x9OaQQTb1N2hPZ/LWJsqushexDvz7NgzuZxiRmZio44WPuolTZNHDBCrOxCzRVOMwamJRO2dWax5NbygOf1OTQ==",
"dev": true
},
"@types/markdown-it": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.0.1.tgz",
"integrity": "sha512-mHfT8j/XkPb1uLEfs0/C3se6nd+webC2kcqcy8tgcVr0GDEONv/xaQzAN+aQvkxQXk/jC0Q6mPS+0xhFwRF35g==",
"dev": true,
"requires": {
"@types/highlight.js": "^9.7.0",
"@types/linkify-it": "*",
"@types/mdurl": "*"
}
},
"@types/mdast": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.3.tgz",
@ -424,6 +338,12 @@
"@types/unist": "*"
}
},
"@types/mdurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz",
"integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==",
"dev": true
},
"@types/node": {
"version": "14.14.28",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.28.tgz",
@ -651,14 +571,6 @@
"object-assign": "4.x"
}
},
"agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"requires": {
"debug": "4"
}
},
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -1271,11 +1183,6 @@
"ieee754": "^1.1.4"
}
},
"buffer-from": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
},
"buffer-xor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
@ -1507,11 +1414,6 @@
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz",
"integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw=="
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@ -1634,14 +1536,6 @@
"sha.js": "^2.4.8"
}
},
"cross-fetch": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.6.tgz",
"integrity": "sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ==",
"requires": {
"node-fetch": "2.6.1"
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -1677,11 +1571,11 @@
"integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s="
},
"cssnano-preset-simple": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/cssnano-preset-simple/-/cssnano-preset-simple-1.2.1.tgz",
"integrity": "sha512-B2KahOIFTV6dw5Ioy9jHshTh/vAYNnUB2enyWRgnAEg3oJBjI/035ExpePaMqS2SwpbH7gCgvQqwpMBH6hTJSw==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cssnano-preset-simple/-/cssnano-preset-simple-1.2.2.tgz",
"integrity": "sha512-gtvrcRSGtP3hA/wS8mFVinFnQdEsEpm3v4I/s/KmNjpdWaThV/4E5EojAzFXxyT5OCSRPLlHR9iQexAqKHlhGQ==",
"requires": {
"caniuse-lite": "^1.0.30001093",
"caniuse-lite": "^1.0.30001179",
"postcss": "^7.0.32"
},
"dependencies": {
@ -1694,6 +1588,19 @@
"source-map": "^0.6.1",
"supports-color": "^6.1.0"
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"supports-color": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
"requires": {
"has-flag": "^3.0.0"
}
}
}
},
@ -1706,15 +1613,6 @@
"postcss": "^7.0.32"
},
"dependencies": {
"cssnano-preset-simple": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cssnano-preset-simple/-/cssnano-preset-simple-1.2.2.tgz",
"integrity": "sha512-gtvrcRSGtP3hA/wS8mFVinFnQdEsEpm3v4I/s/KmNjpdWaThV/4E5EojAzFXxyT5OCSRPLlHR9iQexAqKHlhGQ==",
"requires": {
"caniuse-lite": "^1.0.30001179",
"postcss": "^7.0.32"
}
},
"postcss": {
"version": "7.0.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz",
@ -1724,6 +1622,19 @@
"source-map": "^0.6.1",
"supports-color": "^6.1.0"
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"supports-color": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
"requires": {
"has-flag": "^3.0.0"
}
}
}
},
@ -1928,30 +1839,10 @@
"domelementtype": "^2.0.1"
}
},
"domutils": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.4.2.tgz",
"integrity": "sha512-NKbgaM8ZJOecTZsIzW5gSuplsX2IWW2mIK7xVr8hTQF2v1CJWTmLZ1HOCh5sH+IzVPAGE5IucooOkvwBRAdowA==",
"requires": {
"dom-serializer": "^1.0.1",
"domelementtype": "^2.0.1",
"domhandler": "^3.3.0"
},
"dependencies": {
"domhandler": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz",
"integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==",
"requires": {
"domelementtype": "^2.0.1"
}
}
}
},
"electron-to-chromium": {
"version": "1.3.664",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.664.tgz",
"integrity": "sha512-yb8LrTQXQnh9yhnaIHLk6CYugF/An50T20+X0h++hjjhVfgSp1DGoMSYycF8/aD5eiqS4QwaNhiduFvK8rifRg=="
"version": "1.3.665",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.665.tgz",
"integrity": "sha512-LIjx1JheOz7LM8DMEQ2tPnbBzJ4nVG1MKutsbEMLnJfwfVdPIsyagqfLp56bOWhdBrYGXWHaTayYkllIU2TauA=="
},
"elliptic": {
"version": "6.5.4",
@ -2621,11 +2512,6 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
},
"event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"events": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz",
@ -3321,27 +3207,6 @@
}
}
},
"htmlparser2": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz",
"integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==",
"requires": {
"domelementtype": "^2.0.1",
"domhandler": "^3.3.0",
"domutils": "^2.4.2",
"entities": "^2.0.0"
},
"dependencies": {
"domhandler": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz",
"integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==",
"requires": {
"domelementtype": "^2.0.1"
}
}
}
},
"http-errors": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
@ -3359,15 +3224,6 @@
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
"integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM="
},
"https-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
"integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
"requires": {
"agent-base": "6",
"debug": "4"
}
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -3670,6 +3526,16 @@
"requires": {
"merge-stream": "^2.0.0",
"supports-color": "^6.1.0"
},
"dependencies": {
"supports-color": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
"requires": {
"has-flag": "^3.0.0"
}
}
}
},
"js-tokens": {
@ -3879,6 +3745,33 @@
"object-visit": "^1.0.0"
}
},
"markdown-it": {
"version": "12.0.4",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.0.4.tgz",
"integrity": "sha512-34RwOXZT8kyuOJy25oJNJoulO8L0bTHYWXcdZBYZqFnjIy3NgjeoM3FmPXIOFQ26/lSHYMr8oc62B6adxXcb3Q==",
"requires": {
"argparse": "^2.0.1",
"entities": "~2.1.0",
"linkify-it": "^3.0.1",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
},
"dependencies": {
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"linkify-it": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.2.tgz",
"integrity": "sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ==",
"requires": {
"uc.micro": "^1.0.1"
}
}
}
},
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -3914,6 +3807,11 @@
"resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz",
"integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w=="
},
"mdurl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
"integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
},
"merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -4094,17 +3992,16 @@
"dev": true
},
"next": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/next/-/next-10.0.6.tgz",
"integrity": "sha512-uM5Yv4Ha9iL6Lbg7Ez36GyJ0YTdRLzXLA9b1REH3rX2Wytw0Ls5qPuFGk4BHSQpQhYx6Z61iA2qPkGl33W4iBg==",
"version": "10.0.7",
"resolved": "https://registry.npmjs.org/next/-/next-10.0.7.tgz",
"integrity": "sha512-We0utmwwfkvO12eLyUZd3tX9VLDE3FPpOaHpH3kqKdUTxJzUKt8FLBXCTm0mwsTKW5XColWG8mJvz2OLu3+3QA==",
"requires": {
"@ampproject/toolbox-optimizer": "2.7.1-alpha.0",
"@babel/runtime": "7.12.5",
"@hapi/accept": "5.0.1",
"@next/env": "10.0.6",
"@next/polyfill-module": "10.0.6",
"@next/react-dev-overlay": "10.0.6",
"@next/react-refresh-utils": "10.0.6",
"@next/env": "10.0.7",
"@next/polyfill-module": "10.0.7",
"@next/react-dev-overlay": "10.0.7",
"@next/react-refresh-utils": "10.0.7",
"@opentelemetry/api": "0.14.0",
"ast-types": "0.13.2",
"browserslist": "4.16.1",
@ -4336,11 +4233,6 @@
"integrity": "sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI=",
"optional": true
},
"normalize-html-whitespace": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/normalize-html-whitespace/-/normalize-html-whitespace-1.0.0.tgz",
"integrity": "sha512-9ui7CGtOOlehQu0t/OhhlmDyc71mKVlv+4vF+me4iZLPrNtRL2xoquEdfZxasC/bdQi/Hr3iTrpyRKIG+ocabA=="
},
"normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@ -4737,25 +4629,12 @@
"line-column": "^1.0.2",
"nanoid": "^3.1.16",
"source-map": "^0.6.1"
}
},
"postcss-safe-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-4.0.2.tgz",
"integrity": "sha512-Uw6ekxSWNLCPesSv/cmqf2bY/77z11O7jZGPax3ycZMFU/oi2DMH9i89AdHc1tRwFg/arFoEwX0IS3LCUxJh1g==",
"requires": {
"postcss": "^7.0.26"
},
"dependencies": {
"postcss": {
"version": "7.0.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz",
"integrity": "sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg==",
"requires": {
"chalk": "^2.4.2",
"source-map": "^0.6.1",
"supports-color": "^6.1.0"
}
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}
}
},
@ -4878,9 +4757,9 @@
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz",
"integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg=="
},
"querystring-es3": {
"version": "0.2.1",
@ -5342,13 +5221,12 @@
}
},
"react": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz",
"integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==",
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/react/-/react-17.0.1.tgz",
"integrity": "sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2"
"object-assign": "^4.1.1"
}
},
"react-chartkick": {
@ -5360,14 +5238,13 @@
}
},
"react-dom": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz",
"integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==",
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.1.tgz",
"integrity": "sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"scheduler": "^0.19.1"
"scheduler": "^0.20.1"
}
},
"react-is": {
@ -5406,6 +5283,11 @@
"xtend": "^4.0.1"
}
},
"react-markdown-editor-lite": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/react-markdown-editor-lite/-/react-markdown-editor-lite-1.2.3.tgz",
"integrity": "sha512-t2O9Mi6fG1WcWFsmYFr/qxTZLVfoKu2rAFmb1FwUN28glOQd0b3NcbcsxjnEuPoNy6Uk/TnpJPkMyWlFLnG9uA=="
},
"react-refresh": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz",
@ -5670,9 +5552,9 @@
}
},
"scheduler": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz",
"integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==",
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.1.tgz",
"integrity": "sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
@ -6032,9 +5914,12 @@
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
"version": "0.8.0-beta.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
"integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==",
"requires": {
"whatwg-url": "^7.0.0"
}
},
"source-map-resolve": {
"version": "0.5.3",
@ -6048,15 +5933,6 @@
"urix": "^0.1.0"
}
},
"source-map-support": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
"integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
"requires": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"source-map-url": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz",
@ -6340,11 +6216,18 @@
"integrity": "sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw=="
},
"supports-color": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"requires": {
"has-flag": "^3.0.0"
"has-flag": "^4.0.0"
},
"dependencies": {
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
}
}
},
"table": {
@ -6417,23 +6300,6 @@
}
}
},
"terser": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.5.1.tgz",
"integrity": "sha512-6VGWZNVP2KTUcltUQJ25TtNjx/XgdDsBDKGt8nN0MpydU36LmbPPcMBd2kmtZNNGVVDLg44k7GKeHHj+4zPIBQ==",
"requires": {
"commander": "^2.20.0",
"source-map": "~0.7.2",
"source-map-support": "~0.5.19"
},
"dependencies": {
"source-map": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
"integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ=="
}
}
},
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -6754,6 +6620,11 @@
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
"integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0="
},
"querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
}
}
},

View File

@ -5,7 +5,8 @@
"scripts": {
"dev": "next dev",
"build": "next build && next export",
"start": "next start"
"start": "next start",
"lint": "eslint --ext .js,.ts,.tsx types/ pages/ components/"
},
"dependencies": {
"@ant-design/icons": "^4.5.0",
@ -13,18 +14,21 @@
"chart.js": "^2.9.4",
"classnames": "^2.2.6",
"date-fns": "^2.16.1",
"next": "^10.0.6",
"markdown-it": "^12.0.4",
"next": "^10.0.5",
"prop-types": "^15.7.2",
"react": "16.13.1",
"react": "^17.0.1",
"react-chartkick": "^0.4.1",
"react-dom": "16.13.1",
"react-dom": "^17.0.1",
"react-linkify": "^1.0.0-alpha",
"react-markdown": "^5.0.3",
"react-markdown-editor-lite": "^1.2.3",
"sass": "^1.32.7"
},
"devDependencies": {
"@types/chart.js": "^2.9.30",
"@types/classnames": "^2.2.11",
"@types/markdown-it": "^12.0.1",
"@types/node": "^14.14.28",
"@types/prop-types": "^15.7.3",
"@types/react": "^17.0.2",

View File

@ -1,25 +1,40 @@
// order matters!
import 'antd/dist/antd.css';
import '../styles/colors.scss';
import '../styles/variables.scss';
import '../styles/ant-overrides.scss';
import '../styles/markdown-editor.scss';
import '../styles/globals.scss';
// GW: I can't override ant design styles through components using NextJS's built-in CSS modules. So I'll just import styles here for now and figure out enabling SASS modules later.
import '../styles/main-layout.scss';
import '../styles/form-textfields.scss';
import '../styles/form-misc-elements.scss';
import '../styles/config-socialhandles.scss';
import '../styles/config-storage.scss';
import '../styles/config-tags.scss';
import '../styles/config-video-variants.scss';
import '../styles/config-public-details.scss';
import '../styles/home.scss';
import '../styles/chat.scss';
import '../styles/pages.scss';
import { AppProps } from 'next/app';
import ServerStatusProvider from '../utils/server-status-context';
import MainLayout from './components/main-layout';
import AlertMessageProvider from '../utils/alert-message-context';
import MainLayout from '../components/main-layout';
function App({ Component, pageProps }: AppProps) {
return (
<ServerStatusProvider>
<MainLayout>
<Component {...pageProps} />
</MainLayout>
<AlertMessageProvider>
<MainLayout>
<Component {...pageProps} />
</MainLayout>
</AlertMessageProvider>
</ServerStatusProvider>
)
);
}
export default App;
export default App;

238
web/pages/access-tokens.tsx Normal file
View File

@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import { Table, Tag, Space, Button, Modal, Checkbox, Input, Typography, Tooltip } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import format from 'date-fns/format';
import { fetchData, ACCESS_TOKENS, DELETE_ACCESS_TOKEN, CREATE_ACCESS_TOKEN } from '../utils/apis';
const { Title, Paragraph } = Typography;
const availableScopes = {
CAN_SEND_SYSTEM_MESSAGES: {
name: 'System messages',
description: 'You can send official messages on behalf of the system',
color: 'purple',
},
CAN_SEND_MESSAGES: {
name: 'User chat messages',
description: 'You can send messages on behalf of a username',
color: 'green',
},
HAS_ADMIN_ACCESS: {
name: 'Has admin access',
description: 'Can perform administrative actions such as moderation, get server statuses, etc',
color: 'red',
},
};
function convertScopeStringToTag(scopeString) {
if (!scopeString || !availableScopes[scopeString]) {
return null;
}
const scope = availableScopes[scopeString];
return (
<Tooltip key={scopeString} title={scope.description}>
<Tag color={scope.color}>{scope.name}</Tag>
</Tooltip>
);
}
interface Props {
onCancel: () => void;
onOk: any; // todo: make better type
visible: boolean;
}
function NewTokenModal(props: Props) {
const { onOk, onCancel, visible } = props;
const [selectedScopes, setSelectedScopes] = useState([]);
const [name, setName] = useState('');
const scopes = Object.keys(availableScopes).map(key => {
return { value: key, label: availableScopes[key].description };
});
function onChange(checkedValues) {
setSelectedScopes(checkedValues);
}
function saveToken() {
onOk(name, selectedScopes);
// Clear the modal
setSelectedScopes([]);
setName('');
}
const okButtonProps = {
disabled: selectedScopes.length === 0 || name === '',
};
function selectAll() {
setSelectedScopes(Object.keys(availableScopes));
}
return (
<Modal
title="Create New Access token"
visible={visible}
onOk={saveToken}
onCancel={onCancel}
okButtonProps={okButtonProps}
>
<p>
<Input
value={name}
placeholder="Access token name/description"
onChange={input => setName(input.currentTarget.value)}
/>
</p>
<p>
Select the permissions this access token will have. It cannot be edited after it&apos;s
created.
</p>
<Checkbox.Group options={scopes} value={selectedScopes} onChange={onChange} />
<p>
<Button type="primary" onClick={selectAll}>
Select all
</Button>
</p>
</Modal>
);
}
export default function AccessTokens() {
const [tokens, setTokens] = useState([]);
const [isTokenModalVisible, setIsTokenModalVisible] = useState(false);
function handleError(error) {
console.error('error', error);
alert(error);
}
async function getAccessTokens() {
try {
const result = await fetchData(ACCESS_TOKENS);
setTokens(result);
} catch (error) {
handleError(error);
}
}
useEffect(() => {
getAccessTokens();
}, []);
async function handleDeleteToken(token) {
try {
await fetchData(DELETE_ACCESS_TOKEN, {
method: 'POST',
data: { token },
});
getAccessTokens();
} catch (error) {
handleError(error);
}
}
async function handleSaveToken(name: string, scopes: string[]) {
try {
const newToken = await fetchData(CREATE_ACCESS_TOKEN, {
method: 'POST',
data: { name, scopes },
});
setTokens(tokens.concat(newToken));
} catch (error) {
handleError(error);
}
}
const columns = [
{
title: '',
key: 'delete',
render: (text, record) => (
<Space size="middle">
<Button onClick={() => handleDeleteToken(record.token)} icon={<DeleteOutlined />} />
</Space>
),
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Token',
dataIndex: 'token',
key: 'token',
render: text => <Input.Password size="small" bordered={false} value={text} />,
},
{
title: 'Scopes',
dataIndex: 'scopes',
key: 'scopes',
render: scopes => (
<>
{scopes.map(scope => {
return convertScopeStringToTag(scope);
})}
</>
),
},
{
title: 'Last Used',
dataIndex: 'lastUsed',
key: 'lastUsed',
render: lastUsed => {
if (!lastUsed) {
return 'Never';
}
const dateObject = new Date(lastUsed);
return format(dateObject, 'P p');
},
},
];
const showCreateTokenModal = () => {
setIsTokenModalVisible(true);
};
const handleTokenModalSaveButton = (name, scopes) => {
setIsTokenModalVisible(false);
handleSaveToken(name, scopes);
};
const handleTokenModalCancel = () => {
setIsTokenModalVisible(false);
};
return (
<div>
<Title>Access Tokens</Title>
<Paragraph>
Access tokens are used to allow external, 3rd party tools to perform specific actions on
your Owncast server. They should be kept secure and never included in client code, instead
they should be kept on a server that you control.
</Paragraph>
<Paragraph>
Read more about how to use these tokens, with examples, at{' '}
<a href="https://owncast.online/docs/integrations/">our documentation</a>.
</Paragraph>
<Table rowKey="token" columns={columns} dataSource={tokens} pagination={false} />
<br />
<Button type="primary" onClick={showCreateTokenModal}>
Create Access Token
</Button>
<NewTokenModal
visible={isTokenModalVisible}
onOk={handleTokenModalSaveButton}
onCancel={handleTokenModalCancel}
/>
</div>
);
}

View File

@ -1,18 +0,0 @@
import React, { useContext } from 'react';
import { ServerStatusContext } from '../utils/server-status-context';
export default function BroadcastInfo() {
const context = useContext(ServerStatusContext);
const { broadcaster } = context || {};
const { remoteAddr, time, streamDetails } = broadcaster || {};
return (
<div style={{border: '1px solid green', width: '100%'}}>
<h2>Broadcast Info</h2>
<p>Remote Address: {remoteAddr}</p>
<p>Time: {(new Date(time)).toLocaleTimeString()}</p>
<p>Stream Details: {JSON.stringify(streamDetails)}</p>
</div>
);
}

View File

@ -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,10 +199,10 @@ export default function Chat() {
'bulk-editor': true,
active: selectedRowKeys.length,
});
return (
<div className="chat-messages">
<Title level={2}>Chat Messages</Title>
<Title>Chat Messages</Title>
<p>Manage the messages from viewers that show up on your stream.</p>
<div className={bulkDivClasses}>
<span className="label">Check multiple messages to change their visibility to: </span>
@ -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>
);
}

View File

@ -1,85 +0,0 @@
import React from 'react';
import adminStyles from '../../styles/styles.module.scss';
export default function Logo() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95.68623352050781 104.46271514892578" className={adminStyles.logoSVG}>
<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>
);
}

View File

@ -1,184 +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 } from 'antd';
import {
SettingOutlined,
HomeOutlined,
LineChartOutlined,
ToolOutlined,
PlayCircleFilled,
MinusSquareFilled,
QuestionCircleOutlined,
MessageOutlined
} 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 adminStyles from '../../styles/styles.module.scss';
let performedUpgradeCheck = false;
export default function MainLayout(props) {
const { children } = props;
const context = useContext(ServerStatusContext);
const { online, broadcaster, versionNumber } = context || {};
const router = useRouter();
const { route } = router || {};
const { Header, Footer, Content, Sider } = Layout;
const { SubMenu } = Menu;
const streamDurationString = online ? parseSecondsToDurationString(differenceInSeconds(new Date(), new Date(broadcaster.time))) : "";
const content = (
<div>
<img src="/thumbnail.jpg" width="200px" />
</div>
);
const statusIcon = online ? <PlayCircleFilled /> : <MinusSquareFilled />;
const statusMessage = online ? `Online ${streamDurationString}` : "Offline";
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 && !context.disableUpgradeChecks) {
checkForUpgrade();
performedUpgradeCheck = true
}
});
const appClass = classNames({
"owncast-layout": true,
[adminStyles.online]: online,
});
const upgradeMenuItemStyle = upgradeVersion ? 'block' : 'none';
const upgradeVersionString = upgradeVersion || '';
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={adminStyles.sideNav}
>
<Menu
theme="dark"
defaultSelectedKeys={[route.substring(1) || "home"]}
defaultOpenKeys={["current-stream-menu", "utilities-menu", "configuration"]}
mode="inline"
>
<h1 className={adminStyles.owncastTitleContainer}>
<span className={adminStyles.logoContainer}>
<OwncastLogo />
</span>
<span className={adminStyles.owncastTitle}>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="update-server-config">
<Link href="/update-server-config">Server</Link>
</Menu.Item>
<Menu.Item key="video-config">
<Link href="/video-config">Video</Link>
</Menu.Item>
<Menu.Item key="storage">
<Link href="/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>
<Menu.Item
key="help"
icon={<QuestionCircleOutlined />}
title="Help"
>
<Link href="/help">Help</Link>
</Menu.Item>
</Menu>
</Sider>
<Layout className={adminStyles.layoutMain}>
<Header className={adminStyles.header}>
<Popover content={content} title="Thumbnail" trigger="hover">
<div className={adminStyles.statusIndicatorContainer}>
<span className={adminStyles.statusLabel}>{statusMessage}</span>
<span className={adminStyles.statusIcon}>{statusIcon}</span>
</div>
</Popover>
</Header>
<Content className={adminStyles.contentMain}>{children}</Content>
<Footer style={{ textAlign: "center" }}>
<a href="https://owncast.online/">About Owncast v{versionNumber}</a>
</Footer>
</Layout>
</Layout>
);
}
MainLayout.propTypes = {
children: PropTypes.element.isRequired,
};

View File

@ -0,0 +1,40 @@
import React from 'react';
import { Typography } from 'antd';
import EditInstanceDetails from '../components/config/edit-instance-details';
import EditInstanceTags from '../components/config/edit-tags';
import EditSocialLinks from '../components/config/edit-social-links';
import EditPageContent from '../components/config/edit-page-content';
const { Title } = Typography;
export default function PublicFacingDetails() {
return (
<div className="config-public-details-page">
<Title>General Settings</Title>
<p className="description">
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="top-container">
<div className="form-module instance-details-container">
<EditInstanceDetails />
</div>
<div className="form-module social-items-container ">
<div className="form-module tags-module">
<EditInstanceTags />
</div>
<div className="form-module social-handles-container">
<EditSocialLinks />
</div>
</div>
</div>
<div className="form-module page-content-module">
<EditPageContent />
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
import React from 'react';
import { Typography } from 'antd';
import EditServerDetails from '../components/config/edit-server-details';
const { Title } = Typography;
export default function ConfigServerDetails() {
return (
<div className="config-server-details-form">
<Title>Server Settings</Title>
<p className="description">
You should change your stream key from the default and keep it safe. For most people
it&apos;s likely the other settings will not need to be changed.
</p>
<div className="form-module config-server-details-container">
<EditServerDetails />
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
import React from 'react';
import { Typography } from 'antd';
import EditSocialLinks from '../components/config/edit-social-links';
const { Title } = Typography;
export default function ConfigSocialThings() {
return (
<div className="config-social-items">
<Title>Social Items</Title>
<EditSocialLinks />
</div>
);
}

View File

@ -0,0 +1,24 @@
import { Typography } from 'antd';
import React from 'react';
import EditStorage from '../components/config/edit-storage';
const { Title } = Typography;
export default function ConfigStorageInfo() {
return (
<>
<Title>Storage</Title>
<p className="description">
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 className="description">
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 />
</>
);
}

View File

@ -0,0 +1,35 @@
import React from 'react';
import { Typography, Row, Col } from 'antd';
import VideoVariantsTable from '../components/config/video-variants-table';
import VideoLatency from '../components/config/video-latency';
const { Title } = Typography;
export default function ConfigVideoSettings() {
return (
<div className="config-video-variants">
<Title>Video configuration</Title>
<p className="description">
Before changing your video configuration{' '}
<a href="https://owncast.online/docs/video">visit the video documentation</a> to learn how
it impacts your stream performance. The general rule is to start conservatively by having
one middle quality stream output variant and experiment with adding more of varied
qualities.
</p>
<Row gutter={[16, 16]}>
<Col md={24} lg={12}>
<div className="form-module variants-table-module">
<VideoVariantsTable />
</div>
</Col>
<Col md={24} lg={12}>
<div className="form-module latency-module">
<VideoLatency />
</div>
</Col>
</Row>
</div>
);
}

View File

@ -1,106 +1,111 @@
import { BulbOutlined, LaptopOutlined, SaveOutlined } from "@ant-design/icons";
import { Row } from "antd";
import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons';
import { Row, Col, Typography } 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
}
// TODO: FIX TS WARNING FROM THIS.
// interface TimedValue {
// time: Date;
// value: Number;
// }
export default function HardwareInfo() {
const [hardwareStatus, setHardwareStatus] = useState({
cpu: Array<TimedValue>(),
memory: Array<TimedValue>(),
disk: Array<TimedValue>(),
message: "",
cpu: [], // Array<TimedValue>(),
memory: [], // Array<TimedValue>(),
disk: [], // Array<TimedValue>(),
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 (
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 (
<>
<Typography.Title>Hardware Info</Typography.Title>
<br />
<div>
<div>
<Row gutter={[16, 16]} justify="space-around">
<Row gutter={[16, 16]} justify="space-around">
<Col>
<StatisticItem
title={series[0].name}
value={`${currentCPUUsage}`}
prefix={<LaptopOutlined style={{color: series[0].color }}/>}
prefix={<LaptopOutlined style={{ color: series[0].color }} />}
color={series[0].color}
progress
centered
/>
</Col>
<Col>
<StatisticItem
title={series[1].name}
value={`${currentRamUsage}`}
prefix={<BulbOutlined style={{color: series[1].color }} />}
prefix={<BulbOutlined style={{ color: series[1].color }} />}
color={series[1].color}
progress
centered
/>
</Col>
<Col>
<StatisticItem
title={series[2].name}
value={`${currentDiskUsage}`}
prefix={<SaveOutlined style={{color: series[2].color }} />}
prefix={<SaveOutlined style={{ color: series[2].color }} />}
color={series[2].color}
progress
centered
/>
</Row>
</Col>
</Row>
<Chart title="% used" dataCollections={series} color="#FF7700" unit="%" />
</div>
<Chart title="% used" dataCollections={series} color="#FF7700" unit="%" />
</div>
);
}
</>
);
}

View File

@ -1,167 +1,197 @@
import { Button, Card, Col, Divider, Result, Row } from 'antd'
import Meta from 'antd/lib/card/Meta'
import Title from 'antd/lib/typography/Title'
import { Button, Card, Col, Divider, Result, Row } from 'antd';
import Meta from 'antd/lib/card/Meta';
import Title from 'antd/lib/typography/Title';
import {
AlertOutlined,
ApiTwoTone,
BookOutlined,
BugTwoTone,
CameraTwoTone,
DatabaseTwoTone,
EditTwoTone,
Html5TwoTone,
LinkOutlined,
QuestionCircleTwoTone,
SettingTwoTone,
SlidersTwoTone,
} from '@ant-design/icons'
import React from 'react'
ApiTwoTone,
BugTwoTone,
CameraTwoTone,
DatabaseTwoTone,
EditTwoTone,
Html5TwoTone,
LinkOutlined,
QuestionCircleTwoTone,
SettingTwoTone,
SlidersTwoTone,
} from '@ant-design/icons';
import React from 'react';
interface Props { }
export default function Help(props: Props) {
const questions = [
{
icon: <SettingTwoTone style={{ fontSize: '24px' }} />,
title: "I want to configure my owncast instance",
content: (
<div>
<a href="https://owncast.online/docs/configuration/" target="_blank" rel="noopener noreferrer"><LinkOutlined/> Learn more</a>
</div>
)
},
{
icon: <CameraTwoTone style={{ fontSize: '24px' }} />,
title: "I need help configuring my broadcasting software",
content: (
<div>
<a href="https://owncast.online/docs/broadcasting/" target="_blank" rel="noopener noreferrer"><LinkOutlined/> Learn more</a>
</div>
)
},
{
icon: <Html5TwoTone style={{ fontSize: '24px' }} />,
title: "I want to embed my stream into another site",
content: (
<div>
<a href="https://owncast.online/docs/embed/" target="_blank" rel="noopener noreferrer"><LinkOutlined/> Learn more</a>
</div>
)
},
{
icon: <EditTwoTone style={{ fontSize: '24px' }} />,
title: "I want to customize my website",
content: (
<div>
<a href="https://owncast.online/docs/website/" target="_blank" rel="noopener noreferrer"><LinkOutlined/> Learn more</a>
</div>
)
},
{
icon: <SlidersTwoTone style={{ fontSize: '24px' }} />,
title: "I want to tweak my encoding quality or performance",
content: (
<div>
<a href="https://owncast.online/docs/encoding/" target="_blank" rel="noopener noreferrer"><LinkOutlined/> Learn more</a>
</div>
)
},
{
icon: <DatabaseTwoTone style={{ fontSize: '24px' }} />,
title: "I want to offload my video to an external storage provider",
content: (
<div>
<a href="https://owncast.online/docs/s3/" target="_blank" rel="noopener noreferrer"><LinkOutlined/> Learn more</a>
</div>
)
},
]
const otherResources = [
{
icon: <BugTwoTone style={{ fontSize: '24px' }} />,
title: "I found a bug",
content: (
<div>
If you found a bug, then please
<a href="https://github.com/owncast/owncast/issues/new/choose" target="_blank" rel="noopener noreferrer"> let us know</a>
</div>
)
},
{
icon: <QuestionCircleTwoTone style={{ fontSize: '24px' }} />,
title: "I have a general question",
content: (
<div>
Most general questions are answered in our
<a href="https://owncast.online/docs/faq/" target="_blank" rel="noopener noreferrer"> FAQ</a> or exist in our <a href="https://github.com/owncast/owncast/discussions">discussions</a>
</div>
)
},
{
icon: <ApiTwoTone style={{ fontSize: '24px' }} />,
title: "I want to use the API",
content: (
<div>
You can view the API documentation for either the
<a href="https://owncast.online/api/latest" target="_blank" rel="noopener noreferrer">&nbsp;latest&nbsp;</a>
or
<a href="https://owncast.online/api/development" target="_blank" rel="noopener noreferrer">&nbsp;development&nbsp;</a>
release.
</div>
)
}
]
return (
export default function Help() {
const questions = [
{
icon: <SettingTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to configure my owncast instance',
content: (
<div>
<Title style={{textAlign: 'center'}}>How can we help you?</Title>
<Row gutter={[16, 16]} justify="space-around" align="middle">
<Col xs={24} lg={12} style={{textAlign: 'center'}}>
<Result status="500" />
<Title level={2}>Troubleshooting</Title>
<Button target="_blank" rel="noopener noreferrer" href="https://owncast.online/docs/troubleshooting/" icon={<LinkOutlined/>} type="primary">Read Troubleshoting</Button>
</Col>
<Col xs={24} lg={12} style={{textAlign: 'center'}}>
<Result status="404" />
<Title level={2}>Documentation</Title>
<Button target="_blank" rel="noopener noreferrer" href="https://owncast.online/" icon={<LinkOutlined/>} type="primary">Read the Docs</Button>
</Col>
</Row>
<Divider />
<Title level={2}>Common tasks</Title>
<Row gutter={[16, 16]}>
{
questions.map(question => (
<Col xs={24} lg={12}>
<Card key={question.title}>
<Meta
avatar={question.icon}
title={question.title}
description={question.content}
/>
</Card>
</Col>
))
}
</Row>
<Divider />
<Title level={2}>Other</Title>
<Row gutter={[16, 16]}>
{
otherResources.map(question => (
<Col xs={24} lg={12}>
<Card key={question.title}>
<Meta
avatar={question.icon}
title={question.title}
description={question.content}
/>
</Card>
</Col>
))
}
</Row>
<a
href="https://owncast.online/docs/configuration/"
target="_blank"
rel="noopener noreferrer"
>
<LinkOutlined /> Learn more
</a>
</div>
)
),
},
{
icon: <CameraTwoTone style={{ fontSize: '24px' }} />,
title: 'Help configuring my broadcasting software',
content: (
<div>
<a
href="https://owncast.online/docs/broadcasting/"
target="_blank"
rel="noopener noreferrer"
>
<LinkOutlined /> Learn more
</a>
</div>
),
},
{
icon: <Html5TwoTone style={{ fontSize: '24px' }} />,
title: 'I want to embed my stream into another site',
content: (
<div>
<a href="https://owncast.online/docs/embed/" target="_blank" rel="noopener noreferrer">
<LinkOutlined /> Learn more
</a>
</div>
),
},
{
icon: <EditTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to customize my website',
content: (
<div>
<a href="https://owncast.online/docs/website/" target="_blank" rel="noopener noreferrer">
<LinkOutlined /> Learn more
</a>
</div>
),
},
{
icon: <SlidersTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to tweak my video output',
content: (
<div>
<a href="https://owncast.online/docs/encoding/" target="_blank" rel="noopener noreferrer">
<LinkOutlined /> Learn more
</a>
</div>
),
},
{
icon: <DatabaseTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to use an external storage provider',
content: (
<div>
<a href="https://owncast.online/docs/storage/" target="_blank" rel="noopener noreferrer">
<LinkOutlined /> Learn more
</a>
</div>
),
},
];
const otherResources = [
{
icon: <BugTwoTone style={{ fontSize: '24px' }} />,
title: 'I found a bug',
content: (
<div>
If you found a bug, then please
<a
href="https://github.com/owncast/owncast/issues/new/choose"
target="_blank"
rel="noopener noreferrer"
>
{' '}
let us know
</a>
</div>
),
},
{
icon: <QuestionCircleTwoTone style={{ fontSize: '24px' }} />,
title: 'I have a general question',
content: (
<div>
Most general questions are answered in our
<a href="https://owncast.online/docs/faq/" target="_blank" rel="noopener noreferrer">
{' '}
FAQ
</a>{' '}
or exist in our <a href="https://github.com/owncast/owncast/discussions">discussions</a>
</div>
),
},
{
icon: <ApiTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to build add-ons for Owncast',
content: (
<div>
You can build your own bots, overlays, tools and add-ons with our
<a href="https://owncast.online/thirdparty" target="_blank" rel="noopener noreferrer">
&nbsp;developer APIs.&nbsp;
</a>
</div>
),
},
];
return (
<div className="help-page">
<Title style={{ textAlign: 'center' }}>How can we help you?</Title>
<Row gutter={[16, 16]} justify="space-around" align="middle">
<Col xs={24} lg={12} style={{ textAlign: 'center' }}>
<Result status="500" />
<Title level={2}>Troubleshooting</Title>
<Button
target="_blank"
rel="noopener noreferrer"
href="https://owncast.online/docs/troubleshooting/"
icon={<LinkOutlined />}
type="primary"
>
Fix your problems
</Button>
</Col>
<Col xs={24} lg={12} style={{ textAlign: 'center' }}>
<Result status="404" />
<Title level={2}>Documentation</Title>
<Button
target="_blank"
rel="noopener noreferrer"
href="https://owncast.online/docs"
icon={<LinkOutlined />}
type="primary"
>
Read the Docs
</Button>
</Col>
</Row>
<Divider />
<Title level={2}>Common tasks</Title>
<Row gutter={[16, 16]}>
{questions.map(question => (
<Col xs={24} lg={12}>
<Card key={question.title}>
<Meta avatar={question.icon} title={question.title} description={question.content} />
</Card>
</Col>
))}
</Row>
<Divider />
<Title level={2}>Other</Title>
<Row gutter={[16, 16]}>
{otherResources.map(question => (
<Col xs={24} lg={12}>
<Card key={question.title}>
<Meta avatar={question.icon} title={question.title} description={question.content} />
</Card>
</Col>
))}
</Row>
</div>
);
}

View File

@ -1,34 +1,24 @@
/*
Will display an overview with the following datasources:
1. Current broadcaster.
2. Viewer count.
3. Video settings.
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, Row, Col } from 'antd';
import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons';
import { formatDistanceToNow, formatRelative } from 'date-fns';
import { ServerStatusContext } from '../utils/server-status-context';
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 { LOGS_WARN, fetchData, FETCH_INTERVAL } from '../utils/apis';
import { formatIPAddress, isEmptyObject } from '../utils/format';
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>
);
}
@ -38,7 +28,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 () => {
@ -46,12 +36,12 @@ 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();
@ -61,7 +51,7 @@ export default function Home() {
return () => {
clearInterval(intervalId);
}
};
}, []);
if (isEmptyObject(configData) || isEmptyObject(serverStatusData)) {
@ -75,117 +65,105 @@ export default function Home() {
}
if (!broadcaster) {
return <Offline logs={logsData} />;
return <Offline logs={logsData} config={configData} />;
}
// map out settings
const videoQualitySettings = configData?.videoSettings?.videoQualityVariants?.map((setting, index) => {
const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map(setting => {
const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
const audioSetting = audioPassthrough
? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps`
: `${audioBitrate || 'Unknown'} kbps`;
? `${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`;
let settingTitle = 'Outbound Stream Details';
settingTitle = (videoQualitySettings?.length > 1) ?
`${settingTitle} ${index + 1}` : settingTitle;
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${
streamDetails.width
} x ${streamDetails.height}`
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
return (
<Card title={settingTitle} type="inner" key={`${settingTitle}${index}`}>
<StatisticItem
<div className="stream-details-item-container">
<Statistic
className="stream-details-item"
title="Outbound Video Stream"
value={videoSetting}
prefix={null}
/>
<StatisticItem
<Statistic
className="stream-details-item"
title="Outbound Audio Stream"
value={audioSetting}
prefix={null}
/>
</Card>
</div>
);
});
// 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);
return (
<div className="home-container">
<div className="home-container">
<div className="sections-container">
<div className="section online-status-section">
<Card title="Stream is online" type="inner">
<Statistic
title={`Stream started ${formatRelative(
broadcastDate,
Date.now()
)}`}
value={formatDistanceToNow(broadcastDate)}
prefix={<ClockCircleOutlined />}
/>
<Statistic
title="Viewers"
value={viewerCount}
prefix={<UserOutlined />}
/>
<Statistic
title="Peak viewer count"
value={sessionPeakViewerCount}
prefix={<UserOutlined />}
/>
<div className="online-status-section">
<Card size="small" type="inner" className="online-details-card">
<Row gutter={[16, 16]} align="middle">
<Col span={8} sm={24} md={8}>
<Statistic
title={`Stream started ${formatRelative(broadcastDate, Date.now())}`}
value={formatDistanceToNow(broadcastDate)}
prefix={<ClockCircleOutlined />}
/>
</Col>
<Col span={8} sm={24} md={8}>
<Statistic title="Viewers" value={viewerCount} prefix={<UserOutlined />} />
</Col>
<Col span={8} sm={24} md={8}>
<Statistic
title="Peak viewer count"
value={sessionPeakViewerCount}
prefix={<UserOutlined />}
/>
</Col>
</Row>
</Card>
</div>
</div>
<div className="section stream-details-section">
<Row gutter={[16, 16]} className="section stream-details-section">
<Col className="outbound-details" span={12} sm={24} md={24} lg={12}>
<Card size="small" title="Outbound Stream Details" type="inner">
{videoQualitySettings}
</Card>
</Col>
<div className="details outbound-details">
{videoQualitySettings}
</div>
<div className="details other-details">
<Card title="Inbound Stream Details" type="inner">
<StatisticItem
<Col className="inbound-details" span={12} sm={24} md={24} lg={12}>
<Card size="small" title="Inbound Stream Details" type="inner">
<Statistic
className="stream-details-item"
title="Input"
value={`${encoder} ${formatIPAddress(remoteAddr)}`}
prefix={null}
/>
<StatisticItem
<Statistic
className="stream-details-item"
title="Inbound Video Stream"
value={streamDetails}
formatter={streamDetailsFormatter}
prefix={null}
/>
<StatisticItem
<Statistic
className="stream-details-item"
title="Inbound Audio Stream"
value={streamAudioDetailString}
prefix={null}
/>
</Card>
<div className="server-detail">
<Card title="Server Config" type="inner">
<StatisticItem
title="Stream key"
value={configData.streamKey}
prefix={null}
/>
<StatisticItem
title="Directory registration enabled"
value={configData.yp.enabled.toString()}
prefix={null}
/>
</Card>
</div>
</div>
</div>
</Col>
</Row>
</div>
<br />
<LogTable logs={logsData} pageSize={5} />
</div>
);

View File

@ -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} />;
}

View File

@ -1,72 +1,91 @@
import { Result, Card } from "antd";
import { MessageTwoTone, QuestionCircleTwoTone, BookTwoTone, PlaySquareTwoTone } from '@ant-design/icons';
import OwncastLogo from "./components/logo"
import LogTable from "./components/log-table";
import Link from 'next/link';
import { Result, Card, Row, Col } from 'antd';
import {
MessageTwoTone,
QuestionCircleTwoTone,
BookTwoTone,
PlaySquareTwoTone,
ProfileTwoTone,
} from '@ant-design/icons';
import OwncastLogo from '../components/logo';
import LogTable from '../components/log-table';
const { Meta } = Card;
export default function Offline({ logs = [] }) {
export default function Offline({ logs = [], config }) {
const data = [
{
icon: <BookTwoTone twoToneColor="#6f42c1" />,
title: "Use your broadcasting software",
title: 'Use your broadcasting software',
content: (
<div>
<a href="https://owncast.online/docs/broadcasting/">Learn how to point your existing software to your new server and start streaming your content.</a>
<a href="https://owncast.online/docs/broadcasting/">
Learn how to point your existing software to your new server and start streaming your
content.
</a>
</div>
)
),
},
{
icon: <MessageTwoTone twoToneColor="#0366d6" />,
title: "Chat is disabled",
content: "Chat will continue to be disabled until you begin a live stream."
title: 'Chat is disabled',
content: 'Chat will continue to be disabled until you begin a live stream.',
},
{
icon: <PlaySquareTwoTone twoToneColor="#f9826c" />,
title: "Embed your video onto other sites",
title: 'Embed your video onto other sites',
content: (
<div>
<a href="https://owncast.online/docs/embed">Learn how you can add your Owncast stream to other sites you control.</a>
<a href="https://owncast.online/docs/embed">
Learn how you can add your Owncast stream to other sites you control.
</a>
</div>
)
),
},
{
icon: <QuestionCircleTwoTone twoToneColor="#ffd33d" />,
title: "Not sure what to do next?",
title: 'Not sure what to do next?',
content: (
<div>
If you're having issues or would like to know how to customize and configure your Owncast server visit <Link href="/help">the help page.</Link>
If you&apos;re having issues or would like to know how to customize and configure your
Owncast server visit <Link href="/help">the help page.</Link>
</div>
),
}
},
];
if (!config?.yp?.enabled) {
data.push({
icon: <ProfileTwoTone twoToneColor="#D18BFE" />,
title: 'Find an audience on the Owncast Directory',
content: (
<div>
List yourself in the Owncast Directory and show off your stream. Enable it in{' '}
<Link href="/config-public-details">settings.</Link>
</div>
),
});
}
return (
<>
<div className="offline-content">
<div className="logo-section">
<Row gutter={[16, 16]} className="offline-content">
<Col span={12} xs={24} sm={24} md={24} lg={12} className="logo-section">
<Result
icon={<OwncastLogo />}
title="No stream is active."
subTitle="You should start one."
/>
</div>
<div className="list-section">
{
data.map(item => (
<Card key={item.title}>
<Meta
avatar={item.icon}
title={item.title}
description={item.content}
/>
</Card>
))
}
</div>
</Col>
</div>
<Col span={12} xs={24} sm={24} md={24} lg={12} className="list-section">
{data.map(item => (
<Card key={item.title} size="small" bordered={false}>
<Meta avatar={item.icon} title={item.title} description={item.content} />
</Card>
))}
</Col>
</Row>
<LogTable logs={logs} pageSize={5} />
</>
);

View File

@ -1,70 +0,0 @@
import React, { useContext } from "react";
import KeyValueTable from "./components/key-value-table";
import { ServerStatusContext } from '../utils/server-status-context';
import { Typography } from 'antd';
import Link from 'next/link';
const { Title } = Typography;
function Storage({ config }) {
if (!config || !config.s3) {
return null;
}
if (!config.s3.enabled) {
return (
<div>
<Title>External Storage</Title>
<p>
You are currently using the <Link href="/hardware-info">local storage of this Owncast server</Link> to store and distribute video.
</p>
<p>
Owncast can use S3-compatible external storage providers to offload the responsibility of disk and bandwidth utilization from your own server.
</p>
<p>
Visit our <a href="https://owncast.online/docs/s3/">storage documentation</a> to learn how to configure this.
</p>
</div>
);
}
const data = [
{
name: "Enabled",
value: config.s3.enabled.toString(),
},
{
name: "Endpoint",
value: config.s3.endpoint,
},
{
name: "Access Key",
value: config.s3.accessKey,
},
{
name: "Secret",
value: config.s3.secret,
},
{
name: "Bucket",
value: config.s3.bucket,
},
{
name: "Region",
value: config.s3.region,
},
];
return <KeyValueTable title="External Storage" data={data} />;
}
export default function ServerConfig() {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig: config } = serverStatusData || {};
return (
<div>
<Storage config={config} />
</div>
);
}

View File

@ -1,141 +0,0 @@
import React, { useContext } from 'react';
import { Table, Typography, Input } from 'antd';
import { isEmptyObject } from '../utils/format';
import KeyValueTable from "./components/key-value-table";
import { ServerStatusContext } from '../utils/server-status-context';
import adminStyles from '../styles/styles.module.scss';
const { Title } = Typography;
const { TextArea } = Input;
function SocialHandles({ config }) {
if (!config) {
return null;
}
const columns = [
{
title: "Platform",
dataIndex: "platform",
key: "platform",
},
{
title: "URL",
dataIndex: "url",
key: "url",
render: (url) => <a href={url}>{url}</a>
},
];
if (!config.instanceDetails?.socialHandles) {
return null;
}
return (
<div className={adminStyles.configSection}>
<Title level={2}>Social Handles</Title>
<Table
pagination={false}
columns={columns}
dataSource={config.instanceDetails.socialHandles}
rowKey="platform"
/>
</div>
);
}
function InstanceDetails({ config }) {
if (!config || isEmptyObject(config)) {
return null;
}
const { instanceDetails = {}, yp, streamKey, ffmpegPath, webServerPort } = config;
const data = [
{
name: "Server name",
value: instanceDetails.name,
},
{
name: "Title",
value: instanceDetails.title,
},
{
name: "Summary",
value: instanceDetails.summary,
},
{
name: "Logo",
value: instanceDetails.logo,
},
{
name: "Tags",
value: instanceDetails.tags?.join(", "),
},
{
name: "NSFW",
value: instanceDetails.nsfw?.toString(),
},
{
name: "Shows in Owncast directory",
value: yp.enabled.toString(),
},
];
const configData = [
{
name: "Stream key",
value: streamKey,
},
{
name: "ffmpeg path",
value: ffmpegPath,
},
{
name: "Web server port",
value: webServerPort,
},
];
return (
<>
<div className={adminStyles.configSection}>
<KeyValueTable title="Server details" data={data} />
</div>
<div className={adminStyles.configSection}>
<KeyValueTable title="Server configuration" data={configData} />
</div>
</>
);
}
function PageContent({ config }) {
if (!config?.instanceDetails?.extraPageContent) {
return null;
}
return (
<div className={adminStyles.configSection}>
<Title level={2}>Page content</Title>
<TextArea
disabled rows={4}
value={config.instanceDetails.extraPageContent}
/>
</div>
);
}
export default function ServerConfig() {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig: config } = serverStatusData || {};
return (
<>
<InstanceDetails config={config} />
<SocialHandles config={config} />
<PageContent config={config} />
<Title level={5}>Learn more about configuring Owncast <a href="https://owncast.online/docs/configuration">by visiting the documentation.</a></Title>
</>
);
}

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { Table, Typography } from "antd";
import { getGithubRelease } from "../utils/apis";
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { Table, Typography } from 'antd';
import { getGithubRelease } from '../utils/apis';
const { Title } = Typography;
@ -10,32 +10,29 @@ function AssetTable(assets) {
const columns = [
{
title: "Name",
dataIndex: "name",
key: "name",
render: (text, entry) =>
<a href={entry.browser_download_url}>{text}</a>,
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (text, entry) => <a href={entry.browser_download_url}>{text}</a>,
},
{
title: "Size",
dataIndex: "size",
key: "size",
render: (text) => (`${(text/1024/1024).toFixed(2)} MB`),
title: 'Size',
dataIndex: 'size',
key: 'size',
render: text => `${(text / 1024 / 1024).toFixed(2)} MB`,
},
];
return <Table dataSource={data} columns={columns} rowKey="id" size="large" pagination={false} />
return <Table dataSource={data} columns={columns} rowKey="id" size="large" pagination={false} />;
}
export default function Logs() {
const [release, setRelease] = useState({
html_url: "",
name: "",
html_url: '',
name: '',
created_at: null,
body: "",
body: '',
assets: [],
});
const getRelease = async () => {
@ -43,7 +40,7 @@ export default function Logs() {
const result = await getGithubRelease();
setRelease(result);
} catch (error) {
console.log("==== error", error);
console.log('==== error', error);
}
};
@ -56,14 +53,14 @@ export default function Logs() {
}
return (
<div>
<div className="upgrade-page">
<Title level={2}>
<a href={release.html_url}>{release.name}</a>
</Title>
<Title level={5}>{new Date(release.created_at).toDateString()}</Title>
<ReactMarkdown>{release.body}</ReactMarkdown><h3>Downloads</h3>
<ReactMarkdown>{release.body}</ReactMarkdown>
<h3>Downloads</h3>
<AssetTable {...release.assets} />
</div>
);
}

View File

@ -1,113 +0,0 @@
import React, { useContext } from 'react';
import { Table, Typography } from 'antd';
import { ServerStatusContext } from '../utils/server-status-context';
const { Title } = Typography;
function VideoVariants({ config }) {
if (!config || !config.videoSettings) {
return null;
}
const videoQualityColumns = [
{
title: "#",
dataIndex: "key",
key: "key"
},
{
title: "Video bitrate",
dataIndex: "videoBitrate",
key: "videoBitrate",
render: (bitrate) =>
!bitrate ? "Same as source" : `${bitrate} kbps`,
},
{
title: "Framerate",
dataIndex: "framerate",
key: "framerate",
render: (framerate) =>
!framerate ? "Same as source" : `${framerate} fps`,
},
{
title: "Encoder preset",
dataIndex: "encoderPreset",
key: "framerate",
render: (preset) =>
!preset ? "n/a" : preset,
},
{
title: "Audio bitrate",
dataIndex: "audioBitrate",
key: "audioBitrate",
render: (bitrate) =>
!bitrate ? "Same as source" : `${bitrate} kbps`,
},
];
const miscVideoSettingsColumns = [
{
title: "Name",
dataIndex: "name",
key: "name",
},
{
title: "Value",
dataIndex: "value",
key: "value",
},
];
const miscVideoSettings = [
{
name: "Segment length",
value: config.videoSettings.segmentLengthSeconds,
key: "segmentLength"
},
{
name: "Number of segments",
value: config.videoSettings.numberOfPlaylistItems,
key: "numberOfSegments"
},
];
const videoQualityVariantData = config.videoSettings.videoQualityVariants.map(function(variant, index) {
return {
key: index,
...variant
}
});
return (
<div>
<Title>Video configuration</Title>
<Table
pagination={false}
columns={videoQualityColumns}
dataSource={videoQualityVariantData}
/>
<Table
pagination={false}
columns={miscVideoSettingsColumns}
dataSource={miscVideoSettings}
rowKey={row => row.name}
/>
<br/>
<Title level={5}>Learn more about configuring Owncast <a href="https://owncast.online/docs/configuration">by visiting the documentation.</a></Title>
</div>
);
}
export default function VideoConfig() {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig: config } = serverStatusData || {};
return (
<div>
<VideoVariants config={config} />
</div>
);
}

View File

@ -1,29 +1,20 @@
import React, { useState, useEffect, useContext } from 'react';
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 { Table, Row, Col, Typography } 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 { ServerStatusContext } from '../utils/server-status-context';
import {
CONNECTED_CLIENTS,
VIEWERS_OVER_TIME,
fetchData,
} from "../utils/apis";
import { CONNECTED_CLIENTS, VIEWERS_OVER_TIME, fetchData } from '../utils/apis';
const FETCH_INTERVAL = 60 * 1000; // 1 min
export default function ViewersOverTime() {
const context = useContext(ServerStatusContext);
const {
online,
viewerCount,
overallPeakViewerCount,
sessionPeakViewerCount,
} = context || {};
const { online, viewerCount, overallPeakViewerCount, sessionPeakViewerCount } = context || {};
const [viewerInfo, setViewerInfo] = useState([]);
const [clients, setClients] = useState([]);
@ -33,14 +24,14 @@ export default function ViewersOverTime() {
const result = await fetchData(VIEWERS_OVER_TIME);
setViewerInfo(result);
} catch (error) {
console.log("==== error", error);
console.log('==== error', error);
}
try {
const result = await fetchData(CONNECTED_CLIENTS);
setClients(result);
} catch (error) {
console.log("==== error", error);
console.log('==== error', error);
}
};
@ -62,67 +53,78 @@ export default function ViewersOverTime() {
// todo - check to see if broadcast active has changed. if so, start polling.
if (!viewerInfo.length) {
return "no info";
return 'no info';
}
const columns = [
{
title: "User name",
dataIndex: "username",
key: "username",
render: (username) => username || "-",
title: 'User name',
dataIndex: 'username',
key: 'username',
render: username => username || '-',
sorter: (a, b) => a.username - b.username,
sortDirections: ["descend", "ascend"] as SortOrder[],
sortDirections: ['descend', 'ascend'] as SortOrder[],
},
{
title: "Messages sent",
dataIndex: "messageCount",
key: "messageCount",
title: 'Messages sent',
dataIndex: 'messageCount',
key: 'messageCount',
sorter: (a, b) => a.messageCount - b.messageCount,
sortDirections: ["descend", "ascend"] as SortOrder[],
sortDirections: ['descend', 'ascend'] as SortOrder[],
},
{
title: "Connected Time",
dataIndex: "connectedAt",
key: "connectedAt",
render: (time) => formatDistanceToNow(new Date(time)),
title: 'Connected Time',
dataIndex: 'connectedAt',
key: 'connectedAt',
render: time => formatDistanceToNow(new Date(time)),
sorter: (a, b) => new Date(a.connectedAt).getTime() - new Date(b.connectedAt).getTime(),
sortDirections: ["descend", "ascend"] as SortOrder[],
sortDirections: ['descend', 'ascend'] as SortOrder[],
},
{
title: "User Agent",
dataIndex: "userAgent",
key: "userAgent",
title: 'User Agent',
dataIndex: 'userAgent',
key: 'userAgent',
},
{
title: "Location",
dataIndex: "geo",
key: "geo",
render: (geo) => geo ? `${geo.regionName}, ${geo.countryCode}` : '-',
title: 'Location',
dataIndex: 'geo',
key: 'geo',
render: geo => (geo ? `${geo.regionName}, ${geo.countryCode}` : '-'),
},
];
return (
<div>
<>
<Typography.Title>Viewer Info</Typography.Title>
<br />
<Row gutter={[16, 16]} justify="space-around">
<StatisticItem
title="Current viewers"
value={viewerCount.toString()}
prefix={<UserOutlined />}
/>
<StatisticItem
title="Peak viewers this session"
value={sessionPeakViewerCount.toString()}
prefix={<UserOutlined />}
/>
<StatisticItem
title="Peak viewers overall"
value={overallPeakViewerCount.toString()}
prefix={<UserOutlined />}
/>
{online && (
<Col span={8} md={8}>
<StatisticItem
title="Current viewers"
value={viewerCount.toString()}
prefix={<UserOutlined />}
/>
</Col>
)}
<Col md={online ? 8 : 12}>
<StatisticItem
title={online ? 'Max viewers this session' : 'Max viewers last session'}
value={sessionPeakViewerCount.toString()}
prefix={<UserOutlined />}
/>
</Col>
<Col md={online ? 8 : 12}>
<StatisticItem
title="All-time max viewers"
value={overallPeakViewerCount.toString()}
prefix={<UserOutlined />}
/>
</Col>
</Row>
<Chart title="Viewers" data={viewerInfo} color="#2087E2" unit="" />
<Table dataSource={clients} columns={columns} rowKey={(row) => row.clientID} />
</div>
{online && <Table dataSource={clients} columns={columns} rowKey={row => row.clientID} />}
</>
);
}

214
web/pages/webhooks.tsx Normal file
View File

@ -0,0 +1,214 @@
import React, { useState, useEffect } from 'react';
import { Table, Tag, Space, Button, Modal, Checkbox, Input, Typography, Tooltip } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import isValidUrl from '../utils/urls';
import { fetchData, DELETE_WEBHOOK, CREATE_WEBHOOK, WEBHOOKS } from '../utils/apis';
const { Title, Paragraph } = Typography;
const availableEvents = {
CHAT: { name: 'Chat messages', description: 'When a user sends a chat message', color: 'purple' },
USER_JOINED: { name: 'User joined', description: 'When a user joins the chat', color: 'green' },
NAME_CHANGE: {
name: 'User name changed',
description: 'When a user changes their name',
color: 'blue',
},
'VISIBILITY-UPDATE': {
name: 'Message visibility changed',
description: 'When a message visibility changes, likely due to moderation',
color: 'red',
},
STREAM_STARTED: { name: 'Stream started', description: 'When a stream starts', color: 'orange' },
STREAM_STOPPED: { name: 'Stream stopped', description: 'When a stream stops', color: 'cyan' },
};
function convertEventStringToTag(eventString) {
if (!eventString || !availableEvents[eventString]) {
return null;
}
const event = availableEvents[eventString];
return (
<Tooltip key={eventString} title={event.description}>
<Tag color={event.color}>{event.name}</Tag>
</Tooltip>
);
}
interface Props {
onCancel: () => void;
onOk: any; // todo: make better type
visible: boolean;
}
function NewWebhookModal(props: Props) {
const { onOk, onCancel, visible } = props;
const [selectedEvents, setSelectedEvents] = useState([]);
const [webhookUrl, setWebhookUrl] = useState('');
const events = Object.keys(availableEvents).map(key => {
return { value: key, label: availableEvents[key].description };
});
function onChange(checkedValues) {
setSelectedEvents(checkedValues);
}
function selectAll() {
setSelectedEvents(Object.keys(availableEvents));
}
function save() {
onOk(webhookUrl, selectedEvents);
// Reset the modal
setWebhookUrl('');
setSelectedEvents(null);
}
const okButtonProps = {
disabled: selectedEvents?.length === 0 || !isValidUrl(webhookUrl),
};
return (
<Modal
title="Create New Webhook"
visible={visible}
onOk={save}
onCancel={onCancel}
okButtonProps={okButtonProps}
>
<div>
<Input
value={webhookUrl}
placeholder="https://myserver.com/webhook"
onChange={input => setWebhookUrl(input.currentTarget.value)}
/>
</div>
<p>Select the events that will be sent to this webhook.</p>
<Checkbox.Group options={events} value={selectedEvents} onChange={onChange} />
<p>
<Button type="primary" onClick={selectAll}>
Select all
</Button>
</p>
</Modal>
);
}
export default function Webhooks() {
const [webhooks, setWebhooks] = useState([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const columns = [
{
title: '',
key: 'delete',
render: (text, record) => (
<Space size="middle">
<Button onClick={() => handleDelete(record.id)} icon={<DeleteOutlined />} />
</Space>
),
},
{
title: 'URL',
dataIndex: 'url',
key: 'url',
},
{
title: 'Events',
dataIndex: 'events',
key: 'events',
render: events => (
<>
{events.map(event => {
return convertEventStringToTag(event);
})}
</>
),
},
];
function handleError(error) {
console.error('error', error);
alert(error);
}
async function getWebhooks() {
try {
const result = await fetchData(WEBHOOKS);
setWebhooks(result);
} catch (error) {
handleError(error);
}
}
useEffect(() => {
getWebhooks();
}, []);
async function handleDelete(id) {
try {
await fetchData(DELETE_WEBHOOK, { method: 'POST', data: { id } });
getWebhooks();
} catch (error) {
handleError(error);
}
}
async function handleSave(url: string, events: string[]) {
try {
const newHook = await fetchData(CREATE_WEBHOOK, {
method: 'POST',
data: { url, events },
});
setWebhooks(webhooks.concat(newHook));
} catch (error) {
handleError(error);
}
}
const showCreateModal = () => {
setIsModalVisible(true);
};
const handleModalSaveButton = (url, events) => {
setIsModalVisible(false);
handleSave(url, events);
};
const handleModalCancelButton = () => {
setIsModalVisible(false);
};
return (
<div>
<Title>Webhooks</Title>
<Paragraph>
A webhook is a callback made to an external API in response to an event that takes place
within Owncast. This can be used to build chat bots or sending automatic notifications that
you&apos;ve started streaming.
</Paragraph>
<Paragraph>
Read more about how to use webhooks, with examples, at{' '}
<a href="https://owncast.online/docs/integrations/">our documentation</a>.
</Paragraph>
<Table rowKey="id" columns={columns} dataSource={webhooks} pagination={false} />
<br />
<Button type="primary" onClick={showCreateModal}>
Create Webhook
</Button>
<NewWebhookModal
visible={isModalVisible}
onOk={handleModalSaveButton}
onCancel={handleModalCancelButton}
/>
</div>
);
}

View File

@ -0,0 +1,512 @@
// GENERAL ANT OVERRIDES
// RESET BG, TEXT COLORS
.ant-layout,
.ant-layout-header,
.ant-layout-sider,
.ant-layout-footer,
.ant-card,
.ant-collapse,
.ant-collapse-content,
.ant-statistic,
.ant-statistic-title,
.ant-statistic-content,
.ant-table,
.ant-table-thead > tr > th,
.ant-table-small .ant-table-thead > tr > th,
th.ant-table-column-sort,
td.ant-table-column-sort,
.ant-table-tbody > tr.ant-table-row:hover > td,
.ant-table-thead th.ant-table-column-sort,
.ant-menu,
.ant-menu-submenu > .ant-menu,
.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: transparent;
color: var(--default-text-color);
}
h1.ant-typography,
h2.ant-typography,
h3.ant-typography,
h4.ant-typography,
h5.ant-typography,
.ant-typography,
.ant-typography h1,
.ant-typography h2,
.ant-typography h3,
.ant-typography h4,
.ant-typography h5 {
color: var(--white);
font-weight: 400;
margin: 0.5em 0;
}
.ant-typography.ant-typography-secondary {
color: var(--white);
font-weight: 400;
}
.ant-typography {
font-weight: 300;
color: var(--white-75);
a {
color: var(--owncast-purple);
}
}
.ant-typography h1,
h1.ant-typography {
font-size: 1.75em;
color: var(--pink);
&:first-of-type {
margin-top: 0;
}
}
.ant-typography h2,
h2.ant-typography {
font-size: 1.5em;
}
.ant-typography h3,
h3.ant-typography {
font-size: 1.25em;
}
.ant-progress-text,
.ant-progress-circle .ant-progress-text {
color: var(--default-text-color);
}
// ANT MENU
// menu base
.ant-menu-item {
transition-duration: var(--ant-transition-duration);
.anticon {
transition-duration: var(--ant-transition-duration);
color: var(--nav-text);
}
a {
transition-duration: var(--ant-transition-duration);
color: var(--nav-text);
&:hover {
color: var(--white);
}
}
&:hover {
background-color: var(--black-50);
color: var(--white);
.anticon {
color: var(--white);
}
}
}
.ant-menu-item:active,
.ant-menu-submenu-title:active {
background-color: var(--black-50);
}
// menu item selected
.ant-menu-item-selected,
.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: var(--black);
a {
color: var(--nav-selected-text);
}
.anticon {
color: var(--nav-selected-text);
}
// the right border color
&:after {
border-color: var(--nav-selected-text);
transition-duration: var(--ant-transition-duration);
}
}
// submenu items
.ant-menu-submenu {
& > .ant-menu {
border-left: 1px solid var(--white-50);
background-color: var(--black-35);
}
.ant-menu-submenu-title {
transition-duration: var(--ant-transition-duration);
color: var(--nav-text);
.anticon {
color: var(--nav-text);
}
.ant-menu-submenu-arrow {
&:before,
&:after {
transition-duration: var(--ant-transition-duration);
background-image: linear-gradient(to right, var(--nav-text), var(--nav-text));
}
}
}
&:hover {
.ant-menu-submenu-title {
color: var(--white);
.anticon {
color: var(--white);
}
.ant-menu-submenu-arrow {
&:before,
&:after {
background-image: linear-gradient(to right, var(--white), var(--white));
}
}
}
}
}
// ANT RESULT
.ant-result-title {
color: var(--default-text-color);
}
.ant-result-subtitle {
color: var(--white-75);
}
// ANT CARD
.ant-card {
border-radius: var(--container-border-radius);
background-color: var(--container-bg-color);
color: var(--default-text-color);
}
.ant-card-bordered {
border-color: var(--white-25);
}
.ant-card-meta-title {
color: var(--white);
}
.ant-card-meta-description {
color: var(--white-75);
}
.ant-card-type-inner .ant-card-head {
background-color: var(--black);
color: var(--white-88);
border-color: var(--white-25);
}
// ANT INPUT
input.ant-input,
textarea.ant-input {
background-color: var(--textfield-bg);
color: var(--white-88);
border-color: var(--black);
&::placeholder {
color: var(--owncast-purple-50);
}
}
.ant-input-affix-wrapper,
.ant-input-number {
background-color: var(--textfield-bg);
border-color: var(--textfield-border);
input,
textarea {
background-color: transparent;
color: var(--white-88);
border-color: var(--black);
&::placeholder {
color: var(--owncast-purple-50);
}
&:-webkit-autofill {
background-color: transparent;
}
}
}
.ant-input:hover,
.ant-input-number:hover,
.ant-input-affix-wrapper:hover {
border-color: var(--owncast-purple);
input,
textarea {
border-color: var(--owncast-purple);
}
}
.ant-input,
.ant-input-number:focus,
.ant-input-affix-wrapper:focus,
.ant-input-affix-wrapper-focused {
border-color: var(--owncast-purple);
input,
textarea {
color: var(--white);
border-color: var(--owncast-purple);
}
}
textarea.ant-input {
padding-right: 25px;
}
.ant-input-affix-wrapper {
color: transparent;
}
.ant-input-suffix,
.ant-input-clear-icon,
.ant-input-textarea-clear-icon,
.ant-input-password-icon {
color: var(--white-50);
&:hover {
color: var(--white);
}
}
// ANT BUTTON
.ant-btn {
background-color: var(--owncast-purple-25);
border-color: var(--owncast-purple-25);
color: var(--white-75);
&:hover,
&:focus {
background-color: var(--button-focused);
color: var(--white);
}
}
.ant-btn-primary {
background-color: var(--owncast-purple);
border-color: var(--owncast-purple);
}
.ant-btn-primary:hover,
.ant-btn-primary:focus {
background-color: var(--button-focused);
color: var(--white);
}
.ant-btn.ant-btn-primary:hover {
border-color: var(--white);
}
.ant-btn:focus,
.ant-btn-primary:focus {
border-color: var(--white);
}
.ant-btn-primary[disabled] {
background-color: var(--white-25);
border-color: var(--white-25);
color: var(--white-50);
&:hover {
background-color: var(--white-35);
border-color: var(--white-35);
}
}
.ant-input-affix-wrapper,
.ant-btn {
transition-delay: 0s;
transition-duration: 0.15s;
}
// ANT TABLE
.ant-table-thead > tr > th,
.ant-table-small .ant-table-thead > tr > th {
transition-duration: var(--ant-transition-duration);
background-color: var(--purple-dark);
font-weight: 500;
color: var(--white);
}
.ant-table-tbody > tr > td,
.ant-table-thead > tr > th,
.ant-table-small .ant-table-thead > tr > th {
border-color: var(--white-15);
}
.ant-table-tbody > tr > td {
transition-duration: var(--ant-transition-duration);
background-color: #222325;
color: var(--white-75);
}
.ant-table-tbody > tr.ant-table-row:hover > td {
background-color: var(--gray-dark);
}
.ant-empty {
color: var(--white-75);
}
.ant-table-empty .ant-table-tbody > tr.ant-table-placeholder {
&:hover > td {
background-color: transparent;
}
}
.ant-table-thead th.ant-table-column-has-sorters:hover {
background-color: var(--textfield-border);
.ant-table-filter-trigger-container {
background-color: var(--textfield-border);
}
}
.ant-table-thead th.ant-table-column-sort {
background-color: var(--owncast-purple-25);
opacity: 0.75;
}
// MODAL
.ant-modal,
.ant-modal-body {
font-size: 1em;
}
.ant-modal-content {
border-radius: var(--container-border-radius);
border: 1px solid var(--owncast-purple);
background-color: var(--black);
}
.ant-modal-header {
border-radius: var(--container-border-radius) var(--container-border-radius) 0 0;
}
.ant-modal-close-x {
color: var(--white);
}
.ant-modal-title {
font-weight: 500;
font-size: 1.25em;
color: var(--nav-selected-text);
}
.ant-modal-body {
background-color: var(--gray);
color: var(--default-text-color);
}
.ant-modal-header,
.ant-modal-footer {
background: var(--black);
}
.ant-modal-content,
.ant-modal-header,
.ant-modal-footer {
border-color: var(--white-50);
}
// SELECT
.ant-select-dropdown {
background-color: var(--black);
}
.ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
background-color: var(--black);
border-color: var(--owncast-purple-50);
}
.ant-select-arrow {
color: var(--owncast-purple);
}
.ant-select-selection-placeholder {
color: var(--owncast-purple-50);
}
.ant-select {
color: var(--white);
}
.ant-select-item {
background-color: var(--gray-dark);
color: var(--white-88);
}
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
background-color: var(--gray);
color: var(--white-75);
}
// SLIDER
// .ant-slider-with-marks {
// margin-right: 2em;
// }
.ant-slider-mark-text {
font-size: 0.85em;
white-space: nowrap;
color: var(--white);
opacity: 0.5;
}
.ant-slider-handle {
border-color: var(--blue);
}
.ant-slider:hover .ant-slider-track {
background-color: var(--blue);
}
.ant-slider-rail {
background-color: var(--black);
}
.ant-slider-track {
background-color: var(--nav-text);
}
.ant-slider-mark-text-active {
opacity: 1;
}
// ANT SWITCH
.ant-switch {
background-color: var(--gray-medium);
}
.ant-switch-checked {
background-color: var(--ant-success);
.ant-switch-inner {
color: var(--white);
}
}
// ANT COLLAPSE
.ant-collapse {
font-size: 1em;
border-color: transparent;
& > .ant-collapse-item,
.ant-collapse-content {
border-color: transparent;
& > .ant-collapse-header {
border-radius: var(--container-border-radius);
border-color: transparent;
background-color: var(--purple-dark);
color: var(--white);
font-weight: 500;
}
}
}
.ant-collapse-content {
background-color: var(--black-35); //#181231;
}
.ant-collapse > .ant-collapse-item:last-child,
.ant-collapse > .ant-collapse-item:last-child > .ant-collapse-header {
border-radius: var(--container-border-radius) var(--container-border-radius) 0 0;
}
.ant-collapse-item:last-child > .ant-collapse-content {
border-radius: 0 0 var(--container-border-radius) var(--container-border-radius);
}
// ANT POPOVER
.ant-popover-inner {
background-color: var(--gray);
}
.ant-popover-message,
.ant-popover-inner-content {
color: var(--default-text-color);
}
.ant-popover-placement-topLeft > .ant-popover-content > .ant-popover-arrow {
border-color: var(--gray);
}
// ANT TAGS
.ant-tag-red,
.ant-tag-orange,
.ant-tag-green,
.ant-tag-purple,
.ant-tag-blue {
background-color: var(--black);
}
// ANT PAGINATOR
.ant-pagination-item-active {
color: var(--white);
background-color: var(--default-link-color);
border-color: var(--default-link-color);
a {
color: var(--white);
&:hover {
color: var(--white);
opacity: 0.75;
}
}
}
// ANT CHECKBOX
.ant-checkbox-wrapper {
color: var(--white-75);
margin: 0.5em 0;
}
.ant-checkbox-group {
.ant-checkbox-group-item {
display: block;
}
}

View File

@ -8,11 +8,11 @@
}
.ant-table-row.hidden {
.ant-table-cell {
color: rgba(0,0,0,.25)
color: var(--black-35)
}
@media (prefers-color-scheme: dark) {
.ant-table-cell {
color: rgba(255,255,255,.25)
color: var(--white-25);
}
}
}
@ -47,29 +47,21 @@
.bulk-editor {
margin: .5rem 0;
padding: .5rem;
border: 1px solid #ccc;
border: 1px solid var(--textfield-border);
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
border-radius: 4px;
opacity: .5;
&.active {
opacity: 1;
.label {
color: #000;
}
@media (prefers-color-scheme: dark) {
.label {
color: #fff;
}
color: var(--black);
}
}
.label {
font-size: .75rem;
color: #666;
color: var(--white-50);
margin-right: .5rem;
}
@ -112,11 +104,6 @@
}
}
.ant-btn-text:hover {
background-color: rgba(0,0,0,.1)
}
@media (prefers-color-scheme: dark) {
.ant-btn-text:hover {
background-color: rgba(255,255,255,.3)
}
background-color: var(--black-35)
}
}

View File

@ -1,12 +0,0 @@
:root {
--owncast-purple: rgba(90,103,216,1);
--owncast-purple-highlight: #ccd;
--online-color: #73dd3f;
--ant-error: #ff4d4f;
--ant-success: #52c41a;
--ant-warning: #faad14;
}

View File

@ -0,0 +1,76 @@
// dealiing with some general layout on the public details page
.config-public-details-page {
width: 100%;
.top-container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
@media (max-width: 1200px) {
flex-wrap: wrap;
.social-items-container {
display: flex;
flex-direction: row;
justify-content: space-around;
flex-wrap: nowrap;
margin: 1em 0;
width: 100%;
max-width: none;
.tags-module {
margin-right: 1em;
}
.form-module {
max-width: none;
}
@media (max-width: 980px) {
flex-direction: column;
.form-module {
width: 100%;
}
.tags-module {
margin-bottom: 0;
}
}
}
}
}
.instance-details-container {
width: 100%;
.logo-preview {
display: inline-block;
margin: -1em 0 1em 11em;
height: 120px;
border: 1px solid var(--white-25);
}
}
.social-items-container {
background-color: var(--container-bg-color-alt);
padding: 0 0.75em;
margin-left: 1em;
max-width: 450px;
.form-module {
background-color: var(--black);
}
.social-handles-container {
min-width: 350px;
}
}
.instance-details-container,
.page-content-module {
margin: 1em 0;
}
.field-summary {
textarea {
height: 6em !important;
}
}
}
.other-field-container {
margin: 0.5em 0;
}

View File

@ -0,0 +1,57 @@
// styles for social handles editing section
.social-option,
.social-dropdown {
.ant-select-item-option-content,
.ant-select-selection-item {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
padding: 0.25em;
line-height: normal;
.option-icon {
height: 1.5em;
width: 1.5em;
line-height: normal;
}
.option-label {
display: inline-block;
margin-left: 1em;
line-height: normal;
}
}
}
.social-links-edit-container {
.social-handles-table {
.social-handle-cell {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
color: var(--white-75);
.option-icon {
height: 2em;
width: 2em;
line-height: normal;
}
.option-label {
display: flex;
flex-direction: column;
margin: 0 0 0 1em;
line-height: 2;
font-size: 0.85em;
}
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-evenly;
width: 6em;
}
}
}

View File

@ -0,0 +1,58 @@
// styles for Storage config section
.edit-storage-container {
padding: 1em;
.form-fields {
display: none;
margin-bottom: 1em;
}
&.enabled {
.form-fields {
display: block;
}
}
.button-container {
margin: 2em 0 1em 0;
}
.advanced-section {
margin: 1em 0;
}
}
.edit-server-details-container {
// Do something special for the stream key field
.field-streamkey-container {
margin-bottom: 1.5em;
.field-tip {
color: var(--ant-warning);
}
.left-side {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.textfield-with-submit-container {
margin-bottom: 0;
}
.streamkey-actions {
white-space: nowrap;
button {
margin: .25em;
}
@media (max-width: 800px) {
margin-top: 2em;
}
}
}
.advanced-settings {
max-width: 800px;
}
}

View File

@ -0,0 +1,39 @@
// config tags block
.tag-current-tags {
.ant-tag {
margin: .1rem;
font-size: .85rem;
border-radius: 10em;
padding: .25em 1em;
&:hover {
opacity: 1;
}
.ant-tag-close-icon {
transform: translateY(-1px);
margin-left: .3rem;
padding: 2px;
border-radius: 5rem;
color: var(--black);
border: 1px solid var(--black);
transition-duration: var(--ant-transition-duration);
&:hover {
border-color: var(--owncast-purple);
background-color: var(--white);
svg {
fill: black;
transition: fill var(--ant-transition-duration);
}
}
}
}
}
.add-new-tag-section {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
margin-top: 2em;
}

View File

@ -0,0 +1,43 @@
// styles for Video variant editor (table + modal)
.config-video-variants {
.variants-table {
margin-top: 2em;
}
.variants-table-module {
min-width: 400px;
}
}
// modal content
.config-variant-form {
.description {
margin-top: 0;
}
.cpu-usage-container,
.bitrate-container {
height: 20em;
}
.advanced-settings {
margin-top: 1em;
}
}
.variants-table {
.actions {
display: flex;
align-items: center;
justify-content: center;
}
.delete-button {
margin-left: 0.5em;
opacity: 0.8;
}
}
.read-more-subtext {
font-size: 0.8rem;
}

View File

@ -0,0 +1,79 @@
/* Base styles for misc helper components around forms */
/* STATUS-CONTAINER BASE */
.status-container {
&.status-success {
color: var(--ant-success);
}
&.status-error {
color: var(--ant-error);
}
&.status-warning {
color: var(--ant-warning);
}
&.empty {
display: none;
}
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
font-size: .75rem;
.status-icon {
display: inline-block;
margin-right: .5em;
}
}
/* TIP CONTAINER BASE */
.field-tip {
font-size: .8em;
color: var(--white-50);
}
/*
Ideal for wrapping each Textfield on a page with many text fields in a row. This div will alternate colors and look like a table.
*/
.field-container {
padding: .85em 0 .5em;
}
/* SEGMENT SLIDER GROUP WITH SELECTED NOTE, OR STATUS */
.segment-slider-container {
width: 100%;
margin: auto;
padding: 1em 2em .75em;
background-color: var(--owncast-purple-25);
border-radius: var(--container-border-radius);
.status-container {
width: 100%;
margin: .5em auto;
text-align: center;
}
.selected-value-note {
width: 100%;
margin: 3em auto 0;
text-align: center;
font-size: .75em;
line-height: normal;
color: var(--white);
padding: 1em;
border-radius: var(--container-border-radius);
background-color: var(--black-35);
}
}
.segment-tip {
width: 10em;
text-align: center;
margin: auto;
display: inline-block;
}

View File

@ -0,0 +1,169 @@
/*
Base styles for
- form-textfield,
- form-textfield-with-submit
- form-toggleswitch
Both text and toggle use this class for base layout.
*/
.formfield-container {
--form-label-container-width: 15em;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
max-width: 600px;
.label-side {
padding-right: 1.25em;
text-align: right;
width: var(--form-label-container-width);
margin: .2em 0;
}
.formfield-label {
font-weight: 500;
font-size: 1em;
color: var(--owncast-purple);
&::after {
content: ':';
}
}
&.required {
.formfield-label {
&::before {
content: '*';
display: inline-block;
margin-right: .25em;
color: var(--ant-error);
}
}
}
.input-side {
max-width: 500px;
width: 100%;
}
.input-group {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.status-container {
margin: .25em;
width: 100%;
display: block;
&.empty {
display: none;
visibility: visible;
}
}
.field-tip {
margin: .5em .5em;
}
@media (max-width: 800px) {
flex-wrap: wrap;
.label-side {
width: 100%;
text-align: left;
}
}
}
/* TEXTFIELD-WITH-SUBMIT-CONTAINER BASE */
/* TEXTFIELD-WITH-SUBMIT-CONTAINER BASE */
/* TEXTFIELD-WITH-SUBMIT-CONTAINER BASE */
.textfield-with-submit-container {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 1em;
.textfield-component {
width: 100%;
.textfield-container {
.field-tip,
.status-container {
display: none;
}
}
}
// for lack of a better name
.lower-container {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
.label-spacer {
width: var(--form-label-container-width);
}
.lower-content {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-end;
width: 100%;
.field-tip {
margin-right: 1em;
width: 100%;
}
.status-container {
margin: .5em;
&.empty {
display: none;
}
}
}
.update-button-container {
visibility: hidden;
margin: .25em 0;
}
}
&.submittable {
.lower-container {
.update-button-container {
visibility: visible;
}
}
}
@media (max-width: 800px) {
.label-spacer {
display: none;
}
}
}
/* TOGGLE SWITCH CONTAINER BASE */
.toggleswitch-container {
margin: 2em 0 1em;
.label-side {
margin-top: 0;
}
.input-group {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
.status-container {
width: auto;
margin: 0 0 0 1em;
display: inline-block;
}
}
}

View File

@ -1,4 +1,4 @@
$owncast-purple: rgba(90,103,216,1);
// @import "~antd/dist/antd.dark";
html,
body {
@ -7,42 +7,89 @@ body {
font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
font-size: 16px;
background-color: var(--default-bg-color);
color: var(--default-text-color);
}
a {
color: inherit;
text-decoration: none;
color: rgba(90,103,216,1);
color: var(--default-link-color);
&:hover {
color: var(--default-text-color);
}
}
* {
box-sizing: border-box;
}
p,
p.description,
.ant-typography {
font-weight: 300;
margin: 1em 0;
color: var(--white-75);
}
pre {
display: block;
padding: 1rem;
margin: .5rem 0;
background-color: #eee;
background-color: var(--code-bg-color);
color: var(--white-50);
}
.owncast-layout .ant-menu-dark.ant-menu-dark:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: $owncast-purple;
code {
color: var(--code-color);
background-color: var(--white-15);
display: inline-block;
padding: 2px 4px;
border-radius: 4px;
font-size: .88em;
}
// misc system overrides
.ant-card {
border-radius: .5em;
}
.ant-btn {
transition-duration: .15s;
transition-delay: 0s;
.logo-svg {
height: 2rem;
width: 2rem;
}
@media (prefers-color-scheme: dark) {
@import "~antd/dist/antd.dark";
pre {
background-color: rgb(44, 44, 44);
color:lightgrey;
.line-chart-container {
margin: 2em auto;
padding: 1em;
border: 1px solid var(--gray-dark);
}
.form-module {
margin: 1em 0;
background-color: var(--container-bg-color);
padding: 2em;
border-radius: var(--container-border-radius);
h3 {
&:first-of-type {
margin-top: 0;
}
}
}
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
@media (max-width: 980px) {
flex-direction: column;
.form-module {
width: 100%;
}
}
}

View File

@ -1,137 +1,51 @@
.home-container {
max-width: 1000px;
.statistics-list {
li {
margin-left: -.5em;
}
}
.section {
margin: 1rem 0;
.ant-statistic-content {
font-size: 1rem;
}
}
.online-status-section {
> .ant-card {
box-shadow: 0px 1px 10px 2px rgba(0, 22, 40, 0.1);
margin-bottom: 1em;
.online-details-card {
border-color: var(--online-color);
}
.ant-card-head {
background-color: #40b246;
border-color: #ccc;
color:#fff;
@media (prefers-color-scheme: dark) {
background-color: #2a762e;
border-bottom-color: black;
}
}
.ant-card-head-title {
font-size: .88rem;
.ant-statistic {
text-align: center;
}
.ant-statistic-title {
font-size: .88rem;
}
.ant-card-body {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
.ant-statistic {
width: 30%;
text-align: center;
margin: 0 1rem;
}
color: var(--white-50);
}
}
.ant-card-head {
color: var(--online-color);
}
.stream-details-section {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
width: 100%;
.details {
width: 49%;
> .ant-card {
margin-bottom: 1rem;
}
.ant-card-head {
background-color: #ccd;
color: black;
@media (prefers-color-scheme: dark) {
background-color: #000;
color: #ccd;
}
}
.stream-details-item-container {
margin: 1em 0;
&:first-of-type {
margin-top: 0;
}
.server-detail {
.ant-card-body {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
.ant-card {
width: 45%;
text-align: center;
}
}
.ant-card-head {
background-color: #669;
color: #fff;
}
}
.ant-statistic.stream-details-item {
background-color: var(--black-50);
padding: 1em;
.ant-statistic-title {
color: var(--blue);
}
.ant-statistic-content {
font-size: 1.25em;
white-space: nowrap;
}
}
@media (max-width: 800px) {
.online-status-section{
.ant-card-body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
.ant-statistic {
width: auto;
text-align: left;
margin: 1em;
}
}
}
.stream-details-section {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
width: 100%;
.details {
width: 100%;
}
.outbound-details,
.inbound-details {
> .ant-card-bordered {
border-color: rgba(255, 255, 255, 0.1);
}
}
}
.offline-content {
max-width: 1000px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
width: 100%;
.logo-section {
width: 50%;
.ant-result-title {
font-size: 2rem;
}
@ -144,36 +58,20 @@
}
}
.list-section {
width: 50%;
background-color: var(--container-bg-color-alt);
border-radius: var(--container-border-radius);
padding: 1em;
> .ant-card {
margin-bottom: 1rem;
.ant-card-head {
background-color: #dde;
}
.ant-card-head-title {
font-size: 1rem;
}
background-color: var(--black);
margin-bottom: 1em;
.ant-card-meta-avatar {
margin-top: .25rem;
margin-top: 0.25rem;
svg {
height: 1.25rem;
width: 1.25rem;
height: 1.5em;
width: 1.5em;
}
}
.ant-card-body {
font-size: .88rem;
}
}
}
@media (max-width: 800px) {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
.logo-section,
.list-section {
width: 100%
}
}
}

155
web/styles/main-layout.scss Normal file
View File

@ -0,0 +1,155 @@
.app-container {
.side-nav {
position: fixed;
height: 100vh;
overflow: auto;
z-index: 10;
background-color: var(--nav-bg-color);
}
.menu-container {
border-color: transparent;
}
h1.owncast-title {
padding: 1rem;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
.logo-container {
background-color: var(--white);
padding: 0.35rem;
border-radius: 9999px;
}
.title-label {
display: inline-block;
margin-left: 1rem;
color: var(--white);
font-size: 1.15rem;
font-weight: 200;
text-transform: uppercase;
line-height: normal;
letter-spacing: 0.05em;
}
}
.layout-main {
margin-left: 240px; // width of Ant Sider
}
.layout-header {
display: flex;
flex-direction: row;
justify-content: flex-end;
padding-right: 1rem;
background-color: var(--nav-bg-color);
}
.main-content-container {
padding: 2em 3em 3em;
max-width: 1024px;
min-width: 50vw;
margin: auto;
}
.footer-container {
text-align: center;
}
.online-status-indicator {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
.online-thumbnail {
width: 12.5rem;
}
.status-label {
color: var(--white);
text-transform: uppercase;
font-size: 0.75rem;
display: inline-block;
margin-right: 0.5rem;
color: var(--offline-color);
}
.status-icon {
font-size: 1.5rem;
svg {
fill: var(--offline-color);
}
}
}
&.online {
.online-status-indicator {
.status-icon {
svg {
fill: var(--online-color);
}
}
.status-label {
white-space: nowrap;
color: var(--online-color);
}
}
}
}
// stream title form field in header
.global-stream-title-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
.textfield-with-submit-container {
flex-direction: row;
justify-content: center;
align-items: center;
margin-bottom: 0;
.ant-input-affix-wrapper {
border-color: var(--owncast-purple-50);
}
input.ant-input {
&::placeholder {
color: var(--owncast-purple);
text-align: center;
}
}
.input-side {
width: 400px;
@media (max-width: 800px) {
width: auto;
}
}
.label-side {
display: none;
}
.lower-container {
width: auto;
.lower-content {
flex-direction: column-reverse;
position: relative;
}
.label-spacer,
.field-tip {
display: none;
}
.status-container {
line-height: 1;
position: absolute;
bottom: -2em;
}
.update-button-container {
margin: 0;
margin-left: 0.5em;
line-height: 1;
}
}
}
}

View File

@ -0,0 +1,71 @@
// markdown editor overrides
.rc-virtual-list-scrollbar {
display: block !important;
}
.rc-md-editor {
border-color: var(--black) !important;
border: 1px solid var(--black);
background-color: var(--black) !important;
.rc-md-navigation {
background-color: var(--black);
border-color: var(--black);
}
// Set the background color of the preview container
.editor-container {
p {
color: var(--black-75);
}
background-color: rgba(226, 232, 240, 1) !important;
.sec-html {
background-color: white;
pre,
code {
background-color: #eee;
color: #900;
}
}
}
// Custom CSS for formatting the preview text
.markdown-editor-preview-pane {
color: var(--black-75);
a {
color: var(--owncast-purple);
}
h1 {
font-size: 2em;
}
}
// Custom CSS class used to format the text of the editor
.markdown-editor-pane {
color: rgba(255, 255, 255, 0.85) !important;
border-color: black !important;
background-color: black;
font-family: monospace;
}
// Set the background color of the editor text input
textarea {
background-color: var(--gray) !important;
color: rgba(255, 255, 255, 0.5) !important;
overflow: auto;
}
// Hide extra toolbar buttons.
.button-type-undo,
.button-type-redo,
.button-type-clear,
.button-type-image,
.button-type-wrap,
.button-type-quote,
.button-type-strikethrough,
.button-type-code-inline,
.button-type-code-block {
display: none !important;
}
}

20
web/styles/pages.scss Normal file
View File

@ -0,0 +1,20 @@
// misc styling for various /pages
// .help-page {
// .ant-result-image {
// height: 100px;
// svg {
// height: 100%;
// width: 100%;
// }
// }
// }
.upgrade-page {
h2,h3 {
color: var(--pink);
font-size: 1.25em;
}
}

View File

@ -1,88 +0,0 @@
.logoSVG {
height: 2rem;
width: 2rem;
}
.owncastTitleContainer {
padding: 1rem;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.logoContainer {
background-color: #fff;
padding: .35rem;
border-radius: 9999px;
}
.owncastTitle {
display: inline-block;
margin-left: 1rem;
color: rgba(203,213,224, 1);
font-size: 1.15rem;
font-weight: 200;
text-transform: uppercase;
line-height: normal;
letter-spacing: .05em;
}
.contentMain {
padding: 3em;
}
.header {
display: flex;
flex-direction: row;
justify-content: flex-end;
padding-right: 1rem;
}
.sideNav {
position: fixed;
height: 100vh;
overflow: auto;
z-index: 10;
}
.layoutMain {
margin-left: 240px;
}
.statusIndicatorContainer {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.statusIcon {
font-size: 1.5rem;
}
.statusIcon svg {
fill: #999;
}
.statusLabel {
color: #fff;
text-transform: uppercase;
font-size: .75rem;
display: inline-block;
margin-right: .5rem;
color: #999;
}
.online .statusIcon svg {
fill: var(--online-color)
}
.online .statusLabel {
color: var(--online-color)
}
.lineChartContainer {
margin: 2em auto;
}
.configSection {
margin-bottom: 2em;
}

61
web/styles/variables.scss Normal file
View File

@ -0,0 +1,61 @@
:root {
// colors
--white: rgba(255,255,255,1);
--white-15: rgba(255,255,255,.15);
--white-25: rgba(255,255,255,.25);
--white-35: rgba(255,255,255,.35);
--white-50: rgba(255,255,255,.5);
--white-75: rgba(255,255,255,.75);
--white-88: rgba(255,255,255,.88);
--black: rgba(0,0,0,1);
--black-35: rgba(0,0,0,.35);
--black-50: rgba(0,0,0,.5);
--black-75: rgba(0,0,0,.75);
// owncast logo color family
--owncast-purple: rgba(120,113,255,1); // #7871FF;
--purple-dark: rgba(28,26,59,1); // #1c1a3b;//
--pink: rgba(201,139,254,1); // #D18BFE;
--blue: rgba(32,134,225,1); // #2086E1;
// owncast puprple variations
--owncast-purple-25: rgba(120,113,255,.25);
--owncast-purple-50: rgba(120,113,255,.5);
--gray-light: rgba(168,175,197,1);
--gray-medium: rgba(102,107,120,1);
--gray: rgba(51,53,60,1);
--gray-dark: rgba(23,24,27,1); // #17181b;
--online-color: #73dd3f;
--offline-color: #999;
--ant-error: #ff4d4f;
--ant-success: #52c41a;
--ant-warning: #faad14;
--ant-transition-duration: .15s;
// ////////////////////////////////
--default-text-color: var(--white-88);
--default-bg-color: var(--black);
--default-link-color: var(--owncast-purple);
--container-bg-color: var(--gray-dark);
--container-bg-color-alt: var(--purple-dark);
--container-border-radius: 4px;
--code-color: #9cdcfe;
--code-bg-color: var(--owncast-purple-25);
--nav-bg-color: var(--gray-dark);
--nav-text: #aaa;
--nav-selected-text: var(--pink); //#cd7cff;
--button-focused: var(--owncast-purple-50);
--textfield-border: var(--white-25);;
--textfield-bg: var(--black);
}

View File

@ -0,0 +1,89 @@
// TS types for elements on the Config pages
// for dropdown
export interface SocialHandleDropdownItem {
icon: string;
platform: string;
key: string;
}
export type FieldUpdaterFunc = (args: UpdateArgs) => void;
export interface UpdateArgs {
value: any;
fieldName?: string;
path?: string;
}
export interface ApiPostArgs {
apiPath: string;
data: object;
onSuccess?: (arg: any) => void;
onError?: (arg: any) => void;
}
export interface ConfigDirectoryFields {
enabled: boolean;
instanceUrl: string;
}
export interface ConfigInstanceDetailsFields {
extraPageContent: string;
logo: string;
name: string;
nsfw: boolean;
socialHandles: SocialHandle[];
streamTitle: string;
summary: string;
tags: string[];
title: string;
}
export type CpuUsageLevel = 1 | 2 | 3 | 4 | 5;
// from data
export interface SocialHandle {
platform: string;
url: string;
}
export interface VideoVariant {
key?: number; // unique identifier generated on client side just for ant table rendering
cpuUsageLevel: CpuUsageLevel;
framerate: number;
audioPassthrough: boolean;
audioBitrate: number;
videoPassthrough: boolean;
videoBitrate: number;
scaledWidth: number;
scaledHeight: number;
}
export interface VideoSettingsFields {
latencyLevel: number;
videoQualityVariants: VideoVariant[];
cpuUsageLevel: CpuUsageLevel;
}
export interface S3Field {
acl?: string;
accessKey: string;
bucket: string;
enabled: boolean;
endpoint: string;
region: string;
secret: string;
servingEndpoint?: string;
}
export interface ConfigDetails {
ffmpegPath: string;
instanceDetails: ConfigInstanceDetailsFields;
rtmpServerPort: string;
s3: S3Field;
streamKey: string;
webServerPort: string;
yp: ConfigDirectoryFields;
videoSettings: VideoSettingsFields;
}

View File

@ -0,0 +1,26 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
export const AlertMessageContext = React.createContext({
message: null,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
setMessage: (text?: string) => null,
});
const AlertMessageProvider = ({ children }) => {
const [message, setMessage] = useState('');
const providerValue = {
message,
setMessage,
};
return (
<AlertMessageContext.Provider value={providerValue}>{children}</AlertMessageContext.Provider>
);
};
AlertMessageProvider.propTypes = {
children: PropTypes.element.isRequired,
};
export default AlertMessageProvider;

View File

@ -1,7 +1,7 @@
/* eslint-disable prefer-destructuring */
const ADMIN_USERNAME = process.env.NEXT_PUBLIC_ADMIN_USERNAME;
const ADMIN_STREAMKEY = process.env.NEXT_PUBLIC_ADMIN_STREAMKEY;
const NEXT_PUBLIC_API_HOST = process.env.NEXT_PUBLIC_API_HOST;
export const NEXT_PUBLIC_API_HOST = process.env.NEXT_PUBLIC_API_HOST;
const API_LOCATION = `${NEXT_PUBLIC_API_HOST}api/admin/`;
@ -19,6 +19,9 @@ export const STREAMKEY_CHANGE = `${API_LOCATION}changekey`;
// Current server config
export const SERVER_CONFIG = `${API_LOCATION}serverconfig`;
// Base url to update config settings
export const SERVER_CONFIG_UPDATE_URL = `${API_LOCATION}config`;
// Get viewer count over time
export const VIEWERS_OVER_TIME = `${API_LOCATION}viewersOverTime`;
@ -40,49 +43,71 @@ export const CHAT_HISTORY = `${API_LOCATION}chat/messages`;
// Get chat history
export const UPDATE_CHAT_MESSGAE_VIZ = `${NEXT_PUBLIC_API_HOST}api/admin/chat/updatemessagevisibility`;
// Get all access tokens
export const ACCESS_TOKENS = `${API_LOCATION}accesstokens`;
const GITHUB_RELEASE_URL = "https://api.github.com/repos/owncast/owncast/releases/latest";
// Delete a single access token
export const DELETE_ACCESS_TOKEN = `${API_LOCATION}accesstokens/delete`;
// Create a new access token
export const CREATE_ACCESS_TOKEN = `${API_LOCATION}accesstokens/create`;
// Get webhooks
export const WEBHOOKS = `${API_LOCATION}webhooks`;
// Delete a single webhook
export const DELETE_WEBHOOK = `${API_LOCATION}webhooks/delete`;
// Create a single webhook
export const CREATE_WEBHOOK = `${API_LOCATION}webhooks/create`;
// hard coded social icons list
export const SOCIAL_PLATFORMS_LIST = `${NEXT_PUBLIC_API_HOST}api/socialplatforms`;
export const API_YP_RESET = `${API_LOCATION}yp/reset`;
export const TEMP_UPDATER_API = LOGS_ALL;
const GITHUB_RELEASE_URL = 'https://api.github.com/repos/owncast/owncast/releases/latest';
interface FetchOptions {
data?: any;
method?: string;
auth?: boolean;
};
}
export async function fetchData(url: string, options?: FetchOptions) {
const {
data,
method = 'GET',
auth = true,
} = options || {};
const { data, method = 'GET', auth = true } = options || {};
const requestOptions: RequestInit = {
method,
};
if (data) {
requestOptions.body = JSON.stringify(data)
requestOptions.body = JSON.stringify(data);
}
if (auth && ADMIN_USERNAME && ADMIN_STREAMKEY) {
const encoded = btoa(`${ADMIN_USERNAME}:${ADMIN_STREAMKEY}`);
requestOptions.headers = {
'Authorization': `Basic ${encoded}`
}
Authorization: `Basic ${encoded}`,
};
requestOptions.mode = 'cors';
requestOptions.credentials = 'include';
}
try {
const response = await fetch(url, requestOptions);
const json = await response.json();
if (!response.ok) {
const message = `An error has occured: ${response.status}`;
const message = json.message || `An error has occurred: ${response.status}`;
throw new Error(message);
}
const json = await response.json();
return json;
} catch (error) {
console.log(error)
return error;
// console.log(error)
// throw new Error(error)
}
return {};
}
@ -102,6 +127,30 @@ export async function getGithubRelease() {
return {};
}
// Stolen from https://gist.github.com/prenagha/98bbb03e27163bc2f5e4
const VPAT = /^\d+(\.\d+){0,2}$/;
function upToDate(local, remote) {
if (!local || !remote || local.length === 0 || remote.length === 0) return false;
if (local === remote) return true;
if (VPAT.test(local) && VPAT.test(remote)) {
const lparts = local.split('.');
while (lparts.length < 3) lparts.push('0');
const rparts = remote.split('.');
while (rparts.length < 3) rparts.push('0');
// eslint-disable-next-line no-plusplus
for (let i = 0; i < 3; i++) {
const l = parseInt(lparts[i], 10);
const r = parseInt(rparts[i], 10);
if (l === r)
// eslint-disable-next-line no-continue
continue;
return l > r;
}
return true;
}
return local >= remote;
}
// Make a request to the server status API and the Github releases API
// and return a release if it's newer than the server version.
export async function upgradeVersionAvailable(currentVersion) {
@ -109,41 +158,12 @@ export async function upgradeVersionAvailable(currentVersion) {
let recentReleaseVersion = recentRelease.tag_name;
if (recentReleaseVersion.substr(0, 1) === 'v') {
recentReleaseVersion = recentReleaseVersion.substr(1)
recentReleaseVersion = recentReleaseVersion.substr(1);
}
if (!upToDate(currentVersion, recentReleaseVersion)) {
return recentReleaseVersion
return recentReleaseVersion;
}
return null;
}
// Stolen from https://gist.github.com/prenagha/98bbb03e27163bc2f5e4
const VPAT = /^\d+(\.\d+){0,2}$/;
function upToDate(local, remote) {
if (!local || !remote || local.length === 0 || remote.length === 0)
return false;
if (local === remote)
return true;
if (VPAT.test(local) && VPAT.test(remote)) {
const lparts = local.split('.');
while(lparts.length < 3)
lparts.push("0");
const rparts = remote.split('.');
while (rparts.length < 3)
rparts.push("0");
// eslint-disable-next-line no-plusplus
for (let i=0; i<3; i++) {
const l = parseInt(lparts[i], 10);
const r = parseInt(rparts[i], 10);
if (l === r)
// eslint-disable-next-line no-continue
continue;
return l > r;
}
return true;
}
return local >= remote;
}

View File

@ -0,0 +1,224 @@
// DEFAULT VALUES
import { fetchData, SERVER_CONFIG_UPDATE_URL } from './apis';
import { ApiPostArgs, VideoVariant, SocialHandle } from '../types/config-section';
export const TEXT_MAXLENGTH = 255;
export const RESET_TIMEOUT = 3000;
// 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:
'Name of your logo in the data directory. We recommend that you use a square image that is at least 256x256.',
};
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: 'Server 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. Please respectfully set this flag so unexpected eyes won't accidentally see it in the Directory.",
};
export const FIELD_PROPS_YP = {
apiPath: API_YP_SWITCH,
configPath: 'yp',
label: 'Enable directory',
tip:
'Turn this ON if you want to show up in the directory.',
};
export const DEFAULT_VARIANT_STATE: VideoVariant = {
framerate: 24,
videoPassthrough: false,
videoBitrate: 800,
audioPassthrough: true, // if false, then CAN set audiobitrate
audioBitrate: 0,
cpuUsageLevel: 3,
scaledHeight: null,
scaledWidth: null,
};
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.',
},
};

View File

@ -0,0 +1,68 @@
import {
CheckCircleFilled,
ExclamationCircleFilled,
LoadingOutlined,
WarningOutlined,
} from '@ant-design/icons';
export const STATUS_RESET_TIMEOUT = 3000;
export const STATUS_ERROR = 'error';
export const STATUS_INVALID = 'invalid';
export const STATUS_PROCESSING = 'proessing';
export const STATUS_SUCCESS = 'success';
export const STATUS_WARNING = 'warning';
export type InputStatusTypes = 'error' | 'invalid' | 'proessing' | 'success' | 'warning';
export interface StatusState {
type: InputStatusTypes;
icon: any; // Element type of sorts?
message: string;
}
interface InputStates {
[key: string]: StatusState;
}
export const INPUT_STATES: InputStates = {
[STATUS_SUCCESS]: {
type: STATUS_SUCCESS,
icon: <CheckCircleFilled style={{ color: 'green' }} />,
message: 'Success!',
},
[STATUS_ERROR]: {
type: STATUS_ERROR,
icon: <ExclamationCircleFilled style={{ color: 'red' }} />,
message: 'An error occurred.',
},
[STATUS_INVALID]: {
type: STATUS_INVALID,
icon: <ExclamationCircleFilled style={{ color: 'red' }} />,
message: 'An error occurred.',
},
[STATUS_PROCESSING]: {
type: STATUS_PROCESSING,
icon: <LoadingOutlined />,
message: '',
},
[STATUS_WARNING]: {
type: STATUS_WARNING,
icon: <WarningOutlined style={{ color: '#fc0' }} />,
message: '',
},
};
// Don't like any of the default messages in INPUT_STATES? Create a state with custom message by providing an icon style with your message.
export function createInputStatus(type: InputStatusTypes, message?: string): StatusState {
if (!type || !INPUT_STATES[type]) {
return null;
}
if (!message) {
return INPUT_STATES[type];
}
return {
type,
icon: INPUT_STATES[type].icon,
message,
};
}

View File

@ -1,41 +1,68 @@
// TODO: add a notication after updating info that changes will take place either on a new stream or server restart. may be different for each field.
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { STATUS, fetchData, FETCH_INTERVAL, SERVER_CONFIG } from './apis';
import { ConfigDetails, UpdateArgs } from '../types/config-section';
import { DEFAULT_VARIANT_STATE } from './config-constants';
export const initialServerConfigState = {
export const initialServerConfigState: ConfigDetails = {
streamKey: '',
instanceDetails: {
extraPageContent: '',
logo: '',
name: '',
nsfw: false,
socialHandles: [],
streamTitle: '',
summary: '',
tags: [],
title: '',
},
ffmpegPath: '',
rtmpServerPort: '',
webServerPort: '',
s3: {
accessKey: '',
acl: '',
bucket: '',
enabled: false,
endpoint: '',
region: '',
secret: '',
servingEndpoint: '',
},
yp: {
enabled: false,
instanceUrl: '',
},
videoSettings: {
videoQualityVariants: [
{
audioPassthrough: false,
videoPassthrough: false,
videoBitrate: 0,
audioBitrate: 0,
framerate: 0,
},
],
}
latencyLevel: 4,
cpuUsageLevel: 3,
videoQualityVariants: [DEFAULT_VARIANT_STATE],
},
};
const initialServerStatusState = {
broadcastActive: false,
broadcaster: null,
currentBroadcast: null,
online: false,
viewerCount: 0,
sessionMaxViewerCount: 0,
sessionPeakViewerCount: 0,
overallPeakViewerCount: 0,
disableUpgradeChecks: true,
versionNumber: '0.0.0',
streamTitle: '',
};
export const ServerStatusContext = React.createContext({
...initialServerStatusState,
serverConfig: initialServerConfigState,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
setFieldInConfigState: (args: UpdateArgs) => null,
});
const ServerStatusProvider = ({ children }) => {
@ -46,7 +73,6 @@ const ServerStatusProvider = ({ children }) => {
try {
const result = await fetchData(STATUS);
setStatus({ ...result });
} catch (error) {
// todo
}
@ -60,7 +86,22 @@ const ServerStatusProvider = ({ children }) => {
}
};
const setFieldInConfigState = ({ fieldName, value, path }: UpdateArgs) => {
const updatedConfig = path
? {
...config,
[path]: {
...config[path],
[fieldName]: value,
},
}
: {
...config,
[fieldName]: value,
};
setConfig(updatedConfig);
};
useEffect(() => {
let getStatusIntervalId = null;
@ -69,25 +110,25 @@ const ServerStatusProvider = ({ children }) => {
getConfig();
// returned function will be called on component unmount
// returned function will be called on component unmount
return () => {
clearInterval(getStatusIntervalId);
}
}, [])
};
}, []);
const providerValue = {
...status,
serverConfig: config,
...status,
serverConfig: config,
setFieldInConfigState,
};
return (
<ServerStatusContext.Provider value={providerValue}>
{children}
</ServerStatusContext.Provider>
<ServerStatusContext.Provider value={providerValue}>{children}</ServerStatusContext.Provider>
);
}
};
ServerStatusProvider.propTypes = {
children: PropTypes.element.isRequired,
};
export default ServerStatusProvider;
export default ServerStatusProvider;

14
web/utils/urls.ts Normal file
View File

@ -0,0 +1,14 @@
export default function isValidUrl(url: string): boolean {
const validProtocols = ['http:', 'https:'];
try {
const validationObject = new URL(url);
if (validationObject.protocol === '' || validationObject.hostname === '' || !validProtocols.includes(validationObject.protocol)) {
return false;
}
} catch(e) {
return false;
}
return true;
}