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

@@ -1,8 +1,8 @@
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';
import { USER_ENABLED_TOGGLE, fetchData } from '../../utils/apis';
import { User } from '../../types/chat';
export type BanUserButtonProps = {
user: User;

View File

@@ -3,8 +3,8 @@ 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';
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');

View File

@@ -4,10 +4,10 @@ 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 { Client } from '../../types/chat';
import { UserPopover } from './UserPopover';
import { BanUserButton } from './BanUserButton';
import { formatUAstring } from '../utils/format';
import { formatUAstring } from '../../utils/format';
export type ClientTableProps = {
data: Client[];

View File

@@ -1,8 +1,8 @@
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';
import { STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
import { fetchData, FEDERATION_MESSAGE_SEND } from '../../utils/apis';
const { TextArea } = Input;

View File

@@ -28,6 +28,7 @@ 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);

View File

@@ -22,6 +22,7 @@ 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);

View File

@@ -22,7 +22,8 @@ const Tooltip = dynamic(() => import('antd').then(mod => mod.Tooltip));
const { Panel } = Collapse;
export const EditInstanceDetails = () => {
// 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);
@@ -161,4 +162,4 @@ export const EditInstanceDetails = () => {
</Collapse>
</div>
);
};
}

View File

@@ -25,6 +25,7 @@ import {
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);

View File

@@ -8,6 +8,7 @@ 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);

View File

@@ -20,19 +20,19 @@ import {
import classNames from 'classnames';
import dynamic from 'next/dynamic';
import { upgradeVersionAvailable } from '../utils/apis';
import { parseSecondsToDurationString } from '../utils/format';
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 { OwncastLogo } from '../common/OwncastLogo/OwncastLogo';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import { TextFieldWithSubmit } from './config/TextFieldWithSubmit';
import { TEXTFIELD_PROPS_STREAM_TITLE } from '../utils/config-constants';
import { TextFieldWithSubmit } from './TextFieldWithSubmit';
import { TEXTFIELD_PROPS_STREAM_TITLE } from '../../utils/config-constants';
import { ComposeFederatedPost } from './ComposeFederatedPost';
import { UpdateArgs } from '../types/config-section';
import { UpdateArgs } from '../../types/config-section';
import FediverseIcon from '../assets/images/fediverse-black.png';
import FediverseIcon from '../../assets/images/fediverse-black.png';
// Lazy loaded components

View File

@@ -8,10 +8,9 @@ import {
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 { OUTCOME_TIMEOUT } from '../pages/admin/chat/messages';
import { isEmptyObject } from '../utils/format';
import { fetchData, UPDATE_CHAT_MESSGAE_VIZ } from '../../utils/apis';
import { MessageType } from '../../types/chat';
import { isEmptyObject } from '../../utils/format';
// Lazy loaded components
@@ -40,7 +39,7 @@ export const MessageVisiblityToggle: FC<MessageToggleProps> = ({
const resetOutcome = () => {
outcomeTimeout = setTimeout(() => {
setOutcome(0);
}, OUTCOME_TIMEOUT);
}, 3000);
};
useEffect(() => () => {

View File

@@ -6,8 +6,8 @@ import {
SafetyCertificateTwoTone,
} from '@ant-design/icons';
import { FC } from 'react';
import { USER_SET_MODERATOR, fetchData } from '../utils/apis';
import { User } from '../types/chat';
import { USER_SET_MODERATOR, fetchData } from '../../utils/apis';
import { User } from '../../types/chat';
export type ModeratorUserButtonProps = {
user: User;

View File

@@ -4,7 +4,7 @@ import React, { useState, useEffect, FC } from 'react';
import { Collapse, Typography, Skeleton } from 'antd';
import format from 'date-fns/format';
import { fetchExternalData } from '../utils/apis';
import { fetchExternalData } from '../../utils/apis';
const { Panel } = Collapse;
const { Title, Link } = Typography;

View File

@@ -3,10 +3,10 @@ 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 { OwncastLogo } from '../common/OwncastLogo/OwncastLogo';
import { NewsFeed } from './NewsFeed';
import { ConfigDetails } from '../types/config-section';
import { ServerStatusContext } from '../utils/server-status-context';
import { ConfigDetails } from '../../types/config-section';
import { ServerStatusContext } from '../../utils/server-status-context';
const { Paragraph, Text } = Typography;

View File

@@ -12,6 +12,7 @@ import {
} from '../../utils/input-statuses';
import { FormStatusIndicator } from './FormStatusIndicator';
// eslint-disable-next-line import/prefer-default-export
export const ResetYP: FC = () => {
const { setMessage } = useContext(AlertMessageContext);

View File

@@ -2,7 +2,7 @@ import { CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icon
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';
import { ServerStatusContext } from '../../utils/server-status-context';
export type StreamHealthOverviewProps = {
showTroubleshootButton?: Boolean;

View File

@@ -10,9 +10,9 @@ import dynamic from 'next/dynamic';
import { BanUserButton } from './BanUserButton';
import { ModeratorUserButton } from './ModeratorUserButton';
import { User, UserConnectionInfo } from '../types/chat';
import { User, UserConnectionInfo } from '../../types/chat';
import { formatDisplayDate } from './UserTable';
import { formatUAstring } from '../utils/format';
import { formatUAstring } from '../../utils/format';
// Lazy loaded components

View File

@@ -2,7 +2,7 @@ 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 { User } from '../../types/chat';
import { UserPopover } from './UserPopover';
import { BanUserButton } from './BanUserButton';

View File

@@ -34,6 +34,7 @@ const SLIDER_COMMENTS = {
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);

View File

@@ -3,8 +3,8 @@ 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';
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');

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;