Reorganize admin pages and consolidate some sections. For #1904

This commit is contained in:
Gabe Kangas
2022-12-27 18:48:21 -08:00
parent 389ba36f15
commit 5a41f4a1ea
15 changed files with 123 additions and 87 deletions

View File

@@ -0,0 +1,164 @@
import React, { useState, useContext, useEffect } from 'react';
import { Typography } from 'antd';
import {
TextFieldWithSubmit,
TEXTFIELD_TYPE_TEXTAREA,
TEXTFIELD_TYPE_URL,
} from '../../../../components/config/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 '../../../../components/config/ToggleSwitch';
import { EditLogo } from '../../../../components/config/EditLogo';
const { Title } = Typography;
export default function EditInstanceDetails() {
const [formDataValues, setFormDataValues] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { instanceDetails, yp } = serverConfig;
const { instanceUrl } = yp;
useEffect(() => {
setFormDataValues({
...instanceDetails,
...yp,
});
}, [instanceDetails, yp]);
if (!formDataValues) {
return null;
}
// if instanceUrl is empty, we should also turn OFF the `enabled` field of directory.
const handleSubmitInstanceUrl = () => {
if (formDataValues.instanceUrl === '') {
if (yp.enabled === true) {
postConfigUpdateToAPI({
apiPath: API_YP_SWITCH,
data: { value: false },
});
}
}
};
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
};
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,138 @@
/* 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 '../../../../components/config/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 '../../../../components/config/EditValueArray';
const { Title } = Typography;
export default function EditInstanceTags() {
const [newTagInput, setNewTagInput] = useState<string>('');
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { tags = [] } = instanceDetails;
const { apiPath, maxLength, placeholder, configPath } = FIELD_PROPS_TAGS;
let resetTimer = null;
useEffect(
() => () => {
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,118 @@
// 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 '../../../../components/config/FormStatusIndicator';
const { Title } = Typography;
export default function EditPageContent() {
const [content, setContent] = useState('');
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [hasChanged, setHasChanged] = useState(false);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { extraPageContent: initialContent } = instanceDetails;
let resetTimer = null;
function handleEditorChange(text) {
setContent(text);
if (text !== initialContent && !hasChanged) {
setHasChanged(true);
} else if (text === initialContent && hasChanged) {
setHasChanged(false);
}
}
// Clear out any validation states and messaging
const resetStates = () => {
setSubmitStatus(null);
setHasChanged(false);
clearTimeout(resetTimer);
resetTimer = null;
};
// posts all the tags at once as an array obj
async function handleSave() {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath: API_CUSTOM_CONTENT,
data: { value: content },
onSuccess: (message: string) => {
setFieldInConfigState({
fieldName: 'extraPageContent',
value: content,
path: 'instanceDetails',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, message));
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
},
});
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
useEffect(() => {
setContent(initialContent);
}, [instanceDetails]);
return (
<div className="edit-page-content">
<Title level={3} className="section-title">
Custom Page Content
</Title>
<p className="description">
Edit the content of your page by using simple{' '}
<a
href="https://www.markdownguide.org/basic-syntax/"
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,367 @@
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 '../../../../components/config/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 '../../../../components/config/TextField';
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../../../utils/input-statuses';
import { FormStatusIndicator } from '../../../../components/config/FormStatusIndicator';
const { Title } = Typography;
export default function EditSocialLinks() {
const [availableIconsList, setAvailableIconsList] = useState([]);
const [currentSocialHandles, setCurrentSocialHandles] = useState([]);
const [displayModal, setDisplayModal] = useState(false);
const [displayOther, setDisplayOther] = useState(false);
const [modalProcessing, setModalProcessing] = useState(false);
const [editId, setEditId] = useState(-1);
// current data inside modal
const [modalDataState, setModalDataState] = useState(DEFAULT_SOCIAL_HANDLE);
const [submitStatus, setSubmitStatus] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { socialHandles: initialSocialHandles } = instanceDetails;
let resetTimer = null;
const PLACEHOLDERS = {
mastodon: 'https://mastodon.social/@username',
twitter: 'https://twitter.com/username',
};
const getAvailableIcons = async () => {
try {
const result = await fetchData(SOCIAL_PLATFORMS_LIST, { auth: false });
const list = Object.keys(result).map(item => ({
key: item,
...result[item],
}));
setAvailableIconsList(list);
} catch (error) {
console.log(error);
// do nothing
}
};
const 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,42 @@
import React from 'react';
import EditInstanceDetails from './EditInstanceDetails';
import EditInstanceTags from './EditInstanceTags';
import EditSocialLinks from './EditSocialLinks';
import EditPageContent from './EditPageContent';
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,28 @@
import React from 'react';
import { Tabs } from 'antd';
import GeneralConfig from './GeneralConfig';
import AppearanceConfig from './AppearanceConfig';
export default function PublicFacingDetails() {
return (
<div className="config-public-details-page">
<Tabs
defaultActiveKey="1"
centered
items={[
{
label: `General`,
key: '1',
children: <GeneralConfig />,
},
{
label: `Appearance`,
key: '2',
children: <AppearanceConfig />,
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,258 @@
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 '../../../../components/config/TextField';
import { FormStatusIndicator } from '../../../../components/config/FormStatusIndicator';
import { isValidUrl } from '../../../../utils/urls';
import { ToggleSwitch } from '../../../../components/config/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;
}
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,16 @@
import React from 'react';
import { EditInstanceDetails } from '../../../../components/config/EditInstanceDetails2';
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,30 @@
import React from 'react';
import EditStorage from './EditStorage';
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

@@ -5,7 +5,7 @@ import { ServerStatusContext } from '../../../../utils/server-status-context';
import { fetchData, UPDATE_STREAM_KEYS } from '../../../../utils/apis';
const { Title, Paragraph } = Typography;
const { Paragraph } = Typography;
const { Item } = Form;
const saveKeys = async (keys, setError) => {
@@ -25,7 +25,6 @@ const AddKeyForm = ({ setShowAddKeyForm, setFieldInConfigState, streamKeys, setE
const handleAddKey = (newkey: any) => {
const updatedKeys = [...streamKeys, newkey];
console.log(updatedKeys);
setFieldInConfigState({
fieldName: 'streamKeys',
value: updatedKeys,
@@ -114,7 +113,6 @@ const StreamKeys = () => {
return (
<div>
<Title>Streaming Keys</Title>
<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

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Tabs } from 'antd';
import StreamKeys from './StreamKeys';
import ServerConfig from './ServerConfig';
import StorageConfig from './StorageConfig';
export default function PublicFacingDetails() {
return (
<div className="config-public-details-page">
<Tabs
defaultActiveKey="1"
centered
items={[
{
label: `Server Config`,
key: '1',
children: <ServerConfig />,
},
{
label: `Stream Keys`,
key: '2',
children: <StreamKeys />,
},
{
label: `S3 Object Storage`,
key: '3',
children: <StorageConfig />,
},
]}
/>
</div>
);
}