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