Reorganize admin components to help bundling

This commit is contained in:
Gabe Kangas
2023-01-09 20:57:29 -08:00
parent 29882f1291
commit 7392ae8a54
67 changed files with 138 additions and 126 deletions

View 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,
};

View 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"
/>
);
};

View 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,
};

View 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"
/>
);
};

View 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>
</>
);
};

View 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,
};

View 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>
);
};

View 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>
</>
);
};

View 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 &amp; 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>
);
};

View 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>
);
}

View 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>
);
};

View 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,
};

View 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>
);
};

View 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;

View 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[];
}

View 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>
);
};

View 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" />
</>
);
};

View 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>
);
};

View 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 &amp; 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,
};

View 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>
);
};

View 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,
};

View 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 &amp; Updates from Owncast</Title>
{loadingSpinner}
{feed.map(item => (
<ArticleItem {...item} key={item.url} />
))}
{noNews}
</section>
);
};

View 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;

View 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
&quot;reset&quot; your connection to the service, you can do that here. The next time you go
live it will try and re-register your server with the directory from scratch.
</p>
<Popconfirm
placement="topLeft"
title="Are you sure you want to reset your connection to the Owncast directory?"
onConfirm={resetDirectoryRegistration}
okText="Yes"
cancelText="No"
>
<Button type="primary">Reset Directory Connection</Button>
</Popconfirm>
<p>
<FormStatusIndicator status={submitStatus} />
</p>
</>
);
};

View File

@@ -0,0 +1,63 @@
import React, { 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>
);
};

View 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;

View 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,
};

View 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: () => {},
};

View 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: '',
};

View 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,
};

View 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,
};

View 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"
/>
);
};

View 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&apos;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>
);
};

View 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>
);
};

View 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"
/>
);
};

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,139 @@
/* eslint-disable react/no-array-index-key */
import React, { useContext, useState, useEffect } from 'react';
import { Typography, Tag } from 'antd';
import { ServerStatusContext } from '../../../../utils/server-status-context';
import {
FIELD_PROPS_TAGS,
RESET_TIMEOUT,
postConfigUpdateToAPI,
} from '../../../../utils/config-constants';
import { TextField } from '../../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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,4 @@
.colorPicker {
width: 100%;
height: 50px;
}

View 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>
);
}

View 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&apos;s likely the other settings will not need to be changed.
</p>
<div className="form-module config-server-details-container">
<EditInstanceDetails />
</div>
</div>
);
}

View 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 />
</>
);
}

View 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&apos;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;

View 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;

View 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;

View 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;

View 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;