Merge pull request #23 from owncast/admin-css-overhaul-pt3
Admin css overhaul pt3
This commit is contained in:
commit
800965c455
@ -1,127 +0,0 @@
|
||||
# About the Config editing section
|
||||
|
||||
An adventure with React, React Hooks and Ant Design forms.
|
||||
|
||||
## General data flow in this React app
|
||||
|
||||
### First things to note
|
||||
- When the Admin app loads, the `ServerStatusContext` (in addition to checking server `/status` on a timer) makes a call to the `/serverconfig` API to get your config details. This data will be stored as **`serverConfig`** in app state, and _provided_ to the app via `useContext` hook.
|
||||
|
||||
- The `serverConfig` in state is be the central source of data that pre-populates the forms.
|
||||
|
||||
- The `ServerStatusContext` also provides a method for components to update the serverConfig state, called `setFieldInConfigState()`.
|
||||
|
||||
- After you have updated a config value in a form field, and successfully submitted it through its endpoint, you should call `setFieldInConfigState` to update the global state with the new value.
|
||||
|
||||
- Each top field of the serverConfig has its own API update endpoint.
|
||||
|
||||
### Form Flow
|
||||
Each form input (or group of inputs) you make, you should
|
||||
1. Get the field values that you want out of `serverConfig` from ServerStatusContext with `useContext`.
|
||||
2. Next we'll have to put these field values of interest into a `useState` in each grouping. This will help you edit the form.
|
||||
3. Because ths config data is populated asynchronously, Use a `useEffect` to check when that data has arrived before putting it into state.
|
||||
4. You will be using the state's value to populate the `defaultValue` and the `value` props of each Ant input component (`Input`, `Toggle`, `Switch`, `Select`, `Slider` are currently used).
|
||||
5. When an `onChange` event fires for each type of input component, you will update the local state of each page with the changed value.
|
||||
6. Depending on the form, an `onChange` of the input component, or a subsequent `onClick` of a submit button will take the value from local state and POST the field's API.
|
||||
7. `onSuccess` of the post, you should update the global app state with the new value.
|
||||
|
||||
There are also a variety of other local states to manage the display of error/success messaging.
|
||||
|
||||
## Notes about `form-textfield` and `form-togglefield`
|
||||
- The text field is intentionally designed to make it difficult for the user to submit bad data.
|
||||
- If you make a change on a field, a Submit buttton will show up that you have to click to update. That will be the only way you can update it.
|
||||
- If you clear out a field that is marked as Required, then exit/blur the field, it will repopulate with its original value.
|
||||
|
||||
- Both of these elements are specifically meant to be used with updating `serverConfig` fields, since each field requires its own endpoint.
|
||||
|
||||
- Give these fields a bunch of props, and they will display labelling, some helpful UI around tips, validation messaging, as well as submit the update for you.
|
||||
|
||||
- (currently undergoing re-styling and TS cleanup)
|
||||
|
||||
- NOTE: you don't have to use these components. Some form groups may require a customized UX flow where you're better off using the Ant components straight up.
|
||||
|
||||
|
||||
## Using Ant's `<Form>` with `form-textfield`.
|
||||
UPDATE: No more `<Form>` use!
|
||||
|
||||
~~You may see that a couple of pages (currently **Public Details** and **Server Details** page), is mainly a grouping of similar Text fields.~~
|
||||
|
||||
~~These are utilizing the `<Form>` component, and these calls:~~
|
||||
~~- `const [form] = Form.useForm();`~~
|
||||
~~- `form.setFieldsValue(initialValues);`~~
|
||||
|
||||
~~It seems that a `<Form>` requires its child inputs to be in a `<Form.Item>`, to help manage overall validation on the form before submission.~~
|
||||
|
||||
~~The `form-textfield` component was created to be used with this Form. It wraps with a `<Form.Item>`, which I believe handles the local state change updates of the value.~~
|
||||
|
||||
## Current Refactoring:
|
||||
~~While `Form` + `Form.Item` provides many useful UI features that I'd like to utilize, it's turning out to be too restricting for our uses cases.~~
|
||||
|
||||
~~I am refactoring `form-textfield` so that it does not rely on `<Form.Item>`. But it will require some extra handling and styling of things like error states and success messaging.~~
|
||||
|
||||
### UI things
|
||||
I'm in the middle of refactoring somes tyles and layout, and regorganizing some CSS. See TODO list below.
|
||||
|
||||
|
||||
---
|
||||
## Potential Optimizations
|
||||
|
||||
- There might be some patterns that could be overly engineered!
|
||||
|
||||
- There are also a few patterns across all the form groups that repeat quite a bit. Perhaps these patterns could be consolidated into a custom hook that could handle all the steps.
|
||||
|
||||
|
||||
|
||||
## Current `serverConfig` data structure (with default values)
|
||||
Each of these fields has its own end point for updating.
|
||||
```
|
||||
{
|
||||
streamKey: '',
|
||||
instanceDetails: {
|
||||
extraPageContent: '',
|
||||
logo: '',
|
||||
name: '',
|
||||
nsfw: false,
|
||||
socialHandles: [],
|
||||
streamTitle: '',
|
||||
summary: '',
|
||||
tags: [],
|
||||
title: '',
|
||||
},
|
||||
ffmpegPath: '',
|
||||
rtmpServerPort: '',
|
||||
webServerPort: '',
|
||||
s3: {},
|
||||
yp: {
|
||||
enabled: false,
|
||||
instanceUrl: '',
|
||||
},
|
||||
videoSettings: {
|
||||
latencyLevel: 4,
|
||||
videoQualityVariants: [],
|
||||
}
|
||||
};
|
||||
|
||||
// `yp.instanceUrl` needs to be filled out before `yp.enabled` can be turned on.
|
||||
```
|
||||
|
||||
|
||||
## Ginger's TODO list:
|
||||
|
||||
- cleanup
|
||||
- more consitent constants
|
||||
- cleanup types
|
||||
- cleanup style sheets..? make style module for each config page? (but what about ant deisgn overrides?)
|
||||
- redesign
|
||||
- label+form layout - put them into a table, table of rows?, includes responsive to stacked layout
|
||||
- change Encoder preset into slider
|
||||
- page headers - diff color?
|
||||
- fix social handles icon in table
|
||||
- things could use smaller font?
|
||||
- Design, color ideas
|
||||
|
||||
https://uxcandy.co/demo/label_pro/preview/demo_2/pages/forms/form-elements.html
|
||||
|
||||
https://www.bootstrapdash.com/demo/corona/jquery/template/modern-vertical/pages/forms/basic_elements.html
|
||||
- maybe convert common form pattern to custom hook?
|
||||
|
@ -44,15 +44,11 @@ export default function CPUUsageSelector({ defaultValue, onChange }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="config-video-segements-conatiner">
|
||||
<Title level={3} className="section-title">
|
||||
CPU Usage
|
||||
</Title>
|
||||
<div className="config-video-cpu-container">
|
||||
<Title level={3}>CPU Usage</Title>
|
||||
<p className="description">
|
||||
There are trade-offs when considering CPU usage blah blah more wording here.
|
||||
Reduce the to improve server performance, or increase it to improve video quality.
|
||||
</p>
|
||||
<br />
|
||||
|
||||
<div className="segment-slider-container">
|
||||
<Slider
|
||||
tipFormatter={value => TOOLTIPS[value]}
|
||||
@ -63,6 +59,7 @@ export default function CPUUsageSelector({ defaultValue, onChange }: Props) {
|
||||
defaultValue={selectedOption}
|
||||
value={selectedOption}
|
||||
/>
|
||||
<p className="selected-value-note">Selected: {TOOLTIPS[selectedOption]}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -2,7 +2,7 @@
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import ToggleSwitch from './form-toggleswitch-with-submit';
|
||||
import ToggleSwitch from './form-toggleswitch';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { FIELD_PROPS_NSFW, FIELD_PROPS_YP } from '../../utils/config-constants';
|
||||
|
@ -17,9 +17,10 @@ import {
|
||||
FIELD_PROPS_YP,
|
||||
FIELD_PROPS_NSFW,
|
||||
} from '../../utils/config-constants';
|
||||
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
|
||||
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import ToggleSwitch from './form-toggleswitch-with-submit';
|
||||
import ToggleSwitch from './form-toggleswitch';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@ -102,7 +103,9 @@ export default function EditInstanceDetails() {
|
||||
initialValue={instanceDetails.logo}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
{instanceDetails.logo && (
|
||||
<img src={`${NEXT_PUBLIC_API_HOST}/logo`} alt="uploaded logo" className="logo-preview" />
|
||||
)}
|
||||
<br />
|
||||
|
||||
<Title level={3} className="section-title">
|
||||
|
@ -104,13 +104,13 @@ export default function EditPageContent() {
|
||||
markdownClass: 'markdown-editor-pane',
|
||||
}}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<div className="page-content-actions">
|
||||
{hasChanged ? (
|
||||
{hasChanged && (
|
||||
<Button type="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
) : null}
|
||||
)}
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -86,7 +86,7 @@ export default function EditInstanceDetails() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="edit-public-details-container">
|
||||
<div className="edit-server-details-container">
|
||||
<div className="field-container field-streamkey-container">
|
||||
<div className="left-side">
|
||||
<TextFieldWithSubmit
|
||||
|
@ -196,7 +196,6 @@ export default function EditSocialLinks() {
|
||||
return (
|
||||
<div className="actions">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditId(index);
|
||||
@ -222,6 +221,19 @@ export default function EditSocialLinks() {
|
||||
disabled: !isValidUrl(modalDataState.url),
|
||||
};
|
||||
|
||||
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">
|
||||
@ -250,30 +262,23 @@ export default function EditSocialLinks() {
|
||||
confirmLoading={modalProcessing}
|
||||
okButtonProps={okButtonProps}
|
||||
>
|
||||
<SocialDropdown
|
||||
iconList={availableIconsList}
|
||||
selectedOption={selectedOther ? OTHER_SOCIAL_HANDLE_OPTION : modalDataState.platform}
|
||||
onSelected={handleDropdownSelect}
|
||||
/>
|
||||
{displayOther ? (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Other"
|
||||
defaultValue={modalDataState.platform}
|
||||
onChange={handleOtherNameChange}
|
||||
/>
|
||||
<br />
|
||||
</>
|
||||
) : null}
|
||||
<br />
|
||||
<TextField
|
||||
fieldName="social-url"
|
||||
label="URL"
|
||||
placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
|
||||
value={modalDataState.url}
|
||||
onChange={handleUrlChange}
|
||||
/>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
<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}
|
||||
/>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</Modal>
|
||||
<br />
|
||||
<Button
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Switch, Button, Collapse } from 'antd';
|
||||
import { Button, Collapse } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
@ -21,7 +21,7 @@ import {
|
||||
import TextField from './form-textfield';
|
||||
import FormStatusIndicator from './form-status-indicator';
|
||||
import { isValidUrl } from '../../utils/urls';
|
||||
// import ToggleSwitch from './form-toggleswitch-with-submit';
|
||||
import ToggleSwitch from './form-toggleswitch';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
@ -145,20 +145,21 @@ export default function EditStorage() {
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className="enable-switch">
|
||||
{/* <ToggleSwitch
|
||||
<ToggleSwitch
|
||||
apiPath=""
|
||||
fieldName="enabled"
|
||||
label="Storage Enabled"
|
||||
checked={formDataValues.enabled}
|
||||
onChange={handleSwitchChange}
|
||||
/> */}
|
||||
<Switch
|
||||
/>
|
||||
{/* <Switch
|
||||
checked={formDataValues.enabled}
|
||||
defaultChecked={formDataValues.enabled}
|
||||
onChange={handleSwitchChange}
|
||||
checkedChildren="ON"
|
||||
unCheckedChildren="OFF"
|
||||
/>{' '}
|
||||
Enabled
|
||||
Enabled */}
|
||||
</div>
|
||||
|
||||
<div className="form-fields">
|
||||
|
@ -1,5 +1,6 @@
|
||||
// This is a wrapper for the Ant Switch component.
|
||||
// onChange of the switch, it will automatically post a change to the config api.
|
||||
// This one is styled to match the form-textfield component.
|
||||
// If `useSubmit` is true then it will automatically post to the config API onChange.
|
||||
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
@ -17,9 +18,9 @@ import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constan
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
apiPath: string;
|
||||
fieldName: string;
|
||||
|
||||
apiPath?: string;
|
||||
checked?: boolean;
|
||||
configPath?: string;
|
||||
disabled?: boolean;
|
||||
@ -106,6 +107,7 @@ export default function ToggleSwitch(props: ToggleSwitchProps) {
|
||||
}
|
||||
|
||||
ToggleSwitch.defaultProps = {
|
||||
apiPath: '',
|
||||
checked: false,
|
||||
configPath: '',
|
||||
disabled: false,
|
@ -1,27 +1,46 @@
|
||||
import { Popconfirm, Button, Typography } from 'antd';
|
||||
import { useContext } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { AlertMessageContext } from '../../utils/alert-message-context';
|
||||
|
||||
import { API_YP_RESET, fetchData } from '../../utils/apis';
|
||||
import { RESET_TIMEOUT } from '../../utils/config-constants';
|
||||
import {
|
||||
createInputStatus,
|
||||
STATUS_ERROR,
|
||||
STATUS_PROCESSING,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
import FormStatusIndicator from './form-status-indicator';
|
||||
|
||||
export default function ResetYP() {
|
||||
const { setMessage } = useContext(AlertMessageContext);
|
||||
|
||||
const { Title } = Typography;
|
||||
const [submitStatus, setSubmitStatus] = useState(null);
|
||||
let resetTimer = null;
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
const resetDirectoryRegistration = async () => {
|
||||
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
|
||||
try {
|
||||
await fetchData(API_YP_RESET);
|
||||
setMessage('');
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
} catch (error) {
|
||||
alert(error);
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${error}`));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={3} className="section-title">
|
||||
<Typography.Title level={3} className="section-title">
|
||||
Reset Directory
|
||||
</Title>
|
||||
</Typography.Title>
|
||||
<p className="description">
|
||||
If you are experiencing issues with your listing on the Owncast Directory and were asked to
|
||||
"reset" your connection to the service, you can do that here. The next time you go
|
||||
@ -37,6 +56,9 @@ export default function ResetYP() {
|
||||
>
|
||||
<Button type="primary">Reset Directory Connection</Button>
|
||||
</Popconfirm>
|
||||
<p>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -23,39 +23,41 @@ export default function SocialDropdown({ iconList, selectedOption, onSelected }:
|
||||
If you are looking for a platform name not on this list, please select Other and type in
|
||||
your own name. A logo will not be provided.
|
||||
</p>
|
||||
<p className="description">
|
||||
If you DO have a logo, drop it in to the <code>/webroot/img/platformicons</code> directory
|
||||
and update the <code>/socialHandle.go</code> list. Then restart the server and it will show
|
||||
up in the list.
|
||||
</p>
|
||||
|
||||
<Select
|
||||
style={{ width: 240 }}
|
||||
className="social-dropdown"
|
||||
placeholder="Social platform..."
|
||||
defaultValue={inititalSelected}
|
||||
value={inititalSelected}
|
||||
onSelect={handleSelected}
|
||||
>
|
||||
{iconList.map(item => {
|
||||
const { platform, icon, key } = item;
|
||||
return (
|
||||
<Select.Option className="social-option" key={`platform-${key}`} value={key}>
|
||||
<span className="option-icon">
|
||||
<img src={`${NEXT_PUBLIC_API_HOST}${icon}`} alt="" className="option-icon" />
|
||||
</span>
|
||||
<span className="option-label">{platform}</span>
|
||||
<div className="formfield-container">
|
||||
<div className="label-side">
|
||||
<span className="formfield-label">Social Platform</span>
|
||||
</div>
|
||||
<div className="input-side">
|
||||
<Select
|
||||
style={{ width: 240 }}
|
||||
className="social-dropdown"
|
||||
placeholder="Social platform..."
|
||||
defaultValue={inititalSelected}
|
||||
value={inititalSelected}
|
||||
onSelect={handleSelected}
|
||||
>
|
||||
{iconList.map(item => {
|
||||
const { platform, icon, key } = item;
|
||||
return (
|
||||
<Select.Option className="social-option" key={`platform-${key}`} value={key}>
|
||||
<span className="option-icon">
|
||||
<img src={`${NEXT_PUBLIC_API_HOST}${icon}`} alt="" className="option-icon" />
|
||||
</span>
|
||||
<span className="option-label">{platform}</span>
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
<Select.Option
|
||||
className="social-option"
|
||||
key={`platform-${OTHER_SOCIAL_HANDLE_OPTION}`}
|
||||
value={OTHER_SOCIAL_HANDLE_OPTION}
|
||||
>
|
||||
Other...
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
<Select.Option
|
||||
className="social-option"
|
||||
key={`platform-${OTHER_SOCIAL_HANDLE_OPTION}`}
|
||||
value={OTHER_SOCIAL_HANDLE_OPTION}
|
||||
>
|
||||
Other...
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -36,14 +36,6 @@ const SLIDER_COMMENTS = {
|
||||
6: 'Highest latency, highest error tolerance',
|
||||
};
|
||||
|
||||
interface SegmentToolTipProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
function SegmentToolTip({ value }: SegmentToolTipProps) {
|
||||
return <span className="segment-tip">{value}</span>;
|
||||
}
|
||||
|
||||
export default function VideoLatency() {
|
||||
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
|
||||
const [selectedOption, setSelectedOption] = useState(null);
|
||||
@ -104,7 +96,7 @@ export default function VideoLatency() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="config-video-segements-conatiner">
|
||||
<div className="config-video-latency-container">
|
||||
<Title level={3} className="section-title">
|
||||
Latency Buffer
|
||||
</Title>
|
||||
@ -120,7 +112,7 @@ export default function VideoLatency() {
|
||||
|
||||
<div className="segment-slider-container">
|
||||
<Slider
|
||||
tipFormatter={value => <SegmentToolTip value={SLIDER_COMMENTS[value]} />}
|
||||
tipFormatter={value => SLIDER_COMMENTS[value]}
|
||||
onChange={handleChange}
|
||||
min={1}
|
||||
max={6}
|
||||
@ -128,6 +120,7 @@ export default function VideoLatency() {
|
||||
defaultValue={selectedOption}
|
||||
value={selectedOption}
|
||||
/>
|
||||
<p className="selected-value-note">{SLIDER_COMMENTS[selectedOption]}</p>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,12 +1,11 @@
|
||||
// This content populates the video variant modal, which is spawned from the variants table.
|
||||
import React from 'react';
|
||||
import { Slider, Switch, Collapse, Typography } from 'antd';
|
||||
import { Row, Col, Slider, Collapse, Typography } from 'antd';
|
||||
import { FieldUpdaterFunc, VideoVariant, UpdateArgs } from '../../types/config-section';
|
||||
import TextField from './form-textfield';
|
||||
import { DEFAULT_VARIANT_STATE } from '../../utils/config-constants';
|
||||
import InfoTip from '../info-tip';
|
||||
import CPUUsageSelector from './cpu-usage';
|
||||
// import ToggleSwitch from './form-toggleswitch-with-submit';
|
||||
import ToggleSwitch from './form-toggleswitch';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
@ -17,7 +16,8 @@ const VIDEO_VARIANT_DEFAULTS = {
|
||||
defaultValue: 24,
|
||||
unit: 'fps',
|
||||
incrementBy: null,
|
||||
tip: 'You prob wont need to touch this unless youre a hardcore gamer and need all the bitties',
|
||||
tip:
|
||||
'Reducing your framerate will decrease the amount of video that needs to be encoded and sent to your viewers, saving CPU and bandwidth at the expense of smoothness. A lower value is generally is fine for most content.',
|
||||
},
|
||||
videoBitrate: {
|
||||
min: 600,
|
||||
@ -25,7 +25,7 @@ const VIDEO_VARIANT_DEFAULTS = {
|
||||
defaultValue: 1200,
|
||||
unit: 'kbps',
|
||||
incrementBy: 100,
|
||||
tip: 'This is importatnt yo',
|
||||
tip: 'The overall quality of your stream is generally impacted most by bitrate.',
|
||||
},
|
||||
audioBitrate: {
|
||||
min: 600,
|
||||
@ -118,9 +118,9 @@ export default function VideoVariantForm({
|
||||
|
||||
const selectedVideoBRnote = () => {
|
||||
let note = `Selected: ${dataState.videoBitrate}${videoBRUnit}`;
|
||||
if (dataState.videoBitrate < 3000) {
|
||||
if (dataState.videoBitrate < 2000) {
|
||||
note = `${note} - Good for low bandwidth environments.`;
|
||||
} else if (dataState.videoBitrate < 4500) {
|
||||
} else if (dataState.videoBitrate < 3500) {
|
||||
note = `${note} - Good for most bandwidth environments.`;
|
||||
} else {
|
||||
note = `${note} - Good for high bandwidth environments.`;
|
||||
@ -147,52 +147,47 @@ export default function VideoVariantForm({
|
||||
}
|
||||
return note;
|
||||
};
|
||||
const selectedPresetNote = '';
|
||||
|
||||
return (
|
||||
<div className="config-variant-form">
|
||||
<p className="description">
|
||||
Say a thing here about how this all works. Read more{' '}
|
||||
<a href="https://owncast.online/docs/configuration/">here</a>.
|
||||
<a href="https://owncast.online/docs/video">Learn more</a> about how each of these settings
|
||||
can impact the performance of your server.
|
||||
</p>
|
||||
|
||||
<div className="row">
|
||||
<div>
|
||||
<Row gutter={16}>
|
||||
<Col sm={24} md={12}>
|
||||
{/* ENCODER PRESET FIELD */}
|
||||
<div className="form-module cpu-usage-container">
|
||||
<CPUUsageSelector
|
||||
defaultValue={dataState.cpuUsageLevel}
|
||||
onChange={handleVideoCpuUsageLevelChange}
|
||||
/>
|
||||
{selectedPresetNote && (
|
||||
<span className="selected-value-note">{selectedPresetNote}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* VIDEO PASSTHROUGH FIELD */}
|
||||
<div style={{ display: 'none' }} className="form-module">
|
||||
<p className="label">
|
||||
<InfoTip tip={VIDEO_VARIANT_DEFAULTS.videoPassthrough.tip} />
|
||||
Use Video Passthrough?
|
||||
<p className="read-more-subtext">
|
||||
<a href="https://owncast.online/docs/video/#cpu-usage">Read more about CPU usage.</a>
|
||||
</p>
|
||||
<div className="form-component">
|
||||
{/* todo: change to ToggleSwitch for layout */}
|
||||
<Switch
|
||||
defaultChecked={dataState.videoPassthrough}
|
||||
checked={dataState.videoPassthrough}
|
||||
onChange={handleVideoPassChange}
|
||||
// label="Use Video Passthrough"
|
||||
checkedChildren="Yes"
|
||||
unCheckedChildren="No"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIDEO PASSTHROUGH FIELD - currently disabled */}
|
||||
<div style={{ display: 'none' }} className="form-module">
|
||||
<ToggleSwitch
|
||||
label="Use Video Passthrough?"
|
||||
fieldName="video-passthrough"
|
||||
tip={VIDEO_VARIANT_DEFAULTS.videoPassthrough.tip}
|
||||
checked={dataState.videoPassthrough}
|
||||
onChange={handleVideoPassChange}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col sm={24} md={12}>
|
||||
{/* VIDEO BITRATE FIELD */}
|
||||
<div className={`form-module ${dataState.videoPassthrough ? 'disabled' : ''}`}>
|
||||
<Typography.Title level={3} className="section-title">
|
||||
Video Bitrate
|
||||
</Typography.Title>
|
||||
<div
|
||||
className={`form-module bitrate-container ${
|
||||
dataState.videoPassthrough ? 'disabled' : ''
|
||||
}`}
|
||||
>
|
||||
<Typography.Title level={3}>Video Bitrate</Typography.Title>
|
||||
<p className="description">{VIDEO_VARIANT_DEFAULTS.videoBitrate.tip}</p>
|
||||
<div className="segment-slider-container">
|
||||
<Slider
|
||||
@ -206,58 +201,59 @@ export default function VideoVariantForm({
|
||||
max={videoBRMax}
|
||||
marks={videoBRMarks}
|
||||
/>
|
||||
<span className="selected-value-note">{selectedVideoBRnote()}</span>
|
||||
<p className="selected-value-note">{selectedVideoBRnote()}</p>
|
||||
</div>
|
||||
<p className="read-more-subtext">
|
||||
<a href="https://owncast.online/docs/video/#bitrate">Read more about bitrates.</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Collapse className="advanced-settings">
|
||||
<Panel header="Advanced Settings" key="1">
|
||||
<div className="section-intro">
|
||||
Resizing your content will take additional resources on your server. If you wish to
|
||||
optionally resize your output for this stream variant then you should either set the
|
||||
width <strong>or</strong> the height to keep your aspect ratio.
|
||||
</div>
|
||||
<div className="field">
|
||||
<TextField
|
||||
type="number"
|
||||
{...VIDEO_VARIANT_DEFAULTS.scaledWidth}
|
||||
value={dataState.scaledWidth}
|
||||
onChange={handleScaledWidthChanged}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<TextField
|
||||
type="number"
|
||||
{...VIDEO_VARIANT_DEFAULTS.scaledHeight}
|
||||
value={dataState.scaledHeight}
|
||||
onChange={handleScaledHeightChanged}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Collapse className="advanced-settings">
|
||||
<Panel header="Advanced Settings" key="1">
|
||||
<p className="description">
|
||||
Resizing your content will take additional resources on your server. If you wish to
|
||||
optionally resize your content for this stream output then you should either set the
|
||||
width <strong>or</strong> the height to keep your aspect ratio.{' '}
|
||||
<a href="https://owncast.online/docs/video/#resolution">Read more about resolutions.</a>
|
||||
</p>
|
||||
|
||||
{/* FRAME RATE FIELD */}
|
||||
<div className="field">
|
||||
<p className="label">
|
||||
<InfoTip tip={VIDEO_VARIANT_DEFAULTS.framerate.tip} />
|
||||
Frame rate:
|
||||
</p>
|
||||
<div className="segment-slider-container form-component">
|
||||
<Slider
|
||||
// tooltipVisible
|
||||
tipFormatter={value => `${value} ${framerateUnit}`}
|
||||
defaultValue={dataState.framerate}
|
||||
value={dataState.framerate}
|
||||
onChange={handleFramerateChange}
|
||||
step={framerateDefaults.incrementBy}
|
||||
min={framerateMin}
|
||||
max={framerateMax}
|
||||
marks={framerateMarks}
|
||||
/>
|
||||
<span className="selected-value-note">{selectedFramerateNote()}</span>
|
||||
</div>
|
||||
<TextField
|
||||
type="number"
|
||||
{...VIDEO_VARIANT_DEFAULTS.scaledWidth}
|
||||
value={dataState.scaledWidth}
|
||||
onChange={handleScaledWidthChanged}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
{...VIDEO_VARIANT_DEFAULTS.scaledHeight}
|
||||
value={dataState.scaledHeight}
|
||||
onChange={handleScaledHeightChanged}
|
||||
/>
|
||||
|
||||
{/* FRAME RATE FIELD */}
|
||||
<div className="form-module frame-rate">
|
||||
<Typography.Title level={3}>Frame rate</Typography.Title>
|
||||
<p className="description">{VIDEO_VARIANT_DEFAULTS.framerate.tip}</p>
|
||||
<div className="segment-slider-container">
|
||||
<Slider
|
||||
tipFormatter={value => `${value} ${framerateUnit}`}
|
||||
defaultValue={dataState.framerate}
|
||||
value={dataState.framerate}
|
||||
onChange={handleFramerateChange}
|
||||
step={framerateDefaults.incrementBy}
|
||||
min={framerateMin}
|
||||
max={framerateMax}
|
||||
marks={framerateMarks}
|
||||
/>
|
||||
<p className="selected-value-note">{selectedFramerateNote()}</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
<p className="read-more-subtext">
|
||||
<a href="https://owncast.online/docs/video/#framerate">Read more about framerates.</a>
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -153,7 +153,6 @@ export default function CurrentVariantsTable() {
|
||||
return (
|
||||
<span className="actions">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditId(index);
|
||||
|
@ -75,7 +75,7 @@ export default function LogTable({ logs, pageSize }: Props) {
|
||||
|
||||
return (
|
||||
<div className="logs-section">
|
||||
<Title level={2}>Logs</Title>
|
||||
<Title>Logs</Title>
|
||||
<Table
|
||||
size="middle"
|
||||
dataSource={logs}
|
||||
|
@ -194,7 +194,7 @@ export default function MainLayout(props) {
|
||||
<TextFieldWithSubmit
|
||||
fieldName="streamTitle"
|
||||
{...TEXTFIELD_PROPS_STREAM_TITLE}
|
||||
placeholder="What you're streaming right now"
|
||||
placeholder="What are you streaming now"
|
||||
value={currentStreamTitle}
|
||||
initialValue={instanceDetails.streamTitle}
|
||||
onChange={handleStreamTitleChanged}
|
||||
|
@ -1,3 +1,6 @@
|
||||
/* eslint-disable react/no-unused-prop-types */
|
||||
// TODO: This component should be cleaned up and usage should be re-examined. The types should be reconsidered as well.
|
||||
|
||||
import { Typography, Statistic, Card, Progress } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
@ -5,7 +8,7 @@ const { Text } = Typography;
|
||||
interface StatisticItemProps {
|
||||
title?: string;
|
||||
value?: any;
|
||||
prefix?: JSX.Element;
|
||||
prefix?: any;
|
||||
color?: string;
|
||||
progress?: boolean;
|
||||
centered?: boolean;
|
||||
@ -43,7 +46,7 @@ function ProgressView({ title, value, prefix, color }: StatisticItemProps) {
|
||||
'0%': color,
|
||||
'90%': endColor,
|
||||
}}
|
||||
format={percent => content}
|
||||
format={() => content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
91
web/docs/README.md
Normal file
91
web/docs/README.md
Normal file
@ -0,0 +1,91 @@
|
||||
# Tips for creating a new Admin form
|
||||
|
||||
### Layout
|
||||
- Give your page or form a title. Feel free to use Ant Design's `<Title>` component.
|
||||
- Give your form a description inside of a `<p className="description" />` tag.
|
||||
|
||||
- Use some Ant Design `Row` and `Col`'s to layout your forms if you want to spread them out into responsive columns. If you use an `<Row>`s, be sure to use `<Col>`s with them too!
|
||||
|
||||
- Use the `form-module` CSS class if you want to add a visual separation to a grouping of items.
|
||||
|
||||
|
||||
|
||||
### Form fields
|
||||
- Feel free to use the pre-styled `<TextField>` text form field or the `<ToggleSwitch>` compnent, in a group of form fields together. These have been styled and laid out to match each other.
|
||||
|
||||
- `Slider`'s - If your form uses an Ant Slider component, follow this recommended markup of CSS classes to maintain a consistent look and feel to other Sliders in the app.
|
||||
```
|
||||
<div className="segment-slider-container">
|
||||
<Slider ...props />
|
||||
<p className="selected-value-note">{selected value}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Submit Statuses
|
||||
- It would be nice to display indicators of success/warnings to let users know if something has been successfully updated on the server. It has a lot of steps (sorry, but it could probably be optimized), but it'll provide a consistent way to display messaging.
|
||||
|
||||
- See `reset-yp.tsx` for an example of using `submitStatus` with `useState()` and the `<FormStatusIndicator>` component to achieve this.
|
||||
|
||||
### Styling
|
||||
- This admin site chooses to have a generally Dark color palette, but with colors that are different from Ant design's _dark_ stylesheet, so that style sheet is not included. This results in a very large `ant-overrides.scss` file to reset colors on frequently used Ant components in the system. If you find yourself a new Ant Component that has not yet been used in this app, feel free to add a reset style for that component to the overrides stylesheet.
|
||||
|
||||
- Take a look at `variables.scss` CSS file if you want to give some elements custom css colors.
|
||||
|
||||
|
||||
---
|
||||
---
|
||||
# Creating Admin forms the Config section
|
||||
First things first..
|
||||
|
||||
## General Config data flow in this React app
|
||||
|
||||
- When the Admin app loads, the `ServerStatusContext` (in addition to checking server `/status` on a timer) makes a call to the `/serverconfig` API to get your config details. This data will be stored as **`serverConfig`** in app state, and _provided_ to the app via `useContext` hook.
|
||||
|
||||
- The `serverConfig` in state is be the central source of data that pre-populates the forms.
|
||||
|
||||
- The `ServerStatusContext` also provides a method for components to update the serverConfig state, called `setFieldInConfigState()`.
|
||||
|
||||
- After you have updated a config value in a form field, and successfully submitted it through its endpoint, you should call `setFieldInConfigState` to update the global state with the new value.
|
||||
|
||||
|
||||
## Suggested Config Form Flow
|
||||
- *NOTE: Each top field of the serverConfig has its own API update endpoint.*
|
||||
|
||||
|
||||
There many steps here, but they are highly suggested to ensure that Config values are updated and displayed properly throughout the entire admin form.
|
||||
|
||||
For each form input (or group of inputs) you make, you should:
|
||||
1. Get the field values that you want out of `serverConfig` from ServerStatusContext with `useContext`.
|
||||
2. Next we'll have to put these field values of interest into a `useState` in each grouping. This will help you edit the form.
|
||||
3. Because ths config data is populated asynchronously, Use a `useEffect` to check when that data has arrived before putting it into state.
|
||||
4. You will be using the state's value to populate the `defaultValue` and the `value` props of each Ant input component (`Input`, `Toggle`, `Switch`, `Select`, `Slider` are currently used).
|
||||
5. When an `onChange` event fires for each type of input component, you will update the local state of each page with the changed value.
|
||||
6. Depending on the form, an `onChange` of the input component, or a subsequent `onClick` of a submit button will take the value from local state and POST the field's API.
|
||||
7. `onSuccess` of the post, you should update the global app state with the new value.
|
||||
|
||||
There are also a variety of other local states to manage the display of error/success messaging.
|
||||
|
||||
- It is recommended that you use `form-textfield-with-submit` and `form-toggleswitch`(with `useSubmit=true`) Components to edit Config fields.
|
||||
|
||||
Examples of Config form groups where individual form fields submitting to the update API include:
|
||||
- `edit-instance-details.tsx`
|
||||
- `edit-server-details.tsx`
|
||||
|
||||
Examples of Config form groups where there is 1 submit button for the entire group include:
|
||||
- `edit-storage.tsx`
|
||||
|
||||
|
||||
---
|
||||
#### Notes about `form-textfield-with-submit` and `form-togglefield` (with useSubmit=true)
|
||||
- The text field is intentionally designed to make it difficult for the user to submit bad data.
|
||||
- If you make a change on a field, a Submit buttton will show up that you have to click to update. That will be the only way you can update it.
|
||||
- If you clear out a field that is marked as Required, then exit/blur the field, it will repopulate with its original value.
|
||||
|
||||
- Both of these elements are specifically meant to be used with updating `serverConfig` fields, since each field requires its own endpoint.
|
||||
|
||||
- Give these fields a bunch of props, and they will display labelling, some helpful UI around tips, validation messaging, as well as submit the update for you.
|
||||
|
||||
- (currently undergoing re-styling and TS cleanup)
|
||||
|
||||
- NOTE: you don't have to use these components. Some form groups may require a customized UX flow where you're better off using the Ant components straight up.
|
||||
|
@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build && next export",
|
||||
"start": "next start"
|
||||
"start": "next start",
|
||||
"lint": "eslint --ext .js,.ts,.tsx types/ pages/ components/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^4.2.2",
|
||||
|
@ -1,6 +1,6 @@
|
||||
// order matters!
|
||||
import 'antd/dist/antd.css';
|
||||
import '../styles/colors.scss';
|
||||
import '../styles/variables.scss';
|
||||
import '../styles/ant-overrides.scss';
|
||||
import '../styles/markdown-editor.scss';
|
||||
import '../styles/globals.scss';
|
||||
@ -17,7 +17,7 @@ import '../styles/config-public-details.scss';
|
||||
|
||||
import '../styles/home.scss';
|
||||
import '../styles/chat.scss';
|
||||
import '../styles/config.scss';
|
||||
import '../styles/pages.scss';
|
||||
|
||||
import { AppProps } from 'next/app';
|
||||
import ServerStatusProvider from '../utils/server-status-context';
|
||||
|
@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Tag, Space, Button, Modal, Checkbox, Input, Typography, Tooltip } from 'antd';
|
||||
import { DeleteOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
import format from 'date-fns/format';
|
||||
|
||||
import { fetchData, ACCESS_TOKENS, DELETE_ACCESS_TOKEN, CREATE_ACCESS_TOKEN } from '../utils/apis';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const availableScopes = {
|
||||
CAN_SEND_SYSTEM_MESSAGES: {
|
||||
name: 'System messages',
|
||||
@ -39,11 +40,17 @@ function convertScopeStringToTag(scopeString) {
|
||||
);
|
||||
}
|
||||
|
||||
function NewTokenModal(props) {
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onOk: any; // todo: make better type
|
||||
visible: boolean;
|
||||
}
|
||||
function NewTokenModal(props: Props) {
|
||||
const { onOk, onCancel, visible } = props;
|
||||
const [selectedScopes, setSelectedScopes] = useState([]);
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const scopes = Object.keys(availableScopes).map(function (key) {
|
||||
const scopes = Object.keys(availableScopes).map(key => {
|
||||
return { value: key, label: availableScopes[key].description };
|
||||
});
|
||||
|
||||
@ -52,7 +59,7 @@ function NewTokenModal(props) {
|
||||
}
|
||||
|
||||
function saveToken() {
|
||||
props.onOk(name, selectedScopes);
|
||||
onOk(name, selectedScopes);
|
||||
|
||||
// Clear the modal
|
||||
setSelectedScopes([]);
|
||||
@ -70,9 +77,9 @@ function NewTokenModal(props) {
|
||||
return (
|
||||
<Modal
|
||||
title="Create New Access token"
|
||||
visible={props.visible}
|
||||
visible={visible}
|
||||
onOk={saveToken}
|
||||
onCancel={props.onCancel}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={okButtonProps}
|
||||
>
|
||||
<p>
|
||||
@ -84,12 +91,16 @@ function NewTokenModal(props) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Select the permissions this access token will have. It cannot be edited after it's created.
|
||||
Select the permissions this access token will have. It cannot be edited after it's
|
||||
created.
|
||||
</p>
|
||||
<Checkbox.Group options={scopes} value={selectedScopes} onChange={onChange} />
|
||||
<Button type="text" size="small" onClick={selectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
|
||||
<p>
|
||||
<Button type="primary" onClick={selectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -98,6 +109,47 @@ export default function AccessTokens() {
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [isTokenModalVisible, setIsTokenModalVisible] = useState(false);
|
||||
|
||||
function handleError(error) {
|
||||
console.error('error', error);
|
||||
alert(error);
|
||||
}
|
||||
|
||||
async function getAccessTokens() {
|
||||
try {
|
||||
const result = await fetchData(ACCESS_TOKENS);
|
||||
setTokens(result);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
getAccessTokens();
|
||||
}, []);
|
||||
|
||||
async function handleDeleteToken(token) {
|
||||
try {
|
||||
await fetchData(DELETE_ACCESS_TOKEN, {
|
||||
method: 'POST',
|
||||
data: { token },
|
||||
});
|
||||
getAccessTokens();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveToken(name: string, scopes: string[]) {
|
||||
try {
|
||||
const newToken = await fetchData(CREATE_ACCESS_TOKEN, {
|
||||
method: 'POST',
|
||||
data: { name, scopes },
|
||||
});
|
||||
setTokens(tokens.concat(newToken));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
@ -117,7 +169,7 @@ export default function AccessTokens() {
|
||||
title: 'Token',
|
||||
dataIndex: 'token',
|
||||
key: 'token',
|
||||
render: (text, record) => <Input.Password size="small" bordered={false} value={text} />,
|
||||
render: text => <Input.Password size="small" bordered={false} value={text} />,
|
||||
},
|
||||
{
|
||||
title: 'Scopes',
|
||||
@ -145,48 +197,6 @@ export default function AccessTokens() {
|
||||
},
|
||||
];
|
||||
|
||||
const getAccessTokens = async () => {
|
||||
try {
|
||||
const result = await fetchData(ACCESS_TOKENS);
|
||||
setTokens(result);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getAccessTokens();
|
||||
}, []);
|
||||
|
||||
async function handleDeleteToken(token) {
|
||||
try {
|
||||
const result = await fetchData(DELETE_ACCESS_TOKEN, {
|
||||
method: 'POST',
|
||||
data: { token: token },
|
||||
});
|
||||
getAccessTokens();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveToken(name: string, scopes: string[]) {
|
||||
try {
|
||||
const newToken = await fetchData(CREATE_ACCESS_TOKEN, {
|
||||
method: 'POST',
|
||||
data: { name: name, scopes: scopes },
|
||||
});
|
||||
setTokens(tokens.concat(newToken));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.error('error', error);
|
||||
alert(error);
|
||||
}
|
||||
|
||||
const showCreateTokenModal = () => {
|
||||
setIsTokenModalVisible(true);
|
||||
};
|
||||
|
@ -1,18 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { ServerStatusContext } from '../utils/server-status-context';
|
||||
|
||||
|
||||
export default function BroadcastInfo() {
|
||||
const context = useContext(ServerStatusContext);
|
||||
const { broadcaster } = context || {};
|
||||
const { remoteAddr, time, streamDetails } = broadcaster || {};
|
||||
|
||||
return (
|
||||
<div style={{border: '1px solid green', width: '100%'}}>
|
||||
<h2>Broadcast Info</h2>
|
||||
<p>Remote Address: {remoteAddr}</p>
|
||||
<p>Time: {(new Date(time)).toLocaleTimeString()}</p>
|
||||
<p>Stream Details: {JSON.stringify(streamDetails)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -202,7 +202,7 @@ export default function Chat() {
|
||||
|
||||
return (
|
||||
<div className="chat-messages">
|
||||
<Title level={2}>Chat Messages</Title>
|
||||
<Title>Chat Messages</Title>
|
||||
<p>Manage the messages from viewers that show up on your stream.</p>
|
||||
<div className={bulkDivClasses}>
|
||||
<span className="label">Check multiple messages to change their visibility to: </span>
|
||||
|
@ -11,9 +11,7 @@ const { Title } = Typography;
|
||||
export default function PublicFacingDetails() {
|
||||
return (
|
||||
<div className="config-public-details-page">
|
||||
<Title level={2} className="page-title">
|
||||
General Settings
|
||||
</Title>
|
||||
<Title>General Settings</Title>
|
||||
<p className="description">
|
||||
The following are displayed on your site to describe your stream and its content.{' '}
|
||||
<a href="https://owncast.online/docs/website/">Learn more.</a>
|
||||
|
@ -7,9 +7,7 @@ const { Title } = Typography;
|
||||
export default function ConfigServerDetails() {
|
||||
return (
|
||||
<div className="config-server-details-form">
|
||||
<Title level={2} className="page-title">
|
||||
Server Settings
|
||||
</Title>
|
||||
<Title>Server Settings</Title>
|
||||
<p className="description">
|
||||
You should change your stream key from the default and keep it safe. For most people
|
||||
it's likely the other settings will not need to be changed.
|
||||
|
@ -7,7 +7,7 @@ const { Title } = Typography;
|
||||
export default function ConfigSocialThings() {
|
||||
return (
|
||||
<div className="config-social-items">
|
||||
<Title level={2}>Social Items</Title>
|
||||
<Title>Social Items</Title>
|
||||
|
||||
<EditSocialLinks />
|
||||
</div>
|
||||
|
@ -7,9 +7,7 @@ const { Title } = Typography;
|
||||
export default function ConfigStorageInfo() {
|
||||
return (
|
||||
<>
|
||||
<Title level={2} className="page-title">
|
||||
Storage
|
||||
</Title>
|
||||
<Title>Storage</Title>
|
||||
<p className="description">
|
||||
Owncast supports optionally using external storage providers to distribute your video. Learn
|
||||
more about this by visiting our{' '}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import { Typography, Row, Col } from 'antd';
|
||||
|
||||
import VideoVariantsTable from '../components/config/video-variants-table';
|
||||
import VideoLatency from '../components/config/video-latency';
|
||||
@ -9,24 +9,27 @@ const { Title } = Typography;
|
||||
export default function ConfigVideoSettings() {
|
||||
return (
|
||||
<div className="config-video-variants">
|
||||
<Title level={2} className="page-title">
|
||||
Video configuration
|
||||
</Title>
|
||||
<Title>Video configuration</Title>
|
||||
<p className="description">
|
||||
Before changing your video configuration{' '}
|
||||
<a href="https://owncast.online/docs/encoding">visit the video documentation</a> to learn
|
||||
how it impacts your stream performance.
|
||||
<a href="https://owncast.online/docs/video">visit the video documentation</a> to learn how
|
||||
it impacts your stream performance. The general rule is to start conservatively by having
|
||||
one middle quality stream output variant and experiment with adding more of varied
|
||||
qualities.
|
||||
</p>
|
||||
|
||||
<div className="row">
|
||||
<div className="form-module variants-table-module">
|
||||
<VideoVariantsTable />
|
||||
</div>
|
||||
|
||||
<div className="form-module latency-module">
|
||||
<VideoLatency />
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col md={24} lg={12}>
|
||||
<div className="form-module variants-table-module">
|
||||
<VideoVariantsTable />
|
||||
</div>
|
||||
</Col>
|
||||
<Col md={24} lg={12}>
|
||||
<div className="form-module latency-module">
|
||||
<VideoLatency />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,20 +1,21 @@
|
||||
import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { Row } from 'antd';
|
||||
import { Row, Col, Typography } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fetchData, FETCH_INTERVAL, HARDWARE_STATS } from '../utils/apis';
|
||||
import Chart from '../components/chart';
|
||||
import StatisticItem from '../components/statistic';
|
||||
|
||||
interface TimedValue {
|
||||
time: Date;
|
||||
value: Number;
|
||||
}
|
||||
// TODO: FIX TS WARNING FROM THIS.
|
||||
// interface TimedValue {
|
||||
// time: Date;
|
||||
// value: Number;
|
||||
// }
|
||||
|
||||
export default function HardwareInfo() {
|
||||
const [hardwareStatus, setHardwareStatus] = useState({
|
||||
cpu: Array<TimedValue>(),
|
||||
memory: Array<TimedValue>(),
|
||||
disk: Array<TimedValue>(),
|
||||
cpu: [], // Array<TimedValue>(),
|
||||
memory: [], // Array<TimedValue>(),
|
||||
disk: [], // Array<TimedValue>(),
|
||||
message: '',
|
||||
});
|
||||
|
||||
@ -66,37 +67,45 @@ export default function HardwareInfo() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<Typography.Title>Hardware Info</Typography.Title>
|
||||
<br />
|
||||
<div>
|
||||
<Row gutter={[16, 16]} justify="space-around">
|
||||
<StatisticItem
|
||||
title={series[0].name}
|
||||
value={`${currentCPUUsage}`}
|
||||
prefix={<LaptopOutlined style={{ color: series[0].color }} />}
|
||||
color={series[0].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
<StatisticItem
|
||||
title={series[1].name}
|
||||
value={`${currentRamUsage}`}
|
||||
prefix={<BulbOutlined style={{ color: series[1].color }} />}
|
||||
color={series[1].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
<StatisticItem
|
||||
title={series[2].name}
|
||||
value={`${currentDiskUsage}`}
|
||||
prefix={<SaveOutlined style={{ color: series[2].color }} />}
|
||||
color={series[2].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
<Col>
|
||||
<StatisticItem
|
||||
title={series[0].name}
|
||||
value={`${currentCPUUsage}`}
|
||||
prefix={<LaptopOutlined style={{ color: series[0].color }} />}
|
||||
color={series[0].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<StatisticItem
|
||||
title={series[1].name}
|
||||
value={`${currentRamUsage}`}
|
||||
prefix={<BulbOutlined style={{ color: series[1].color }} />}
|
||||
color={series[1].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<StatisticItem
|
||||
title={series[2].name}
|
||||
value={`${currentDiskUsage}`}
|
||||
prefix={<SaveOutlined style={{ color: series[2].color }} />}
|
||||
color={series[2].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Chart title="% used" dataCollections={series} color="#FF7700" unit="%" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -2,9 +2,7 @@ import { Button, Card, Col, Divider, Result, Row } from 'antd';
|
||||
import Meta from 'antd/lib/card/Meta';
|
||||
import Title from 'antd/lib/typography/Title';
|
||||
import {
|
||||
AlertOutlined,
|
||||
ApiTwoTone,
|
||||
BookOutlined,
|
||||
BugTwoTone,
|
||||
CameraTwoTone,
|
||||
DatabaseTwoTone,
|
||||
@ -17,9 +15,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {}
|
||||
|
||||
export default function Help(props: Props) {
|
||||
export default function Help() {
|
||||
const questions = [
|
||||
{
|
||||
icon: <SettingTwoTone style={{ fontSize: '24px' }} />,
|
||||
@ -144,7 +140,7 @@ export default function Help(props: Props) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="help-page">
|
||||
<Title style={{ textAlign: 'center' }}>How can we help you?</Title>
|
||||
<Row gutter={[16, 16]} justify="space-around" align="middle">
|
||||
<Col xs={24} lg={12} style={{ textAlign: 'center' }}>
|
||||
|
@ -1,24 +1,13 @@
|
||||
/*
|
||||
Will display an overview with the following datasources:
|
||||
1. Current broadcaster.
|
||||
2. Viewer count.
|
||||
3. Video settings.
|
||||
|
||||
TODO: Link each overview value to the sub-page that focuses on it.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Skeleton, Card, Statistic } from 'antd';
|
||||
import { Skeleton, Card, Statistic, Row, Col } from 'antd';
|
||||
import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { formatDistanceToNow, formatRelative } from 'date-fns';
|
||||
import { ServerStatusContext } from '../utils/server-status-context';
|
||||
import StatisticItem from '../components/statistic';
|
||||
import LogTable from '../components/log-table';
|
||||
import Offline from './offline-notice';
|
||||
|
||||
import { LOGS_WARN, fetchData, FETCH_INTERVAL } from '../utils/apis';
|
||||
import { formatIPAddress, isEmptyObject } from '../utils/format';
|
||||
import { UpdateArgs } from '../types/config-section';
|
||||
|
||||
function streamDetailsFormatter(streamDetails) {
|
||||
return (
|
||||
@ -80,31 +69,34 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// map out settings
|
||||
const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map(
|
||||
(setting, index) => {
|
||||
const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
|
||||
const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map(setting => {
|
||||
const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
|
||||
|
||||
const audioSetting = audioPassthrough
|
||||
? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps`
|
||||
: `${audioBitrate || 'Unknown'} kbps`;
|
||||
const audioSetting = audioPassthrough
|
||||
? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps`
|
||||
: `${audioBitrate || 'Unknown'} kbps`;
|
||||
|
||||
const videoSetting = videoPassthrough
|
||||
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${
|
||||
streamDetails.width
|
||||
} x ${streamDetails.height}`
|
||||
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
|
||||
const videoSetting = videoPassthrough
|
||||
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${
|
||||
streamDetails.width
|
||||
} x ${streamDetails.height}`
|
||||
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
|
||||
|
||||
let settingTitle = 'Outbound Stream Details';
|
||||
settingTitle =
|
||||
videoQualitySettings?.length > 1 ? `${settingTitle} ${index + 1}` : settingTitle;
|
||||
return (
|
||||
<Card title={settingTitle} type="inner" key={`${settingTitle}${index}`}>
|
||||
<StatisticItem title="Outbound Video Stream" value={videoSetting} prefix={null} />
|
||||
<StatisticItem title="Outbound Audio Stream" value={audioSetting} prefix={null} />
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div className="stream-details-item-container">
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Outbound Video Stream"
|
||||
value={videoSetting}
|
||||
/>
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Outbound Audio Stream"
|
||||
value={audioSetting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// inbound
|
||||
const { viewerCount, sessionPeakViewerCount } = serverStatusData;
|
||||
@ -118,57 +110,60 @@ export default function Home() {
|
||||
return (
|
||||
<div className="home-container">
|
||||
<div className="sections-container">
|
||||
<div className="section online-status-section">
|
||||
<Card title="Stream is online" type="inner">
|
||||
<Statistic
|
||||
title={`Stream started ${formatRelative(broadcastDate, Date.now())}`}
|
||||
value={formatDistanceToNow(broadcastDate)}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
/>
|
||||
<Statistic title="Viewers" value={viewerCount} prefix={<UserOutlined />} />
|
||||
<Statistic
|
||||
title="Peak viewer count"
|
||||
value={sessionPeakViewerCount}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
<div className="online-status-section">
|
||||
<Card size="small" type="inner" className="online-details-card">
|
||||
<Row gutter={[16, 16]} align="middle">
|
||||
<Col span={8} sm={24} md={8}>
|
||||
<Statistic
|
||||
title={`Stream started ${formatRelative(broadcastDate, Date.now())}`}
|
||||
value={formatDistanceToNow(broadcastDate)}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8} sm={24} md={8}>
|
||||
<Statistic title="Viewers" value={viewerCount} prefix={<UserOutlined />} />
|
||||
</Col>
|
||||
<Col span={8} sm={24} md={8}>
|
||||
<Statistic
|
||||
title="Peak viewer count"
|
||||
value={sessionPeakViewerCount}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="section stream-details-section">
|
||||
<div className="details outbound-details">{videoQualitySettings}</div>
|
||||
<Row gutter={[16, 16]} className="section stream-details-section">
|
||||
<Col className="outbound-details" span={12} sm={24} md={24} lg={12}>
|
||||
<Card size="small" title="Outbound Stream Details" type="inner">
|
||||
{videoQualitySettings}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<div className="details other-details">
|
||||
<Card title="Inbound Stream Details" type="inner">
|
||||
<StatisticItem
|
||||
<Col className="inbound-details" span={12} sm={24} md={24} lg={12}>
|
||||
<Card size="small" title="Inbound Stream Details" type="inner">
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Input"
|
||||
value={`${encoder} ${formatIPAddress(remoteAddr)}`}
|
||||
prefix={null}
|
||||
/>
|
||||
<StatisticItem
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Inbound Video Stream"
|
||||
value={streamDetails}
|
||||
formatter={streamDetailsFormatter}
|
||||
prefix={null}
|
||||
/>
|
||||
<StatisticItem
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Inbound Audio Stream"
|
||||
value={streamAudioDetailString}
|
||||
prefix={null}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="server-detail">
|
||||
<Card title="Server Config" type="inner">
|
||||
<StatisticItem
|
||||
title="Directory registration enabled"
|
||||
value={configData.yp.enabled.toString()}
|
||||
prefix={null}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<br />
|
||||
<LogTable logs={logsData} pageSize={5} />
|
||||
</div>
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { Result, Card } from 'antd';
|
||||
import { Result, Card, Row, Col } from 'antd';
|
||||
import {
|
||||
MessageTwoTone,
|
||||
QuestionCircleTwoTone,
|
||||
@ -55,22 +55,23 @@ export default function Offline({ logs = [] }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="offline-content">
|
||||
<div className="logo-section">
|
||||
<Row gutter={[16, 16]} className="offline-content">
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={12} className="logo-section">
|
||||
<Result
|
||||
icon={<OwncastLogo />}
|
||||
title="No stream is active."
|
||||
subTitle="You should start one."
|
||||
/>
|
||||
</div>
|
||||
<div className="list-section">
|
||||
</Col>
|
||||
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={12} className="list-section">
|
||||
{data.map(item => (
|
||||
<Card key={item.title}>
|
||||
<Card key={item.title} size="small" bordered={false}>
|
||||
<Meta avatar={item.icon} title={item.title} description={item.content} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<LogTable logs={logs} pageSize={5} />
|
||||
</>
|
||||
);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Table, Typography } from "antd";
|
||||
import { getGithubRelease } from "../utils/apis";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Table, Typography } from 'antd';
|
||||
import { getGithubRelease } from '../utils/apis';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@ -10,32 +10,29 @@ function AssetTable(assets) {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (text, entry) =>
|
||||
<a href={entry.browser_download_url}>{text}</a>,
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, entry) => <a href={entry.browser_download_url}>{text}</a>,
|
||||
},
|
||||
{
|
||||
title: "Size",
|
||||
dataIndex: "size",
|
||||
key: "size",
|
||||
render: (text) => (`${(text/1024/1024).toFixed(2)} MB`),
|
||||
title: 'Size',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
render: text => `${(text / 1024 / 1024).toFixed(2)} MB`,
|
||||
},
|
||||
];
|
||||
|
||||
return <Table dataSource={data} columns={columns} rowKey="id" size="large" pagination={false} />
|
||||
return <Table dataSource={data} columns={columns} rowKey="id" size="large" pagination={false} />;
|
||||
}
|
||||
|
||||
|
||||
export default function Logs() {
|
||||
const [release, setRelease] = useState({
|
||||
html_url: "",
|
||||
name: "",
|
||||
html_url: '',
|
||||
name: '',
|
||||
created_at: null,
|
||||
body: "",
|
||||
body: '',
|
||||
assets: [],
|
||||
|
||||
});
|
||||
|
||||
const getRelease = async () => {
|
||||
@ -43,7 +40,7 @@ export default function Logs() {
|
||||
const result = await getGithubRelease();
|
||||
setRelease(result);
|
||||
} catch (error) {
|
||||
console.log("==== error", error);
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -56,14 +53,14 @@ export default function Logs() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="upgrade-page">
|
||||
<Title level={2}>
|
||||
<a href={release.html_url}>{release.name}</a>
|
||||
</Title>
|
||||
<Title level={5}>{new Date(release.created_at).toDateString()}</Title>
|
||||
<ReactMarkdown>{release.body}</ReactMarkdown><h3>Downloads</h3>
|
||||
<ReactMarkdown>{release.body}</ReactMarkdown>
|
||||
<h3>Downloads</h3>
|
||||
<AssetTable {...release.assets} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Table, Row } from 'antd';
|
||||
import { Table, Row, Col, Typography } from 'antd';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import { SortOrder } from 'antd/lib/table/interface';
|
||||
@ -94,28 +94,37 @@ export default function ViewersOverTime() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<Typography.Title>Viewer Info</Typography.Title>
|
||||
<br />
|
||||
<Row gutter={[16, 16]} justify="space-around">
|
||||
{online && (
|
||||
<Col span={8} md={8}>
|
||||
<StatisticItem
|
||||
title="Current viewers"
|
||||
value={viewerCount.toString()}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<Col md={online ? 8 : 12}>
|
||||
<StatisticItem
|
||||
title="Current viewers"
|
||||
value={viewerCount.toString()}
|
||||
title={online ? 'Max viewers this session' : 'Max viewers last session'}
|
||||
value={sessionPeakViewerCount.toString()}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
)}
|
||||
<StatisticItem
|
||||
title={online ? 'Max viewers this session' : 'Max viewers last session'}
|
||||
value={sessionPeakViewerCount.toString()}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
<StatisticItem
|
||||
title="All-time max viewers"
|
||||
value={overallPeakViewerCount.toString()}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col md={online ? 8 : 12}>
|
||||
<StatisticItem
|
||||
title="All-time max viewers"
|
||||
value={overallPeakViewerCount.toString()}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Chart title="Viewers" data={viewerInfo} color="#2087E2" unit="" />
|
||||
{online && <Table dataSource={clients} columns={columns} rowKey={row => row.clientID} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,24 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Tag,
|
||||
Space,
|
||||
Button,
|
||||
Modal,
|
||||
Checkbox,
|
||||
Input,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Select,
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
import { Table, Tag, Space, Button, Modal, Checkbox, Input, Typography, Tooltip } from 'antd';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { isValidUrl } from '../utils/urls';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
import { fetchData, DELETE_WEBHOOK, CREATE_WEBHOOK, WEBHOOKS } from '../utils/apis';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const availableEvents = {
|
||||
CHAT: { name: 'Chat messages', description: 'When a user sends a chat message', color: 'purple' },
|
||||
USER_JOINED: { name: 'User joined', description: 'When a user joins the chat', color: 'green' },
|
||||
@ -49,12 +37,19 @@ function convertEventStringToTag(eventString) {
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onOk: any; // todo: make better type
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function NewWebhookModal(props: Props) {
|
||||
const { onOk, onCancel, visible } = props;
|
||||
|
||||
function NewWebhookModal(props) {
|
||||
const [selectedEvents, setSelectedEvents] = useState([]);
|
||||
const [webhookUrl, setWebhookUrl] = useState('');
|
||||
|
||||
const events = Object.keys(availableEvents).map(function (key) {
|
||||
const events = Object.keys(availableEvents).map(key => {
|
||||
return { value: key, label: availableEvents[key].description };
|
||||
});
|
||||
|
||||
@ -67,7 +62,7 @@ function NewWebhookModal(props) {
|
||||
}
|
||||
|
||||
function save() {
|
||||
props.onOk(webhookUrl, selectedEvents);
|
||||
onOk(webhookUrl, selectedEvents);
|
||||
|
||||
// Reset the modal
|
||||
setWebhookUrl('');
|
||||
@ -81,9 +76,9 @@ function NewWebhookModal(props) {
|
||||
return (
|
||||
<Modal
|
||||
title="Create New Webhook"
|
||||
visible={props.visible}
|
||||
visible={visible}
|
||||
onOk={save}
|
||||
onCancel={props.onCancel}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={okButtonProps}
|
||||
>
|
||||
<div>
|
||||
@ -96,9 +91,12 @@ function NewWebhookModal(props) {
|
||||
|
||||
<p>Select the events that will be sent to this webhook.</p>
|
||||
<Checkbox.Group options={events} value={selectedEvents} onChange={onChange} />
|
||||
<Button type="text" size="small" onClick={selectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
|
||||
<p>
|
||||
<Button type="primary" onClick={selectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -136,14 +134,19 @@ export default function Webhooks() {
|
||||
},
|
||||
];
|
||||
|
||||
const getWebhooks = async () => {
|
||||
function handleError(error) {
|
||||
console.error('error', error);
|
||||
alert(error);
|
||||
}
|
||||
|
||||
async function getWebhooks() {
|
||||
try {
|
||||
const result = await fetchData(WEBHOOKS);
|
||||
setWebhooks(result);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getWebhooks();
|
||||
@ -151,7 +154,7 @@ export default function Webhooks() {
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
const result = await fetchData(DELETE_WEBHOOK, { method: 'POST', data: { id: id } });
|
||||
await fetchData(DELETE_WEBHOOK, { method: 'POST', data: { id } });
|
||||
getWebhooks();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
@ -162,7 +165,7 @@ export default function Webhooks() {
|
||||
try {
|
||||
const newHook = await fetchData(CREATE_WEBHOOK, {
|
||||
method: 'POST',
|
||||
data: { url: url, events: events },
|
||||
data: { url, events },
|
||||
});
|
||||
setWebhooks(webhooks.concat(newHook));
|
||||
} catch (error) {
|
||||
@ -170,11 +173,6 @@ export default function Webhooks() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.error('error', error);
|
||||
alert(error);
|
||||
}
|
||||
|
||||
const showCreateModal = () => {
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
@ -194,7 +192,7 @@ export default function Webhooks() {
|
||||
<Paragraph>
|
||||
A webhook is a callback made to an external API in response to an event that takes place
|
||||
within Owncast. This can be used to build chat bots or sending automatic notifications that
|
||||
you've started streaming.
|
||||
you've started streaming.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Read more about how to use webhooks, with examples, at{' '}
|
||||
|
@ -1,6 +1,5 @@
|
||||
// GENERAL ANT OVERRIDES
|
||||
|
||||
|
||||
// RESET BG, TEXT COLORS
|
||||
.ant-layout,
|
||||
.ant-layout-header,
|
||||
@ -9,6 +8,9 @@
|
||||
.ant-card,
|
||||
.ant-collapse,
|
||||
.ant-collapse-content,
|
||||
.ant-statistic,
|
||||
.ant-statistic-title,
|
||||
.ant-statistic-content,
|
||||
.ant-table,
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-small .ant-table-thead > tr > th,
|
||||
@ -20,28 +22,52 @@ td.ant-table-column-sort,
|
||||
.ant-menu-submenu > .ant-menu,
|
||||
.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
|
||||
background-color: transparent;
|
||||
color: var(--default-text-color)
|
||||
color: var(--default-text-color);
|
||||
}
|
||||
|
||||
|
||||
h1.ant-typography,
|
||||
h2.ant-typography,
|
||||
h3.ant-typography,
|
||||
h4.ant-typography,
|
||||
h5.ant-typography,
|
||||
h1.ant-typography,
|
||||
h2.ant-typography,
|
||||
h3.ant-typography,
|
||||
h4.ant-typography,
|
||||
h5.ant-typography,
|
||||
.ant-typography,
|
||||
.ant-typography h1,
|
||||
.ant-typography h2,
|
||||
.ant-typography h3,
|
||||
.ant-typography h4,
|
||||
.ant-typography h5 {
|
||||
color: var(--default-text-color);
|
||||
font-weight: 500;
|
||||
color: var(--white);
|
||||
font-weight: 400;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.ant-typography.ant-typography-secondary {
|
||||
color: var(--white);
|
||||
font-weight: 400;
|
||||
}
|
||||
.ant-typography {
|
||||
font-weight: 300;
|
||||
color: var(--white-75);
|
||||
a {
|
||||
color: var(--owncast-purple);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-typography.ant-typography-secondary {
|
||||
color: rgba(255,255,255,.85);
|
||||
font-weight: 400;
|
||||
.ant-typography h1,
|
||||
h1.ant-typography {
|
||||
font-size: 1.75em;
|
||||
color: var(--pink);
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
.ant-typography h2,
|
||||
h2.ant-typography {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.ant-typography h3,
|
||||
h3.ant-typography {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.ant-progress-text,
|
||||
@ -49,8 +75,6 @@ h5.ant-typography,
|
||||
color: var(--default-text-color);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ANT MENU
|
||||
// menu base
|
||||
.ant-menu-item {
|
||||
@ -58,7 +82,7 @@ h5.ant-typography,
|
||||
|
||||
.anticon {
|
||||
transition-duration: var(--ant-transition-duration);
|
||||
color: var(--nav-text);
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
a {
|
||||
@ -66,21 +90,26 @@ h5.ant-typography,
|
||||
color: var(--nav-text);
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba(0,0,0,.15);
|
||||
|
||||
background-color: var(--black-50);
|
||||
color: var(--white);
|
||||
.anticon {
|
||||
color: white;
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-menu-item:active,
|
||||
.ant-menu-submenu-title:active {
|
||||
background-color: var(--black-50);
|
||||
}
|
||||
|
||||
// menu item selected
|
||||
.ant-menu-item-selected,
|
||||
.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
|
||||
background-color: black;
|
||||
background-color: var(--black);
|
||||
a {
|
||||
color: var(--nav-selected-text);
|
||||
}
|
||||
@ -96,14 +125,14 @@ h5.ant-typography,
|
||||
}
|
||||
// submenu items
|
||||
.ant-menu-submenu {
|
||||
&> .ant-menu {
|
||||
border-left: 1px solid rgba(255,255,255,.4);
|
||||
background-color: rgba(0,0,0,.15);
|
||||
& > .ant-menu {
|
||||
border-left: 1px solid var(--white-50);
|
||||
background-color: var(--black-35);
|
||||
}
|
||||
.ant-menu-submenu-title {
|
||||
transition-duration: var(--ant-transition-duration);
|
||||
color: var(--nav-text);
|
||||
|
||||
|
||||
.anticon {
|
||||
color: var(--nav-text);
|
||||
}
|
||||
@ -117,30 +146,28 @@ h5.ant-typography,
|
||||
}
|
||||
&:hover {
|
||||
.ant-menu-submenu-title {
|
||||
color: white;
|
||||
color: var(--white);
|
||||
.anticon {
|
||||
color: white;
|
||||
color: var(--white);
|
||||
}
|
||||
.ant-menu-submenu-arrow {
|
||||
&:before,
|
||||
&:after {
|
||||
background-image: linear-gradient(to right, white, white);
|
||||
background-image: linear-gradient(to right, var(--white), var(--white));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ANT RESULT
|
||||
.ant-result-title {
|
||||
color: var(--default-text-color);
|
||||
}
|
||||
.ant-result-subtitle {
|
||||
color: var(--default-text-color);
|
||||
color: var(--white-75);
|
||||
}
|
||||
|
||||
|
||||
// ANT CARD
|
||||
.ant-card {
|
||||
border-radius: var(--container-border-radius);
|
||||
@ -148,16 +175,30 @@ h5.ant-typography,
|
||||
color: var(--default-text-color);
|
||||
}
|
||||
.ant-card-bordered {
|
||||
border-color: rgba(255,255,255,.25);
|
||||
border-color: var(--white-25);
|
||||
}
|
||||
.ant-card-meta-title {
|
||||
color: var(--white);
|
||||
}
|
||||
.ant-card-meta-title,
|
||||
.ant-card-meta-description {
|
||||
color: white;
|
||||
color: var(--white-75);
|
||||
}
|
||||
.ant-card-type-inner .ant-card-head {
|
||||
background-color: var(--black);
|
||||
color: var(--white-88);
|
||||
border-color: var(--white-25);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ANT INPUT
|
||||
input.ant-input,
|
||||
textarea.ant-input {
|
||||
background-color: var(--textfield-bg);
|
||||
color: var(--white-88);
|
||||
border-color: var(--black);
|
||||
&::placeholder {
|
||||
color: var(--owncast-purple-50);
|
||||
}
|
||||
}
|
||||
.ant-input-affix-wrapper,
|
||||
.ant-input-number {
|
||||
background-color: var(--textfield-bg);
|
||||
@ -165,66 +206,90 @@ h5.ant-typography,
|
||||
input,
|
||||
textarea {
|
||||
background-color: transparent;
|
||||
color: rgba(255,255,255,.85);
|
||||
border-color: rgba(0,0,0,1);
|
||||
color: var(--white-88);
|
||||
border-color: var(--black);
|
||||
&::placeholder {
|
||||
color: var(--textfield-border);
|
||||
color: var(--owncast-purple-50);
|
||||
}
|
||||
&:-webkit-autofill {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-input:hover,
|
||||
.ant-input-number:hover,
|
||||
.ant-input-affix-wrapper:hover {
|
||||
border-color: var(--owncast-purple-highlight);
|
||||
border-color: var(--owncast-purple);
|
||||
input,
|
||||
textarea {
|
||||
border-color: var(--owncast-purple-highlight);
|
||||
border-color: var(--owncast-purple);
|
||||
}
|
||||
}
|
||||
.ant-input,
|
||||
.ant-input-number:focus,
|
||||
.ant-input-affix-wrapper:focus,
|
||||
.ant-input-affix-wrapper:focus,
|
||||
.ant-input-affix-wrapper-focused {
|
||||
border-color: var(--owncast-purple);
|
||||
input,
|
||||
textarea {
|
||||
color: white;
|
||||
color: var(--white);
|
||||
border-color: var(--owncast-purple);
|
||||
}
|
||||
}
|
||||
.ant-input-textarea-clear-icon,
|
||||
.ant-input-clear-icon {
|
||||
color: rgba(255,255,255,.5);
|
||||
}
|
||||
|
||||
textarea.ant-input {
|
||||
padding-right: 25px;
|
||||
}
|
||||
.ant-input-affix-wrapper {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.ant-input-suffix,
|
||||
.ant-input-clear-icon,
|
||||
.ant-input-textarea-clear-icon,
|
||||
.ant-input-password-icon {
|
||||
color: var(--white-50);
|
||||
&:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
// ANT BUTTON
|
||||
.ant-btn {
|
||||
background-color: var(--owncast-purple-25);
|
||||
border-color: var(--owncast-purple-25);
|
||||
color: var(--white-75);
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--button-focused);
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
.ant-btn-primary {
|
||||
background-color: var(--owncast-purple);
|
||||
border-color: var(--owncast-purple);
|
||||
}
|
||||
.ant-btn-primary:hover,
|
||||
.ant-btn-primary:hover,
|
||||
.ant-btn-primary:focus {
|
||||
background-color: var(--form-focused);
|
||||
border-color: var(--form-focused);
|
||||
background-color: var(--button-focused);
|
||||
color: var(--white);
|
||||
}
|
||||
.ant-btn.ant-btn-primary:hover {
|
||||
border-color: white;
|
||||
border-color: var(--white);
|
||||
}
|
||||
.ant-btn:focus,
|
||||
.ant-btn-primary:focus {
|
||||
border-color: var(--white);
|
||||
}
|
||||
|
||||
.ant-btn-primary[disabled] {
|
||||
background-color: rgba(255,255,255,.2);
|
||||
border-color: rgba(255,255,255,.2);
|
||||
color: white;
|
||||
background-color: var(--white-25);
|
||||
border-color: var(--white-25);
|
||||
color: var(--white-50);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255,255,255,.2);
|
||||
border-color: rgba(255,255,255,.2);
|
||||
background-color: var(--white-35);
|
||||
border-color: var(--white-35);
|
||||
}
|
||||
}
|
||||
.ant-input-affix-wrapper,
|
||||
@ -233,30 +298,31 @@ textarea.ant-input {
|
||||
transition-duration: 0.15s;
|
||||
}
|
||||
|
||||
// ANT TABLE
|
||||
// ANT TABLE
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-small .ant-table-thead > tr > th {
|
||||
transition-duration: var(--ant-transition-duration);
|
||||
background-color: #112;
|
||||
background-color: var(--purple-dark);
|
||||
font-weight: 500;
|
||||
color: var(--owncast-purple);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td,
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-small .ant-table-thead > tr > th {
|
||||
border-color: var(--textfield-border);
|
||||
border-color: var(--white-15);
|
||||
}
|
||||
.ant-table-tbody > tr > td {
|
||||
transition-duration: var(--ant-transition-duration);
|
||||
background-color: var(--textfield-bg);
|
||||
background-color: #222325;
|
||||
color: var(--white-75);
|
||||
}
|
||||
.ant-table-tbody > tr:nth-child(odd) > td {
|
||||
background-color: var(--textfield-bg);
|
||||
.ant-table-tbody > tr.ant-table-row:hover > td {
|
||||
background-color: var(--gray-dark);
|
||||
}
|
||||
|
||||
.ant-empty {
|
||||
color: white;
|
||||
opacity: .75;
|
||||
color: var(--white-75);
|
||||
}
|
||||
.ant-table-empty .ant-table-tbody > tr.ant-table-placeholder {
|
||||
&:hover > td {
|
||||
@ -269,18 +335,26 @@ textarea.ant-input {
|
||||
background-color: var(--textfield-border);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-thead th.ant-table-column-sort {
|
||||
background-color: var(--owncast-purple-25);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// MODAL
|
||||
.ant-modal,
|
||||
.ant-modal-body {
|
||||
font-size: 1em;
|
||||
}
|
||||
.ant-modal-content {
|
||||
border-radius: var(--container-border-radius);
|
||||
border: 1px solid var(--owncast-purple-highlight);
|
||||
border: 1px solid var(--owncast-purple);
|
||||
background-color: var(--black);
|
||||
}
|
||||
.ant-modal-header {
|
||||
border-radius: var(--container-border-radius) var(--container-border-radius) 0 0;
|
||||
}
|
||||
.ant-modal-close-x {
|
||||
color: white;
|
||||
color: var(--white);
|
||||
}
|
||||
.ant-modal-title {
|
||||
font-weight: 500;
|
||||
@ -288,79 +362,151 @@ textarea.ant-input {
|
||||
color: var(--nav-selected-text);
|
||||
}
|
||||
.ant-modal-body {
|
||||
background-color: var(--nav-bg-color);
|
||||
background-color: var(--gray);
|
||||
color: var(--default-text-color);
|
||||
}
|
||||
.ant-modal-header,
|
||||
.ant-modal-footer {
|
||||
background-color: black;
|
||||
background: var(--black);
|
||||
}
|
||||
.ant-modal-content,
|
||||
.ant-modal-header,
|
||||
.ant-modal-footer {
|
||||
border-color: #333;
|
||||
border-color: var(--white-50);
|
||||
}
|
||||
|
||||
// SELECT
|
||||
.ant-select-dropdown {
|
||||
background-color: #334;
|
||||
background-color: var(--black);
|
||||
}
|
||||
.ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
|
||||
background-color: var(--black);
|
||||
border-color: var(--owncast-purple-50);
|
||||
}
|
||||
.ant-select-arrow {
|
||||
color: var(--owncast-purple);
|
||||
}
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--owncast-purple-50);
|
||||
}
|
||||
.ant-select {
|
||||
color: var(--white);
|
||||
}
|
||||
.ant-select-item {
|
||||
background-color: var(--gray-dark);
|
||||
color: var(--white-88);
|
||||
}
|
||||
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
|
||||
background-color: var(--gray);
|
||||
color: var(--white-75);
|
||||
}
|
||||
|
||||
|
||||
// SLIDER
|
||||
// .ant-slider-with-marks {
|
||||
// margin-right: 2em;
|
||||
// }
|
||||
.ant-slider-mark-text {
|
||||
font-size: .85em;
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
color: var(--white);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ant-slider-handle {
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.ant-slider:hover .ant-slider-track {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
.ant-slider-rail {
|
||||
background-color: var(--black);
|
||||
}
|
||||
.ant-slider-track {
|
||||
background-color: var(--nav-text);
|
||||
}
|
||||
.ant-slider-mark-text-active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// ANT SWITCH
|
||||
.ant-switch {
|
||||
background-color: #666;
|
||||
background-color: var(--gray-medium);
|
||||
}
|
||||
.ant-switch-checked {
|
||||
background-color: var(--ant-success);
|
||||
.ant-switch-inner {
|
||||
color: white;
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ANT COLLAPSE
|
||||
.ant-collapse {
|
||||
font-size: 1em;
|
||||
border-color: transparent;
|
||||
&> .ant-collapse-item,
|
||||
& > .ant-collapse-item,
|
||||
.ant-collapse-content {
|
||||
border-color: transparent;
|
||||
&> .ant-collapse-header {
|
||||
& > .ant-collapse-header {
|
||||
border-radius: var(--container-border-radius);
|
||||
border-color: transparent;
|
||||
background-color: var(--textfield-bg);
|
||||
color: var(--nav-text);
|
||||
background-color: var(--purple-dark);
|
||||
color: var(--white);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ant-collapse-content {
|
||||
background-color: #181231;
|
||||
background-color: var(--black-35); //#181231;
|
||||
}
|
||||
.ant-collapse > .ant-collapse-item:last-child,
|
||||
.ant-collapse > .ant-collapse-item:last-child > .ant-collapse-header {
|
||||
border-radius: var(--container-border-radius) var(--container-border-radius) 0 0;
|
||||
}
|
||||
.ant-collapse-item:last-child > .ant-collapse-content {
|
||||
border-radius: 0 0 var(--container-border-radius) var(--container-border-radius);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ANT POPOVER
|
||||
.ant-popover {
|
||||
|
||||
}
|
||||
.ant-popover-inner {
|
||||
background-color: black;
|
||||
background-color: var(--gray);
|
||||
}
|
||||
.ant-popover-message,
|
||||
.ant-popover-inner-content {
|
||||
color: var(--default-text-color);
|
||||
|
||||
}
|
||||
.ant-popover-placement-topLeft > .ant-popover-content > .ant-popover-arrow {
|
||||
border-color: black;
|
||||
border-color: var(--gray);
|
||||
}
|
||||
|
||||
// ANT TAGS
|
||||
.ant-tag-red,
|
||||
.ant-tag-orange,
|
||||
.ant-tag-green,
|
||||
.ant-tag-purple,
|
||||
.ant-tag-blue {
|
||||
background-color: var(--black);
|
||||
}
|
||||
|
||||
// ANT PAGINATOR
|
||||
.ant-pagination-item-active {
|
||||
color: var(--white);
|
||||
background-color: var(--default-link-color);
|
||||
border-color: var(--default-link-color);
|
||||
a {
|
||||
color: var(--white);
|
||||
&:hover {
|
||||
color: var(--white);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ANT CHECKBOX
|
||||
.ant-checkbox-wrapper {
|
||||
color: var(--white-75);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.ant-checkbox-group {
|
||||
.ant-checkbox-group-item {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
@ -8,11 +8,11 @@
|
||||
}
|
||||
.ant-table-row.hidden {
|
||||
.ant-table-cell {
|
||||
color: rgba(0,0,0,.25)
|
||||
color: var(--black-35)
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ant-table-cell {
|
||||
color: rgba(255,255,255,.25)
|
||||
color: var(--white-25);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -47,29 +47,21 @@
|
||||
.bulk-editor {
|
||||
margin: .5rem 0;
|
||||
padding: .5rem;
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid var(--textfield-border);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
border-radius: 4px;
|
||||
opacity: .5;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
.label {
|
||||
color: #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.label {
|
||||
color: #fff;
|
||||
}
|
||||
color: var(--black);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: .75rem;
|
||||
color: #666;
|
||||
color: var(--white-50);
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
@ -112,11 +104,6 @@
|
||||
}
|
||||
}
|
||||
.ant-btn-text:hover {
|
||||
background-color: rgba(0,0,0,.1)
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ant-btn-text:hover {
|
||||
background-color: rgba(255,255,255,.3)
|
||||
}
|
||||
background-color: var(--black-35)
|
||||
}
|
||||
}
|
||||
|
@ -1,35 +0,0 @@
|
||||
// rename to variables.scss
|
||||
|
||||
:root {
|
||||
|
||||
--default-text-color: #fff;
|
||||
|
||||
--owncast-purple: rgba(90,103,216,1); //5a67d8
|
||||
--owncast-purple-highlight: #ccd;
|
||||
|
||||
--online-color: #73dd3f;
|
||||
|
||||
--owncast-dark1: #1f1f21;
|
||||
|
||||
--ant-error: #ff4d4f;
|
||||
--ant-success: #52c41a;
|
||||
--ant-warning: #faad14;
|
||||
--ant-transition-duration: .15s;
|
||||
|
||||
|
||||
--container-bg-color: #1A1C24;
|
||||
--container-bg-color-alt: #251c49;
|
||||
--container-border-radius: 2px;
|
||||
|
||||
--code-purple: #82aaff;
|
||||
|
||||
--nav-bg-color: #1A1C24;
|
||||
--nav-text: #6a76ba;
|
||||
--nav-selected-text: #c48dff;
|
||||
|
||||
--form-focused: #8d71ff;
|
||||
|
||||
--textfield-border: #373640;
|
||||
--textfield-bg: #100f0f;
|
||||
|
||||
}
|
@ -38,14 +38,21 @@
|
||||
}
|
||||
.instance-details-container {
|
||||
width: 100%;
|
||||
|
||||
.logo-preview {
|
||||
display: inline-block;
|
||||
margin: -1em 0 1em 11em;
|
||||
height: 120px;
|
||||
border: 1px solid var(--white-25);
|
||||
}
|
||||
}
|
||||
.social-items-container {
|
||||
background-color: var(--container-bg-color-alt);
|
||||
padding: 0 .75em;
|
||||
padding: 0 0.75em;
|
||||
margin-left: 1em;
|
||||
max-width: 450px;
|
||||
.form-module {
|
||||
background-color: #000;
|
||||
background-color: var(--black);
|
||||
}
|
||||
|
||||
.social-handles-container {
|
||||
@ -62,4 +69,8 @@
|
||||
height: 6em !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.other-field-container {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: .25em;
|
||||
padding: 0.25em;
|
||||
line-height: normal;
|
||||
|
||||
.option-icon {
|
||||
@ -31,9 +31,9 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
color: rgba(255,255,255,.85);
|
||||
color: var(--white-75);
|
||||
|
||||
.option-icon {
|
||||
.option-icon {
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
line-height: normal;
|
||||
@ -43,7 +43,7 @@
|
||||
flex-direction: column;
|
||||
margin: 0 0 0 1em;
|
||||
line-height: 2;
|
||||
font-size: .85em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
.actions {
|
||||
@ -54,4 +54,4 @@
|
||||
width: 6em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -22,29 +22,37 @@
|
||||
}
|
||||
|
||||
|
||||
// Do something special for the stream key field
|
||||
.field-streamkey-container {
|
||||
margin-bottom: 1.5em;
|
||||
.field-tip {
|
||||
color: var(--ant-warning);
|
||||
}
|
||||
.left-side {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.textfield-with-submit-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.streamkey-actions {
|
||||
white-space: nowrap;
|
||||
button {
|
||||
margin: .25em;
|
||||
.edit-server-details-container {
|
||||
|
||||
// Do something special for the stream key field
|
||||
.field-streamkey-container {
|
||||
margin-bottom: 1.5em;
|
||||
.field-tip {
|
||||
color: var(--ant-warning);
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
margin-top: 2em;
|
||||
.left-side {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.textfield-with-submit-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.streamkey-actions {
|
||||
white-space: nowrap;
|
||||
button {
|
||||
margin: .25em;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
margin-top: 2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
}
|
@ -15,12 +15,12 @@
|
||||
margin-left: .3rem;
|
||||
padding: 2px;
|
||||
border-radius: 5rem;
|
||||
color: black;
|
||||
border: 1px solid #000;
|
||||
color: var(--black);
|
||||
border: 1px solid var(--black);
|
||||
transition-duration: var(--ant-transition-duration);
|
||||
&:hover {
|
||||
border-color: #5a67d8;
|
||||
background-color: white;
|
||||
border-color: var(--owncast-purple);
|
||||
background-color: var(--white);
|
||||
svg {
|
||||
fill: black;
|
||||
transition: fill var(--ant-transition-duration);
|
||||
|
@ -1,17 +1,13 @@
|
||||
// styles for Video variant editor (table + modal)
|
||||
|
||||
.config-video-variants {
|
||||
|
||||
.variants-table {
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
.variants-table-module {
|
||||
min-width: 48%;
|
||||
max-width: 600px;
|
||||
margin-right: 1em
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// modal content
|
||||
@ -20,84 +16,16 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cpu-usage-container,
|
||||
.bitrate-container {
|
||||
height: 20em;
|
||||
}
|
||||
|
||||
.advanced-settings {
|
||||
width: 48%;
|
||||
margin-left: 2em;
|
||||
}
|
||||
.blurb {
|
||||
margin: 1em;
|
||||
opacity: .75;
|
||||
}
|
||||
.note {
|
||||
display: inline-block;
|
||||
margin-left: 1em;
|
||||
font-size: .75em;
|
||||
opacity: .5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
// .field {
|
||||
// margin-bottom: 2em;
|
||||
// display: flex;
|
||||
// flex-direction: row;
|
||||
// justify-content: center;
|
||||
// align-items: flex-start;
|
||||
// transform: opacity .15s;
|
||||
// &.disabled {
|
||||
// opacity: .25;
|
||||
// }
|
||||
|
||||
// .label {
|
||||
// width: 40%;
|
||||
// text-align: right;
|
||||
// padding-right: 2em;
|
||||
// font-weight: bold;
|
||||
// color: var(--owncast-purple);
|
||||
// }
|
||||
// .info-tip {
|
||||
// margin-right: 1em;
|
||||
// }
|
||||
// .form-component {
|
||||
// width: 60%;
|
||||
|
||||
// .selected-value-note {
|
||||
// font-size: .85em;
|
||||
// display: inline-block;
|
||||
// text-align: center;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .ant-collapse {
|
||||
// border: none;
|
||||
// border-radius: 6px;
|
||||
// }
|
||||
// .ant-collapse > .ant-collapse-item:last-child,
|
||||
// .ant-collapse > .ant-collapse-item:last-child > .ant-collapse-header {
|
||||
// border: none;
|
||||
// background-color: rgba(0,0,0,.25);
|
||||
// border-radius: 6px;
|
||||
// }
|
||||
// .ant-collapse-content {
|
||||
// background-color: rgba(0,0,0,.1);
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
|
||||
.config-video-segements-conatiner {
|
||||
// display: flex;
|
||||
// flex-direction: row;
|
||||
// justify-content: center;
|
||||
// align-items: flex-start;
|
||||
|
||||
.status-message {
|
||||
text-align: center;
|
||||
margin-top: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.variants-table {
|
||||
.actions {
|
||||
display: flex;
|
||||
@ -105,11 +33,11 @@
|
||||
justify-content: center;
|
||||
}
|
||||
.delete-button {
|
||||
margin-left: .5em;
|
||||
opacity: .8;
|
||||
margin-left: 0.5em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.read-more-subtext {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
@ -1,29 +0,0 @@
|
||||
|
||||
|
||||
// todo: put these somewhere else
|
||||
|
||||
|
||||
|
||||
.edit-page-content {
|
||||
.page-content-actions {
|
||||
margin-top: 1em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
.status-message {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.segment-tip {
|
||||
width: 10em;
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
display: inline-block;
|
||||
}
|
@ -30,7 +30,7 @@
|
||||
/* TIP CONTAINER BASE */
|
||||
.field-tip {
|
||||
font-size: .8em;
|
||||
color: rgba(255,255,255,.5)
|
||||
color: var(--white-50);
|
||||
}
|
||||
|
||||
|
||||
@ -39,38 +39,34 @@ Ideal for wrapping each Textfield on a page with many text fields in a row. This
|
||||
*/
|
||||
.field-container {
|
||||
padding: .85em 0 .5em;
|
||||
// &:nth-child(even) {
|
||||
// background-color: rgba(0,0,0,.25);
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
/* SEGMENT SLIDER */
|
||||
/* SEGMENT SLIDER GROUP WITH SELECTED NOTE, OR STATUS */
|
||||
.segment-slider-container {
|
||||
width: 90%;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
padding: 1em 2em .75em;
|
||||
background-color: var(--textfield-border);
|
||||
border-radius: 1em;
|
||||
.ant-slider-rail {
|
||||
background-color: black;
|
||||
}
|
||||
.ant-slider-track {
|
||||
background-color: var(--nav-text);
|
||||
}
|
||||
.ant-slider-mark-text,
|
||||
.ant-slider-mark-text-active {
|
||||
color: white;
|
||||
opacity: .5;
|
||||
}
|
||||
.ant-slider-mark-text-active {
|
||||
opacity: 1;
|
||||
}
|
||||
background-color: var(--owncast-purple-25);
|
||||
border-radius: var(--container-border-radius);
|
||||
|
||||
.status-container {
|
||||
width: 100%;
|
||||
margin: .5em auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.selected-value-note {
|
||||
width: 100%;
|
||||
margin: 3em auto 0;
|
||||
text-align: center;
|
||||
font-size: .75em;
|
||||
line-height: normal;
|
||||
color: var(--white);
|
||||
padding: 1em;
|
||||
border-radius: var(--container-border-radius);
|
||||
background-color: var(--black-35);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -6,16 +6,16 @@ body {
|
||||
margin: 0;
|
||||
font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
|
||||
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
|
||||
background-color: #000;
|
||||
color: var(--default-text-color);;
|
||||
background-color: var(--default-bg-color);
|
||||
color: var(--default-text-color);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
color: var(--owncast-purple);
|
||||
color: var(--default-link-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--default-text-color);
|
||||
@ -25,19 +25,29 @@ a {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
p {
|
||||
|
||||
p,
|
||||
p.description,
|
||||
.ant-typography {
|
||||
font-weight: 300;
|
||||
margin: 1em 0;
|
||||
color: var(--white-75);
|
||||
}
|
||||
pre {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
margin: .5rem 0;
|
||||
background-color: rgb(44, 44, 44);
|
||||
color:lightgrey;
|
||||
background-color: var(--code-bg-color);
|
||||
color: var(--white-50);
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--owncast-purple);
|
||||
color: var(--code-color);
|
||||
background-color: var(--white-15);
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: .88em;
|
||||
}
|
||||
|
||||
|
||||
@ -46,37 +56,28 @@ code {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
p.description {
|
||||
margin: 1em 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.line-chart-container {
|
||||
margin: 2em auto;
|
||||
padding: 1em;
|
||||
border: 1px solid var(--gray-dark);
|
||||
}
|
||||
|
||||
h2.ant-typography.page-title,
|
||||
h3.ant-typography.page-title
|
||||
{
|
||||
font-weight: 400;
|
||||
font-size: 1.5em;
|
||||
color: var(--nav-selected-text);
|
||||
}
|
||||
h2.section-title,
|
||||
h3.section-title {
|
||||
font-weight: 400;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
|
||||
.form-module {
|
||||
// width: 100%;
|
||||
// max-width: 500px;
|
||||
// min-width: 300px;
|
||||
margin: 1em 0;
|
||||
background-color: var(--container-bg-color);
|
||||
padding: 2em;
|
||||
border-radius: var(--container-border-radius);
|
||||
|
||||
h3 {
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
|
@ -1,137 +1,51 @@
|
||||
.home-container {
|
||||
max-width: 1000px;
|
||||
|
||||
.statistics-list {
|
||||
li {
|
||||
margin-left: -.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 1rem 0;
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.online-status-section {
|
||||
> .ant-card {
|
||||
box-shadow: 0px 1px 10px 2px rgba(0, 22, 40, 0.1);
|
||||
margin-bottom: 1em;
|
||||
.online-details-card {
|
||||
border-color: var(--online-color);
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
background-color: #40b246;
|
||||
border-color: #ccc;
|
||||
color:#fff;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #2a762e;
|
||||
border-bottom-color: black;
|
||||
}
|
||||
}
|
||||
.ant-card-head-title {
|
||||
font-size: .88rem;
|
||||
.ant-statistic {
|
||||
text-align: center;
|
||||
}
|
||||
.ant-statistic-title {
|
||||
font-size: .88rem;
|
||||
}
|
||||
.ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
.ant-statistic {
|
||||
width: 30%;
|
||||
text-align: center;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
color: var(--white-50);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
color: var(--online-color);
|
||||
}
|
||||
|
||||
.stream-details-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
.details {
|
||||
width: 49%;
|
||||
|
||||
> .ant-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
background-color: #ccd;
|
||||
color: black;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #000;
|
||||
color: #ccd;
|
||||
}
|
||||
|
||||
}
|
||||
.stream-details-item-container {
|
||||
margin: 1em 0;
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
.server-detail {
|
||||
.ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.ant-card {
|
||||
width: 45%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
.ant-card-head {
|
||||
background-color: #669;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
.ant-statistic.stream-details-item {
|
||||
background-color: var(--black-50);
|
||||
padding: 1em;
|
||||
.ant-statistic-title {
|
||||
color: var(--blue);
|
||||
}
|
||||
.ant-statistic-content {
|
||||
font-size: 1.25em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.online-status-section{
|
||||
.ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
.ant-statistic {
|
||||
width: auto;
|
||||
text-align: left;
|
||||
margin: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stream-details-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
.details {
|
||||
width: 100%;
|
||||
}
|
||||
.outbound-details,
|
||||
.inbound-details {
|
||||
> .ant-card-bordered {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.offline-content {
|
||||
max-width: 1000px;
|
||||
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
.logo-section {
|
||||
width: 50%;
|
||||
.ant-result-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
@ -144,36 +58,20 @@
|
||||
}
|
||||
}
|
||||
.list-section {
|
||||
width: 50%;
|
||||
background-color: var(--container-bg-color-alt);
|
||||
border-radius: var(--container-border-radius);
|
||||
padding: 1em;
|
||||
|
||||
> .ant-card {
|
||||
margin-bottom: 1rem;
|
||||
.ant-card-head {
|
||||
background-color: #dde;
|
||||
}
|
||||
.ant-card-head-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
background-color: var(--black);
|
||||
margin-bottom: 1em;
|
||||
.ant-card-meta-avatar {
|
||||
margin-top: .25rem;
|
||||
margin-top: 0.25rem;
|
||||
svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
}
|
||||
}
|
||||
.ant-card-body {
|
||||
font-size: .88rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
.logo-section,
|
||||
.list-section {
|
||||
width: 100%
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
.app-container {
|
||||
|
||||
.side-nav {
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
@ -20,20 +19,20 @@
|
||||
align-items: center;
|
||||
|
||||
.logo-container {
|
||||
background-color: #fff;
|
||||
padding: .35rem;
|
||||
background-color: var(--white);
|
||||
padding: 0.35rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.title-label {
|
||||
display: inline-block;
|
||||
margin-left: 1rem;
|
||||
color: rgba(203,213,224, 1);
|
||||
color: var(--white);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 200;
|
||||
text-transform: uppercase;
|
||||
line-height: normal;
|
||||
letter-spacing: .05em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,17 +47,17 @@
|
||||
background-color: var(--nav-bg-color);
|
||||
}
|
||||
|
||||
|
||||
.main-content-container {
|
||||
padding: 3em;
|
||||
padding: 2em 3em 3em;
|
||||
max-width: 1024px;
|
||||
min-width: 50vw;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.online-status-indicator {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -70,21 +69,21 @@
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #fff;
|
||||
color: var(--white);
|
||||
text-transform: uppercase;
|
||||
font-size: .75rem;
|
||||
font-size: 0.75rem;
|
||||
display: inline-block;
|
||||
margin-right: .5rem;
|
||||
color: #999;
|
||||
margin-right: 0.5rem;
|
||||
color: var(--offline-color);
|
||||
}
|
||||
.status-icon {
|
||||
font-size: 1.5rem;
|
||||
svg {
|
||||
fill: #999;
|
||||
fill: var(--offline-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
.online {
|
||||
&.online {
|
||||
.online-status-indicator {
|
||||
.status-icon {
|
||||
svg {
|
||||
@ -92,13 +91,13 @@
|
||||
}
|
||||
}
|
||||
.status-label {
|
||||
white-space: nowrap;
|
||||
color: var(--online-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// stream title form field in header
|
||||
.global-stream-title-container {
|
||||
display: flex;
|
||||
@ -111,8 +110,21 @@
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
|
||||
.ant-input-affix-wrapper {
|
||||
border-color: var(--owncast-purple-50);
|
||||
}
|
||||
input.ant-input {
|
||||
&::placeholder {
|
||||
color: var(--owncast-purple);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.input-side {
|
||||
width: 400px;
|
||||
@media (max-width: 800px) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.label-side {
|
||||
@ -135,10 +147,9 @@
|
||||
}
|
||||
.update-button-container {
|
||||
margin: 0;
|
||||
margin-left: .5em;
|
||||
margin-left: 0.5em;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,26 +1,38 @@
|
||||
|
||||
// markdown editor overrides
|
||||
|
||||
.rc-virtual-list-scrollbar {
|
||||
display: block !important;
|
||||
}
|
||||
.rc-md-editor {
|
||||
border-color: black !important;
|
||||
border: 1px solid black;
|
||||
background-color: black !important;
|
||||
border-color: var(--black) !important;
|
||||
border: 1px solid var(--black);
|
||||
background-color: var(--black) !important;
|
||||
.rc-md-navigation {
|
||||
background-color: black;
|
||||
border-color: black;
|
||||
background-color: var(--black);
|
||||
border-color: var(--black);
|
||||
}
|
||||
// Set the background color of the preview container
|
||||
.editor-container {
|
||||
color: rgba(45,55,72,1);
|
||||
background-color: rgba(226,232,240, 1) !important;
|
||||
p {
|
||||
color: var(--black-75);
|
||||
}
|
||||
background-color: rgba(226, 232, 240, 1) !important;
|
||||
|
||||
.sec-html {
|
||||
background-color: white;
|
||||
|
||||
pre,
|
||||
code {
|
||||
background-color: #eee;
|
||||
color: #900;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom CSS for formatting the preview text
|
||||
.markdown-editor-preview-pane {
|
||||
color: var(--black-75);
|
||||
|
||||
a {
|
||||
color: var(--owncast-purple);
|
||||
}
|
||||
@ -31,23 +43,29 @@
|
||||
|
||||
// Custom CSS class used to format the text of the editor
|
||||
.markdown-editor-pane {
|
||||
color: rgba(255,255,255,.85) !important;
|
||||
border-color: black !important;
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
border-color: black !important;
|
||||
background-color: black;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Set the background color of the editor text input
|
||||
textarea {
|
||||
background-color: #223 !important;
|
||||
color: rgba(255,255,255,.5) !important;
|
||||
background-color: var(--gray) !important;
|
||||
color: rgba(255, 255, 255, 0.5) !important;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
// Hide extra toolbar buttons.
|
||||
.button-type-undo, .button-type-redo, .button-type-clear, .button-type-image, .button-type-wrap, .button-type-quote, .button-type-strikethrough, .button-type-code-inline, .button-type-code-block {
|
||||
.button-type-undo,
|
||||
.button-type-redo,
|
||||
.button-type-clear,
|
||||
.button-type-image,
|
||||
.button-type-wrap,
|
||||
.button-type-quote,
|
||||
.button-type-strikethrough,
|
||||
.button-type-code-inline,
|
||||
.button-type-code-block {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
20
web/styles/pages.scss
Normal file
20
web/styles/pages.scss
Normal file
@ -0,0 +1,20 @@
|
||||
// misc styling for various /pages
|
||||
|
||||
|
||||
// .help-page {
|
||||
// .ant-result-image {
|
||||
// height: 100px;
|
||||
// svg {
|
||||
// height: 100%;
|
||||
// width: 100%;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
.upgrade-page {
|
||||
h2,h3 {
|
||||
color: var(--pink);
|
||||
font-size: 1.25em;
|
||||
}
|
||||
}
|
61
web/styles/variables.scss
Normal file
61
web/styles/variables.scss
Normal file
@ -0,0 +1,61 @@
|
||||
:root {
|
||||
// colors
|
||||
--white: rgba(255,255,255,1);
|
||||
--white-15: rgba(255,255,255,.15);
|
||||
--white-25: rgba(255,255,255,.25);
|
||||
--white-35: rgba(255,255,255,.35);
|
||||
--white-50: rgba(255,255,255,.5);
|
||||
--white-75: rgba(255,255,255,.75);
|
||||
--white-88: rgba(255,255,255,.88);
|
||||
|
||||
--black: rgba(0,0,0,1);
|
||||
--black-35: rgba(0,0,0,.35);
|
||||
--black-50: rgba(0,0,0,.5);
|
||||
--black-75: rgba(0,0,0,.75);
|
||||
|
||||
// owncast logo color family
|
||||
--owncast-purple: rgba(120,113,255,1); // #7871FF;
|
||||
--purple-dark: rgba(28,26,59,1); // #1c1a3b;//
|
||||
--pink: rgba(201,139,254,1); // #D18BFE;
|
||||
--blue: rgba(32,134,225,1); // #2086E1;
|
||||
|
||||
// owncast puprple variations
|
||||
--owncast-purple-25: rgba(120,113,255,.25);
|
||||
--owncast-purple-50: rgba(120,113,255,.5);
|
||||
|
||||
--gray-light: rgba(168,175,197,1);
|
||||
--gray-medium: rgba(102,107,120,1);
|
||||
--gray: rgba(51,53,60,1);
|
||||
--gray-dark: rgba(23,24,27,1); // #17181b;
|
||||
|
||||
--online-color: #73dd3f;
|
||||
--offline-color: #999;
|
||||
|
||||
--ant-error: #ff4d4f;
|
||||
--ant-success: #52c41a;
|
||||
--ant-warning: #faad14;
|
||||
--ant-transition-duration: .15s;
|
||||
|
||||
|
||||
// ////////////////////////////////
|
||||
--default-text-color: var(--white-88);
|
||||
--default-bg-color: var(--black);
|
||||
--default-link-color: var(--owncast-purple);
|
||||
|
||||
--container-bg-color: var(--gray-dark);
|
||||
--container-bg-color-alt: var(--purple-dark);
|
||||
--container-border-radius: 4px;
|
||||
|
||||
--code-color: #9cdcfe;
|
||||
--code-bg-color: var(--owncast-purple-25);
|
||||
|
||||
--nav-bg-color: var(--gray-dark);
|
||||
--nav-text: #aaa;
|
||||
--nav-selected-text: var(--pink); //#cd7cff;
|
||||
|
||||
--button-focused: var(--owncast-purple-50);
|
||||
|
||||
--textfield-border: var(--white-25);;
|
||||
--textfield-bg: var(--black);
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user