Reorganize admin components to help bundling
This commit is contained in:
313
web/components/admin/config/general/AppearanceConfig.tsx
Normal file
313
web/components/admin/config/general/AppearanceConfig.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { Button, Col, Collapse, Row, Slider, Space } from 'antd';
|
||||
import Paragraph from 'antd/lib/typography/Paragraph';
|
||||
import Title from 'antd/lib/typography/Title';
|
||||
import { EditCustomStyles } from '../../EditCustomStyles';
|
||||
import s from './appearance.module.scss';
|
||||
import { postConfigUpdateToAPI, RESET_TIMEOUT } from '../../../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../../../utils/input-statuses';
|
||||
import { ServerStatusContext } from '../../../../utils/server-status-context';
|
||||
import { FormStatusIndicator } from '../../FormStatusIndicator';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
const ENDPOINT = '/appearance';
|
||||
|
||||
interface AppearanceVariable {
|
||||
value: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const chatColorVariables = [
|
||||
{ name: 'theme-color-users-0', description: '' },
|
||||
{ name: 'theme-color-users-1', description: '' },
|
||||
{ name: 'theme-color-users-2', description: '' },
|
||||
{ name: 'theme-color-users-3', description: '' },
|
||||
{ name: 'theme-color-users-4', description: '' },
|
||||
{ name: 'theme-color-users-5', description: '' },
|
||||
{ name: 'theme-color-users-6', description: '' },
|
||||
{ name: 'theme-color-users-7', description: '' },
|
||||
];
|
||||
|
||||
const paletteVariables = [
|
||||
{ name: 'theme-color-palette-0', description: '' },
|
||||
{ name: 'theme-color-palette-1', description: '' },
|
||||
{ name: 'theme-color-palette-2', description: '' },
|
||||
{ name: 'theme-color-palette-3', description: '' },
|
||||
{ name: 'theme-color-palette-4', description: '' },
|
||||
{ name: 'theme-color-palette-5', description: '' },
|
||||
{ name: 'theme-color-palette-6', description: '' },
|
||||
{ name: 'theme-color-palette-7', description: '' },
|
||||
{ name: 'theme-color-palette-8', description: '' },
|
||||
{ name: 'theme-color-palette-9', description: '' },
|
||||
{ name: 'theme-color-palette-10', description: '' },
|
||||
{ name: 'theme-color-palette-11', description: '' },
|
||||
{ name: 'theme-color-palette-12', description: '' },
|
||||
];
|
||||
|
||||
const componentColorVariables = [
|
||||
{ name: 'theme-color-background-main', description: 'Background' },
|
||||
{ name: 'theme-color-action', description: 'Action' },
|
||||
{ name: 'theme-color-action-hover', description: 'Action Hover' },
|
||||
{ name: 'theme-color-components-chat-background', description: 'Chat Background' },
|
||||
{ name: 'theme-color-components-chat-text', description: 'Text: Chat' },
|
||||
{ name: 'theme-color-components-text-on-dark', description: 'Text: Light' },
|
||||
{ name: 'theme-color-components-text-on-light', description: 'Text: Dark' },
|
||||
{ name: 'theme-color-background-header', description: 'Header/Footer' },
|
||||
{ name: 'theme-color-components-content-background', description: 'Page Content' },
|
||||
{ name: 'theme-color-components-scrollbar-background', description: 'Scrollbar Background' },
|
||||
{ name: 'theme-color-components-scrollbar-thumb', description: 'Scrollbar Thumb' },
|
||||
];
|
||||
|
||||
const others = [{ name: 'theme-rounded-corners', description: 'Corner radius' }];
|
||||
|
||||
// Create an object so these vars can be indexed by name.
|
||||
const allAvailableValues = [
|
||||
...paletteVariables,
|
||||
...componentColorVariables,
|
||||
...chatColorVariables,
|
||||
...others,
|
||||
].reduce((obj, val) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
obj[val.name] = { name: val.name, description: val.description };
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
function ColorPicker({
|
||||
value,
|
||||
name,
|
||||
description,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
name: string;
|
||||
description: string;
|
||||
onChange: (name: string, value: string, description: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Col span={3} key={name}>
|
||||
<input
|
||||
type="color"
|
||||
id={name}
|
||||
name={description}
|
||||
title={description}
|
||||
value={value}
|
||||
className={s.colorPicker}
|
||||
onChange={e => onChange(name, e.target.value, description)}
|
||||
/>
|
||||
<div style={{ padding: '2px' }}>{description}</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function Appearance() {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig } = serverStatusData;
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { appearanceVariables } = instanceDetails;
|
||||
|
||||
const [colors, setColors] = useState<Record<string, AppearanceVariable>>();
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
let resetTimer = null;
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
const setColorDefaults = () => {
|
||||
const c = {};
|
||||
[...paletteVariables, ...componentColorVariables, ...chatColorVariables, ...others].forEach(
|
||||
color => {
|
||||
const resolvedColor = getComputedStyle(document.documentElement).getPropertyValue(
|
||||
`--${color.name}`,
|
||||
);
|
||||
c[color.name] = { value: resolvedColor.trim(), description: color.description };
|
||||
},
|
||||
);
|
||||
setColors(c);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setColorDefaults();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(appearanceVariables).length === 0) return;
|
||||
|
||||
const c = colors || {};
|
||||
Object.keys(appearanceVariables).forEach(key => {
|
||||
c[key] = {
|
||||
value: appearanceVariables[key],
|
||||
description: allAvailableValues[key]?.description || '',
|
||||
};
|
||||
});
|
||||
setColors(c);
|
||||
}, [appearanceVariables]);
|
||||
|
||||
const updateColor = (variable: string, color: string, description: string) => {
|
||||
setColors({
|
||||
...colors,
|
||||
[variable]: { value: color, description },
|
||||
});
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: ENDPOINT,
|
||||
data: { value: {} },
|
||||
onSuccess: () => {
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
setColorDefaults();
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const c = {};
|
||||
Object.keys(colors).forEach(color => {
|
||||
c[color] = colors[color].value;
|
||||
});
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: ENDPOINT,
|
||||
data: { value: c },
|
||||
onSuccess: () => {
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onBorderRadiusChange = (value: string) => {
|
||||
const variableName = 'theme-rounded-corners';
|
||||
|
||||
updateColor(variableName, `${value.toString()}px`, '');
|
||||
};
|
||||
|
||||
if (!colors) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical">
|
||||
<Title>Customize Appearance</Title>
|
||||
<Paragraph>The following colors are used across the user interface.</Paragraph>
|
||||
<div>
|
||||
<Collapse defaultActiveKey={['1']}>
|
||||
<Panel header={<Title level={3}>Section Colors</Title>} key="1">
|
||||
<p>
|
||||
Certain sections of the interface can be customized by selecting new colors for them.
|
||||
</p>
|
||||
<Row gutter={[16, 16]}>
|
||||
{componentColorVariables.map(colorVar => {
|
||||
const { name } = colorVar;
|
||||
const c = colors[name];
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={c.value}
|
||||
name={name}
|
||||
description={c.description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Panel>
|
||||
<Panel header={<Title level={3}>Chat User Colors</Title>} key="2">
|
||||
<Row gutter={[16, 16]}>
|
||||
{chatColorVariables.map(colorVar => {
|
||||
const { name } = colorVar;
|
||||
const c = colors[name];
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={c.value}
|
||||
name={name}
|
||||
description={c.description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Panel>
|
||||
<Panel header={<Title level={3}>Theme Colors</Title>} key="3">
|
||||
<Row gutter={[16, 16]}>
|
||||
{paletteVariables.map(colorVar => {
|
||||
const { name } = colorVar;
|
||||
const c = colors[name];
|
||||
return (
|
||||
<ColorPicker
|
||||
key={name}
|
||||
value={c.value}
|
||||
name={name}
|
||||
description={c.description}
|
||||
onChange={updateColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Panel>
|
||||
<Panel header={<Title level={3}>Other Settings</Title>} key="4">
|
||||
How rounded should corners be?
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
tooltip={{ formatter: null }}
|
||||
onChange={v => {
|
||||
onBorderRadiusChange(v);
|
||||
}}
|
||||
value={Number(colors['theme-rounded-corners']?.value?.replace('px', '') || 0)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<div
|
||||
style={{
|
||||
width: '100px',
|
||||
height: '30px',
|
||||
borderRadius: `${colors['theme-rounded-corners']?.value}`,
|
||||
backgroundColor: 'var(--theme-color-palette-7)',
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<Space direction="horizontal">
|
||||
<Button type="primary" onClick={save}>
|
||||
Save Colors
|
||||
</Button>
|
||||
<Button type="ghost" onClick={reset}>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</Space>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
<div className="form-module page-content-module">
|
||||
<EditCustomStyles />
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
165
web/components/admin/config/general/EditInstanceDetails.tsx
Normal file
165
web/components/admin/config/general/EditInstanceDetails.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import {
|
||||
TextFieldWithSubmit,
|
||||
TEXTFIELD_TYPE_TEXTAREA,
|
||||
TEXTFIELD_TYPE_URL,
|
||||
} from '../../TextFieldWithSubmit';
|
||||
import { ServerStatusContext } from '../../../../utils/server-status-context';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
TEXTFIELD_PROPS_INSTANCE_URL,
|
||||
TEXTFIELD_PROPS_SERVER_NAME,
|
||||
TEXTFIELD_PROPS_SERVER_SUMMARY,
|
||||
TEXTFIELD_PROPS_SERVER_OFFLINE_MESSAGE,
|
||||
API_YP_SWITCH,
|
||||
FIELD_PROPS_YP,
|
||||
FIELD_PROPS_NSFW,
|
||||
FIELD_PROPS_HIDE_VIEWER_COUNT,
|
||||
} from '../../../../utils/config-constants';
|
||||
import { UpdateArgs } from '../../../../types/config-section';
|
||||
import { ToggleSwitch } from '../../ToggleSwitch';
|
||||
import { EditLogo } from '../../EditLogo';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function EditInstanceDetails() {
|
||||
const [formDataValues, setFormDataValues] = useState(null);
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
|
||||
const { instanceDetails, yp } = serverConfig;
|
||||
const { instanceUrl } = yp;
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
...instanceDetails,
|
||||
...yp,
|
||||
});
|
||||
}, [instanceDetails, yp]);
|
||||
|
||||
if (!formDataValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if instanceUrl is empty, we should also turn OFF the `enabled` field of directory.
|
||||
const handleSubmitInstanceUrl = () => {
|
||||
if (formDataValues.instanceUrl === '') {
|
||||
if (yp.enabled === true) {
|
||||
postConfigUpdateToAPI({
|
||||
apiPath: API_YP_SWITCH,
|
||||
data: { value: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
function handleHideViewerCountChange(enabled: boolean) {
|
||||
handleFieldChange({ fieldName: 'hideViewerCount', value: enabled });
|
||||
}
|
||||
|
||||
const hasInstanceUrl = instanceUrl !== '';
|
||||
|
||||
return (
|
||||
<div className="edit-general-settings">
|
||||
<Title level={3} className="section-title">
|
||||
Configure Instance Details
|
||||
</Title>
|
||||
<br />
|
||||
|
||||
<TextFieldWithSubmit
|
||||
fieldName="name"
|
||||
{...TEXTFIELD_PROPS_SERVER_NAME}
|
||||
value={formDataValues.name}
|
||||
initialValue={instanceDetails.name}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
<TextFieldWithSubmit
|
||||
fieldName="instanceUrl"
|
||||
{...TEXTFIELD_PROPS_INSTANCE_URL}
|
||||
value={formDataValues.instanceUrl}
|
||||
initialValue={yp.instanceUrl}
|
||||
type={TEXTFIELD_TYPE_URL}
|
||||
onChange={handleFieldChange}
|
||||
onSubmit={handleSubmitInstanceUrl}
|
||||
/>
|
||||
|
||||
<TextFieldWithSubmit
|
||||
fieldName="summary"
|
||||
{...TEXTFIELD_PROPS_SERVER_SUMMARY}
|
||||
type={TEXTFIELD_TYPE_TEXTAREA}
|
||||
value={formDataValues.summary}
|
||||
initialValue={instanceDetails.summary}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
<TextFieldWithSubmit
|
||||
fieldName="offlineMessage"
|
||||
{...TEXTFIELD_PROPS_SERVER_OFFLINE_MESSAGE}
|
||||
type={TEXTFIELD_TYPE_TEXTAREA}
|
||||
value={formDataValues.offlineMessage}
|
||||
initialValue={instanceDetails.offlineMessage}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
{/* Logo section */}
|
||||
<EditLogo />
|
||||
|
||||
<ToggleSwitch
|
||||
fieldName="hideViewerCount"
|
||||
useSubmit
|
||||
{...FIELD_PROPS_HIDE_VIEWER_COUNT}
|
||||
checked={formDataValues.hideViewerCount}
|
||||
onChange={handleHideViewerCountChange}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<p className="description">
|
||||
Increase your audience by appearing in the{' '}
|
||||
<a href="https://directory.owncast.online" target="_blank" rel="noreferrer">
|
||||
<strong>Owncast Directory</strong>
|
||||
</a>
|
||||
. This is an external service run by the Owncast project.{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/directory/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{!yp.instanceUrl && (
|
||||
<p className="description">
|
||||
You must set your <strong>Server URL</strong> above to enable the directory.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="config-yp-container">
|
||||
<ToggleSwitch
|
||||
fieldName="enabled"
|
||||
useSubmit
|
||||
{...FIELD_PROPS_YP}
|
||||
checked={formDataValues.enabled}
|
||||
disabled={!hasInstanceUrl}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
fieldName="nsfw"
|
||||
useSubmit
|
||||
{...FIELD_PROPS_NSFW}
|
||||
checked={formDataValues.nsfw}
|
||||
disabled={!hasInstanceUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
web/components/admin/config/general/EditInstanceTags.tsx
Normal file
139
web/components/admin/config/general/EditInstanceTags.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { Typography, Tag } from 'antd';
|
||||
import { ServerStatusContext } from '../../../../utils/server-status-context';
|
||||
import {
|
||||
FIELD_PROPS_TAGS,
|
||||
RESET_TIMEOUT,
|
||||
postConfigUpdateToAPI,
|
||||
} from '../../../../utils/config-constants';
|
||||
import { TextField } from '../../TextField';
|
||||
import { UpdateArgs } from '../../../../types/config-section';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
STATUS_WARNING,
|
||||
} from '../../../../utils/input-statuses';
|
||||
import { TAG_COLOR } from '../../EditValueArray';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function EditInstanceTags() {
|
||||
const [newTagInput, setNewTagInput] = useState<string>('');
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { tags = [] } = instanceDetails;
|
||||
|
||||
const { apiPath, maxLength, placeholder, configPath } = FIELD_PROPS_TAGS;
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(resetTimer);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
// posts all the tags at once as an array obj
|
||||
const postUpdateToAPI = async (postValue: any) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath,
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({ fieldName: 'tags', value: postValue, path: configPath });
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Tags updated.'));
|
||||
setNewTagInput('');
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = ({ value }: UpdateArgs) => {
|
||||
if (!submitStatus) {
|
||||
setSubmitStatus(null);
|
||||
}
|
||||
setNewTagInput(value);
|
||||
};
|
||||
|
||||
// send to api and do stuff
|
||||
const handleSubmitNewTag = () => {
|
||||
resetStates();
|
||||
const newTag = newTagInput.trim();
|
||||
if (newTag === '') {
|
||||
setSubmitStatus(createInputStatus(STATUS_WARNING, 'Please enter a tag'));
|
||||
return;
|
||||
}
|
||||
if (tags.some(tag => tag.toLowerCase() === newTag.toLowerCase())) {
|
||||
setSubmitStatus(createInputStatus(STATUS_WARNING, 'This tag is already used!'));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTags = [...tags, newTag];
|
||||
postUpdateToAPI(updatedTags);
|
||||
};
|
||||
|
||||
const handleDeleteTag = index => {
|
||||
resetStates();
|
||||
const updatedTags = [...tags];
|
||||
updatedTags.splice(index, 1);
|
||||
postUpdateToAPI(updatedTags);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tag-editor-container">
|
||||
<Title level={3} className="section-title">
|
||||
Add Tags
|
||||
</Title>
|
||||
<p className="description">
|
||||
This is a great way to categorize your Owncast server on the Directory!
|
||||
</p>
|
||||
|
||||
<div className="edit-current-strings">
|
||||
{tags.map((tag, index) => {
|
||||
const handleClose = () => {
|
||||
handleDeleteTag(index);
|
||||
};
|
||||
return (
|
||||
<Tag closable onClose={handleClose} color={TAG_COLOR} key={`tag-${tag}-${index}`}>
|
||||
{tag}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="add-new-string-section">
|
||||
<TextField
|
||||
fieldName="tag-input"
|
||||
value={newTagInput}
|
||||
className="new-tag-input"
|
||||
onChange={handleInputChange}
|
||||
onPressEnter={handleSubmitNewTag}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
status={submitStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
web/components/admin/config/general/EditPageContent.tsx
Normal file
119
web/components/admin/config/general/EditPageContent.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// EDIT CUSTOM DETAILS ON YOUR PAGE
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Typography, Button } from 'antd';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { bbedit } from '@uiw/codemirror-theme-bbedit';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
|
||||
import { ServerStatusContext } from '../../../../utils/server-status-context';
|
||||
import {
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
API_CUSTOM_CONTENT,
|
||||
} from '../../../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../../../utils/input-statuses';
|
||||
import { FormStatusIndicator } from '../../FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function EditPageContent() {
|
||||
const [content, setContent] = useState('');
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
const [hasChanged, setHasChanged] = useState(false);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { extraPageContent: initialContent } = instanceDetails;
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
function handleEditorChange(text) {
|
||||
setContent(text);
|
||||
if (text !== initialContent && !hasChanged) {
|
||||
setHasChanged(true);
|
||||
} else if (text === initialContent && hasChanged) {
|
||||
setHasChanged(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear out any validation states and messaging
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
setHasChanged(false);
|
||||
clearTimeout(resetTimer);
|
||||
resetTimer = null;
|
||||
};
|
||||
|
||||
// posts all the tags at once as an array obj
|
||||
async function handleSave() {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: API_CUSTOM_CONTENT,
|
||||
data: { value: content },
|
||||
onSuccess: (message: string) => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'extraPageContent',
|
||||
value: content,
|
||||
path: 'instanceDetails',
|
||||
});
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, message));
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
},
|
||||
});
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setContent(initialContent);
|
||||
}, [instanceDetails]);
|
||||
|
||||
return (
|
||||
<div className="edit-page-content">
|
||||
<Title level={3} className="section-title">
|
||||
Custom Page Content
|
||||
</Title>
|
||||
|
||||
<p className="description">
|
||||
Edit the content of your page by using simple{' '}
|
||||
<a
|
||||
href="https://www.markdownguide.org/basic-syntax/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Markdown syntax
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<CodeMirror
|
||||
value={content}
|
||||
placeholder="Enter your custom page content here..."
|
||||
theme={bbedit}
|
||||
onChange={handleEditorChange}
|
||||
extensions={[markdown({ base: markdownLanguage, codeLanguages: languages })]}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<div className="page-content-actions">
|
||||
{hasChanged && (
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
web/components/admin/config/general/EditSocialLinks.tsx
Normal file
368
web/components/admin/config/general/EditSocialLinks.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Typography, Table, Button, Modal, Input } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { CaretDownOutlined, CaretUpOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { SocialDropdown } from '../../SocialDropdown';
|
||||
import { fetchData, SOCIAL_PLATFORMS_LIST } from '../../../../utils/apis';
|
||||
import { ServerStatusContext } from '../../../../utils/server-status-context';
|
||||
import {
|
||||
API_SOCIAL_HANDLES,
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
DEFAULT_SOCIAL_HANDLE,
|
||||
OTHER_SOCIAL_HANDLE_OPTION,
|
||||
} from '../../../../utils/config-constants';
|
||||
import { SocialHandle, UpdateArgs } from '../../../../types/config-section';
|
||||
import {
|
||||
isValidMatrixAccount,
|
||||
isValidAccount,
|
||||
isValidUrl,
|
||||
DEFAULT_TEXTFIELD_URL_PATTERN,
|
||||
} from '../../../../utils/urls';
|
||||
import { TextField } from '../../TextField';
|
||||
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../../../utils/input-statuses';
|
||||
import { FormStatusIndicator } from '../../FormStatusIndicator';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function EditSocialLinks() {
|
||||
const [availableIconsList, setAvailableIconsList] = useState([]);
|
||||
const [currentSocialHandles, setCurrentSocialHandles] = useState([]);
|
||||
|
||||
const [displayModal, setDisplayModal] = useState(false);
|
||||
const [displayOther, setDisplayOther] = useState(false);
|
||||
const [modalProcessing, setModalProcessing] = useState(false);
|
||||
const [editId, setEditId] = useState(-1);
|
||||
|
||||
// current data inside modal
|
||||
const [modalDataState, setModalDataState] = useState(DEFAULT_SOCIAL_HANDLE);
|
||||
|
||||
const [submitStatus, setSubmitStatus] = useState(null);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const { instanceDetails } = serverConfig;
|
||||
const { socialHandles: initialSocialHandles } = instanceDetails;
|
||||
|
||||
let resetTimer = null;
|
||||
|
||||
const PLACEHOLDERS = {
|
||||
mastodon: 'https://mastodon.social/@username',
|
||||
twitter: 'https://twitter.com/username',
|
||||
};
|
||||
|
||||
const getAvailableIcons = async () => {
|
||||
try {
|
||||
const result = await fetchData(SOCIAL_PLATFORMS_LIST, { auth: false });
|
||||
const list = Object.keys(result).map(item => ({
|
||||
key: item,
|
||||
...result[item],
|
||||
}));
|
||||
setAvailableIconsList(list);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
||||
const isPredefinedSocial = (platform: string) =>
|
||||
availableIconsList.find(item => item.key === platform) || false;
|
||||
|
||||
const selectedOther =
|
||||
modalDataState.platform !== '' &&
|
||||
!availableIconsList.find(item => item.key === modalDataState.platform);
|
||||
|
||||
useEffect(() => {
|
||||
getAvailableIcons();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceDetails.socialHandles) {
|
||||
setCurrentSocialHandles(initialSocialHandles);
|
||||
}
|
||||
}, [instanceDetails]);
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
const resetModal = () => {
|
||||
setDisplayModal(false);
|
||||
setEditId(-1);
|
||||
setDisplayOther(false);
|
||||
setModalProcessing(false);
|
||||
setModalDataState({ ...DEFAULT_SOCIAL_HANDLE });
|
||||
};
|
||||
|
||||
const handleModalCancel = () => {
|
||||
resetModal();
|
||||
};
|
||||
|
||||
const updateModalState = (fieldName: string, value: string) => {
|
||||
setModalDataState({
|
||||
...modalDataState,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
const handleDropdownSelect = (value: string) => {
|
||||
if (value === OTHER_SOCIAL_HANDLE_OPTION) {
|
||||
setDisplayOther(true);
|
||||
updateModalState('platform', '');
|
||||
} else {
|
||||
setDisplayOther(false);
|
||||
updateModalState('platform', value);
|
||||
}
|
||||
};
|
||||
const handleOtherNameChange = event => {
|
||||
const { value } = event.target;
|
||||
updateModalState('platform', value);
|
||||
};
|
||||
|
||||
const handleUrlChange = ({ value }: UpdateArgs) => {
|
||||
updateModalState('url', value);
|
||||
};
|
||||
|
||||
// posts all the variants at once as an array obj
|
||||
const postUpdateToAPI = async (postValue: any) => {
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: API_SOCIAL_HANDLES,
|
||||
data: { value: postValue },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'socialHandles',
|
||||
value: postValue,
|
||||
path: 'instanceDetails',
|
||||
});
|
||||
|
||||
// close modal
|
||||
setModalProcessing(false);
|
||||
handleModalCancel();
|
||||
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
|
||||
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
|
||||
setModalProcessing(false);
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// on Ok, send all of dataState to api
|
||||
// show loading
|
||||
// close modal when api is done
|
||||
const handleModalOk = () => {
|
||||
setModalProcessing(true);
|
||||
const postData = currentSocialHandles.length ? [...currentSocialHandles] : [];
|
||||
if (editId === -1) {
|
||||
postData.push(modalDataState);
|
||||
} else {
|
||||
postData.splice(editId, 1, modalDataState);
|
||||
}
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (index: number) => {
|
||||
const postData = [...currentSocialHandles];
|
||||
postData.splice(index, 1);
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const handleMoveItemUp = (index: number) => {
|
||||
if (index <= 0 || index >= currentSocialHandles.length) {
|
||||
return;
|
||||
}
|
||||
const postData = [...currentSocialHandles];
|
||||
const tmp = postData[index - 1];
|
||||
postData[index - 1] = postData[index];
|
||||
postData[index] = tmp;
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const handleMoveItemDown = (index: number) => {
|
||||
if (index < 0 || index >= currentSocialHandles.length - 1) {
|
||||
return;
|
||||
}
|
||||
const postData = [...currentSocialHandles];
|
||||
const tmp = postData[index + 1];
|
||||
postData[index + 1] = postData[index];
|
||||
postData[index] = tmp;
|
||||
postUpdateToAPI(postData);
|
||||
};
|
||||
|
||||
const socialHandlesColumns: ColumnsType<SocialHandle> = [
|
||||
{
|
||||
title: 'Social Link',
|
||||
dataIndex: '',
|
||||
key: 'combo',
|
||||
render: (data, record) => {
|
||||
const { platform, url } = record;
|
||||
const platformInfo = isPredefinedSocial(platform);
|
||||
|
||||
// custom platform case
|
||||
if (!platformInfo) {
|
||||
return (
|
||||
<div className="social-handle-cell">
|
||||
<p className="option-label">
|
||||
<strong>{platform}</strong>
|
||||
<span className="handle-url" title={url}>
|
||||
{url}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { icon, platform: platformName } = platformInfo;
|
||||
return (
|
||||
<div className="social-handle-cell">
|
||||
<span className="option-icon">
|
||||
<img src={icon} alt="" className="option-icon" />
|
||||
</span>
|
||||
<p className="option-label">
|
||||
<strong>{platformName}</strong>
|
||||
<span className="handle-url" title={url}>
|
||||
{url}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: '',
|
||||
key: 'edit',
|
||||
render: (data, record, index) => (
|
||||
<div className="actions">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const platformInfo = currentSocialHandles[index];
|
||||
setEditId(index);
|
||||
setModalDataState({ ...platformInfo });
|
||||
setDisplayModal(true);
|
||||
if (!isPredefinedSocial(platformInfo.platform)) {
|
||||
setDisplayOther(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CaretUpOutlined />}
|
||||
size="small"
|
||||
hidden={index === 0}
|
||||
onClick={() => handleMoveItemUp(index)}
|
||||
/>
|
||||
<Button
|
||||
icon={<CaretDownOutlined />}
|
||||
size="small"
|
||||
hidden={index === currentSocialHandles.length - 1}
|
||||
onClick={() => handleMoveItemDown(index)}
|
||||
/>
|
||||
<Button
|
||||
className="delete-button"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleDeleteItem(index)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const isValid = (url: string, platform: string) => {
|
||||
if (platform === 'xmpp') {
|
||||
return isValidAccount(url, 'xmpp');
|
||||
}
|
||||
if (platform === 'matrix') {
|
||||
return isValidMatrixAccount(url);
|
||||
}
|
||||
|
||||
return isValidUrl(url);
|
||||
};
|
||||
|
||||
const okButtonProps = {
|
||||
disabled: !isValid(modalDataState.url, modalDataState.platform),
|
||||
};
|
||||
|
||||
const otherField = (
|
||||
<div className="other-field-container formfield-container">
|
||||
<div className="label-side" />
|
||||
<div className="input-side">
|
||||
<Input
|
||||
placeholder="Other platform name"
|
||||
defaultValue={modalDataState.platform}
|
||||
onChange={handleOtherNameChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="social-links-edit-container">
|
||||
<Title level={3} className="section-title">
|
||||
Your Social Handles
|
||||
</Title>
|
||||
<p className="description">
|
||||
Add all your social media handles and links to your other profiles here.
|
||||
</p>
|
||||
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
|
||||
<Table
|
||||
className="social-handles-table"
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey={record => `${record.platform}-${record.url}`}
|
||||
columns={socialHandlesColumns}
|
||||
dataSource={currentSocialHandles}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Edit Social Handle"
|
||||
open={displayModal}
|
||||
onOk={handleModalOk}
|
||||
onCancel={handleModalCancel}
|
||||
confirmLoading={modalProcessing}
|
||||
okButtonProps={okButtonProps}
|
||||
>
|
||||
<div className="social-handle-modal-content">
|
||||
<SocialDropdown
|
||||
iconList={availableIconsList}
|
||||
selectedOption={selectedOther ? OTHER_SOCIAL_HANDLE_OPTION : modalDataState.platform}
|
||||
onSelected={handleDropdownSelect}
|
||||
/>
|
||||
{displayOther && otherField}
|
||||
<br />
|
||||
<TextField
|
||||
fieldName="social-url"
|
||||
label="URL"
|
||||
placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
|
||||
value={modalDataState.url}
|
||||
onChange={handleUrlChange}
|
||||
useTrim
|
||||
type="url"
|
||||
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
|
||||
/>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</Modal>
|
||||
<br />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
resetModal();
|
||||
setDisplayModal(true);
|
||||
}}
|
||||
>
|
||||
Add a new social link
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
web/components/admin/config/general/GeneralConfig.tsx
Normal file
43
web/components/admin/config/general/GeneralConfig.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import EditInstanceDetails from './EditInstanceDetails';
|
||||
import EditInstanceTags from './EditInstanceTags';
|
||||
import EditSocialLinks from './EditSocialLinks';
|
||||
import EditPageContent from './EditPageContent';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function PublicFacingDetails() {
|
||||
return (
|
||||
<div className="config-public-details-page">
|
||||
<p className="description">
|
||||
The following are displayed on your site to describe your stream and its content.{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/website/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div className="top-container">
|
||||
<div className="form-module instance-details-container">
|
||||
<EditInstanceDetails />
|
||||
</div>
|
||||
|
||||
<div className="form-module social-items-container ">
|
||||
<div className="form-module tags-module">
|
||||
<EditInstanceTags />
|
||||
</div>
|
||||
|
||||
<div className="form-module social-handles-container">
|
||||
<EditSocialLinks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-module page-content-module">
|
||||
<EditPageContent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.colorPicker {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||
Reference in New Issue
Block a user