Reorganize admin components to help bundling
This commit is contained in:
88
web/components/admin/BanUserButton.tsx
Normal file
88
web/components/admin/BanUserButton.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Modal, Button } from 'antd';
|
||||
import { ExclamationCircleFilled, QuestionCircleFilled, StopTwoTone } from '@ant-design/icons';
|
||||
import { FC } from 'react';
|
||||
import { USER_ENABLED_TOGGLE, fetchData } from '../../utils/apis';
|
||||
import { User } from '../../types/chat';
|
||||
|
||||
export type BanUserButtonProps = {
|
||||
user: User;
|
||||
isEnabled: Boolean; // = this user's current status
|
||||
label?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const BanUserButton: FC<BanUserButtonProps> = ({ user, isEnabled, label, onClick }) => {
|
||||
async function buttonClicked({ id }): Promise<Boolean> {
|
||||
const data = {
|
||||
userId: id,
|
||||
enabled: !isEnabled, // set user to this value
|
||||
};
|
||||
try {
|
||||
const result = await fetchData(USER_ENABLED_TOGGLE, {
|
||||
data,
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
});
|
||||
return result.success;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const actionString = isEnabled ? 'ban' : 'unban';
|
||||
const icon = isEnabled ? (
|
||||
<ExclamationCircleFilled style={{ color: 'var(--ant-error)' }} />
|
||||
) : (
|
||||
<QuestionCircleFilled style={{ color: 'var(--ant-warning)' }} />
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
Are you sure you want to {actionString} <strong>{user.displayName}</strong>
|
||||
{isEnabled ? ' and remove their messages?' : '?'}
|
||||
</>
|
||||
);
|
||||
|
||||
const confirmBlockAction = () => {
|
||||
Modal.confirm({
|
||||
title: `Confirm ${actionString}`,
|
||||
content,
|
||||
onCancel: () => {},
|
||||
onOk: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const result = buttonClicked(user);
|
||||
if (result) {
|
||||
// wait a bit before closing so the user/client tables repopulate
|
||||
// GW: TODO: put users/clients data in global app context instead, then call a function here to update that state. (current in another branch)
|
||||
setTimeout(() => {
|
||||
resolve(result);
|
||||
onClick?.();
|
||||
}, 3000);
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
}),
|
||||
okType: 'danger',
|
||||
okText: isEnabled ? 'Absolutely' : null,
|
||||
icon,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={confirmBlockAction}
|
||||
size="small"
|
||||
icon={isEnabled ? <StopTwoTone twoToneColor="#ff4d4f" /> : null}
|
||||
className="block-user-button"
|
||||
>
|
||||
{label || actionString}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
BanUserButton.defaultProps = {
|
||||
label: '',
|
||||
onClick: null,
|
||||
};
|
||||
75
web/components/admin/BannedIPsTable.tsx
Normal file
75
web/components/admin/BannedIPsTable.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Table, Button } from 'antd';
|
||||
import format from 'date-fns/format';
|
||||
import { SortOrder } from 'antd/lib/table/interface';
|
||||
import React, { FC } from 'react';
|
||||
import { StopTwoTone } from '@ant-design/icons';
|
||||
import { User } from '../../types/chat';
|
||||
import { BANNED_IP_REMOVE, fetchData } from '../../utils/apis';
|
||||
|
||||
function formatDisplayDate(date: string | Date) {
|
||||
return format(new Date(date), 'MMM d H:mma');
|
||||
}
|
||||
|
||||
async function removeIPAddressBan(ipAddress: String) {
|
||||
try {
|
||||
await fetchData(BANNED_IP_REMOVE, {
|
||||
data: { value: ipAddress },
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export type UserTableProps = {
|
||||
data: User[];
|
||||
};
|
||||
|
||||
export const BannedIPsTable: FC<UserTableProps> = ({ data }) => {
|
||||
const columns = [
|
||||
{
|
||||
title: 'IP Address',
|
||||
dataIndex: 'ipAddress',
|
||||
key: 'ipAddress',
|
||||
},
|
||||
{
|
||||
title: 'Reason',
|
||||
dataIndex: 'notes',
|
||||
key: 'notes',
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: Date) => formatDisplayDate(date),
|
||||
sorter: (a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'block',
|
||||
className: 'actions-col',
|
||||
render: (_, ip) => (
|
||||
<Button
|
||||
title="Remove IP Address Ban"
|
||||
onClick={() => removeIPAddressBan(ip.ipAddress)}
|
||||
icon={<StopTwoTone twoToneColor="#ff4d4f" />}
|
||||
className="block-user-button"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
pagination={{ hideOnSinglePage: true }}
|
||||
className="table-container"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
size="large"
|
||||
rowKey="ipAddress"
|
||||
/>
|
||||
);
|
||||
};
|
||||
99
web/components/admin/Chart.tsx
Normal file
99
web/components/admin/Chart.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import ChartJs from 'chart.js/auto';
|
||||
import Chartkick from 'chartkick';
|
||||
import format from 'date-fns/format';
|
||||
import { LineChart } from 'react-chartkick';
|
||||
import { FC } from 'react';
|
||||
|
||||
// from https://github.com/ankane/chartkick.js/blob/master/chart.js/chart.esm.js
|
||||
Chartkick.use(ChartJs);
|
||||
|
||||
interface TimedValue {
|
||||
time: Date;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type ChartProps = {
|
||||
data?: TimedValue[];
|
||||
title?: string;
|
||||
color: string;
|
||||
unit: string;
|
||||
yFlipped?: boolean;
|
||||
yLogarithmic?: boolean;
|
||||
dataCollections?: any[];
|
||||
};
|
||||
|
||||
function createGraphDataset(dataArray) {
|
||||
const dataValues = {};
|
||||
dataArray.forEach(item => {
|
||||
const dateObject = new Date(item.time);
|
||||
const dateString = format(dateObject, 'H:mma');
|
||||
dataValues[dateString] = item.value;
|
||||
});
|
||||
return dataValues;
|
||||
}
|
||||
|
||||
export const Chart: FC<ChartProps> = ({
|
||||
data,
|
||||
title,
|
||||
color,
|
||||
unit,
|
||||
dataCollections,
|
||||
yFlipped,
|
||||
yLogarithmic,
|
||||
}) => {
|
||||
const renderData = [];
|
||||
|
||||
if (data && data.length > 0) {
|
||||
renderData.push({
|
||||
name: title,
|
||||
color,
|
||||
data: createGraphDataset(data),
|
||||
});
|
||||
}
|
||||
|
||||
dataCollections.forEach(collection => {
|
||||
renderData.push({
|
||||
name: collection.name,
|
||||
data: createGraphDataset(collection.data),
|
||||
color: collection.color,
|
||||
dataset: collection.options,
|
||||
});
|
||||
});
|
||||
|
||||
// ChartJs.defaults.scales.linear.reverse = true;
|
||||
|
||||
const options = {
|
||||
scales: {
|
||||
y: { reverse: false, type: 'linear' },
|
||||
x: {
|
||||
type: 'time',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
options.scales.y.reverse = yFlipped;
|
||||
options.scales.y.type = yLogarithmic ? 'logarithmic' : 'linear';
|
||||
|
||||
return (
|
||||
<div className="line-chart-container">
|
||||
<LineChart
|
||||
xtitle="Time"
|
||||
ytitle={title}
|
||||
suffix={unit}
|
||||
legend="bottom"
|
||||
color={color}
|
||||
data={renderData}
|
||||
download={title}
|
||||
library={options}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Chart.defaultProps = {
|
||||
dataCollections: [],
|
||||
data: [],
|
||||
title: '',
|
||||
yFlipped: false,
|
||||
yLogarithmic: false,
|
||||
};
|
||||
99
web/components/admin/ClientTable.tsx
Normal file
99
web/components/admin/ClientTable.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Input, Table } from 'antd';
|
||||
import { FilterDropdownProps, SortOrder } from 'antd/lib/table/interface';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { FC } from 'react';
|
||||
import { Client } from '../../types/chat';
|
||||
import { UserPopover } from './UserPopover';
|
||||
import { BanUserButton } from './BanUserButton';
|
||||
import { formatUAstring } from '../../utils/format';
|
||||
|
||||
export type ClientTableProps = {
|
||||
data: Client[];
|
||||
};
|
||||
|
||||
export const ClientTable: FC<ClientTableProps> = ({ data }) => {
|
||||
const columns: ColumnsType<Client> = [
|
||||
{
|
||||
title: 'Display Name',
|
||||
key: 'username',
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
render: (client: Client) => {
|
||||
const { user, connectedAt, messageCount, userAgent } = client;
|
||||
const connectionInfo = { connectedAt, messageCount, userAgent };
|
||||
return (
|
||||
<UserPopover user={user} connectionInfo={connectionInfo}>
|
||||
<span className="display-name">{user.displayName}</span>
|
||||
</UserPopover>
|
||||
);
|
||||
},
|
||||
sorter: (a: any, b: any) => b.user.displayName.localeCompare(a.user.displayName),
|
||||
filterIcon: <SearchOutlined />,
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm }: FilterDropdownProps) => (
|
||||
<div style={{ padding: 8 }}>
|
||||
<Input
|
||||
placeholder="Search display names..."
|
||||
value={selectedKeys[0]}
|
||||
onChange={e => {
|
||||
setSelectedKeys(e.target.value ? [e.target.value] : []);
|
||||
confirm({ closeDropdown: false });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
onFilter: (value: string, record: Client) => record.user.displayName.includes(value),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
},
|
||||
{
|
||||
title: 'Messages sent',
|
||||
dataIndex: 'messageCount',
|
||||
key: 'messageCount',
|
||||
className: 'number-col',
|
||||
width: '12%',
|
||||
sorter: (a: any, b: any) => a.messageCount - b.messageCount,
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
render: (count: number) => <div style={{ textAlign: 'center' }}>{count}</div>,
|
||||
},
|
||||
{
|
||||
title: 'Connected Time',
|
||||
dataIndex: 'connectedAt',
|
||||
key: 'connectedAt',
|
||||
defaultSortOrder: 'ascend',
|
||||
render: (time: Date) => formatDistanceToNow(new Date(time)),
|
||||
sorter: (a: any, b: any) =>
|
||||
new Date(b.connectedAt).getTime() - new Date(a.connectedAt).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
},
|
||||
{
|
||||
title: 'User Agent',
|
||||
dataIndex: 'userAgent',
|
||||
key: 'userAgent',
|
||||
render: (ua: string) => formatUAstring(ua),
|
||||
},
|
||||
{
|
||||
title: 'Location',
|
||||
dataIndex: 'geo',
|
||||
key: 'geo',
|
||||
render: geo => (geo ? `${geo.regionName}, ${geo.countryCode}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'block',
|
||||
className: 'actions-col',
|
||||
render: (_, row) => <BanUserButton user={row.user} isEnabled={!row.user.disabledAt} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="table-container"
|
||||
pagination={{ hideOnSinglePage: true }}
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
size="small"
|
||||
rowKey="id"
|
||||
/>
|
||||
);
|
||||
};
|
||||
174
web/components/admin/CodecSelector.tsx
Normal file
174
web/components/admin/CodecSelector.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Popconfirm, Select, Typography } from 'antd';
|
||||
import React, { FC, useContext, useEffect, useState } from 'react';
|
||||
import { AlertMessageContext } from '../../utils/alert-message-context';
|
||||
import {
|
||||
API_VIDEO_CODEC,
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
} from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { FormStatusIndicator } from './FormStatusIndicator';
|
||||
|
||||
export type CodecSelectorProps = {};
|
||||
|
||||
export const CodecSelector: FC<CodecSelectorProps> = () => {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const { videoCodec, supportedCodecs } = serverConfig || {};
|
||||
const { Title } = Typography;
|
||||
const { Option } = Select;
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
const { setMessage } = useContext(AlertMessageContext);
|
||||
const [selectedCodec, setSelectedCodec] = useState(videoCodec);
|
||||
const [pendingSaveCodec, setPendingSavecodec] = useState(videoCodec);
|
||||
const [confirmPopupOpen, setConfirmPopupOpen] = React.useState(false);
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCodec(videoCodec);
|
||||
}, [videoCodec]);
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
function handleChange(value) {
|
||||
setPendingSavecodec(value);
|
||||
setConfirmPopupOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSelectedCodec(pendingSaveCodec);
|
||||
setPendingSavecodec('');
|
||||
setConfirmPopupOpen(false);
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: API_VIDEO_CODEC,
|
||||
data: { value: pendingSaveCodec },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'videoCodec',
|
||||
value: pendingSaveCodec,
|
||||
path: 'videoSettings',
|
||||
});
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Video codec 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 items = supportedCodecs.map(codec => {
|
||||
let title = codec;
|
||||
if (title === 'libx264') {
|
||||
title = 'Default (libx264)';
|
||||
} else if (title === 'h264_nvenc') {
|
||||
title = 'NVIDIA GPU acceleration';
|
||||
} else if (title === 'h264_vaapi') {
|
||||
title = 'VA-API hardware encoding';
|
||||
} else if (title === 'h264_qsv') {
|
||||
title = 'Intel QuickSync';
|
||||
} else if (title === 'h264_v4l2m2m') {
|
||||
title = 'Video4Linux hardware encoding';
|
||||
} else if (title === 'h264_omx') {
|
||||
title = 'OpenMax (omx) for Raspberry Pi';
|
||||
} else if (title === 'h264_videotoolbox') {
|
||||
title = 'Apple VideoToolbox (hardware)';
|
||||
}
|
||||
|
||||
return (
|
||||
<Option key={codec} value={codec}>
|
||||
{title}
|
||||
</Option>
|
||||
);
|
||||
});
|
||||
|
||||
let description = '';
|
||||
if (selectedCodec === 'libx264') {
|
||||
description =
|
||||
'libx264 is the default codec and generally the only working choice for shared VPS environments. This is likely what you should be using unless you know you have set up other options.';
|
||||
} else if (selectedCodec === 'h264_nvenc') {
|
||||
description =
|
||||
'You can use your NVIDIA GPU for encoding if you have a modern NVIDIA card with encoding cores.';
|
||||
} else if (selectedCodec === 'h264_vaapi') {
|
||||
description =
|
||||
'VA-API may be supported by your NVIDIA proprietary drivers, Mesa open-source drivers for AMD or Intel graphics.';
|
||||
} else if (selectedCodec === 'h264_qsv') {
|
||||
description =
|
||||
"Quick Sync Video is Intel's brand for its dedicated video encoding and decoding hardware. It may be an option if you have a modern Intel CPU with integrated graphics.";
|
||||
} else if (selectedCodec === 'h264_v4l2m2m') {
|
||||
description =
|
||||
'Video4Linux is an interface to multiple different hardware encoding platforms such as Intel and AMD.';
|
||||
} else if (selectedCodec === 'h264_omx') {
|
||||
description = 'OpenMax is a codec most often used with a Raspberry Pi.';
|
||||
} else if (selectedCodec === 'h264_videotoolbox') {
|
||||
description =
|
||||
'Apple VideoToolbox is a low-level framework that provides direct access to hardware encoders and decoders.';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={3} className="section-title">
|
||||
Video Codec
|
||||
</Title>
|
||||
<div className="description">
|
||||
If you have access to specific hardware with the drivers and software installed for them,
|
||||
you may be able to improve your video encoding performance.
|
||||
<p>
|
||||
<a
|
||||
href="https://owncast.online/docs/codecs?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the documentation about this setting before changing it or you may make your stream
|
||||
unplayable.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="segment-slider-container">
|
||||
<Popconfirm
|
||||
title={`Are you sure you want to change your video codec to ${pendingSaveCodec} and understand what this means?`}
|
||||
open={confirmPopupOpen}
|
||||
placement="leftBottom"
|
||||
onConfirm={save}
|
||||
onCancel={() => setConfirmPopupOpen(false)}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
<Select
|
||||
defaultValue={selectedCodec}
|
||||
value={selectedCodec}
|
||||
style={{ width: '100%' }}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{items}
|
||||
</Select>
|
||||
</Popconfirm>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
|
||||
<p id="selected-codec-note" className="selected-value-note">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
82
web/components/admin/Color.tsx
Normal file
82
web/components/admin/Color.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { FC } from 'react';
|
||||
|
||||
export type ColorProps = {
|
||||
color: any; // TODO specify better type
|
||||
};
|
||||
|
||||
export const Color: FC<ColorProps> = ({ color }) => {
|
||||
const resolvedColor = getComputedStyle(document.documentElement).getPropertyValue(`--${color}`);
|
||||
|
||||
const containerStyle = {
|
||||
borderRadius: '20px',
|
||||
width: '12vw',
|
||||
height: '12vw',
|
||||
minWidth: '100px',
|
||||
minHeight: '100px',
|
||||
borderWidth: '1.5px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'lightgray',
|
||||
overflow: 'hidden',
|
||||
margin: '0.3vw',
|
||||
};
|
||||
|
||||
const colorBlockStyle = {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
textShadow: '0 0 15px black',
|
||||
height: '70%',
|
||||
width: '100%',
|
||||
backgroundColor: resolvedColor,
|
||||
};
|
||||
|
||||
const colorTextStyle = {
|
||||
color: 'white',
|
||||
alignText: 'center',
|
||||
};
|
||||
|
||||
const colorDescriptionStyle = {
|
||||
margin: '5px',
|
||||
color: 'gray',
|
||||
fontSize: '0.95vw',
|
||||
textAlign: 'center' as 'center',
|
||||
lineHeight: 1.0,
|
||||
};
|
||||
|
||||
return (
|
||||
<figure style={containerStyle}>
|
||||
<div style={colorBlockStyle}>
|
||||
<div style={colorTextStyle}>{resolvedColor}</div>
|
||||
</div>
|
||||
<figcaption style={colorDescriptionStyle}>{color}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
Color.propTypes = {
|
||||
color: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const rowStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'row' as 'row',
|
||||
flexWrap: 'wrap' as 'wrap',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
export const ColorRow = props => {
|
||||
const { colors } = props;
|
||||
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
{colors.map(color => (
|
||||
<Color key={color} color={color} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ColorRow.propTypes = {
|
||||
colors: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
83
web/components/admin/ComposeFederatedPost.tsx
Normal file
83
web/components/admin/ComposeFederatedPost.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
|
||||
import { Button, Input, Modal } from 'antd';
|
||||
import { STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
|
||||
import { fetchData, FEDERATION_MESSAGE_SEND } from '../../utils/apis';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export type ComposeFederatedPostProps = {
|
||||
open: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const ComposeFederatedPost: FC<ComposeFederatedPostProps> = ({ open, handleClose }) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [postPending, setPostPending] = useState(false);
|
||||
const [postSuccessState, setPostSuccessState] = useState(null);
|
||||
|
||||
function handleEditorChange(e) {
|
||||
setContent(e.target.value);
|
||||
}
|
||||
|
||||
function close() {
|
||||
setPostPending(false);
|
||||
setPostSuccessState(null);
|
||||
handleClose();
|
||||
}
|
||||
|
||||
async function sendButtonClicked() {
|
||||
setPostPending(true);
|
||||
|
||||
const data = {
|
||||
value: content,
|
||||
};
|
||||
try {
|
||||
await fetchData(FEDERATION_MESSAGE_SEND, {
|
||||
data,
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
});
|
||||
setPostSuccessState(STATUS_SUCCESS);
|
||||
setTimeout(close, 1000);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
setPostSuccessState(STATUS_ERROR);
|
||||
}
|
||||
setPostPending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
destroyOnClose
|
||||
width={600}
|
||||
title="Post to Followers"
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={[
|
||||
<Button onClick={() => handleClose()}>Cancel</Button>,
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={sendButtonClicked}
|
||||
disabled={postPending || postSuccessState}
|
||||
loading={postPending}
|
||||
>
|
||||
{postSuccessState?.toUpperCase() || 'Post'}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<h3>
|
||||
Tell the world about your future streaming plans or let your followers know to tune in.
|
||||
</h3>
|
||||
<TextArea
|
||||
placeholder="I'm still live, come join me!"
|
||||
size="large"
|
||||
showCount
|
||||
maxLength={500}
|
||||
style={{ height: '150px', width: '100%' }}
|
||||
onChange={handleEditorChange}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
246
web/components/admin/CurrentVariantsTable.tsx
Normal file
246
web/components/admin/CurrentVariantsTable.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
// Updating a variant will post ALL the variants in an array as an update to the API.
|
||||
|
||||
import React, { FC, useContext, useState } from 'react';
|
||||
import { Typography, Table, Modal, Button, Alert } 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 './VideoVariantForm';
|
||||
import {
|
||||
API_VIDEO_VARIANTS,
|
||||
DEFAULT_VARIANT_STATE,
|
||||
RESET_TIMEOUT,
|
||||
postConfigUpdateToAPI,
|
||||
ENCODER_PRESET_TOOLTIPS,
|
||||
ENCODER_RECOMMENDATION_THRESHOLD,
|
||||
} from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import { FormStatusIndicator } from './FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const CurrentVariantsTable: FC = () => {
|
||||
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));
|
||||
setModalProcessing(false);
|
||||
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: 'Name',
|
||||
dataIndex: 'name',
|
||||
render: (name: string) => (!name ? 'No name' : name),
|
||||
},
|
||||
{
|
||||
title: 'Video bitrate',
|
||||
dataIndex: 'videoBitrate',
|
||||
key: 'videoBitrate',
|
||||
render: (bitrate: number, variant: VideoVariant) =>
|
||||
!bitrate || variant.videoPassthrough ? 'Same as source' : `${bitrate} kbps`,
|
||||
},
|
||||
|
||||
{
|
||||
title: 'CPU Usage',
|
||||
dataIndex: 'cpuUsageLevel',
|
||||
key: 'cpuUsageLevel',
|
||||
render: (level: string, variant: VideoVariant) =>
|
||||
!level || variant.videoPassthrough ? 'n/a' : ENCODER_PRESET_TOOLTIPS[level].split(' ')[0],
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: 'edit',
|
||||
render: ({ key }: VideoVariant) => {
|
||||
const index = 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,
|
||||
}));
|
||||
|
||||
const showSecondVariantRecommendation = (): boolean => {
|
||||
if (videoQualityVariants.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [variant] = videoQualityVariants;
|
||||
|
||||
return (
|
||||
ENCODER_RECOMMENDATION_THRESHOLD.VIDEO_HEIGHT <= variant.scaledHeight ||
|
||||
ENCODER_RECOMMENDATION_THRESHOLD.VIDEO_BITRATE <= variant.videoBitrate
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={3} className="section-title">
|
||||
Stream output
|
||||
</Title>
|
||||
|
||||
{showSecondVariantRecommendation() && (
|
||||
<Alert message={ENCODER_RECOMMENDATION_THRESHOLD.HELP_TEXT} type="info" closable />
|
||||
)}
|
||||
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
|
||||
<Table
|
||||
className="variants-table"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={videoQualityColumns}
|
||||
dataSource={videoQualityVariantData}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Edit Video Variant Details"
|
||||
open={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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
119
web/components/admin/EditCustomStyles.tsx
Normal file
119
web/components/admin/EditCustomStyles.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// EDIT CUSTOM CSS STYLES
|
||||
import React, { useState, useEffect, useContext, FC } from 'react';
|
||||
import { Typography, Button } from 'antd';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { bbedit } from '@uiw/codemirror-theme-bbedit';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
API_CUSTOM_CSS_STYLES,
|
||||
} from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import { FormStatusIndicator } from './FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const EditCustomStyles: FC = () => {
|
||||
const [content, setContent] = useState('/* Enter custom CSS here */');
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { customStyles: initialContent } = instanceDetails;
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
// 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_CSS_STYLES,
|
||||
data: { value: content },
|
||||
onSuccess: (message: string) => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'customStyles',
|
||||
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]);
|
||||
|
||||
const onCSSValueChange = React.useCallback(value => {
|
||||
setContent(value);
|
||||
if (value !== initialContent && !hasChanged) {
|
||||
setHasChanged(true);
|
||||
} else if (value === initialContent && hasChanged) {
|
||||
setHasChanged(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="edit-custom-css">
|
||||
<Title level={3} className="section-title">
|
||||
Customize your page styling with CSS
|
||||
</Title>
|
||||
|
||||
<p className="description">
|
||||
Customize the look and feel of your Owncast instance by overriding the CSS styles of various
|
||||
components on the page. Refer to the{' '}
|
||||
<a href="https://owncast.online/docs/website/" rel="noopener noreferrer" target="_blank">
|
||||
CSS & Components guide
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="description">
|
||||
Please input plain CSS text, as this will be directly injected onto your page during load.
|
||||
</p>
|
||||
|
||||
<CodeMirror
|
||||
value={content}
|
||||
placeholder="/* Enter custom CSS here */"
|
||||
theme={bbedit}
|
||||
height="200px"
|
||||
extensions={[css()]}
|
||||
onChange={onCSSValueChange}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<div className="page-content-actions">
|
||||
{hasChanged && (
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
165
web/components/admin/EditInstanceDetails2.tsx
Normal file
165
web/components/admin/EditInstanceDetails2.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Button, Collapse, Typography } from 'antd';
|
||||
import { CopyOutlined, RedoOutlined } from '@ant-design/icons';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { TEXTFIELD_TYPE_NUMBER, TEXTFIELD_TYPE_PASSWORD, TEXTFIELD_TYPE_URL } from './TextField';
|
||||
import { TextFieldWithSubmit } from './TextFieldWithSubmit';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { AlertMessageContext } from '../../utils/alert-message-context';
|
||||
import {
|
||||
TEXTFIELD_PROPS_FFMPEG,
|
||||
TEXTFIELD_PROPS_RTMP_PORT,
|
||||
TEXTFIELD_PROPS_SOCKET_HOST_OVERRIDE,
|
||||
TEXTFIELD_PROPS_ADMIN_PASSWORD,
|
||||
TEXTFIELD_PROPS_WEB_PORT,
|
||||
} from '../../utils/config-constants';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import { ResetYP } from './ResetYP';
|
||||
|
||||
// Lazy loaded components
|
||||
|
||||
const Tooltip = dynamic(() => import('antd').then(mod => mod.Tooltip));
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function EditInstanceDetails() {
|
||||
const [formDataValues, setFormDataValues] = useState(null);
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { setMessage } = useContext(AlertMessageContext);
|
||||
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
|
||||
const { adminPassword, ffmpegPath, rtmpServerPort, webServerPort, yp, socketHostOverride } =
|
||||
serverConfig;
|
||||
|
||||
const [copyIsVisible, setCopyVisible] = useState(false);
|
||||
|
||||
const COPY_TOOLTIP_TIMEOUT = 3000;
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
adminPassword,
|
||||
ffmpegPath,
|
||||
rtmpServerPort,
|
||||
webServerPort,
|
||||
socketHostOverride,
|
||||
});
|
||||
}, [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="adminPassword"
|
||||
{...TEXTFIELD_PROPS_ADMIN_PASSWORD}
|
||||
value={formDataValues.adminPassword}
|
||||
initialValue={adminPassword}
|
||||
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}
|
||||
/>
|
||||
<Collapse className="advanced-settings">
|
||||
<Panel header="Advanced Settings" key="1">
|
||||
<Typography.Paragraph>
|
||||
If you have a CDN in front of your entire Owncast instance, specify your origin server
|
||||
here for the websocket to connect to. Most people will never need to set this.
|
||||
</Typography.Paragraph>
|
||||
<TextFieldWithSubmit
|
||||
fieldName="socketHostOverride"
|
||||
{...TEXTFIELD_PROPS_SOCKET_HOST_OVERRIDE}
|
||||
value={formDataValues.socketHostOverride}
|
||||
initialValue={socketHostOverride || ''}
|
||||
type={TEXTFIELD_TYPE_URL}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
{yp.enabled && <ResetYP />}
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
web/components/admin/EditLogo.tsx
Normal file
135
web/components/admin/EditLogo.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Button, Upload } from 'antd';
|
||||
import { RcFile } from 'antd/lib/upload/interface';
|
||||
import { LoadingOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import React, { useState, useContext, FC } from 'react';
|
||||
import { FormStatusIndicator } from './FormStatusIndicator';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
TEXTFIELD_PROPS_LOGO,
|
||||
} from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
|
||||
|
||||
import {
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
getBase64,
|
||||
MAX_IMAGE_FILESIZE,
|
||||
readableBytes,
|
||||
} from '../../utils/images';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const EditLogo: FC = () => {
|
||||
const [logoUrl, setlogoUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logoCachedbuster, setLogoCacheBuster] = useState(0);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { setFieldInConfigState, serverConfig } = serverStatusData || {};
|
||||
const currentLogo = serverConfig?.instanceDetails?.logo;
|
||||
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
let resetTimer = null;
|
||||
|
||||
const { apiPath, tip } = TEXTFIELD_PROPS_LOGO;
|
||||
|
||||
// Clear out any validation states and messaging
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
clearTimeout(resetTimer);
|
||||
resetTimer = null;
|
||||
};
|
||||
|
||||
// validate file type and create base64 encoded img
|
||||
const beforeUpload = (file: RcFile) => {
|
||||
setLoading(true);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return new Promise<void>((res, rej) => {
|
||||
if (file.size > MAX_IMAGE_FILESIZE) {
|
||||
const msg = `File size is too big: ${readableBytes(file.size)}`;
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
setLoading(false);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
return rej();
|
||||
}
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
const msg = `File type is not supported: ${file.type}`;
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
setLoading(false);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
return rej();
|
||||
}
|
||||
|
||||
getBase64(file, (url: string) => {
|
||||
setlogoUrl(url);
|
||||
setTimeout(() => res(), 100);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Post new logo to api
|
||||
const handleLogoUpdate = async () => {
|
||||
if (logoUrl !== currentLogo) {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath,
|
||||
data: { value: logoUrl },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({ fieldName: 'logo', value: logoUrl, path: '' });
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
|
||||
setLoading(false);
|
||||
setLogoCacheBuster(Math.floor(Math.random() * 100)); // Force logo to re-load
|
||||
},
|
||||
onError: (msg: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`));
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
}
|
||||
};
|
||||
|
||||
const logoDisplayUrl = `${NEXT_PUBLIC_API_HOST}logo?random=${logoCachedbuster}`;
|
||||
|
||||
return (
|
||||
<div className="formfield-container logo-upload-container">
|
||||
<div className="label-side">
|
||||
<span className="formfield-label">Logo</span>
|
||||
</div>
|
||||
|
||||
<div className="input-side">
|
||||
<div className="input-group">
|
||||
<img src={logoDisplayUrl} alt="avatar" className="logo-preview" />
|
||||
<Upload
|
||||
name="logo"
|
||||
listType="picture"
|
||||
className="avatar-uploader"
|
||||
showUploadList={false}
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
||||
beforeUpload={beforeUpload}
|
||||
customRequest={handleLogoUpdate}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingOutlined style={{ color: 'white' }} />
|
||||
) : (
|
||||
<Button icon={<UploadOutlined />} />
|
||||
)}
|
||||
</Upload>
|
||||
</div>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
<p className="field-tip">{tip}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
93
web/components/admin/EditValueArray.tsx
Normal file
93
web/components/admin/EditValueArray.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
import React, { FC, useState } from 'react';
|
||||
import { Typography, Tag } from 'antd';
|
||||
|
||||
import { TextField } from './TextField';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import { StatusState } from '../../utils/input-statuses';
|
||||
import { FormStatusIndicator } from './FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const TAG_COLOR = '#5a67d8';
|
||||
|
||||
export type EditStringArrayProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
placeholder: string;
|
||||
maxLength?: number;
|
||||
values: string[];
|
||||
submitStatus?: StatusState;
|
||||
continuousStatusMessage?: StatusState;
|
||||
handleDeleteIndex: (index: number) => void;
|
||||
handleCreateString: (arg: string) => void;
|
||||
};
|
||||
|
||||
export const EditValueArray: FC<EditStringArrayProps> = ({
|
||||
title,
|
||||
description,
|
||||
placeholder,
|
||||
maxLength,
|
||||
values,
|
||||
handleDeleteIndex,
|
||||
handleCreateString,
|
||||
submitStatus,
|
||||
continuousStatusMessage,
|
||||
}) => {
|
||||
const [newStringInput, setNewStringInput] = useState<string>('');
|
||||
|
||||
const handleInputChange = ({ value }: UpdateArgs) => {
|
||||
setNewStringInput(value);
|
||||
};
|
||||
|
||||
const handleSubmitNewString = () => {
|
||||
const newString = newStringInput.trim();
|
||||
handleCreateString(newString);
|
||||
setNewStringInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="edit-string-array-container">
|
||||
<Title level={3} className="section-title">
|
||||
{title}
|
||||
</Title>
|
||||
<p className="description">{description}</p>
|
||||
|
||||
<div className="edit-current-strings">
|
||||
{values?.map((tag, index) => {
|
||||
const handleClose = () => {
|
||||
handleDeleteIndex(index);
|
||||
};
|
||||
return (
|
||||
<Tag closable onClose={handleClose} color={TAG_COLOR} key={`tag-${tag}-${index}`}>
|
||||
{tag}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{continuousStatusMessage && (
|
||||
<div className="continuous-status-section">
|
||||
<FormStatusIndicator status={continuousStatusMessage} />
|
||||
</div>
|
||||
)}
|
||||
<div className="add-new-string-section">
|
||||
<TextField
|
||||
fieldName="string-input"
|
||||
value={newStringInput}
|
||||
onChange={handleInputChange}
|
||||
onPressEnter={handleSubmitNewString}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
status={submitStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EditValueArray.defaultProps = {
|
||||
maxLength: 50,
|
||||
description: null,
|
||||
submitStatus: null,
|
||||
continuousStatusMessage: null,
|
||||
};
|
||||
71
web/components/admin/EditYPDetails.tsx
Normal file
71
web/components/admin/EditYPDetails.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// Note: references to "yp" in the app are likely related to Owncast Directory
|
||||
import React, { useState, useContext, useEffect, FC } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import { ToggleSwitch } from './ToggleSwitch';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { FIELD_PROPS_NSFW, FIELD_PROPS_YP } from '../../utils/config-constants';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const EditYPDetails: FC = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
24
web/components/admin/FormStatusIndicator.tsx
Normal file
24
web/components/admin/FormStatusIndicator.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { FC } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { StatusState } from '../../utils/input-statuses';
|
||||
|
||||
export type FormStatusIndicatorProps = {
|
||||
status: StatusState;
|
||||
};
|
||||
|
||||
export const FormStatusIndicator: FC<FormStatusIndicatorProps> = ({ status }) => {
|
||||
const { type, icon, message } = status || {};
|
||||
const classes = classNames({
|
||||
'status-container': true,
|
||||
[`status-${type}`]: type,
|
||||
empty: !message,
|
||||
});
|
||||
return (
|
||||
<span className={classes}>
|
||||
{icon ? <span className="status-icon">{icon}</span> : null}
|
||||
{message ? <span className="status-message">{message}</span> : null}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
export default FormStatusIndicator;
|
||||
70
web/components/admin/ImageAsset.tsx
Normal file
70
web/components/admin/ImageAsset.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
export type ImageAssetProps = {
|
||||
name: string;
|
||||
src: string;
|
||||
};
|
||||
|
||||
export const ImageAsset: FC<ImageAssetProps> = ({ name, src }) => {
|
||||
const containerStyle = {
|
||||
borderRadius: '20px',
|
||||
width: '12vw',
|
||||
height: '12vw',
|
||||
minWidth: '100px',
|
||||
minHeight: '100px',
|
||||
borderWidth: '1.5px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'lightgray',
|
||||
overflow: 'hidden',
|
||||
margin: '0.3vw',
|
||||
};
|
||||
|
||||
const colorDescriptionStyle = {
|
||||
textAlign: 'center' as 'center',
|
||||
color: 'gray',
|
||||
fontSize: '0.8em',
|
||||
};
|
||||
|
||||
const imageStyle = {
|
||||
width: '100%',
|
||||
height: '80%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
marginTop: '5px',
|
||||
backgroundImage: `url(${src})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<figure style={containerStyle}>
|
||||
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||
<div style={imageStyle} />
|
||||
<figcaption style={colorDescriptionStyle}>{name}</figcaption>
|
||||
</a>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
const rowStyle = {
|
||||
display: 'flex',
|
||||
flexDirection: 'row' as 'row',
|
||||
flexWrap: 'wrap' as 'wrap',
|
||||
// justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
export const ImageRow = (props: ImageRowProps) => {
|
||||
const { images } = props;
|
||||
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
{images.map(image => (
|
||||
<ImageAsset key={image.src} src={image.src} name={image.name} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImageRowProps {
|
||||
images: ImageAssetProps[];
|
||||
}
|
||||
21
web/components/admin/InfoTip.tsx
Normal file
21
web/components/admin/InfoTip.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Tooltip } from 'antd';
|
||||
import { FC } from 'react';
|
||||
|
||||
export type InfoTipProps = {
|
||||
tip: string | null;
|
||||
};
|
||||
|
||||
export const InfoTip: FC<InfoTipProps> = ({ tip }) => {
|
||||
if (tip === '' || tip === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="info-tip">
|
||||
<Tooltip title={tip}>
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
31
web/components/admin/KeyValueTable.tsx
Normal file
31
web/components/admin/KeyValueTable.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Table, Typography } from 'antd';
|
||||
import { FC } from 'react';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export type KeyValueTableProps = {
|
||||
title: string;
|
||||
data: any;
|
||||
};
|
||||
|
||||
export const KeyValueTable: FC<KeyValueTableProps> = ({ title, data }) => {
|
||||
const columns = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Value',
|
||||
dataIndex: 'value',
|
||||
key: 'value',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={2}>{title}</Title>
|
||||
<Table pagination={false} columns={columns} dataSource={data} rowKey="name" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
88
web/components/admin/LogTable.tsx
Normal file
88
web/components/admin/LogTable.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Table, Tag, Typography } from 'antd';
|
||||
import Linkify from 'react-linkify';
|
||||
import { SortOrder } from 'antd/lib/table/interface';
|
||||
import format from 'date-fns/format';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
function renderColumnLevel(text, entry) {
|
||||
let color = 'black';
|
||||
|
||||
if (entry.level === 'warning') {
|
||||
color = 'orange';
|
||||
} else if (entry.level === 'error') {
|
||||
color = 'red';
|
||||
}
|
||||
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
}
|
||||
|
||||
function renderMessage(text) {
|
||||
return <Linkify>{text}</Linkify>;
|
||||
}
|
||||
|
||||
export type LogTableProps = {
|
||||
logs: object[];
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export const LogTable: FC<LogTableProps> = ({ logs, pageSize }) => {
|
||||
if (!logs?.length) {
|
||||
return null;
|
||||
}
|
||||
const columns = [
|
||||
{
|
||||
title: 'Level',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
filters: [
|
||||
{
|
||||
text: 'Info',
|
||||
value: 'info',
|
||||
},
|
||||
{
|
||||
text: 'Warning',
|
||||
value: 'warning',
|
||||
},
|
||||
{
|
||||
text: 'Error',
|
||||
value: 'Error',
|
||||
},
|
||||
],
|
||||
onFilter: (level, row) => row.level.indexOf(level) === 0,
|
||||
render: renderColumnLevel,
|
||||
},
|
||||
{
|
||||
title: 'Timestamp',
|
||||
dataIndex: 'time',
|
||||
key: 'time',
|
||||
render: timestamp => {
|
||||
const dateObject = new Date(timestamp);
|
||||
return format(dateObject, 'pp P');
|
||||
},
|
||||
sorter: (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
defaultSortOrder: 'descend' as SortOrder,
|
||||
},
|
||||
{
|
||||
title: 'Message',
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
render: renderMessage,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="logs-section">
|
||||
<Title>Logs</Title>
|
||||
<Table
|
||||
size="middle"
|
||||
dataSource={logs}
|
||||
columns={columns}
|
||||
rowKey={row => row.time}
|
||||
pagination={{ pageSize: pageSize || 20 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
320
web/components/admin/MainLayout.tsx
Normal file
320
web/components/admin/MainLayout.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import React, { FC, ReactNode, 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, Alert, Button, Space } from 'antd';
|
||||
import {
|
||||
SettingOutlined,
|
||||
HomeOutlined,
|
||||
LineChartOutlined,
|
||||
ToolOutlined,
|
||||
PlayCircleFilled,
|
||||
MinusSquareFilled,
|
||||
QuestionCircleOutlined,
|
||||
MessageOutlined,
|
||||
ExperimentOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { upgradeVersionAvailable } from '../../utils/apis';
|
||||
import { parseSecondsToDurationString } from '../../utils/format';
|
||||
|
||||
import { OwncastLogo } from '../common/OwncastLogo/OwncastLogo';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { AlertMessageContext } from '../../utils/alert-message-context';
|
||||
|
||||
import { TextFieldWithSubmit } from './TextFieldWithSubmit';
|
||||
import { TEXTFIELD_PROPS_STREAM_TITLE } from '../../utils/config-constants';
|
||||
import { ComposeFederatedPost } from './ComposeFederatedPost';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
|
||||
import FediverseIcon from '../../assets/images/fediverse-black.png';
|
||||
|
||||
// Lazy loaded components
|
||||
|
||||
const Tooltip = dynamic(() => import('antd').then(mod => mod.Tooltip));
|
||||
|
||||
export type MainLayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
|
||||
const context = useContext(ServerStatusContext);
|
||||
const { serverConfig, online, broadcaster, versionNumber } = context || {};
|
||||
const { instanceDetails, chatDisabled, federation } = serverConfig;
|
||||
const { enabled: federationEnabled } = federation;
|
||||
|
||||
const [currentStreamTitle, setCurrentStreamTitle] = useState('');
|
||||
const [postModalDisplayed, setPostModalDisplayed] = useState(false);
|
||||
|
||||
const alertMessage = useContext(AlertMessageContext);
|
||||
|
||||
const router = useRouter();
|
||||
const { route } = router || {};
|
||||
|
||||
const { Header, Footer, Content, Sider } = Layout;
|
||||
|
||||
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 handleCreatePostButtonPressed = () => {
|
||||
setPostModalDisplayed(true);
|
||||
};
|
||||
|
||||
const appClass = classNames({
|
||||
'app-container': true,
|
||||
online,
|
||||
});
|
||||
|
||||
const upgradeVersionString = `${upgradeVersion}` || '';
|
||||
const upgradeMessage = `Upgrade to v${upgradeVersionString}`;
|
||||
const openMenuItems = upgradeVersion ? ['utilities-menu'] : [];
|
||||
|
||||
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 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 integrationsMenu = [
|
||||
{
|
||||
label: <Link href="/admin/webhooks">Webhooks</Link>,
|
||||
key: 'webhooks',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/access-tokens">Access Tokens</Link>,
|
||||
key: 'access-tokens',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/actions">External Actions</Link>,
|
||||
key: 'actions',
|
||||
},
|
||||
];
|
||||
|
||||
const chatMenu = [
|
||||
{
|
||||
label: <Link href="/admin/chat/messages">Messages</Link>,
|
||||
key: 'messages',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/chat/users">Users</Link>,
|
||||
key: 'chat-users',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/chat/emojis">Emojis</Link>,
|
||||
key: 'emojis',
|
||||
},
|
||||
];
|
||||
|
||||
const utilitiesMenu = [
|
||||
{
|
||||
label: <Link href="/admin/hardware-info">Hardware</Link>,
|
||||
key: 'hardware-info',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/stream-health">Stream Health</Link>,
|
||||
key: 'stream-health',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/logs">Logs</Link>,
|
||||
key: 'logs',
|
||||
},
|
||||
federationEnabled && {
|
||||
label: <Link href="/admin/federation/actions">Social Actions</Link>,
|
||||
key: 'federation-activities',
|
||||
},
|
||||
];
|
||||
|
||||
const configurationMenu = [
|
||||
{
|
||||
label: <Link href="/admin/config/general">General</Link>,
|
||||
key: 'config-public-details',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/config/server">Server Setup</Link>,
|
||||
key: 'config-server',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/config-video">Video</Link>,
|
||||
key: 'config-video',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/config-chat">Chat</Link>,
|
||||
key: 'config-chat',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/config-federation">Social</Link>,
|
||||
key: 'config-federation',
|
||||
},
|
||||
{
|
||||
label: <Link href="/admin/config-notify">Notifications</Link>,
|
||||
key: 'config-notify',
|
||||
},
|
||||
];
|
||||
|
||||
const menuItems = [
|
||||
{ label: <Link href="/admin">Home</Link>, icon: <HomeOutlined />, key: 'home' },
|
||||
{
|
||||
label: <Link href="/admin/viewer-info">Viewers</Link>,
|
||||
icon: <LineChartOutlined />,
|
||||
key: 'viewer-info',
|
||||
},
|
||||
!chatDisabled && {
|
||||
label: <Link href="/admin/viewer-info">Chat & Users</Link>,
|
||||
icon: <MessageOutlined />,
|
||||
children: chatMenu,
|
||||
key: 'chat-and-users',
|
||||
},
|
||||
federationEnabled && {
|
||||
key: 'fediverse-followers',
|
||||
label: <Link href="/admin/federation/followers">Followers</Link>,
|
||||
icon: (
|
||||
<img
|
||||
alt="fediverse icon"
|
||||
src={FediverseIcon.src}
|
||||
width="17rem"
|
||||
style={{ opacity: 0.6, position: 'relative', top: '-1px' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'configuration',
|
||||
label: 'Configuration',
|
||||
icon: <SettingOutlined />,
|
||||
children: configurationMenu,
|
||||
},
|
||||
{
|
||||
key: 'utilities',
|
||||
label: 'Utilities',
|
||||
icon: <ToolOutlined />,
|
||||
children: utilitiesMenu,
|
||||
},
|
||||
{
|
||||
key: 'integrations',
|
||||
label: 'Integrations',
|
||||
icon: <ExperimentOutlined />,
|
||||
children: integrationsMenu,
|
||||
},
|
||||
upgradeVersion && {
|
||||
key: 'upgrade',
|
||||
label: <Link href="/upgrade">{upgradeMessage}</Link>,
|
||||
},
|
||||
{
|
||||
key: 'help',
|
||||
label: <Link href="/admin/help">Help</Link>,
|
||||
icon: <QuestionCircleOutlined />,
|
||||
},
|
||||
];
|
||||
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">
|
||||
<h1 className="owncast-title">
|
||||
<span className="logo-container">
|
||||
<OwncastLogo variant="simple" />
|
||||
</span>
|
||||
<span className="title-label">Owncast Admin</span>
|
||||
</h1>
|
||||
<Menu
|
||||
defaultSelectedKeys={[route.substring(1) || 'home']}
|
||||
defaultOpenKeys={openMenuItems}
|
||||
mode="inline"
|
||||
className="menu-container"
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
|
||||
<Layout className="layout-main">
|
||||
<Header className="layout-header">
|
||||
<Space direction="horizontal">
|
||||
<Tooltip title="Compose post to your social followers">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={handleCreatePostButtonPressed}
|
||||
style={{ display: federationEnabled ? 'block' : 'none', margin: '10px' }}
|
||||
>
|
||||
Compose Post
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<div className="global-stream-title-container">
|
||||
<TextFieldWithSubmit
|
||||
fieldName="streamTitle"
|
||||
{...TEXTFIELD_PROPS_STREAM_TITLE}
|
||||
placeholder="What are you streaming now? (Stream title)"
|
||||
value={currentStreamTitle}
|
||||
initialValue={instanceDetails.streamTitle}
|
||||
onChange={handleStreamTitleChanged}
|
||||
/>
|
||||
</div>
|
||||
<Space direction="horizontal">{statusIndicator}</Space>
|
||||
</Header>
|
||||
|
||||
{headerAlertMessage}
|
||||
|
||||
<Content className="main-content-container">{children}</Content>
|
||||
|
||||
<Footer className="footer-container">
|
||||
<a href="https://owncast.online/?source=admin" target="_blank" rel="noopener noreferrer">
|
||||
About Owncast v{versionNumber}
|
||||
</a>
|
||||
</Footer>
|
||||
</Layout>
|
||||
|
||||
<ComposeFederatedPost
|
||||
open={postModalDisplayed}
|
||||
handleClose={() => setPostModalDisplayed(false)}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
MainLayout.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
};
|
||||
96
web/components/admin/MessageVisiblityToggle.tsx
Normal file
96
web/components/admin/MessageVisiblityToggle.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
// Custom component for AntDesign Button that makes an api call, then displays a confirmation icon upon
|
||||
import React, { useState, useEffect, FC } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import {
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
CheckCircleFilled,
|
||||
ExclamationCircleFilled,
|
||||
} from '@ant-design/icons';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { fetchData, UPDATE_CHAT_MESSGAE_VIZ } from '../../utils/apis';
|
||||
import { MessageType } from '../../types/chat';
|
||||
import { isEmptyObject } from '../../utils/format';
|
||||
|
||||
// Lazy loaded components
|
||||
|
||||
const Tooltip = dynamic(() => import('antd').then(mod => mod.Tooltip));
|
||||
|
||||
export type MessageToggleProps = {
|
||||
isVisible: boolean;
|
||||
message: MessageType;
|
||||
setMessage: (message: MessageType) => void;
|
||||
};
|
||||
|
||||
export const MessageVisiblityToggle: FC<MessageToggleProps> = ({
|
||||
isVisible,
|
||||
message,
|
||||
setMessage,
|
||||
}) => {
|
||||
if (!message || isEmptyObject(message)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let outcomeTimeout = null;
|
||||
const [outcome, setOutcome] = useState(0);
|
||||
|
||||
const { id: messageId } = message || {};
|
||||
|
||||
const resetOutcome = () => {
|
||||
outcomeTimeout = setTimeout(() => {
|
||||
setOutcome(0);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
clearTimeout(outcomeTimeout);
|
||||
});
|
||||
|
||||
const updateChatMessage = async () => {
|
||||
clearTimeout(outcomeTimeout);
|
||||
setOutcome(0);
|
||||
const result = await fetchData(UPDATE_CHAT_MESSGAE_VIZ, {
|
||||
auth: true,
|
||||
method: 'POST',
|
||||
data: {
|
||||
visible: !isVisible,
|
||||
idArray: [messageId],
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success && result.message === 'changed') {
|
||||
setMessage({ ...message, visible: !isVisible });
|
||||
setOutcome(1);
|
||||
} else {
|
||||
setMessage({ ...message, visible: isVisible });
|
||||
setOutcome(-1);
|
||||
}
|
||||
resetOutcome();
|
||||
};
|
||||
|
||||
let outcomeIcon = <CheckCircleFilled style={{ color: 'transparent' }} />;
|
||||
if (outcome) {
|
||||
outcomeIcon =
|
||||
outcome > 0 ? (
|
||||
<CheckCircleFilled style={{ color: 'var(--ant-success)' }} />
|
||||
) : (
|
||||
<ExclamationCircleFilled style={{ color: 'var(--ant-warning)' }} />
|
||||
);
|
||||
}
|
||||
|
||||
const toolTipMessage = `Click to ${isVisible ? 'hide' : 'show'} this message`;
|
||||
return (
|
||||
<div className={`toggle-switch ${isVisible ? '' : 'hidden'}`}>
|
||||
<span className="outcome-icon">{outcomeIcon}</span>
|
||||
<Tooltip title={toolTipMessage} placement="topRight">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="small"
|
||||
type="text"
|
||||
icon={isVisible ? <EyeOutlined /> : <EyeInvisibleOutlined />}
|
||||
onClick={updateChatMessage}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
web/components/admin/ModeratorUserButton.tsx
Normal file
97
web/components/admin/ModeratorUserButton.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Modal, Button } from 'antd';
|
||||
import {
|
||||
ExclamationCircleFilled,
|
||||
QuestionCircleFilled,
|
||||
StopTwoTone,
|
||||
SafetyCertificateTwoTone,
|
||||
} from '@ant-design/icons';
|
||||
import { FC } from 'react';
|
||||
import { USER_SET_MODERATOR, fetchData } from '../../utils/apis';
|
||||
import { User } from '../../types/chat';
|
||||
|
||||
export type ModeratorUserButtonProps = {
|
||||
user: User;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const ModeratorUserButton: FC<ModeratorUserButtonProps> = ({ user, onClick }) => {
|
||||
async function buttonClicked({ id }, setAsModerator: Boolean): Promise<Boolean> {
|
||||
const data = {
|
||||
userId: id,
|
||||
isModerator: setAsModerator,
|
||||
};
|
||||
try {
|
||||
const result = await fetchData(USER_SET_MODERATOR, {
|
||||
data,
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
});
|
||||
return result.success;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const isModerator = user.scopes?.includes('MODERATOR');
|
||||
const actionString = isModerator ? 'remove moderator' : 'add moderator';
|
||||
const icon = isModerator ? (
|
||||
<ExclamationCircleFilled style={{ color: 'var(--ant-error)' }} />
|
||||
) : (
|
||||
<QuestionCircleFilled style={{ color: 'var(--ant-warning)' }} />
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
Are you sure you want to {actionString} <strong>{user.displayName}</strong>?
|
||||
</>
|
||||
);
|
||||
|
||||
const confirmBlockAction = () => {
|
||||
Modal.confirm({
|
||||
title: `Confirm ${actionString}`,
|
||||
content,
|
||||
onCancel: () => {},
|
||||
onOk: () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const result = buttonClicked(user, !isModerator);
|
||||
if (result) {
|
||||
// wait a bit before closing so the user/client tables repopulate
|
||||
// GW: TODO: put users/clients data in global app context instead, then call a function here to update that state. (current in another branch)
|
||||
setTimeout(() => {
|
||||
resolve(result);
|
||||
onClick?.();
|
||||
}, 3000);
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
}),
|
||||
okType: 'danger',
|
||||
okText: isModerator ? 'Yup!' : null,
|
||||
icon,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={confirmBlockAction}
|
||||
size="small"
|
||||
icon={
|
||||
isModerator ? (
|
||||
<StopTwoTone twoToneColor="#ff4d4f" />
|
||||
) : (
|
||||
<SafetyCertificateTwoTone twoToneColor="#22bb44" />
|
||||
)
|
||||
}
|
||||
className="block-user-button"
|
||||
>
|
||||
{actionString}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
ModeratorUserButton.defaultProps = {
|
||||
onClick: null,
|
||||
};
|
||||
83
web/components/admin/NewsFeed.tsx
Normal file
83
web/components/admin/NewsFeed.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/* eslint-disable camelcase */
|
||||
/* eslint-disable react/no-danger */
|
||||
import React, { useState, useEffect, FC } from 'react';
|
||||
import { Collapse, Typography, Skeleton } from 'antd';
|
||||
import format from 'date-fns/format';
|
||||
|
||||
import { fetchExternalData } from '../../utils/apis';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
const { Title, Link } = Typography;
|
||||
|
||||
const OWNCAST_FEED_URL = 'https://owncast.online/news/index.json';
|
||||
const OWNCAST_BASE_URL = 'https://owncast.online';
|
||||
|
||||
export type ArticleProps = {
|
||||
title: string;
|
||||
url: string;
|
||||
content_html: string;
|
||||
date_published: string;
|
||||
};
|
||||
|
||||
const ArticleItem: FC<ArticleProps> = ({
|
||||
title,
|
||||
url,
|
||||
content_html: content,
|
||||
date_published: date,
|
||||
}) => {
|
||||
const dateObject = new Date(date);
|
||||
const dateString = format(dateObject, 'MMM dd, yyyy, HH:mm');
|
||||
return (
|
||||
<article>
|
||||
<Collapse>
|
||||
<Panel header={title} key={url}>
|
||||
<p className="timestamp">
|
||||
{dateString} (
|
||||
<Link href={`${OWNCAST_BASE_URL}${url}`} target="_blank" rel="noopener noreferrer">
|
||||
Link
|
||||
</Link>
|
||||
)
|
||||
</p>
|
||||
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewsFeed = () => {
|
||||
const [feed, setFeed] = useState<ArticleProps[]>([]);
|
||||
const [loading, setLoading] = useState<Boolean>(true);
|
||||
|
||||
const getFeed = async () => {
|
||||
setLoading(false);
|
||||
|
||||
try {
|
||||
const result = await fetchExternalData(OWNCAST_FEED_URL);
|
||||
if (result?.items.length > 0) {
|
||||
setFeed(result.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getFeed();
|
||||
}, []);
|
||||
|
||||
const loadingSpinner = loading ? <Skeleton loading active /> : null;
|
||||
const noNews = !loading && feed.length === 0 ? <div>No news.</div> : null;
|
||||
|
||||
return (
|
||||
<section className="news-feed form-module">
|
||||
<Title level={2}>News & Updates from Owncast</Title>
|
||||
{loadingSpinner}
|
||||
{feed.map(item => (
|
||||
<ArticleItem {...item} key={item.url} />
|
||||
))}
|
||||
|
||||
{noNews}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
153
web/components/admin/Offline.tsx
Normal file
153
web/components/admin/Offline.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { BookTwoTone, MessageTwoTone, PlaySquareTwoTone, ProfileTwoTone } from '@ant-design/icons';
|
||||
import { Card, Col, Row, Typography } from 'antd';
|
||||
import Link from 'next/link';
|
||||
import { FC, useContext } from 'react';
|
||||
import { LogTable } from './LogTable';
|
||||
import { OwncastLogo } from '../common/OwncastLogo/OwncastLogo';
|
||||
import { NewsFeed } from './NewsFeed';
|
||||
import { ConfigDetails } from '../../types/config-section';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Meta } = Card;
|
||||
|
||||
function generateStreamURL(serverURL, rtmpServerPort) {
|
||||
return `rtmp://${serverURL.replace(/(^\w+:|^)\/\//, '')}:${rtmpServerPort}/live`;
|
||||
}
|
||||
|
||||
export type OfflineProps = {
|
||||
logs: any[];
|
||||
config: ConfigDetails;
|
||||
};
|
||||
|
||||
export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
const { rtmpServerPort } = serverConfig;
|
||||
const instanceUrl = global.window?.location.hostname || '';
|
||||
|
||||
let rtmpURL;
|
||||
if (instanceUrl && rtmpServerPort) {
|
||||
rtmpURL = generateStreamURL(instanceUrl, rtmpServerPort);
|
||||
}
|
||||
|
||||
const data = [
|
||||
{
|
||||
icon: <BookTwoTone twoToneColor="#6f42c1" />,
|
||||
title: 'Use your broadcasting software',
|
||||
content: (
|
||||
<div>
|
||||
<a
|
||||
href="https://owncast.online/docs/broadcasting/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn how to point your existing software to your new server and start streaming your
|
||||
content.
|
||||
</a>
|
||||
<div className="stream-info-container">
|
||||
<Text strong className="stream-info-label">
|
||||
Streaming URL:
|
||||
</Text>
|
||||
{rtmpURL && (
|
||||
<Paragraph className="stream-info-box" copyable>
|
||||
{rtmpURL}
|
||||
</Paragraph>
|
||||
)}
|
||||
<Text strong className="stream-info-label">
|
||||
Streaming Keys:
|
||||
</Text>
|
||||
<Text strong className="stream-info-box">
|
||||
<Link href="/admin/config/streamkeys"> View </Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <PlaySquareTwoTone twoToneColor="#f9826c" />,
|
||||
title: 'Embed your video onto other sites',
|
||||
content: (
|
||||
<div>
|
||||
<a
|
||||
href="https://owncast.online/docs/embed?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn how you can add your Owncast stream to other sites you control.
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!config?.chatDisabled) {
|
||||
data.push({
|
||||
icon: <MessageTwoTone twoToneColor="#0366d6" />,
|
||||
title: 'Chat is disabled',
|
||||
content: <span>Chat will continue to be disabled until you begin a live stream.</span>,
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (!config?.federation?.enabled) {
|
||||
data.push({
|
||||
icon: <img alt="fediverse" width="20px" src="fediverse-white.png" />,
|
||||
title: 'Add your Owncast instance to the Fediverse',
|
||||
content: (
|
||||
<div>
|
||||
<Link href="/config-federation">Enable Owncast social</Link> features to have your
|
||||
instance join the Fediverse, allowing people to follow, share and engage with your live
|
||||
stream.
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col span={12} offset={6}>
|
||||
<div className="offline-intro">
|
||||
<span className="logo">
|
||||
<OwncastLogo variant="simple" />
|
||||
</span>
|
||||
<div>
|
||||
<Title level={2}>No stream is active</Title>
|
||||
<p>You should start one.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]} className="offline-content">
|
||||
<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>
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={12}>
|
||||
<NewsFeed />
|
||||
</Col>
|
||||
</Row>
|
||||
<LogTable logs={logs} pageSize={5} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Offline;
|
||||
65
web/components/admin/ResetYP.tsx
Normal file
65
web/components/admin/ResetYP.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Popconfirm, Button, Typography } from 'antd';
|
||||
import { FC, 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 './FormStatusIndicator';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const ResetYP: FC = () => {
|
||||
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
|
||||
"reset" 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
63
web/components/admin/SocialDropdown.tsx
Normal file
63
web/components/admin/SocialDropdown.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { SocialHandleDropdownItem } from '../../types/config-section';
|
||||
import { OTHER_SOCIAL_HANDLE_OPTION } from '../../utils/config-constants';
|
||||
|
||||
export type DropdownProps = {
|
||||
iconList: SocialHandleDropdownItem[];
|
||||
selectedOption: string;
|
||||
onSelected: any;
|
||||
};
|
||||
|
||||
export const SocialDropdown: FC<DropdownProps> = ({ iconList, selectedOption, onSelected }) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
92
web/components/admin/StatisticItem.tsx
Normal file
92
web/components/admin/StatisticItem.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable react/no-unused-prop-types */
|
||||
/* eslint-disable react/no-unstable-nested-components */
|
||||
// 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';
|
||||
import { FC } from 'react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export type StatisticItemProps = {
|
||||
title?: string;
|
||||
value?: any;
|
||||
prefix?: any;
|
||||
suffix?: string;
|
||||
color?: string;
|
||||
progress?: boolean;
|
||||
centered?: boolean;
|
||||
formatter?: any;
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
title: '',
|
||||
value: 0,
|
||||
prefix: null,
|
||||
suffix: null,
|
||||
color: '',
|
||||
progress: false,
|
||||
centered: false,
|
||||
formatter: null,
|
||||
};
|
||||
|
||||
export type ContentProps = {
|
||||
prefix: string;
|
||||
value: any;
|
||||
suffix: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const Content: FC<ContentProps> = ({ prefix, value, suffix, title }) => (
|
||||
<div>
|
||||
{prefix}
|
||||
<div>
|
||||
<Text type="secondary">{title}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
{value}
|
||||
{suffix || '%'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProgressView: FC<StatisticItemProps> = ({ title, value, prefix, suffix, color }) => {
|
||||
const endColor = value > 90 ? 'red' : color;
|
||||
const content = <Content prefix={prefix} value={value} suffix={suffix} title={title} />;
|
||||
|
||||
return (
|
||||
<Progress
|
||||
type="dashboard"
|
||||
percent={value}
|
||||
width={120}
|
||||
strokeColor={{
|
||||
'0%': color,
|
||||
'90%': endColor,
|
||||
}}
|
||||
format={() => content}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ProgressView.defaultProps = defaultProps;
|
||||
|
||||
const StatisticView: FC<StatisticItemProps> = ({ title, value, prefix, formatter }) => (
|
||||
<Statistic title={title} value={value} prefix={prefix} formatter={formatter} />
|
||||
);
|
||||
StatisticView.defaultProps = defaultProps;
|
||||
|
||||
export const StatisticItem: FC<StatisticItemProps> = props => {
|
||||
const { progress, centered } = props;
|
||||
const View = progress ? ProgressView : StatisticView;
|
||||
|
||||
const style = centered ? { display: 'flex', alignItems: 'center', justifyContent: 'center' } : {};
|
||||
|
||||
return (
|
||||
<Card type="inner">
|
||||
<div style={style}>
|
||||
<View {...props} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
StatisticItem.defaultProps = defaultProps;
|
||||
85
web/components/admin/StreamHealthOverview.tsx
Normal file
85
web/components/admin/StreamHealthOverview.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Col, Row, Statistic, Typography } from 'antd';
|
||||
import Link from 'next/link';
|
||||
import React, { FC, useContext } from 'react';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
|
||||
export type StreamHealthOverviewProps = {
|
||||
showTroubleshootButton?: Boolean;
|
||||
};
|
||||
|
||||
export const StreamHealthOverview: FC<StreamHealthOverviewProps> = ({ showTroubleshootButton }) => {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { health } = serverStatusData;
|
||||
if (!health) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { healthy, healthPercentage, message, representation } = health;
|
||||
let color = '#3f8600';
|
||||
let icon: 'success' | 'info' | 'warning' | 'error' = 'info';
|
||||
if (healthPercentage < 80) {
|
||||
color = '#cf000f';
|
||||
icon = 'error';
|
||||
} else if (healthPercentage < 30) {
|
||||
color = '#f0ad4e';
|
||||
icon = 'error';
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="Healthy Stream"
|
||||
value={healthy ? 'Yes' : 'No'}
|
||||
valueStyle={{ color }}
|
||||
prefix={healthy ? <CheckCircleOutlined /> : <ExclamationCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="Playback Health"
|
||||
value={healthPercentage}
|
||||
valueStyle={{ color }}
|
||||
suffix="%"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{ display: representation < 100 && representation !== 0 ? 'grid' : 'none' }}>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ textAlign: 'center', fontSize: '0.7em', opacity: '0.3' }}
|
||||
>
|
||||
Stream health represents {representation}% of all known players. Other player status is
|
||||
unknown.
|
||||
</Typography.Text>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={16}
|
||||
style={{ width: '100%', display: message ? 'grid' : 'none', marginTop: '10px' }}
|
||||
>
|
||||
<Col span={24}>
|
||||
<Alert
|
||||
message={message}
|
||||
type={icon}
|
||||
showIcon
|
||||
action={
|
||||
showTroubleshootButton && (
|
||||
<Link passHref href="/stream-health">
|
||||
<Button size="small" type="text" style={{ color: 'black' }}>
|
||||
TROUBLESHOOT
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
StreamHealthOverview.defaultProps = {
|
||||
showTroubleshootButton: true,
|
||||
};
|
||||
176
web/components/admin/TextField.tsx
Normal file
176
web/components/admin/TextField.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { FC } 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 './FormStatusIndicator';
|
||||
|
||||
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 type TextFieldProps = {
|
||||
fieldName: string;
|
||||
|
||||
onSubmit?: () => void;
|
||||
onPressEnter?: () => void;
|
||||
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
status?: StatusState;
|
||||
tip?: string;
|
||||
type?: string;
|
||||
useTrim?: boolean;
|
||||
useTrimLead?: boolean;
|
||||
value?: string | number;
|
||||
onBlur?: FieldUpdaterFunc;
|
||||
onChange?: FieldUpdaterFunc;
|
||||
};
|
||||
|
||||
export const TextField: FC<TextFieldProps> = ({
|
||||
className,
|
||||
disabled,
|
||||
fieldName,
|
||||
label,
|
||||
maxLength,
|
||||
onBlur,
|
||||
onChange,
|
||||
onPressEnter,
|
||||
pattern,
|
||||
placeholder,
|
||||
required,
|
||||
status,
|
||||
tip,
|
||||
type,
|
||||
useTrim,
|
||||
value,
|
||||
}) => {
|
||||
const handleChange = (e: any) => {
|
||||
// if an extra onChange handler was sent in as a prop, let's run that too.
|
||||
if (onChange) {
|
||||
const val = type === TEXTFIELD_TYPE_NUMBER ? e : e.target.value;
|
||||
onChange({ fieldName, value: useTrim ? val.trim() : 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',
|
||||
pattern,
|
||||
};
|
||||
}
|
||||
|
||||
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}
|
||||
{...(type !== TEXTFIELD_TYPE_NUMBER && { allowClear: true })}
|
||||
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>
|
||||
);
|
||||
};
|
||||
export default TextField;
|
||||
|
||||
TextField.defaultProps = {
|
||||
className: '',
|
||||
disabled: false,
|
||||
label: '',
|
||||
maxLength: 255,
|
||||
|
||||
placeholder: '',
|
||||
required: false,
|
||||
status: null,
|
||||
tip: '',
|
||||
type: TEXTFIELD_TYPE_TEXT,
|
||||
value: '',
|
||||
|
||||
pattern: '',
|
||||
useTrim: false,
|
||||
useTrimLead: false,
|
||||
|
||||
onSubmit: () => {},
|
||||
onBlur: () => {},
|
||||
onChange: () => {},
|
||||
onPressEnter: () => {},
|
||||
};
|
||||
156
web/components/admin/TextFieldWithSubmit.tsx
Normal file
156
web/components/admin/TextFieldWithSubmit.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Button } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import React, { FC, useContext, useEffect, useState } from 'react';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import { postConfigUpdateToAPI, RESET_TIMEOUT } from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { FormStatusIndicator } from './FormStatusIndicator';
|
||||
import { TextField, TextFieldProps } from './TextField';
|
||||
|
||||
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';
|
||||
|
||||
export type TextFieldWithSubmitProps = TextFieldProps & {
|
||||
apiPath: string;
|
||||
configPath?: string;
|
||||
initialValue?: string;
|
||||
};
|
||||
|
||||
export const TextFieldWithSubmit: FC<TextFieldWithSubmitProps> = ({
|
||||
apiPath,
|
||||
configPath = '',
|
||||
initialValue,
|
||||
useTrim,
|
||||
useTrimLead,
|
||||
...textFieldProps // rest of props
|
||||
}) => {
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
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) {
|
||||
let newValue: string = changedValue;
|
||||
if (useTrim) {
|
||||
newValue = changedValue.trim();
|
||||
} else if (useTrimLead) {
|
||||
newValue = changedValue.replace(/^\s+/g, '');
|
||||
}
|
||||
onChange({
|
||||
fieldName: changedFieldName,
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 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: '',
|
||||
};
|
||||
121
web/components/admin/ToggleSwitch.tsx
Normal file
121
web/components/admin/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
// 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, FC } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import { FormStatusIndicator } from './FormStatusIndicator';
|
||||
|
||||
import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constants';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
|
||||
export type ToggleSwitchProps = {
|
||||
fieldName: string;
|
||||
|
||||
apiPath?: string;
|
||||
checked?: boolean;
|
||||
reversed?: boolean;
|
||||
configPath?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
tip?: string;
|
||||
useSubmit?: boolean;
|
||||
onChange?: (arg: boolean) => void;
|
||||
};
|
||||
|
||||
export const ToggleSwitch: FC<ToggleSwitchProps> = ({
|
||||
apiPath,
|
||||
checked,
|
||||
reversed = false,
|
||||
configPath = '',
|
||||
disabled = false,
|
||||
fieldName,
|
||||
label,
|
||||
tip,
|
||||
useSubmit,
|
||||
onChange,
|
||||
}) => {
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
clearTimeout(resetTimer);
|
||||
resetTimer = null;
|
||||
};
|
||||
|
||||
const handleChange = async (isChecked: boolean) => {
|
||||
if (useSubmit) {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
const isCheckedSend = reversed ? !isChecked : isChecked;
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath,
|
||||
data: { value: isCheckedSend },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({ fieldName, value: isCheckedSend, 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>
|
||||
);
|
||||
};
|
||||
export default ToggleSwitch;
|
||||
|
||||
ToggleSwitch.defaultProps = {
|
||||
apiPath: '',
|
||||
checked: false,
|
||||
reversed: false,
|
||||
configPath: '',
|
||||
disabled: false,
|
||||
label: '',
|
||||
tip: '',
|
||||
useSubmit: false,
|
||||
onChange: null,
|
||||
};
|
||||
156
web/components/admin/UserPopover.tsx
Normal file
156
web/components/admin/UserPopover.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// This displays a clickable user name (or whatever children element you provide), and displays a simple tooltip of created time. OnClick a modal with more information about the user is displayed.
|
||||
|
||||
import { useState, ReactNode, FC } from 'react';
|
||||
import { Divider, Modal, Typography, Row, Col, Space } from 'antd';
|
||||
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
|
||||
import format from 'date-fns/format';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { BanUserButton } from './BanUserButton';
|
||||
import { ModeratorUserButton } from './ModeratorUserButton';
|
||||
|
||||
import { User, UserConnectionInfo } from '../../types/chat';
|
||||
import { formatDisplayDate } from './UserTable';
|
||||
import { formatUAstring } from '../../utils/format';
|
||||
|
||||
// Lazy loaded components
|
||||
|
||||
const Tooltip = dynamic(() => import('antd').then(mod => mod.Tooltip));
|
||||
|
||||
export type UserPopoverProps = {
|
||||
user: User;
|
||||
connectionInfo?: UserConnectionInfo | null;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const UserPopover: FC<UserPopoverProps> = ({ user, connectionInfo, children }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const handleShowModal = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const { displayName, createdAt, previousNames, nameChangedAt, disabledAt } = user;
|
||||
const { connectedAt, messageCount, userAgent } = connectionInfo || {};
|
||||
|
||||
let lastNameChangeDate = null;
|
||||
const nameList = previousNames && [...previousNames];
|
||||
|
||||
if (previousNames && previousNames.length > 1 && nameChangedAt) {
|
||||
lastNameChangeDate = new Date(nameChangedAt);
|
||||
// reverse prev names for display purposes
|
||||
nameList.reverse();
|
||||
}
|
||||
|
||||
const dateObject = new Date(createdAt);
|
||||
const createdAtDate = format(dateObject, 'PP pp');
|
||||
|
||||
const lastNameChangeDuration = lastNameChangeDate
|
||||
? formatDistanceToNow(lastNameChangeDate)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
Created at: {createdAtDate}.
|
||||
<br /> Click for more info.
|
||||
</>
|
||||
}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Display more details about this user"
|
||||
className="user-item-container"
|
||||
onClick={handleShowModal}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Modal
|
||||
destroyOnClose
|
||||
width={600}
|
||||
cancelText="Close"
|
||||
okButtonProps={{ style: { display: 'none' } }}
|
||||
title={`User details: ${displayName}`}
|
||||
open={isModalOpen}
|
||||
onOk={handleCloseModal}
|
||||
onCancel={handleCloseModal}
|
||||
>
|
||||
<div className="user-details">
|
||||
<Typography.Title level={4}>{displayName}</Typography.Title>
|
||||
<p className="created-at">User created at {createdAtDate}.</p>
|
||||
<Row gutter={16}>
|
||||
{connectionInfo && (
|
||||
<Col md={lastNameChangeDate ? 12 : 24}>
|
||||
<Typography.Title level={5}>
|
||||
This user is currently connected to Chat.
|
||||
</Typography.Title>
|
||||
<ul className="connection-info">
|
||||
<li>
|
||||
<strong>Active for:</strong> {formatDistanceToNow(new Date(connectedAt))}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Messages sent:</strong> {messageCount}
|
||||
</li>
|
||||
<li>
|
||||
<strong>User Agent:</strong>
|
||||
<br />
|
||||
{formatUAstring(userAgent)}
|
||||
</li>
|
||||
</ul>
|
||||
</Col>
|
||||
)}
|
||||
{lastNameChangeDate && (
|
||||
<Col md={connectionInfo ? 12 : 24}>
|
||||
<Typography.Title level={5}>This user is also seen as:</Typography.Title>
|
||||
<ul className="previous-names-list">
|
||||
{uniq(nameList).map((name, index) => (
|
||||
<li className={index === 0 ? 'latest' : ''}>
|
||||
<span className="user-name-item">{name}</span>
|
||||
{index === 0 ? ` (Changed ${lastNameChangeDuration} ago)` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
<Divider />
|
||||
<Space direction="horizontal">
|
||||
{disabledAt ? (
|
||||
<>
|
||||
This user was banned on <code>{formatDisplayDate(disabledAt)}</code>.
|
||||
<br />
|
||||
<br />
|
||||
<BanUserButton
|
||||
label="Unban this user"
|
||||
user={user}
|
||||
isEnabled={false}
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<BanUserButton
|
||||
label="Ban this user"
|
||||
user={user}
|
||||
isEnabled
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
<ModeratorUserButton user={user} onClick={handleCloseModal} />
|
||||
</Space>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
UserPopover.defaultProps = {
|
||||
connectionInfo: null,
|
||||
};
|
||||
66
web/components/admin/UserTable.tsx
Normal file
66
web/components/admin/UserTable.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Table } from 'antd';
|
||||
import format from 'date-fns/format';
|
||||
import { SortOrder } from 'antd/lib/table/interface';
|
||||
import { FC } from 'react';
|
||||
import { User } from '../../types/chat';
|
||||
import { UserPopover } from './UserPopover';
|
||||
import { BanUserButton } from './BanUserButton';
|
||||
|
||||
export function formatDisplayDate(date: string | Date) {
|
||||
return format(new Date(date), 'MMM d H:mma');
|
||||
}
|
||||
|
||||
export type UserTableProps = {
|
||||
data: User[];
|
||||
};
|
||||
|
||||
export const UserTable: FC<UserTableProps> = ({ data }) => {
|
||||
const columns = [
|
||||
{
|
||||
title: 'Last Known Display Name',
|
||||
dataIndex: 'displayName',
|
||||
key: 'displayName',
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
render: (displayName: string, user: User) => (
|
||||
<UserPopover user={user}>
|
||||
<span className="display-name">{displayName}</span>
|
||||
</UserPopover>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: Date) => formatDisplayDate(date),
|
||||
sorter: (a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
},
|
||||
{
|
||||
title: 'Disabled at',
|
||||
dataIndex: 'disabledAt',
|
||||
key: 'disabledAt',
|
||||
defaultSortOrder: 'descend' as SortOrder,
|
||||
render: (date: Date) => (date ? formatDisplayDate(date) : null),
|
||||
sorter: (a: any, b: any) =>
|
||||
new Date(a.disabledAt).getTime() - new Date(b.disabledAt).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'block',
|
||||
className: 'actions-col',
|
||||
render: (_, user) => <BanUserButton user={user} isEnabled={!user.disabledAt} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
pagination={{ hideOnSinglePage: true }}
|
||||
className="table-container"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
size="small"
|
||||
rowKey="id"
|
||||
/>
|
||||
);
|
||||
};
|
||||
134
web/components/admin/VideoLatency.tsx
Normal file
134
web/components/admin/VideoLatency.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useContext, useState, useEffect, FC } 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 './FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const SLIDER_MARKS = {
|
||||
0: 'Lowest',
|
||||
1: '',
|
||||
2: '',
|
||||
3: '',
|
||||
4: 'Highest',
|
||||
};
|
||||
|
||||
const SLIDER_COMMENTS = {
|
||||
0: 'Lowest latency, lowest error tolerance (Not recommended, may not work for all content/configurations.)',
|
||||
1: 'Low latency, low error tolerance',
|
||||
2: 'Medium latency, medium error tolerance (Default)',
|
||||
3: 'High latency, high error tolerance',
|
||||
4: 'Highest latency, highest error tolerance',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const VideoLatency: FC = () => {
|
||||
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's natural to want to keep your latency as low as possible, you may experience
|
||||
reduced error tolerance and stability the lower you go. The lowest setting is not
|
||||
recommended.
|
||||
</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?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read to learn more.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div className="segment-slider-container">
|
||||
<Slider
|
||||
tipFormatter={value => SLIDER_COMMENTS[value]}
|
||||
onChange={handleChange}
|
||||
min={0}
|
||||
max={4}
|
||||
marks={SLIDER_MARKS}
|
||||
defaultValue={selectedOption}
|
||||
value={selectedOption}
|
||||
/>
|
||||
<p className="selected-value-note">{SLIDER_COMMENTS[selectedOption]}</p>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
336
web/components/admin/VideoVariantForm.tsx
Normal file
336
web/components/admin/VideoVariantForm.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
// This content populates the video variant modal, which is spawned from the variants table. This relies on the `dataState` prop fed in by the table.
|
||||
import React, { FC } from 'react';
|
||||
import { Popconfirm, Row, Col, Slider, Collapse, Typography, Alert, Button } from 'antd';
|
||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import classNames from 'classnames';
|
||||
import { FieldUpdaterFunc, VideoVariant, UpdateArgs } from '../../types/config-section';
|
||||
import { TextField } from './TextField';
|
||||
import {
|
||||
DEFAULT_VARIANT_STATE,
|
||||
VIDEO_VARIANT_SETTING_DEFAULTS,
|
||||
VIDEO_NAME_DEFAULTS,
|
||||
ENCODER_PRESET_SLIDER_MARKS,
|
||||
ENCODER_PRESET_TOOLTIPS,
|
||||
VIDEO_BITRATE_DEFAULTS,
|
||||
VIDEO_BITRATE_SLIDER_MARKS,
|
||||
FRAMERATE_SLIDER_MARKS,
|
||||
FRAMERATE_DEFAULTS,
|
||||
FRAMERATE_TOOLTIPS,
|
||||
} from '../../utils/config-constants';
|
||||
import { ToggleSwitch } from './ToggleSwitch';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
export type VideoVariantFormProps = {
|
||||
dataState: VideoVariant;
|
||||
onUpdateField: FieldUpdaterFunc;
|
||||
};
|
||||
|
||||
export const VideoVariantForm: FC<VideoVariantFormProps> = ({
|
||||
dataState = DEFAULT_VARIANT_STATE,
|
||||
onUpdateField,
|
||||
}) => {
|
||||
const videoPassthroughEnabled = dataState.videoPassthrough;
|
||||
|
||||
const handleFramerateChange = (value: number) => {
|
||||
onUpdateField({ fieldName: 'framerate', value });
|
||||
};
|
||||
const handleVideoBitrateChange = (value: number) => {
|
||||
onUpdateField({ fieldName: 'videoBitrate', 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 || 0 });
|
||||
};
|
||||
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 || 0 });
|
||||
};
|
||||
|
||||
// Video passthrough handling
|
||||
const handleVideoPassConfirm = () => {
|
||||
onUpdateField({ fieldName: 'videoPassthrough', value: true });
|
||||
};
|
||||
// If passthrough is currently on, set it back to false on toggle.
|
||||
// Else let the Popconfirm turn it on.
|
||||
const handleVideoPassthroughToggle = (value: boolean) => {
|
||||
if (videoPassthroughEnabled) {
|
||||
onUpdateField({ fieldName: 'videoPassthrough', value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChanged = (args: UpdateArgs) => {
|
||||
onUpdateField({ fieldName: 'name', value: args.value });
|
||||
};
|
||||
|
||||
// Slider notes
|
||||
const selectedVideoBRnote = () => {
|
||||
if (videoPassthroughEnabled) {
|
||||
return 'Bitrate selection is disabled when Video Passthrough is enabled.';
|
||||
}
|
||||
let note = `${dataState.videoBitrate}${VIDEO_BITRATE_DEFAULTS.unit}`;
|
||||
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 = () => {
|
||||
if (videoPassthroughEnabled) {
|
||||
return 'Framerate selection is disabled when Video Passthrough is enabled.';
|
||||
}
|
||||
return FRAMERATE_TOOLTIPS[dataState.framerate] || '';
|
||||
};
|
||||
const cpuUsageNote = () => {
|
||||
if (videoPassthroughEnabled) {
|
||||
return 'CPU usage selection is disabled when Video Passthrough is enabled.';
|
||||
}
|
||||
return ENCODER_PRESET_TOOLTIPS[dataState.cpuUsageLevel] || '';
|
||||
};
|
||||
|
||||
const classes = classNames({
|
||||
'config-variant-form': true,
|
||||
'video-passthrough-enabled': videoPassthroughEnabled,
|
||||
});
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className="video-varient-alert">
|
||||
<Alert
|
||||
type="info"
|
||||
action={
|
||||
<a
|
||||
href="https://owncast.online/docs/video?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="video-varient-alert-button-container">
|
||||
<Button size="small" type="text" icon={<ExclamationCircleFilled />}>
|
||||
Read more about how each of these settings can impact the performance of your
|
||||
server.
|
||||
</Button>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{videoPassthroughEnabled && (
|
||||
<p className="passthrough-warning">
|
||||
NOTE: Video Passthrough for this output stream variant is <em>enabled</em>, disabling the
|
||||
below video encoding settings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={{ span: 24, pull: 3 }} className="video-text-field-container">
|
||||
<TextField
|
||||
maxLength="10"
|
||||
{...VIDEO_NAME_DEFAULTS}
|
||||
value={dataState.name}
|
||||
onChange={handleNameChanged}
|
||||
/>
|
||||
</Col>
|
||||
<Col sm={24} md={12}>
|
||||
<div className="form-module cpu-usage-container">
|
||||
<Typography.Title level={3}>CPU or GPU Utilization</Typography.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 => ENCODER_PRESET_TOOLTIPS[value]}
|
||||
onChange={handleVideoCpuUsageLevelChange}
|
||||
min={1}
|
||||
max={Object.keys(ENCODER_PRESET_SLIDER_MARKS).length}
|
||||
marks={ENCODER_PRESET_SLIDER_MARKS}
|
||||
defaultValue={dataState.cpuUsageLevel}
|
||||
value={dataState.cpuUsageLevel}
|
||||
disabled={dataState.videoPassthrough}
|
||||
/>
|
||||
<p className="selected-value-note">{cpuUsageNote()}</p>
|
||||
</div>
|
||||
<p className="read-more-subtext">
|
||||
This could mean GPU or CPU usage depending on your server environment.
|
||||
<br />
|
||||
<br />
|
||||
<a
|
||||
href="https://owncast.online/docs/video/?source=admin#cpu-usage"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read more about hardware performance.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Col>
|
||||
<Col sm={24} md={11} offset={1}>
|
||||
{/* VIDEO BITRATE FIELD */}
|
||||
<div
|
||||
className={`form-module bitrate-container ${
|
||||
dataState.videoPassthrough ? 'disabled' : ''
|
||||
}`}
|
||||
>
|
||||
<Typography.Title level={3}>Video Bitrate</Typography.Title>
|
||||
<p className="description">{VIDEO_BITRATE_DEFAULTS.tip}</p>
|
||||
<div className="segment-slider-container">
|
||||
<Slider
|
||||
tipFormatter={value => `${value} ${VIDEO_BITRATE_DEFAULTS.unit}`}
|
||||
disabled={dataState.videoPassthrough}
|
||||
defaultValue={dataState.videoBitrate}
|
||||
value={dataState.videoBitrate}
|
||||
onChange={handleVideoBitrateChange}
|
||||
step={VIDEO_BITRATE_DEFAULTS.incrementBy}
|
||||
min={VIDEO_BITRATE_DEFAULTS.min}
|
||||
max={VIDEO_BITRATE_DEFAULTS.max}
|
||||
marks={VIDEO_BITRATE_SLIDER_MARKS}
|
||||
/>
|
||||
<p className="selected-value-note">{selectedVideoBRnote()}</p>
|
||||
</div>
|
||||
<p className="read-more-subtext">
|
||||
<a
|
||||
href="https://owncast.online/docs/video/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read more about bitrates.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Collapse className="advanced-settings">
|
||||
<Panel header="Advanced Settings" key="1">
|
||||
<Row gutter={16}>
|
||||
<Col sm={24} md={12}>
|
||||
<div className="form-module resolution-module">
|
||||
<Typography.Title level={3}>Resolution</Typography.Title>
|
||||
<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. <br />
|
||||
<br />
|
||||
<a
|
||||
href="https://owncast.online/docs/video/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read more about resolutions.
|
||||
</a>
|
||||
</p>
|
||||
<br />
|
||||
<TextField
|
||||
type="number"
|
||||
{...VIDEO_VARIANT_SETTING_DEFAULTS.scaledWidth}
|
||||
value={dataState.scaledWidth}
|
||||
onChange={handleScaledWidthChanged}
|
||||
disabled={dataState.videoPassthrough}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
{...VIDEO_VARIANT_SETTING_DEFAULTS.scaledHeight}
|
||||
value={dataState.scaledHeight}
|
||||
onChange={handleScaledHeightChanged}
|
||||
disabled={dataState.videoPassthrough}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col sm={24} md={12}>
|
||||
{/* VIDEO PASSTHROUGH FIELD */}
|
||||
<div className="form-module video-passthrough-module">
|
||||
<Typography.Title level={3}>Video Passthrough</Typography.Title>
|
||||
<div className="description">
|
||||
<p>
|
||||
Enabling video passthrough may allow for less hardware utilization, but may also
|
||||
make your stream <strong>unplayable</strong>.
|
||||
</p>
|
||||
<p>
|
||||
All other settings for this stream output will be disabled if passthrough is
|
||||
used.
|
||||
</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://owncast.online/docs/video/?source=admin#video-passthrough"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read the documentation before enabling, as it impacts your stream.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="advanced-switch-container">
|
||||
<Popconfirm
|
||||
disabled={dataState.videoPassthrough === true}
|
||||
title="Did you read the documentation about video passthrough and understand the risks involved with enabling it?"
|
||||
icon={<ExclamationCircleFilled />}
|
||||
onConfirm={handleVideoPassConfirm}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
{/* adding an <a> tag to force Popcofirm to register click on toggle */}
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
<a href="#">
|
||||
<div className="advanced-description-switch-container">
|
||||
<div className="advanced-description-wrapper">
|
||||
<p>Use Video Passthrough?</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
label=""
|
||||
fieldName="video-passthrough"
|
||||
checked={dataState.videoPassthrough}
|
||||
onChange={handleVideoPassthroughToggle}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<p>*{VIDEO_VARIANT_SETTING_DEFAULTS.videoPassthrough.tip}</p>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* FRAME RATE FIELD */}
|
||||
<div className="form-module frame-rate-module">
|
||||
<Typography.Title level={3}>Frame rate</Typography.Title>
|
||||
<p className="description">{FRAMERATE_DEFAULTS.tip}</p>
|
||||
<div className="segment-slider-container">
|
||||
<Slider
|
||||
tipFormatter={value => `${value} ${FRAMERATE_DEFAULTS.unit}`}
|
||||
defaultValue={dataState.framerate}
|
||||
value={dataState.framerate}
|
||||
onChange={handleFramerateChange}
|
||||
step={FRAMERATE_DEFAULTS.incrementBy}
|
||||
min={FRAMERATE_DEFAULTS.min}
|
||||
max={FRAMERATE_DEFAULTS.max}
|
||||
marks={FRAMERATE_SLIDER_MARKS}
|
||||
disabled={dataState.videoPassthrough}
|
||||
/>
|
||||
<p className="selected-value-note">{selectedFramerateNote()}</p>
|
||||
</div>
|
||||
<p className="read-more-subtext">
|
||||
<a
|
||||
href="https://owncast.online/docs/video/?source=admin#framerate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read more about framerates.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
52
web/components/admin/ViewerTable.tsx
Normal file
52
web/components/admin/ViewerTable.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Table } from 'antd';
|
||||
import format from 'date-fns/format';
|
||||
import { SortOrder } from 'antd/lib/table/interface';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { FC } from 'react';
|
||||
import { User } from '../../types/chat';
|
||||
import { formatUAstring } from '../../utils/format';
|
||||
|
||||
export function formatDisplayDate(date: string | Date) {
|
||||
return format(new Date(date), 'MMM d H:mma');
|
||||
}
|
||||
|
||||
export type ViewerTableProps = {
|
||||
data: User[];
|
||||
};
|
||||
|
||||
export const ViewerTable: FC<ViewerTableProps> = ({ data }) => {
|
||||
const columns = [
|
||||
{
|
||||
title: 'User Agent',
|
||||
dataIndex: 'userAgent',
|
||||
key: 'userAgent',
|
||||
render: (ua: string) => formatUAstring(ua),
|
||||
},
|
||||
{
|
||||
title: 'Location',
|
||||
dataIndex: 'geo',
|
||||
key: 'geo',
|
||||
render: geo => (geo ? `${geo.regionName}, ${geo.countryCode}` : '-'),
|
||||
},
|
||||
{
|
||||
title: 'Watch Time',
|
||||
dataIndex: 'firstSeen',
|
||||
key: 'firstSeen',
|
||||
defaultSortOrder: 'ascend' as SortOrder,
|
||||
render: (time: Date) => formatDistanceToNow(new Date(time)),
|
||||
sorter: (a: any, b: any) => new Date(a.firstSeen).getTime() - new Date(b.firstSeen).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
pagination={{ hideOnSinglePage: true }}
|
||||
className="table-container"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
size="small"
|
||||
rowKey="id"
|
||||
/>
|
||||
);
|
||||
};
|
||||
313
web/components/admin/config/general/AppearanceConfig.tsx
Normal file
313
web/components/admin/config/general/AppearanceConfig.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { Button, Col, Collapse, Row, Slider, Space } from 'antd';
|
||||
import Paragraph from 'antd/lib/typography/Paragraph';
|
||||
import Title from 'antd/lib/typography/Title';
|
||||
import { EditCustomStyles } from '../../EditCustomStyles';
|
||||
import s from './appearance.module.scss';
|
||||
import { postConfigUpdateToAPI, RESET_TIMEOUT } from '../../../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../../../utils/input-statuses';
|
||||
import { ServerStatusContext } from '../../../../utils/server-status-context';
|
||||
import { FormStatusIndicator } from '../../FormStatusIndicator';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
const ENDPOINT = '/appearance';
|
||||
|
||||
interface AppearanceVariable {
|
||||
value: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const chatColorVariables = [
|
||||
{ name: 'theme-color-users-0', description: '' },
|
||||
{ name: 'theme-color-users-1', description: '' },
|
||||
{ name: 'theme-color-users-2', description: '' },
|
||||
{ name: 'theme-color-users-3', description: '' },
|
||||
{ name: 'theme-color-users-4', description: '' },
|
||||
{ name: 'theme-color-users-5', description: '' },
|
||||
{ name: 'theme-color-users-6', description: '' },
|
||||
{ name: 'theme-color-users-7', description: '' },
|
||||
];
|
||||
|
||||
const paletteVariables = [
|
||||
{ name: 'theme-color-palette-0', description: '' },
|
||||
{ name: 'theme-color-palette-1', description: '' },
|
||||
{ name: 'theme-color-palette-2', description: '' },
|
||||
{ name: 'theme-color-palette-3', description: '' },
|
||||
{ name: 'theme-color-palette-4', description: '' },
|
||||
{ name: 'theme-color-palette-5', description: '' },
|
||||
{ name: 'theme-color-palette-6', description: '' },
|
||||
{ name: 'theme-color-palette-7', description: '' },
|
||||
{ name: 'theme-color-palette-8', description: '' },
|
||||
{ name: 'theme-color-palette-9', description: '' },
|
||||
{ name: 'theme-color-palette-10', description: '' },
|
||||
{ name: 'theme-color-palette-11', description: '' },
|
||||
{ name: 'theme-color-palette-12', description: '' },
|
||||
];
|
||||
|
||||
const componentColorVariables = [
|
||||
{ name: 'theme-color-background-main', description: 'Background' },
|
||||
{ name: 'theme-color-action', description: 'Action' },
|
||||
{ name: 'theme-color-action-hover', description: 'Action Hover' },
|
||||
{ name: 'theme-color-components-chat-background', description: 'Chat Background' },
|
||||
{ name: 'theme-color-components-chat-text', description: 'Text: Chat' },
|
||||
{ name: 'theme-color-components-text-on-dark', description: 'Text: Light' },
|
||||
{ name: 'theme-color-components-text-on-light', description: 'Text: Dark' },
|
||||
{ name: 'theme-color-background-header', description: 'Header/Footer' },
|
||||
{ name: 'theme-color-components-content-background', description: 'Page Content' },
|
||||
{ name: 'theme-color-components-scrollbar-background', description: 'Scrollbar Background' },
|
||||
{ name: 'theme-color-components-scrollbar-thumb', description: 'Scrollbar Thumb' },
|
||||
];
|
||||
|
||||
const others = [{ name: 'theme-rounded-corners', description: 'Corner radius' }];
|
||||
|
||||
// Create an object so these vars can be indexed by name.
|
||||
const allAvailableValues = [
|
||||
...paletteVariables,
|
||||
...componentColorVariables,
|
||||
...chatColorVariables,
|
||||
...others,
|
||||
].reduce((obj, val) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
obj[val.name] = { name: val.name, description: val.description };
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
function ColorPicker({
|
||||
value,
|
||||
name,
|
||||
description,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
name: string;
|
||||
description: string;
|
||||
onChange: (name: string, value: string, description: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Col span={3} key={name}>
|
||||
<input
|
||||
type="color"
|
||||
id={name}
|
||||
name={description}
|
||||
title={description}
|
||||
value={value}
|
||||
className={s.colorPicker}
|
||||
onChange={e => onChange(name, e.target.value, description)}
|
||||
/>
|
||||
<div style={{ padding: '2px' }}>{description}</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function Appearance() {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig } = serverStatusData;
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { appearanceVariables } = instanceDetails;
|
||||
|
||||
const [colors, setColors] = useState<Record<string, AppearanceVariable>>();
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
let resetTimer = null;
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
const setColorDefaults = () => {
|
||||
const c = {};
|
||||
[...paletteVariables, ...componentColorVariables, ...chatColorVariables, ...others].forEach(
|
||||
color => {
|
||||
const resolvedColor = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
`--${color.name}`,
|
||||
);
|
||||
c[color.name] = { value: resolvedColor.trim(), description: color.description };
|
||||
},
|
||||
);
|
||||
setColors(c);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setColorDefaults();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(appearanceVariables).length === 0) return;
|
||||
|
||||
const c = colors || {};
|
||||
Object.keys(appearanceVariables).forEach(key => {
|
||||
c[key] = {
|
||||
value: appearanceVariables[key],
|
||||
description: allAvailableValues[key]?.description || '',
|
||||
};
|
||||
});
|
||||
setColors(c);
|
||||
}, [appearanceVariables]);
|
||||
|
||||
const updateColor = (variable: string, color: string, description: string) => {
|
||||
setColors({
|
||||
...colors,
|
||||
[variable]: { value: color, description },
|
||||
});
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: ENDPOINT,
|
||||
data: { value: {} },
|
||||
onSuccess: () => {
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
setColorDefaults();
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const c = {};
|
||||
Object.keys(colors).forEach(color => {
|
||||
c[color] = colors[color].value;
|
||||
});
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: ENDPOINT,
|
||||
data: { value: c },
|
||||
onSuccess: () => {
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onBorderRadiusChange = (value: string) => {
|
||||
const variableName = 'theme-rounded-corners';
|
||||
|
||||
updateColor(variableName, `${value.toString()}px`, '');
|
||||
};
|
||||
|
||||
if (!colors) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical">
|
||||
<Title>Customize Appearance</Title>
|
||||
<Paragraph>The following colors are used across the user interface.</Paragraph>
|
||||
<div>
|
||||
<Collapse defaultActiveKey={['1']}>
|
||||
<Panel header={<Title level={3}>Section Colors</Title>} key="1">
|
||||
<p>
|
||||
Certain sections of the interface can be customized by selecting new colors for them.
|
||||
</p>
|
||||
<Row gutter={[16, 16]}>
|
||||
{componentColorVariables.map(colorVar => {
|
||||
const { name } = colorVar;
|
||||
const c = colors[name];
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={c.value}
|
||||
name={name}
|
||||
description={c.description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Panel>
|
||||
<Panel header={<Title level={3}>Chat User Colors</Title>} key="2">
|
||||
<Row gutter={[16, 16]}>
|
||||
{chatColorVariables.map(colorVar => {
|
||||
const { name } = colorVar;
|
||||
const c = colors[name];
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={c.value}
|
||||
name={name}
|
||||
description={c.description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Panel>
|
||||
<Panel header={<Title level={3}>Theme Colors</Title>} key="3">
|
||||
<Row gutter={[16, 16]}>
|
||||
{paletteVariables.map(colorVar => {
|
||||
const { name } = colorVar;
|
||||
const c = colors[name];
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={c.value}
|
||||
name={name}
|
||||
description={c.description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Panel>
|
||||
<Panel header={<Title level={3}>Other Settings</Title>} key="4">
|
||||
How rounded should corners be?
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
tooltip={{ formatter: null }}
|
||||
onChange={v => {
|
||||
onBorderRadiusChange(v);
|
||||
}}
|
||||
value={Number(colors['theme-rounded-corners']?.value?.replace('px', '') || 0)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<div
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '30px',
|
||||
borderRadius: `${colors['theme-rounded-corners']?.value}`,
|
||||
backgroundColor: 'var(--theme-color-palette-7)',
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<Space direction="horizontal">
|
||||
<Button type="primary" onClick={save}>
|
||||
Save Colors
|
||||
</Button>
|
||||
<Button type="ghost" onClick={reset}>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</Space>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
<div className="form-module page-content-module">
|
||||
<EditCustomStyles />
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
165
web/components/admin/config/general/EditInstanceDetails.tsx
Normal file
165
web/components/admin/config/general/EditInstanceDetails.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import {
|
||||
TextFieldWithSubmit,
|
||||
TEXTFIELD_TYPE_TEXTAREA,
|
||||
TEXTFIELD_TYPE_URL,
|
||||
} from '../../TextFieldWithSubmit';
|
||||
import { ServerStatusContext } from '../../../../utils/server-status-context';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
TEXTFIELD_PROPS_INSTANCE_URL,
|
||||
TEXTFIELD_PROPS_SERVER_NAME,
|
||||
TEXTFIELD_PROPS_SERVER_SUMMARY,
|
||||
TEXTFIELD_PROPS_SERVER_OFFLINE_MESSAGE,
|
||||
API_YP_SWITCH,
|
||||
FIELD_PROPS_YP,
|
||||
FIELD_PROPS_NSFW,
|
||||
FIELD_PROPS_HIDE_VIEWER_COUNT,
|
||||
} from '../../../../utils/config-constants';
|
||||
import { UpdateArgs } from '../../../../types/config-section';
|
||||
import { ToggleSwitch } from '../../ToggleSwitch';
|
||||
import { EditLogo } from '../../EditLogo';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
function handleHideViewerCountChange(enabled: boolean) {
|
||||
handleFieldChange({ fieldName: 'hideViewerCount', value: enabled });
|
||||
}
|
||||
|
||||
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="offlineMessage"
|
||||
{...TEXTFIELD_PROPS_SERVER_OFFLINE_MESSAGE}
|
||||
type={TEXTFIELD_TYPE_TEXTAREA}
|
||||
value={formDataValues.offlineMessage}
|
||||
initialValue={instanceDetails.offlineMessage}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
{/* Logo section */}
|
||||
<EditLogo />
|
||||
|
||||
<ToggleSwitch
|
||||
fieldName="hideViewerCount"
|
||||
useSubmit
|
||||
{...FIELD_PROPS_HIDE_VIEWER_COUNT}
|
||||
checked={formDataValues.hideViewerCount}
|
||||
onChange={handleHideViewerCountChange}
|
||||
/>
|
||||
|
||||
<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/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{!yp.instanceUrl && (
|
||||
<p className="description">
|
||||
You must set your <strong>Server URL</strong> above to enable the directory.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
139
web/components/admin/config/general/EditInstanceTags.tsx
Normal file
139
web/components/admin/config/general/EditInstanceTags.tsx
Normal 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 '../../TextField';
|
||||
import { UpdateArgs } from '../../../../types/config-section';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
STATUS_WARNING,
|
||||
} from '../../../../utils/input-statuses';
|
||||
import { TAG_COLOR } from '../../EditValueArray';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
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(
|
||||
() => () => {
|
||||
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="edit-current-strings">
|
||||
{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-string-section">
|
||||
<TextField
|
||||
fieldName="tag-input"
|
||||
value={newTagInput}
|
||||
className="new-tag-input"
|
||||
onChange={handleInputChange}
|
||||
onPressEnter={handleSubmitNewTag}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
status={submitStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
web/components/admin/config/general/EditPageContent.tsx
Normal file
119
web/components/admin/config/general/EditPageContent.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// EDIT CUSTOM DETAILS ON YOUR PAGE
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Typography, Button } from 'antd';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { bbedit } from '@uiw/codemirror-theme-bbedit';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
|
||||
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 '../../FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
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/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Markdown syntax
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<CodeMirror
|
||||
value={content}
|
||||
placeholder="Enter your custom page content here..."
|
||||
theme={bbedit}
|
||||
onChange={handleEditorChange}
|
||||
extensions={[markdown({ base: markdownLanguage, codeLanguages: languages })]}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<div className="page-content-actions">
|
||||
{hasChanged && (
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
web/components/admin/config/general/EditSocialLinks.tsx
Normal file
368
web/components/admin/config/general/EditSocialLinks.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Typography, Table, Button, Modal, Input } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { CaretDownOutlined, CaretUpOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { SocialDropdown } from '../../SocialDropdown';
|
||||
import { fetchData, 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 {
|
||||
isValidMatrixAccount,
|
||||
isValidAccount,
|
||||
isValidUrl,
|
||||
DEFAULT_TEXTFIELD_URL_PATTERN,
|
||||
} from '../../../../utils/urls';
|
||||
import { TextField } from '../../TextField';
|
||||
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../../../utils/input-statuses';
|
||||
import { FormStatusIndicator } from '../../FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
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 isPredefinedSocial = (platform: string) =>
|
||||
availableIconsList.find(item => item.key === platform) || false;
|
||||
|
||||
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}`));
|
||||
setModalProcessing(false);
|
||||
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 handleMoveItemUp = (index: number) => {
|
||||
if (index <= 0 || index >= currentSocialHandles.length) {
|
||||
return;
|
||||
}
|
||||
const postData = [...currentSocialHandles];
|
||||
const tmp = postData[index - 1];
|
||||
postData[index - 1] = postData[index];
|
||||
postData[index] = tmp;
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const handleMoveItemDown = (index: number) => {
|
||||
if (index < 0 || index >= currentSocialHandles.length - 1) {
|
||||
return;
|
||||
}
|
||||
const postData = [...currentSocialHandles];
|
||||
const tmp = postData[index + 1];
|
||||
postData[index + 1] = postData[index];
|
||||
postData[index] = tmp;
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const socialHandlesColumns: ColumnsType<SocialHandle> = [
|
||||
{
|
||||
title: 'Social Link',
|
||||
dataIndex: '',
|
||||
key: 'combo',
|
||||
render: (data, record) => {
|
||||
const { platform, url } = record;
|
||||
const platformInfo = isPredefinedSocial(platform);
|
||||
|
||||
// custom platform case
|
||||
if (!platformInfo) {
|
||||
return (
|
||||
<div className="social-handle-cell">
|
||||
<p className="option-label">
|
||||
<strong>{platform}</strong>
|
||||
<span className="handle-url" title={url}>
|
||||
{url}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 className="handle-url" title={url}>
|
||||
{url}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: 'edit',
|
||||
render: (data, record, index) => (
|
||||
<div className="actions">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const platformInfo = currentSocialHandles[index];
|
||||
setEditId(index);
|
||||
setModalDataState({ ...platformInfo });
|
||||
setDisplayModal(true);
|
||||
if (!isPredefinedSocial(platformInfo.platform)) {
|
||||
setDisplayOther(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CaretUpOutlined />}
|
||||
size="small"
|
||||
hidden={index === 0}
|
||||
onClick={() => handleMoveItemUp(index)}
|
||||
/>
|
||||
<Button
|
||||
icon={<CaretDownOutlined />}
|
||||
size="small"
|
||||
hidden={index === currentSocialHandles.length - 1}
|
||||
onClick={() => handleMoveItemDown(index)}
|
||||
/>
|
||||
<Button
|
||||
className="delete-button"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleDeleteItem(index)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const isValid = (url: string, platform: string) => {
|
||||
if (platform === 'xmpp') {
|
||||
return isValidAccount(url, 'xmpp');
|
||||
}
|
||||
if (platform === 'matrix') {
|
||||
return isValidMatrixAccount(url);
|
||||
}
|
||||
|
||||
return isValidUrl(url);
|
||||
};
|
||||
|
||||
const okButtonProps = {
|
||||
disabled: !isValid(modalDataState.url, modalDataState.platform),
|
||||
};
|
||||
|
||||
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.platform}-${record.url}`}
|
||||
columns={socialHandlesColumns}
|
||||
dataSource={currentSocialHandles}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Edit Social Handle"
|
||||
open={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}
|
||||
useTrim
|
||||
type="url"
|
||||
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
|
||||
/>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</Modal>
|
||||
<br />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
resetModal();
|
||||
setDisplayModal(true);
|
||||
}}
|
||||
>
|
||||
Add a new social link
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
web/components/admin/config/general/GeneralConfig.tsx
Normal file
43
web/components/admin/config/general/GeneralConfig.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import EditInstanceDetails from './EditInstanceDetails';
|
||||
import EditInstanceTags from './EditInstanceTags';
|
||||
import EditSocialLinks from './EditSocialLinks';
|
||||
import EditPageContent from './EditPageContent';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function PublicFacingDetails() {
|
||||
return (
|
||||
<div className="config-public-details-page">
|
||||
<p className="description">
|
||||
The following are displayed on your site to describe your stream and its content.{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/website/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.colorPicker {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||
259
web/components/admin/config/server/EditStorage.tsx
Normal file
259
web/components/admin/config/server/EditStorage.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
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 '../../TextField';
|
||||
import { FormStatusIndicator } from '../../FormStatusIndicator';
|
||||
import { isValidUrl } from '../../../../utils/urls';
|
||||
import { ToggleSwitch } from '../../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,
|
||||
forcePathStyle,
|
||||
} = 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 ||
|
||||
bucket !== currentValues.bucket ||
|
||||
region !== currentValues.region ||
|
||||
(!currentValues.servingEndpoint && servingEndpoint !== '') ||
|
||||
(!!currentValues.servingEndpoint && servingEndpoint !== currentValues.servingEndpoint) ||
|
||||
(!currentValues.acl && acl !== '') ||
|
||||
(!!currentValues.acl && acl !== currentValues.acl) ||
|
||||
forcePathStyle !== currentValues.forcePathStyle
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (enabled !== currentValues.enabled) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
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 = '',
|
||||
forcePathStyle = false,
|
||||
} = s3;
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
accessKey,
|
||||
acl,
|
||||
bucket,
|
||||
enabled,
|
||||
endpoint,
|
||||
region,
|
||||
secret,
|
||||
servingEndpoint,
|
||||
forcePathStyle,
|
||||
});
|
||||
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 handleForcePathStyleSwitchChange = (forcePathStyleEnabled: boolean) => {
|
||||
handleFieldChange({ fieldName: 'forcePathStyle', value: forcePathStyleEnabled });
|
||||
};
|
||||
|
||||
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="Use S3 Storage Provider"
|
||||
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>
|
||||
<div className="enable-switch">
|
||||
<ToggleSwitch
|
||||
{...S3_TEXT_FIELDS_INFO.forcePathStyle}
|
||||
fieldName="forcePathStyle"
|
||||
checked={formDataValues.forcePathStyle}
|
||||
onChange={handleForcePathStyleSwitchChange}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<div className="button-container">
|
||||
<Button type="primary" onClick={handleSave} disabled={!isSaveable}>
|
||||
Save
|
||||
</Button>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
web/components/admin/config/server/ServerConfig.tsx
Normal file
17
web/components/admin/config/server/ServerConfig.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import EditInstanceDetails from '../../EditInstanceDetails2';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function ConfigServerDetails() {
|
||||
return (
|
||||
<div className="config-server-details-form">
|
||||
<p className="description">
|
||||
You should change your admin password from the default and keep it safe. For most people
|
||||
it's likely the other settings will not need to be changed.
|
||||
</p>
|
||||
<div className="form-module config-server-details-container">
|
||||
<EditInstanceDetails />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
web/components/admin/config/server/StorageConfig.tsx
Normal file
31
web/components/admin/config/server/StorageConfig.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import EditStorage from './EditStorage';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function ConfigStorageInfo() {
|
||||
return (
|
||||
<>
|
||||
<p className="description">
|
||||
Owncast supports optionally using external storage providers to stream your video. Learn
|
||||
more about this by visiting our{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/storage/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
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>
|
||||
<p className="description">
|
||||
Keep in mind this is for live streaming, not for archival, recording or VOD purposes.
|
||||
</p>
|
||||
<EditStorage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
172
web/components/admin/config/server/StreamKeys.tsx
Normal file
172
web/components/admin/config/server/StreamKeys.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { Table, Space, Button, Typography, Alert, Input, Form } from 'antd';
|
||||
import { DeleteOutlined, EyeOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { ServerStatusContext } from '../../../../utils/server-status-context';
|
||||
|
||||
import { fetchData, UPDATE_STREAM_KEYS } from '../../../../utils/apis';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
const { Item } = Form;
|
||||
|
||||
const saveKeys = async (keys, setError) => {
|
||||
try {
|
||||
await fetchData(UPDATE_STREAM_KEYS, {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
data: { value: keys },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const AddKeyForm = ({ setShowAddKeyForm, setFieldInConfigState, streamKeys, setError }) => {
|
||||
const handleAddKey = (newkey: any) => {
|
||||
const updatedKeys = [...streamKeys, newkey];
|
||||
|
||||
setFieldInConfigState({
|
||||
fieldName: 'streamKeys',
|
||||
value: updatedKeys,
|
||||
});
|
||||
|
||||
saveKeys(updatedKeys, setError);
|
||||
|
||||
setShowAddKeyForm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form layout="inline" autoComplete="off" onFinish={handleAddKey}>
|
||||
<Item label="Key" name="key" tooltip="The key you provide your broadcasting software">
|
||||
<Input placeholder="def456" />
|
||||
</Item>
|
||||
<Item label="Comment" name="comment" tooltip="For remembering why you added this key">
|
||||
<Input placeholder="My OBS Key" />
|
||||
</Item>
|
||||
|
||||
<Button type="primary" htmlType="submit">
|
||||
Add
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
const AddKeyButton = ({ setShowAddKeyForm }) => (
|
||||
<Button type="default" onClick={() => setShowAddKeyForm(true)}>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const StreamKeys = () => {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const { streamKeys } = serverConfig;
|
||||
const [showAddKeyForm, setShowAddKeyForm] = useState(false);
|
||||
const [showKeyMap, setShowKeyMap] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleDeleteKey = keyToRemove => {
|
||||
const newKeys = streamKeys.filter(k => k !== keyToRemove);
|
||||
setFieldInConfigState({
|
||||
fieldName: 'streamKeys',
|
||||
value: newKeys,
|
||||
});
|
||||
saveKeys(newKeys, setError);
|
||||
};
|
||||
|
||||
const handleToggleShowKey = key => {
|
||||
setShowKeyMap({
|
||||
...showKeyMap,
|
||||
[key]: !showKeyMap[key],
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'key',
|
||||
key: 'key',
|
||||
render: text => (
|
||||
<Space direction="horizontal">
|
||||
<Paragraph copyable>{showKeyMap[text] ? text : '**********'}</Paragraph>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
style={{ top: '-7px' }}
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleToggleShowKey(text)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Comment',
|
||||
dataIndex: 'comment',
|
||||
key: 'comment',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'delete',
|
||||
render: text => <Button onClick={() => handleDeleteKey(text)} icon={<DeleteOutlined />} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Paragraph>
|
||||
A streaming key is used with your broadcasting software to authenticate itself to Owncast.
|
||||
Most people will only need one. However, if you share a server with others or you want
|
||||
different keys for different broadcasting sources you can add more here.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
These keys are unrelated to the admin password and will not grant you access to make changes
|
||||
to Owncast's configuration.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Read more about broadcasting at{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/broadcasting/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
the documentation
|
||||
</a>
|
||||
.
|
||||
</Paragraph>
|
||||
|
||||
<Space direction="vertical" style={{ width: '70%' }}>
|
||||
{error && <Alert type="error" message="Saving Keys Error" description={error} />}
|
||||
|
||||
{streamKeys.length === 0 && (
|
||||
<Alert
|
||||
message="No stream keys!"
|
||||
description="You will not be able to stream until you create at least one stream key and add it to your broadcasting software."
|
||||
type="error"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey="key"
|
||||
columns={columns}
|
||||
dataSource={streamKeys}
|
||||
pagination={false}
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
footer={() =>
|
||||
showAddKeyForm ? (
|
||||
<AddKeyForm
|
||||
setShowAddKeyForm={setShowAddKeyForm}
|
||||
streamKeys={streamKeys}
|
||||
setFieldInConfigState={setFieldInConfigState}
|
||||
setError={setError}
|
||||
/>
|
||||
) : (
|
||||
<AddKeyButton setShowAddKeyForm={setShowAddKeyForm} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default StreamKeys;
|
||||
130
web/components/admin/notification/browser.tsx
Normal file
130
web/components/admin/notification/browser.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { ServerStatusContext } from '../../../utils/server-status-context';
|
||||
import { TextField, TEXTFIELD_TYPE_TEXTAREA } from '../TextField';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
BROWSER_PUSH_CONFIG_FIELDS,
|
||||
} from '../../../utils/config-constants';
|
||||
import { ToggleSwitch } from '../ToggleSwitch';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../../utils/input-statuses';
|
||||
import { UpdateArgs } from '../../../types/config-section';
|
||||
import { FormStatusIndicator } from '../FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const ConfigNotify = () => {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const { notifications } = serverConfig || {};
|
||||
const { browser } = notifications || {};
|
||||
|
||||
const { enabled, goLiveMessage } = browser || {};
|
||||
|
||||
const [formDataValues, setFormDataValues] = useState<any>({});
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
const [enableSaveButton, setEnableSaveButton] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
enabled,
|
||||
goLiveMessage,
|
||||
});
|
||||
}, [notifications, browser]);
|
||||
|
||||
const canSave = (): boolean => true;
|
||||
|
||||
// update individual values in state
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
console.log(fieldName, value);
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
|
||||
setEnableSaveButton(canSave());
|
||||
};
|
||||
|
||||
// toggle switch.
|
||||
const handleSwitchChange = (switchEnabled: boolean) => {
|
||||
// setShouldDisplayForm(storageEnabled);
|
||||
handleFieldChange({ fieldName: 'enabled', value: switchEnabled });
|
||||
};
|
||||
|
||||
let resetTimer = null;
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const postValue = formDataValues;
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: '/notifications/browser',
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'browser',
|
||||
value: postValue,
|
||||
path: 'notifications',
|
||||
});
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Browser Alerts</Title>
|
||||
<p className="description reduced-margins">
|
||||
Viewers can opt into being notified when you go live with their browser.
|
||||
</p>
|
||||
<p className="description reduced-margins">Not all browsers support this.</p>
|
||||
<ToggleSwitch
|
||||
apiPath=""
|
||||
fieldName="enabled"
|
||||
label="Enable browser notifications"
|
||||
onChange={handleSwitchChange}
|
||||
checked={formDataValues.enabled}
|
||||
/>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...BROWSER_PUSH_CONFIG_FIELDS.goLiveMessage}
|
||||
required
|
||||
type={TEXTFIELD_TYPE_TEXTAREA}
|
||||
value={formDataValues.goLiveMessage}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
display: enableSaveButton ? 'inline-block' : 'none',
|
||||
position: 'relative',
|
||||
marginLeft: 'auto',
|
||||
right: '0',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
onClick={save}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ConfigNotify;
|
||||
154
web/components/admin/notification/discord.tsx
Normal file
154
web/components/admin/notification/discord.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { ServerStatusContext } from '../../../utils/server-status-context';
|
||||
import { TextField } from '../TextField';
|
||||
import { FormStatusIndicator } from '../FormStatusIndicator';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
DISCORD_CONFIG_FIELDS,
|
||||
} from '../../../utils/config-constants';
|
||||
import { ToggleSwitch } from '../ToggleSwitch';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../../utils/input-statuses';
|
||||
import { UpdateArgs } from '../../../types/config-section';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const ConfigNotify = () => {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const { notifications } = serverConfig || {};
|
||||
const { discord } = notifications || {};
|
||||
|
||||
const { enabled, webhook, goLiveMessage } = discord || {};
|
||||
|
||||
const [formDataValues, setFormDataValues] = useState<any>({});
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
const [enableSaveButton, setEnableSaveButton] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
enabled,
|
||||
webhook,
|
||||
goLiveMessage,
|
||||
});
|
||||
}, [notifications, discord]);
|
||||
|
||||
const canSave = (): boolean => {
|
||||
if (webhook === '' || goLiveMessage === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// update individual values in state
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
|
||||
setEnableSaveButton(canSave());
|
||||
};
|
||||
|
||||
let resetTimer = null;
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const postValue = formDataValues;
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: '/notifications/discord',
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'discord',
|
||||
value: postValue,
|
||||
path: 'notifications',
|
||||
});
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// toggle switch.
|
||||
const handleSwitchChange = (switchEnabled: boolean) => {
|
||||
// setShouldDisplayForm(storageEnabled);
|
||||
handleFieldChange({ fieldName: 'enabled', value: switchEnabled });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Discord</Title>
|
||||
<p className="description reduced-margins">
|
||||
Let your Discord channel know each time you go live.
|
||||
</p>
|
||||
<p className="description reduced-margins">
|
||||
<a
|
||||
href="https://support.discord.com/hc/en-us/articles/228383668"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Create a webhook
|
||||
</a>{' '}
|
||||
under <i>Edit Channel / Integrations</i> on your Discord channel and provide it below.
|
||||
</p>
|
||||
|
||||
<ToggleSwitch
|
||||
apiPath=""
|
||||
fieldName="discordEnabled"
|
||||
label="Enable Discord"
|
||||
checked={formDataValues.enabled}
|
||||
onChange={handleSwitchChange}
|
||||
/>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...DISCORD_CONFIG_FIELDS.webhookUrl}
|
||||
required
|
||||
value={formDataValues.webhook}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...DISCORD_CONFIG_FIELDS.goLiveMessage}
|
||||
required
|
||||
value={formDataValues.goLiveMessage}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={save}
|
||||
style={{
|
||||
display: enableSaveButton ? 'inline-block' : 'none',
|
||||
position: 'relative',
|
||||
marginLeft: 'auto',
|
||||
right: '0',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ConfigNotify;
|
||||
52
web/components/admin/notification/federation.tsx
Normal file
52
web/components/admin/notification/federation.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ServerStatusContext } from '../../../utils/server-status-context';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const ConfigNotify = () => {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
const { federation } = serverConfig || {};
|
||||
|
||||
const { enabled } = federation || {};
|
||||
const [formDataValues, setFormDataValues] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
enabled,
|
||||
});
|
||||
}, [enabled]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Fediverse Social</Title>
|
||||
<p className="description">
|
||||
Enabling the Fediverse social features will not just alert people to when you go live, but
|
||||
also enable other functionality.
|
||||
</p>
|
||||
<p>
|
||||
Fediverse social features:{' '}
|
||||
<span style={{ color: federation.enabled ? 'green' : 'red' }}>
|
||||
{formDataValues.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<Link passHref href="/config-federation">
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
position: 'relative',
|
||||
marginLeft: 'auto',
|
||||
right: '0',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ConfigNotify;
|
||||
220
web/components/admin/notification/twitter.tsx
Normal file
220
web/components/admin/notification/twitter.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { ServerStatusContext } from '../../../utils/server-status-context';
|
||||
import { TextField, TEXTFIELD_TYPE_PASSWORD } from '../TextField';
|
||||
import { FormStatusIndicator } from '../FormStatusIndicator';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
TWITTER_CONFIG_FIELDS,
|
||||
} from '../../../utils/config-constants';
|
||||
import { ToggleSwitch } from '../ToggleSwitch';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../../utils/input-statuses';
|
||||
import { UpdateArgs } from '../../../types/config-section';
|
||||
import { TEXTFIELD_TYPE_TEXT } from '../TextFieldWithSubmit';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const ConfigNotify = () => {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const { notifications } = serverConfig || {};
|
||||
const { twitter } = notifications || {};
|
||||
|
||||
const [formDataValues, setFormDataValues] = useState<any>({});
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
const [enableSaveButton, setEnableSaveButton] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
enabled,
|
||||
apiKey,
|
||||
apiSecret,
|
||||
accessToken,
|
||||
accessTokenSecret,
|
||||
bearerToken,
|
||||
goLiveMessage,
|
||||
} = twitter || {};
|
||||
setFormDataValues({
|
||||
enabled,
|
||||
apiKey,
|
||||
apiSecret,
|
||||
accessToken,
|
||||
accessTokenSecret,
|
||||
bearerToken,
|
||||
goLiveMessage,
|
||||
});
|
||||
}, [twitter]);
|
||||
|
||||
const canSave = (): boolean => {
|
||||
const { apiKey, apiSecret, accessToken, accessTokenSecret, bearerToken, goLiveMessage } =
|
||||
formDataValues;
|
||||
|
||||
return (
|
||||
!!apiKey &&
|
||||
!!apiSecret &&
|
||||
!!accessToken &&
|
||||
!!accessTokenSecret &&
|
||||
!!bearerToken &&
|
||||
!!goLiveMessage
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setEnableSaveButton(canSave());
|
||||
}, [formDataValues]);
|
||||
|
||||
// update individual values in state
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
// toggle switch.
|
||||
const handleSwitchChange = (switchEnabled: boolean) => {
|
||||
const previouslySaved = formDataValues.enabled;
|
||||
|
||||
handleFieldChange({ fieldName: 'enabled', value: switchEnabled });
|
||||
|
||||
return switchEnabled !== previouslySaved;
|
||||
};
|
||||
|
||||
let resetTimer = null;
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
setEnableSaveButton(false);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const postValue = formDataValues;
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: '/notifications/twitter',
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'twitter',
|
||||
value: postValue,
|
||||
path: 'notifications',
|
||||
});
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Twitter</Title>
|
||||
<p className="description reduced-margins">
|
||||
Let your Twitter followers know each time you go live.
|
||||
</p>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<p className="description reduced-margins">
|
||||
<a href="https://owncast.online/docs/notifications" target="_blank" rel="noreferrer">
|
||||
Read how to configure your Twitter account
|
||||
</a>{' '}
|
||||
to support posting from Owncast.
|
||||
</p>
|
||||
<p className="description reduced-margins">
|
||||
<a
|
||||
href="https://developer.twitter.com/en/portal/dashboard"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
And then get your Twitter developer credentials
|
||||
</a>{' '}
|
||||
to fill in below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
apiPath=""
|
||||
fieldName="enabled"
|
||||
label="Enable Twitter"
|
||||
onChange={handleSwitchChange}
|
||||
checked={formDataValues.enabled}
|
||||
/>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.apiKey}
|
||||
required
|
||||
value={formDataValues.apiKey}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.apiSecret}
|
||||
type={TEXTFIELD_TYPE_PASSWORD}
|
||||
required
|
||||
value={formDataValues.apiSecret}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.accessToken}
|
||||
required
|
||||
value={formDataValues.accessToken}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.accessTokenSecret}
|
||||
type={TEXTFIELD_TYPE_PASSWORD}
|
||||
required
|
||||
value={formDataValues.accessTokenSecret}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.bearerToken}
|
||||
required
|
||||
value={formDataValues.bearerToken}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
|
||||
<TextField
|
||||
{...TWITTER_CONFIG_FIELDS.goLiveMessage}
|
||||
type={TEXTFIELD_TYPE_TEXT}
|
||||
required
|
||||
value={formDataValues.goLiveMessage}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={save}
|
||||
style={{
|
||||
display: enableSaveButton ? 'inline-block' : 'none',
|
||||
position: 'relative',
|
||||
marginLeft: 'auto',
|
||||
right: '0',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ConfigNotify;
|
||||
Reference in New Issue
Block a user