Merge pull request #23 from owncast/admin-css-overhaul-pt3

Admin css overhaul pt3
This commit is contained in:
Gabe Kangas 2021-02-15 21:34:33 -08:00 committed by GitHub
commit 800965c455
51 changed files with 1120 additions and 1117 deletions

View File

@ -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?

View File

@ -44,15 +44,11 @@ export default function CPUUsageSelector({ defaultValue, onChange }: Props) {
}; };
return ( return (
<div className="config-video-segements-conatiner"> <div className="config-video-cpu-container">
<Title level={3} className="section-title"> <Title level={3}>CPU Usage</Title>
CPU Usage
</Title>
<p className="description"> <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> </p>
<br />
<div className="segment-slider-container"> <div className="segment-slider-container">
<Slider <Slider
tipFormatter={value => TOOLTIPS[value]} tipFormatter={value => TOOLTIPS[value]}
@ -63,6 +59,7 @@ export default function CPUUsageSelector({ defaultValue, onChange }: Props) {
defaultValue={selectedOption} defaultValue={selectedOption}
value={selectedOption} value={selectedOption}
/> />
<p className="selected-value-note">Selected: {TOOLTIPS[selectedOption]}</p>
</div> </div>
</div> </div>
); );

View File

@ -2,7 +2,7 @@
import React, { useState, useContext, useEffect } from 'react'; import React, { useState, useContext, useEffect } from 'react';
import { Typography } from 'antd'; import { Typography } from 'antd';
import ToggleSwitch from './form-toggleswitch-with-submit'; import ToggleSwitch from './form-toggleswitch';
import { ServerStatusContext } from '../../utils/server-status-context'; import { ServerStatusContext } from '../../utils/server-status-context';
import { FIELD_PROPS_NSFW, FIELD_PROPS_YP } from '../../utils/config-constants'; import { FIELD_PROPS_NSFW, FIELD_PROPS_YP } from '../../utils/config-constants';

View File

@ -17,9 +17,10 @@ import {
FIELD_PROPS_YP, FIELD_PROPS_YP,
FIELD_PROPS_NSFW, FIELD_PROPS_NSFW,
} from '../../utils/config-constants'; } from '../../utils/config-constants';
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
import { UpdateArgs } from '../../types/config-section'; import { UpdateArgs } from '../../types/config-section';
import ToggleSwitch from './form-toggleswitch-with-submit'; import ToggleSwitch from './form-toggleswitch';
const { Title } = Typography; const { Title } = Typography;
@ -102,7 +103,9 @@ export default function EditInstanceDetails() {
initialValue={instanceDetails.logo} initialValue={instanceDetails.logo}
onChange={handleFieldChange} onChange={handleFieldChange}
/> />
{instanceDetails.logo && (
<img src={`${NEXT_PUBLIC_API_HOST}/logo`} alt="uploaded logo" className="logo-preview" />
)}
<br /> <br />
<Title level={3} className="section-title"> <Title level={3} className="section-title">

View File

@ -104,13 +104,13 @@ export default function EditPageContent() {
markdownClass: 'markdown-editor-pane', markdownClass: 'markdown-editor-pane',
}} }}
/> />
<br />
<div className="page-content-actions"> <div className="page-content-actions">
{hasChanged ? ( {hasChanged && (
<Button type="primary" onClick={handleSave}> <Button type="primary" onClick={handleSave}>
Save Save
</Button> </Button>
) : null} )}
<FormStatusIndicator status={submitStatus} /> <FormStatusIndicator status={submitStatus} />
</div> </div>
</div> </div>

View File

@ -86,7 +86,7 @@ export default function EditInstanceDetails() {
} }
return ( return (
<div className="edit-public-details-container"> <div className="edit-server-details-container">
<div className="field-container field-streamkey-container"> <div className="field-container field-streamkey-container">
<div className="left-side"> <div className="left-side">
<TextFieldWithSubmit <TextFieldWithSubmit

View File

@ -196,7 +196,6 @@ export default function EditSocialLinks() {
return ( return (
<div className="actions"> <div className="actions">
<Button <Button
type="primary"
size="small" size="small"
onClick={() => { onClick={() => {
setEditId(index); setEditId(index);
@ -222,6 +221,19 @@ export default function EditSocialLinks() {
disabled: !isValidUrl(modalDataState.url), 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 ( return (
<div className="social-links-edit-container"> <div className="social-links-edit-container">
<Title level={3} className="section-title"> <Title level={3} className="section-title">
@ -250,30 +262,23 @@ export default function EditSocialLinks() {
confirmLoading={modalProcessing} confirmLoading={modalProcessing}
okButtonProps={okButtonProps} okButtonProps={okButtonProps}
> >
<SocialDropdown <div className="social-handle-modal-content">
iconList={availableIconsList} <SocialDropdown
selectedOption={selectedOther ? OTHER_SOCIAL_HANDLE_OPTION : modalDataState.platform} iconList={availableIconsList}
onSelected={handleDropdownSelect} selectedOption={selectedOther ? OTHER_SOCIAL_HANDLE_OPTION : modalDataState.platform}
/> onSelected={handleDropdownSelect}
{displayOther ? ( />
<> {displayOther && otherField}
<Input <br />
placeholder="Other" <TextField
defaultValue={modalDataState.platform} fieldName="social-url"
onChange={handleOtherNameChange} label="URL"
/> placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
<br /> value={modalDataState.url}
</> onChange={handleUrlChange}
) : null} />
<br /> <FormStatusIndicator status={submitStatus} />
<TextField </div>
fieldName="social-url"
label="URL"
placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
value={modalDataState.url}
onChange={handleUrlChange}
/>
<FormStatusIndicator status={submitStatus} />
</Modal> </Modal>
<br /> <br />
<Button <Button

View File

@ -1,4 +1,4 @@
import { Switch, Button, Collapse } from 'antd'; import { Button, Collapse } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useContext, useState, useEffect } from 'react'; import React, { useContext, useState, useEffect } from 'react';
import { UpdateArgs } from '../../types/config-section'; import { UpdateArgs } from '../../types/config-section';
@ -21,7 +21,7 @@ import {
import TextField from './form-textfield'; import TextField from './form-textfield';
import FormStatusIndicator from './form-status-indicator'; import FormStatusIndicator from './form-status-indicator';
import { isValidUrl } from '../../utils/urls'; import { isValidUrl } from '../../utils/urls';
// import ToggleSwitch from './form-toggleswitch-with-submit'; import ToggleSwitch from './form-toggleswitch';
const { Panel } = Collapse; const { Panel } = Collapse;
@ -145,20 +145,21 @@ export default function EditStorage() {
return ( return (
<div className={containerClass}> <div className={containerClass}>
<div className="enable-switch"> <div className="enable-switch">
{/* <ToggleSwitch <ToggleSwitch
apiPath=""
fieldName="enabled" fieldName="enabled"
label="Storage Enabled" label="Storage Enabled"
checked={formDataValues.enabled} checked={formDataValues.enabled}
onChange={handleSwitchChange} onChange={handleSwitchChange}
/> */} />
<Switch {/* <Switch
checked={formDataValues.enabled} checked={formDataValues.enabled}
defaultChecked={formDataValues.enabled} defaultChecked={formDataValues.enabled}
onChange={handleSwitchChange} onChange={handleSwitchChange}
checkedChildren="ON" checkedChildren="ON"
unCheckedChildren="OFF" unCheckedChildren="OFF"
/>{' '} />{' '}
Enabled Enabled */}
</div> </div>
<div className="form-fields"> <div className="form-fields">

View File

@ -1,5 +1,6 @@
// This is a wrapper for the Ant Switch component. // 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 React, { useState, useContext } from 'react';
import { Switch } from 'antd'; import { Switch } from 'antd';
@ -17,9 +18,9 @@ import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constan
import { ServerStatusContext } from '../../utils/server-status-context'; import { ServerStatusContext } from '../../utils/server-status-context';
interface ToggleSwitchProps { interface ToggleSwitchProps {
apiPath: string;
fieldName: string; fieldName: string;
apiPath?: string;
checked?: boolean; checked?: boolean;
configPath?: string; configPath?: string;
disabled?: boolean; disabled?: boolean;
@ -106,6 +107,7 @@ export default function ToggleSwitch(props: ToggleSwitchProps) {
} }
ToggleSwitch.defaultProps = { ToggleSwitch.defaultProps = {
apiPath: '',
checked: false, checked: false,
configPath: '', configPath: '',
disabled: false, disabled: false,

View File

@ -1,27 +1,46 @@
import { Popconfirm, Button, Typography } from 'antd'; import { Popconfirm, Button, Typography } from 'antd';
import { useContext } from 'react'; import { useContext, useState } from 'react';
import { AlertMessageContext } from '../../utils/alert-message-context'; import { AlertMessageContext } from '../../utils/alert-message-context';
import { API_YP_RESET, fetchData } from '../../utils/apis'; 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() { export default function ResetYP() {
const { setMessage } = useContext(AlertMessageContext); 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 () => { const resetDirectoryRegistration = async () => {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
try { try {
await fetchData(API_YP_RESET); await fetchData(API_YP_RESET);
setMessage(''); setMessage('');
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
} catch (error) { } catch (error) {
alert(error); setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${error}`));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
} }
}; };
return ( return (
<> <>
<Title level={3} className="section-title"> <Typography.Title level={3} className="section-title">
Reset Directory Reset Directory
</Title> </Typography.Title>
<p className="description"> <p className="description">
If you are experiencing issues with your listing on the Owncast Directory and were asked to If you are experiencing issues with your listing on the Owncast Directory and were asked to
&quot;reset&quot; your connection to the service, you can do that here. The next time you go &quot;reset&quot; 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> <Button type="primary">Reset Directory Connection</Button>
</Popconfirm> </Popconfirm>
<p>
<FormStatusIndicator status={submitStatus} />
</p>
</> </>
); );
} }

View File

@ -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 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. your own name. A logo will not be provided.
</p> </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 <div className="formfield-container">
style={{ width: 240 }} <div className="label-side">
className="social-dropdown" <span className="formfield-label">Social Platform</span>
placeholder="Social platform..." </div>
defaultValue={inititalSelected} <div className="input-side">
value={inititalSelected} <Select
onSelect={handleSelected} style={{ width: 240 }}
> className="social-dropdown"
{iconList.map(item => { placeholder="Social platform..."
const { platform, icon, key } = item; defaultValue={inititalSelected}
return ( value={inititalSelected}
<Select.Option className="social-option" key={`platform-${key}`} value={key}> onSelect={handleSelected}
<span className="option-icon"> >
<img src={`${NEXT_PUBLIC_API_HOST}${icon}`} alt="" className="option-icon" /> {iconList.map(item => {
</span> const { platform, icon, key } = item;
<span className="option-label">{platform}</span> 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>
); </Select>
})} </div>
<Select.Option </div>
className="social-option"
key={`platform-${OTHER_SOCIAL_HANDLE_OPTION}`}
value={OTHER_SOCIAL_HANDLE_OPTION}
>
Other...
</Select.Option>
</Select>
</div> </div>
); );
} }

View File

@ -36,14 +36,6 @@ const SLIDER_COMMENTS = {
6: 'Highest latency, highest error tolerance', 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() { export default function VideoLatency() {
const [submitStatus, setSubmitStatus] = useState<StatusState>(null); const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [selectedOption, setSelectedOption] = useState(null); const [selectedOption, setSelectedOption] = useState(null);
@ -104,7 +96,7 @@ export default function VideoLatency() {
}; };
return ( return (
<div className="config-video-segements-conatiner"> <div className="config-video-latency-container">
<Title level={3} className="section-title"> <Title level={3} className="section-title">
Latency Buffer Latency Buffer
</Title> </Title>
@ -120,7 +112,7 @@ export default function VideoLatency() {
<div className="segment-slider-container"> <div className="segment-slider-container">
<Slider <Slider
tipFormatter={value => <SegmentToolTip value={SLIDER_COMMENTS[value]} />} tipFormatter={value => SLIDER_COMMENTS[value]}
onChange={handleChange} onChange={handleChange}
min={1} min={1}
max={6} max={6}
@ -128,6 +120,7 @@ export default function VideoLatency() {
defaultValue={selectedOption} defaultValue={selectedOption}
value={selectedOption} value={selectedOption}
/> />
<p className="selected-value-note">{SLIDER_COMMENTS[selectedOption]}</p>
<FormStatusIndicator status={submitStatus} /> <FormStatusIndicator status={submitStatus} />
</div> </div>
</div> </div>

View File

@ -1,12 +1,11 @@
// This content populates the video variant modal, which is spawned from the variants table. // This content populates the video variant modal, which is spawned from the variants table.
import React from 'react'; 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 { FieldUpdaterFunc, VideoVariant, UpdateArgs } from '../../types/config-section';
import TextField from './form-textfield'; import TextField from './form-textfield';
import { DEFAULT_VARIANT_STATE } from '../../utils/config-constants'; import { DEFAULT_VARIANT_STATE } from '../../utils/config-constants';
import InfoTip from '../info-tip';
import CPUUsageSelector from './cpu-usage'; import CPUUsageSelector from './cpu-usage';
// import ToggleSwitch from './form-toggleswitch-with-submit'; import ToggleSwitch from './form-toggleswitch';
const { Panel } = Collapse; const { Panel } = Collapse;
@ -17,7 +16,8 @@ const VIDEO_VARIANT_DEFAULTS = {
defaultValue: 24, defaultValue: 24,
unit: 'fps', unit: 'fps',
incrementBy: null, 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: { videoBitrate: {
min: 600, min: 600,
@ -25,7 +25,7 @@ const VIDEO_VARIANT_DEFAULTS = {
defaultValue: 1200, defaultValue: 1200,
unit: 'kbps', unit: 'kbps',
incrementBy: 100, incrementBy: 100,
tip: 'This is importatnt yo', tip: 'The overall quality of your stream is generally impacted most by bitrate.',
}, },
audioBitrate: { audioBitrate: {
min: 600, min: 600,
@ -118,9 +118,9 @@ export default function VideoVariantForm({
const selectedVideoBRnote = () => { const selectedVideoBRnote = () => {
let note = `Selected: ${dataState.videoBitrate}${videoBRUnit}`; let note = `Selected: ${dataState.videoBitrate}${videoBRUnit}`;
if (dataState.videoBitrate < 3000) { if (dataState.videoBitrate < 2000) {
note = `${note} - Good for low bandwidth environments.`; note = `${note} - Good for low bandwidth environments.`;
} else if (dataState.videoBitrate < 4500) { } else if (dataState.videoBitrate < 3500) {
note = `${note} - Good for most bandwidth environments.`; note = `${note} - Good for most bandwidth environments.`;
} else { } else {
note = `${note} - Good for high bandwidth environments.`; note = `${note} - Good for high bandwidth environments.`;
@ -147,52 +147,47 @@ export default function VideoVariantForm({
} }
return note; return note;
}; };
const selectedPresetNote = '';
return ( return (
<div className="config-variant-form"> <div className="config-variant-form">
<p className="description"> <p className="description">
Say a thing here about how this all works. Read more{' '} <a href="https://owncast.online/docs/video">Learn more</a> about how each of these settings
<a href="https://owncast.online/docs/configuration/">here</a>. can impact the performance of your server.
</p> </p>
<div className="row"> <Row gutter={16}>
<div> <Col sm={24} md={12}>
{/* ENCODER PRESET FIELD */} {/* ENCODER PRESET FIELD */}
<div className="form-module cpu-usage-container"> <div className="form-module cpu-usage-container">
<CPUUsageSelector <CPUUsageSelector
defaultValue={dataState.cpuUsageLevel} defaultValue={dataState.cpuUsageLevel}
onChange={handleVideoCpuUsageLevelChange} onChange={handleVideoCpuUsageLevelChange}
/> />
{selectedPresetNote && ( <p className="read-more-subtext">
<span className="selected-value-note">{selectedPresetNote}</span> <a href="https://owncast.online/docs/video/#cpu-usage">Read more about CPU usage.</a>
)}
</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> </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> </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 */} {/* VIDEO BITRATE FIELD */}
<div className={`form-module ${dataState.videoPassthrough ? 'disabled' : ''}`}> <div
<Typography.Title level={3} className="section-title"> className={`form-module bitrate-container ${
Video Bitrate dataState.videoPassthrough ? 'disabled' : ''
</Typography.Title> }`}
>
<Typography.Title level={3}>Video Bitrate</Typography.Title>
<p className="description">{VIDEO_VARIANT_DEFAULTS.videoBitrate.tip}</p> <p className="description">{VIDEO_VARIANT_DEFAULTS.videoBitrate.tip}</p>
<div className="segment-slider-container"> <div className="segment-slider-container">
<Slider <Slider
@ -206,58 +201,59 @@ export default function VideoVariantForm({
max={videoBRMax} max={videoBRMax}
marks={videoBRMarks} marks={videoBRMarks}
/> />
<span className="selected-value-note">{selectedVideoBRnote()}</span> <p className="selected-value-note">{selectedVideoBRnote()}</p>
</div> </div>
<p className="read-more-subtext">
<a href="https://owncast.online/docs/video/#bitrate">Read more about bitrates.</a>
</p>
</div> </div>
</div> </Col>
<Collapse className="advanced-settings"> </Row>
<Panel header="Advanced Settings" key="1"> <Collapse className="advanced-settings">
<div className="section-intro"> <Panel header="Advanced Settings" key="1">
Resizing your content will take additional resources on your server. If you wish to <p className="description">
optionally resize your output for this stream variant then you should either set the Resizing your content will take additional resources on your server. If you wish to
width <strong>or</strong> the height to keep your aspect ratio. optionally resize your content for this stream output then you should either set the
</div> width <strong>or</strong> the height to keep your aspect ratio.{' '}
<div className="field"> <a href="https://owncast.online/docs/video/#resolution">Read more about resolutions.</a>
<TextField </p>
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>
{/* FRAME RATE FIELD */} <TextField
<div className="field"> type="number"
<p className="label"> {...VIDEO_VARIANT_DEFAULTS.scaledWidth}
<InfoTip tip={VIDEO_VARIANT_DEFAULTS.framerate.tip} /> value={dataState.scaledWidth}
Frame rate: onChange={handleScaledWidthChanged}
</p> />
<div className="segment-slider-container form-component"> <TextField
<Slider type="number"
// tooltipVisible {...VIDEO_VARIANT_DEFAULTS.scaledHeight}
tipFormatter={value => `${value} ${framerateUnit}`} value={dataState.scaledHeight}
defaultValue={dataState.framerate} onChange={handleScaledHeightChanged}
value={dataState.framerate} />
onChange={handleFramerateChange}
step={framerateDefaults.incrementBy} {/* FRAME RATE FIELD */}
min={framerateMin} <div className="form-module frame-rate">
max={framerateMax} <Typography.Title level={3}>Frame rate</Typography.Title>
marks={framerateMarks} <p className="description">{VIDEO_VARIANT_DEFAULTS.framerate.tip}</p>
/> <div className="segment-slider-container">
<span className="selected-value-note">{selectedFramerateNote()}</span> <Slider
</div> 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> </div>
</Panel> <p className="read-more-subtext">
</Collapse> <a href="https://owncast.online/docs/video/#framerate">Read more about framerates.</a>
</div> </p>
</div>
</Panel>
</Collapse>
</div> </div>
); );
} }

View File

@ -153,7 +153,6 @@ export default function CurrentVariantsTable() {
return ( return (
<span className="actions"> <span className="actions">
<Button <Button
type="primary"
size="small" size="small"
onClick={() => { onClick={() => {
setEditId(index); setEditId(index);

View File

@ -75,7 +75,7 @@ export default function LogTable({ logs, pageSize }: Props) {
return ( return (
<div className="logs-section"> <div className="logs-section">
<Title level={2}>Logs</Title> <Title>Logs</Title>
<Table <Table
size="middle" size="middle"
dataSource={logs} dataSource={logs}

View File

@ -194,7 +194,7 @@ export default function MainLayout(props) {
<TextFieldWithSubmit <TextFieldWithSubmit
fieldName="streamTitle" fieldName="streamTitle"
{...TEXTFIELD_PROPS_STREAM_TITLE} {...TEXTFIELD_PROPS_STREAM_TITLE}
placeholder="What you're streaming right now" placeholder="What are you streaming now"
value={currentStreamTitle} value={currentStreamTitle}
initialValue={instanceDetails.streamTitle} initialValue={instanceDetails.streamTitle}
onChange={handleStreamTitleChanged} onChange={handleStreamTitleChanged}

View File

@ -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'; import { Typography, Statistic, Card, Progress } from 'antd';
const { Text } = Typography; const { Text } = Typography;
@ -5,7 +8,7 @@ const { Text } = Typography;
interface StatisticItemProps { interface StatisticItemProps {
title?: string; title?: string;
value?: any; value?: any;
prefix?: JSX.Element; prefix?: any;
color?: string; color?: string;
progress?: boolean; progress?: boolean;
centered?: boolean; centered?: boolean;
@ -43,7 +46,7 @@ function ProgressView({ title, value, prefix, color }: StatisticItemProps) {
'0%': color, '0%': color,
'90%': endColor, '90%': endColor,
}} }}
format={percent => content} format={() => content}
/> />
); );
} }

91
web/docs/README.md Normal file
View 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.

View File

@ -5,7 +5,8 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build && next export", "build": "next build && next export",
"start": "next start" "start": "next start",
"lint": "eslint --ext .js,.ts,.tsx types/ pages/ components/"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^4.2.2", "@ant-design/icons": "^4.2.2",

View File

@ -1,6 +1,6 @@
// order matters! // order matters!
import 'antd/dist/antd.css'; import 'antd/dist/antd.css';
import '../styles/colors.scss'; import '../styles/variables.scss';
import '../styles/ant-overrides.scss'; import '../styles/ant-overrides.scss';
import '../styles/markdown-editor.scss'; import '../styles/markdown-editor.scss';
import '../styles/globals.scss'; import '../styles/globals.scss';
@ -17,7 +17,7 @@ import '../styles/config-public-details.scss';
import '../styles/home.scss'; import '../styles/home.scss';
import '../styles/chat.scss'; import '../styles/chat.scss';
import '../styles/config.scss'; import '../styles/pages.scss';
import { AppProps } from 'next/app'; import { AppProps } from 'next/app';
import ServerStatusProvider from '../utils/server-status-context'; import ServerStatusProvider from '../utils/server-status-context';

View File

@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Table, Tag, Space, Button, Modal, Checkbox, Input, Typography, Tooltip } from 'antd'; import { Table, Tag, Space, Button, Modal, Checkbox, Input, Typography, Tooltip } from 'antd';
import { DeleteOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons'; import { DeleteOutlined } from '@ant-design/icons';
const { Title, Paragraph, Text } = Typography;
import format from 'date-fns/format'; import format from 'date-fns/format';
import { fetchData, ACCESS_TOKENS, DELETE_ACCESS_TOKEN, CREATE_ACCESS_TOKEN } from '../utils/apis'; import { fetchData, ACCESS_TOKENS, DELETE_ACCESS_TOKEN, CREATE_ACCESS_TOKEN } from '../utils/apis';
const { Title, Paragraph } = Typography;
const availableScopes = { const availableScopes = {
CAN_SEND_SYSTEM_MESSAGES: { CAN_SEND_SYSTEM_MESSAGES: {
name: '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 [selectedScopes, setSelectedScopes] = useState([]);
const [name, setName] = 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 }; return { value: key, label: availableScopes[key].description };
}); });
@ -52,7 +59,7 @@ function NewTokenModal(props) {
} }
function saveToken() { function saveToken() {
props.onOk(name, selectedScopes); onOk(name, selectedScopes);
// Clear the modal // Clear the modal
setSelectedScopes([]); setSelectedScopes([]);
@ -70,9 +77,9 @@ function NewTokenModal(props) {
return ( return (
<Modal <Modal
title="Create New Access token" title="Create New Access token"
visible={props.visible} visible={visible}
onOk={saveToken} onOk={saveToken}
onCancel={props.onCancel} onCancel={onCancel}
okButtonProps={okButtonProps} okButtonProps={okButtonProps}
> >
<p> <p>
@ -84,12 +91,16 @@ function NewTokenModal(props) {
</p> </p>
<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&apos;s
created.
</p> </p>
<Checkbox.Group options={scopes} value={selectedScopes} onChange={onChange} /> <Checkbox.Group options={scopes} value={selectedScopes} onChange={onChange} />
<Button type="text" size="small" onClick={selectAll}>
Select all <p>
</Button> <Button type="primary" onClick={selectAll}>
Select all
</Button>
</p>
</Modal> </Modal>
); );
} }
@ -98,6 +109,47 @@ export default function AccessTokens() {
const [tokens, setTokens] = useState([]); const [tokens, setTokens] = useState([]);
const [isTokenModalVisible, setIsTokenModalVisible] = useState(false); 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 = [ const columns = [
{ {
title: '', title: '',
@ -117,7 +169,7 @@ export default function AccessTokens() {
title: 'Token', title: 'Token',
dataIndex: 'token', dataIndex: 'token',
key: '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', 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 = () => { const showCreateTokenModal = () => {
setIsTokenModalVisible(true); setIsTokenModalVisible(true);
}; };

View File

@ -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>
);
}

View File

@ -202,7 +202,7 @@ export default function Chat() {
return ( return (
<div className="chat-messages"> <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> <p>Manage the messages from viewers that show up on your stream.</p>
<div className={bulkDivClasses}> <div className={bulkDivClasses}>
<span className="label">Check multiple messages to change their visibility to: </span> <span className="label">Check multiple messages to change their visibility to: </span>

View File

@ -11,9 +11,7 @@ const { Title } = Typography;
export default function PublicFacingDetails() { export default function PublicFacingDetails() {
return ( return (
<div className="config-public-details-page"> <div className="config-public-details-page">
<Title level={2} className="page-title"> <Title>General Settings</Title>
General Settings
</Title>
<p className="description"> <p className="description">
The following are displayed on your site to describe your stream and its content.{' '} The following are displayed on your site to describe your stream and its content.{' '}
<a href="https://owncast.online/docs/website/">Learn more.</a> <a href="https://owncast.online/docs/website/">Learn more.</a>

View File

@ -7,9 +7,7 @@ const { Title } = Typography;
export default function ConfigServerDetails() { export default function ConfigServerDetails() {
return ( return (
<div className="config-server-details-form"> <div className="config-server-details-form">
<Title level={2} className="page-title"> <Title>Server Settings</Title>
Server Settings
</Title>
<p className="description"> <p className="description">
You should change your stream key from the default and keep it safe. For most people You should change your stream key from the default and keep it safe. For most people
it&apos;s likely the other settings will not need to be changed. it&apos;s likely the other settings will not need to be changed.

View File

@ -7,7 +7,7 @@ const { Title } = Typography;
export default function ConfigSocialThings() { export default function ConfigSocialThings() {
return ( return (
<div className="config-social-items"> <div className="config-social-items">
<Title level={2}>Social Items</Title> <Title>Social Items</Title>
<EditSocialLinks /> <EditSocialLinks />
</div> </div>

View File

@ -7,9 +7,7 @@ const { Title } = Typography;
export default function ConfigStorageInfo() { export default function ConfigStorageInfo() {
return ( return (
<> <>
<Title level={2} className="page-title"> <Title>Storage</Title>
Storage
</Title>
<p className="description"> <p className="description">
Owncast supports optionally using external storage providers to distribute your video. Learn Owncast supports optionally using external storage providers to distribute your video. Learn
more about this by visiting our{' '} more about this by visiting our{' '}

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Typography } from 'antd'; import { Typography, Row, Col } from 'antd';
import VideoVariantsTable from '../components/config/video-variants-table'; import VideoVariantsTable from '../components/config/video-variants-table';
import VideoLatency from '../components/config/video-latency'; import VideoLatency from '../components/config/video-latency';
@ -9,24 +9,27 @@ const { Title } = Typography;
export default function ConfigVideoSettings() { export default function ConfigVideoSettings() {
return ( return (
<div className="config-video-variants"> <div className="config-video-variants">
<Title level={2} className="page-title"> <Title>Video configuration</Title>
Video configuration
</Title>
<p className="description"> <p className="description">
Before changing your video configuration{' '} Before changing your video configuration{' '}
<a href="https://owncast.online/docs/encoding">visit the video documentation</a> to learn <a href="https://owncast.online/docs/video">visit the video documentation</a> to learn how
how it impacts your stream performance. 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> </p>
<div className="row"> <Row gutter={[16, 16]}>
<div className="form-module variants-table-module"> <Col md={24} lg={12}>
<VideoVariantsTable /> <div className="form-module variants-table-module">
</div> <VideoVariantsTable />
</div>
<div className="form-module latency-module"> </Col>
<VideoLatency /> <Col md={24} lg={12}>
</div> <div className="form-module latency-module">
</div> <VideoLatency />
</div>
</Col>
</Row>
</div> </div>
); );
} }

View File

@ -1,20 +1,21 @@
import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons'; 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 React, { useEffect, useState } from 'react';
import { fetchData, FETCH_INTERVAL, HARDWARE_STATS } from '../utils/apis'; import { fetchData, FETCH_INTERVAL, HARDWARE_STATS } from '../utils/apis';
import Chart from '../components/chart'; import Chart from '../components/chart';
import StatisticItem from '../components/statistic'; import StatisticItem from '../components/statistic';
interface TimedValue { // TODO: FIX TS WARNING FROM THIS.
time: Date; // interface TimedValue {
value: Number; // time: Date;
} // value: Number;
// }
export default function HardwareInfo() { export default function HardwareInfo() {
const [hardwareStatus, setHardwareStatus] = useState({ const [hardwareStatus, setHardwareStatus] = useState({
cpu: Array<TimedValue>(), cpu: [], // Array<TimedValue>(),
memory: Array<TimedValue>(), memory: [], // Array<TimedValue>(),
disk: Array<TimedValue>(), disk: [], // Array<TimedValue>(),
message: '', message: '',
}); });
@ -66,37 +67,45 @@ export default function HardwareInfo() {
]; ];
return ( return (
<div> <>
<Typography.Title>Hardware Info</Typography.Title>
<br />
<div> <div>
<Row gutter={[16, 16]} justify="space-around"> <Row gutter={[16, 16]} justify="space-around">
<StatisticItem <Col>
title={series[0].name} <StatisticItem
value={`${currentCPUUsage}`} title={series[0].name}
prefix={<LaptopOutlined style={{ color: series[0].color }} />} value={`${currentCPUUsage}`}
color={series[0].color} prefix={<LaptopOutlined style={{ color: series[0].color }} />}
progress color={series[0].color}
centered progress
/> centered
<StatisticItem />
title={series[1].name} </Col>
value={`${currentRamUsage}`} <Col>
prefix={<BulbOutlined style={{ color: series[1].color }} />} <StatisticItem
color={series[1].color} title={series[1].name}
progress value={`${currentRamUsage}`}
centered prefix={<BulbOutlined style={{ color: series[1].color }} />}
/> color={series[1].color}
<StatisticItem progress
title={series[2].name} centered
value={`${currentDiskUsage}`} />
prefix={<SaveOutlined style={{ color: series[2].color }} />} </Col>
color={series[2].color} <Col>
progress <StatisticItem
centered title={series[2].name}
/> value={`${currentDiskUsage}`}
prefix={<SaveOutlined style={{ color: series[2].color }} />}
color={series[2].color}
progress
centered
/>
</Col>
</Row> </Row>
<Chart title="% used" dataCollections={series} color="#FF7700" unit="%" /> <Chart title="% used" dataCollections={series} color="#FF7700" unit="%" />
</div> </div>
</div> </>
); );
} }

View File

@ -2,9 +2,7 @@ import { Button, Card, Col, Divider, Result, Row } from 'antd';
import Meta from 'antd/lib/card/Meta'; import Meta from 'antd/lib/card/Meta';
import Title from 'antd/lib/typography/Title'; import Title from 'antd/lib/typography/Title';
import { import {
AlertOutlined,
ApiTwoTone, ApiTwoTone,
BookOutlined,
BugTwoTone, BugTwoTone,
CameraTwoTone, CameraTwoTone,
DatabaseTwoTone, DatabaseTwoTone,
@ -17,9 +15,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import React from 'react'; import React from 'react';
interface Props {} export default function Help() {
export default function Help(props: Props) {
const questions = [ const questions = [
{ {
icon: <SettingTwoTone style={{ fontSize: '24px' }} />, icon: <SettingTwoTone style={{ fontSize: '24px' }} />,
@ -144,7 +140,7 @@ export default function Help(props: Props) {
]; ];
return ( return (
<div> <div className="help-page">
<Title style={{ textAlign: 'center' }}>How can we help you?</Title> <Title style={{ textAlign: 'center' }}>How can we help you?</Title>
<Row gutter={[16, 16]} justify="space-around" align="middle"> <Row gutter={[16, 16]} justify="space-around" align="middle">
<Col xs={24} lg={12} style={{ textAlign: 'center' }}> <Col xs={24} lg={12} style={{ textAlign: 'center' }}>

View File

@ -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 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 { UserOutlined, ClockCircleOutlined } from '@ant-design/icons';
import { formatDistanceToNow, formatRelative } from 'date-fns'; import { formatDistanceToNow, formatRelative } from 'date-fns';
import { ServerStatusContext } from '../utils/server-status-context'; import { ServerStatusContext } from '../utils/server-status-context';
import StatisticItem from '../components/statistic';
import LogTable from '../components/log-table'; import LogTable from '../components/log-table';
import Offline from './offline-notice'; import Offline from './offline-notice';
import { LOGS_WARN, fetchData, FETCH_INTERVAL } from '../utils/apis'; import { LOGS_WARN, fetchData, FETCH_INTERVAL } from '../utils/apis';
import { formatIPAddress, isEmptyObject } from '../utils/format'; import { formatIPAddress, isEmptyObject } from '../utils/format';
import { UpdateArgs } from '../types/config-section';
function streamDetailsFormatter(streamDetails) { function streamDetailsFormatter(streamDetails) {
return ( return (
@ -80,31 +69,34 @@ export default function Home() {
} }
// map out settings // map out settings
const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map( const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map(setting => {
(setting, index) => { const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
const audioSetting = audioPassthrough const audioSetting = audioPassthrough
? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps` ? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps`
: `${audioBitrate || 'Unknown'} kbps`; : `${audioBitrate || 'Unknown'} kbps`;
const videoSetting = videoPassthrough const videoSetting = videoPassthrough
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${ ? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${
streamDetails.width streamDetails.width
} x ${streamDetails.height}` } x ${streamDetails.height}`
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`; : `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
let settingTitle = 'Outbound Stream Details'; return (
settingTitle = <div className="stream-details-item-container">
videoQualitySettings?.length > 1 ? `${settingTitle} ${index + 1}` : settingTitle; <Statistic
return ( className="stream-details-item"
<Card title={settingTitle} type="inner" key={`${settingTitle}${index}`}> title="Outbound Video Stream"
<StatisticItem title="Outbound Video Stream" value={videoSetting} prefix={null} /> value={videoSetting}
<StatisticItem title="Outbound Audio Stream" value={audioSetting} prefix={null} /> />
</Card> <Statistic
); className="stream-details-item"
}, title="Outbound Audio Stream"
); value={audioSetting}
/>
</div>
);
});
// inbound // inbound
const { viewerCount, sessionPeakViewerCount } = serverStatusData; const { viewerCount, sessionPeakViewerCount } = serverStatusData;
@ -118,57 +110,60 @@ export default function Home() {
return ( return (
<div className="home-container"> <div className="home-container">
<div className="sections-container"> <div className="sections-container">
<div className="section online-status-section"> <div className="online-status-section">
<Card title="Stream is online" type="inner"> <Card size="small" type="inner" className="online-details-card">
<Statistic <Row gutter={[16, 16]} align="middle">
title={`Stream started ${formatRelative(broadcastDate, Date.now())}`} <Col span={8} sm={24} md={8}>
value={formatDistanceToNow(broadcastDate)} <Statistic
prefix={<ClockCircleOutlined />} title={`Stream started ${formatRelative(broadcastDate, Date.now())}`}
/> value={formatDistanceToNow(broadcastDate)}
<Statistic title="Viewers" value={viewerCount} prefix={<UserOutlined />} /> prefix={<ClockCircleOutlined />}
<Statistic />
title="Peak viewer count" </Col>
value={sessionPeakViewerCount} <Col span={8} sm={24} md={8}>
prefix={<UserOutlined />} <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> </Card>
</div> </div>
<div className="section stream-details-section"> <Row gutter={[16, 16]} className="section stream-details-section">
<div className="details outbound-details">{videoQualitySettings}</div> <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"> <Col className="inbound-details" span={12} sm={24} md={24} lg={12}>
<Card title="Inbound Stream Details" type="inner"> <Card size="small" title="Inbound Stream Details" type="inner">
<StatisticItem <Statistic
className="stream-details-item"
title="Input" title="Input"
value={`${encoder} ${formatIPAddress(remoteAddr)}`} value={`${encoder} ${formatIPAddress(remoteAddr)}`}
prefix={null}
/> />
<StatisticItem <Statistic
className="stream-details-item"
title="Inbound Video Stream" title="Inbound Video Stream"
value={streamDetails} value={streamDetails}
formatter={streamDetailsFormatter} formatter={streamDetailsFormatter}
prefix={null}
/> />
<StatisticItem <Statistic
className="stream-details-item"
title="Inbound Audio Stream" title="Inbound Audio Stream"
value={streamAudioDetailString} value={streamAudioDetailString}
prefix={null}
/> />
</Card> </Card>
</Col>
<div className="server-detail"> </Row>
<Card title="Server Config" type="inner">
<StatisticItem
title="Directory registration enabled"
value={configData.yp.enabled.toString()}
prefix={null}
/>
</Card>
</div>
</div>
</div>
</div> </div>
<br />
<LogTable logs={logsData} pageSize={5} /> <LogTable logs={logsData} pageSize={5} />
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { Result, Card } from 'antd'; import { Result, Card, Row, Col } from 'antd';
import { import {
MessageTwoTone, MessageTwoTone,
QuestionCircleTwoTone, QuestionCircleTwoTone,
@ -55,22 +55,23 @@ export default function Offline({ logs = [] }) {
return ( return (
<> <>
<div className="offline-content"> <Row gutter={[16, 16]} className="offline-content">
<div className="logo-section"> <Col span={12} xs={24} sm={24} md={24} lg={12} className="logo-section">
<Result <Result
icon={<OwncastLogo />} icon={<OwncastLogo />}
title="No stream is active." title="No stream is active."
subTitle="You should start one." subTitle="You should start one."
/> />
</div> </Col>
<div className="list-section">
<Col span={12} xs={24} sm={24} md={24} lg={12} className="list-section">
{data.map(item => ( {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} /> <Meta avatar={item.icon} title={item.title} description={item.content} />
</Card> </Card>
))} ))}
</div> </Col>
</div> </Row>
<LogTable logs={logs} pageSize={5} /> <LogTable logs={logs} pageSize={5} />
</> </>
); );

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from 'react';
import ReactMarkdown from "react-markdown"; import ReactMarkdown from 'react-markdown';
import { Table, Typography } from "antd"; import { Table, Typography } from 'antd';
import { getGithubRelease } from "../utils/apis"; import { getGithubRelease } from '../utils/apis';
const { Title } = Typography; const { Title } = Typography;
@ -10,32 +10,29 @@ function AssetTable(assets) {
const columns = [ const columns = [
{ {
title: "Name", title: 'Name',
dataIndex: "name", dataIndex: 'name',
key: "name", key: 'name',
render: (text, entry) => render: (text, entry) => <a href={entry.browser_download_url}>{text}</a>,
<a href={entry.browser_download_url}>{text}</a>,
}, },
{ {
title: "Size", title: 'Size',
dataIndex: "size", dataIndex: 'size',
key: "size", key: 'size',
render: (text) => (`${(text/1024/1024).toFixed(2)} MB`), 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() { export default function Logs() {
const [release, setRelease] = useState({ const [release, setRelease] = useState({
html_url: "", html_url: '',
name: "", name: '',
created_at: null, created_at: null,
body: "", body: '',
assets: [], assets: [],
}); });
const getRelease = async () => { const getRelease = async () => {
@ -43,7 +40,7 @@ export default function Logs() {
const result = await getGithubRelease(); const result = await getGithubRelease();
setRelease(result); setRelease(result);
} catch (error) { } catch (error) {
console.log("==== error", error); console.log('==== error', error);
} }
}; };
@ -56,14 +53,14 @@ export default function Logs() {
} }
return ( return (
<div> <div className="upgrade-page">
<Title level={2}> <Title level={2}>
<a href={release.html_url}>{release.name}</a> <a href={release.html_url}>{release.name}</a>
</Title> </Title>
<Title level={5}>{new Date(release.created_at).toDateString()}</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} /> <AssetTable {...release.assets} />
</div> </div>
); );
} }

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useContext } from 'react'; 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 { formatDistanceToNow } from 'date-fns';
import { UserOutlined } from '@ant-design/icons'; import { UserOutlined } from '@ant-design/icons';
import { SortOrder } from 'antd/lib/table/interface'; import { SortOrder } from 'antd/lib/table/interface';
@ -94,28 +94,37 @@ export default function ViewersOverTime() {
]; ];
return ( return (
<div> <>
<Typography.Title>Viewer Info</Typography.Title>
<br />
<Row gutter={[16, 16]} justify="space-around"> <Row gutter={[16, 16]} justify="space-around">
{online && ( {online && (
<Col span={8} md={8}>
<StatisticItem
title="Current viewers"
value={viewerCount.toString()}
prefix={<UserOutlined />}
/>
</Col>
)}
<Col md={online ? 8 : 12}>
<StatisticItem <StatisticItem
title="Current viewers" title={online ? 'Max viewers this session' : 'Max viewers last session'}
value={viewerCount.toString()} value={sessionPeakViewerCount.toString()}
prefix={<UserOutlined />} prefix={<UserOutlined />}
/> />
)} </Col>
<StatisticItem <Col md={online ? 8 : 12}>
title={online ? 'Max viewers this session' : 'Max viewers last session'} <StatisticItem
value={sessionPeakViewerCount.toString()} title="All-time max viewers"
prefix={<UserOutlined />} value={overallPeakViewerCount.toString()}
/> prefix={<UserOutlined />}
<StatisticItem />
title="All-time max viewers" </Col>
value={overallPeakViewerCount.toString()}
prefix={<UserOutlined />}
/>
</Row> </Row>
<Chart title="Viewers" data={viewerInfo} color="#2087E2" unit="" /> <Chart title="Viewers" data={viewerInfo} color="#2087E2" unit="" />
{online && <Table dataSource={clients} columns={columns} rowKey={row => row.clientID} />} {online && <Table dataSource={clients} columns={columns} rowKey={row => row.clientID} />}
</div> </>
); );
} }

View File

@ -1,24 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import { Table, Tag, Space, Button, Modal, Checkbox, Input, Typography, Tooltip } from 'antd';
Table, import { DeleteOutlined } from '@ant-design/icons';
Tag,
Space,
Button,
Modal,
Checkbox,
Input,
Typography,
Tooltip,
Select,
} from 'antd';
import { DeleteOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
import { isValidUrl } from '../utils/urls'; import { isValidUrl } from '../utils/urls';
const { Title, Paragraph, Text } = Typography;
const { Option } = Select;
import { fetchData, DELETE_WEBHOOK, CREATE_WEBHOOK, WEBHOOKS } from '../utils/apis'; import { fetchData, DELETE_WEBHOOK, CREATE_WEBHOOK, WEBHOOKS } from '../utils/apis';
const { Title, Paragraph } = Typography;
const availableEvents = { const availableEvents = {
CHAT: { name: 'Chat messages', description: 'When a user sends a chat message', color: 'purple' }, 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' }, USER_JOINED: { name: 'User joined', description: 'When a user joins the chat', color: 'green' },
@ -49,12 +37,19 @@ function convertEventStringToTag(eventString) {
</Tooltip> </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 [selectedEvents, setSelectedEvents] = useState([]);
const [webhookUrl, setWebhookUrl] = 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 }; return { value: key, label: availableEvents[key].description };
}); });
@ -67,7 +62,7 @@ function NewWebhookModal(props) {
} }
function save() { function save() {
props.onOk(webhookUrl, selectedEvents); onOk(webhookUrl, selectedEvents);
// Reset the modal // Reset the modal
setWebhookUrl(''); setWebhookUrl('');
@ -81,9 +76,9 @@ function NewWebhookModal(props) {
return ( return (
<Modal <Modal
title="Create New Webhook" title="Create New Webhook"
visible={props.visible} visible={visible}
onOk={save} onOk={save}
onCancel={props.onCancel} onCancel={onCancel}
okButtonProps={okButtonProps} okButtonProps={okButtonProps}
> >
<div> <div>
@ -96,9 +91,12 @@ function NewWebhookModal(props) {
<p>Select the events that will be sent to this webhook.</p> <p>Select the events that will be sent to this webhook.</p>
<Checkbox.Group options={events} value={selectedEvents} onChange={onChange} /> <Checkbox.Group options={events} value={selectedEvents} onChange={onChange} />
<Button type="text" size="small" onClick={selectAll}>
Select all <p>
</Button> <Button type="primary" onClick={selectAll}>
Select all
</Button>
</p>
</Modal> </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 { try {
const result = await fetchData(WEBHOOKS); const result = await fetchData(WEBHOOKS);
setWebhooks(result); setWebhooks(result);
} catch (error) { } catch (error) {
handleError(error); handleError(error);
} }
}; }
useEffect(() => { useEffect(() => {
getWebhooks(); getWebhooks();
@ -151,7 +154,7 @@ export default function Webhooks() {
async function handleDelete(id) { async function handleDelete(id) {
try { try {
const result = await fetchData(DELETE_WEBHOOK, { method: 'POST', data: { id: id } }); await fetchData(DELETE_WEBHOOK, { method: 'POST', data: { id } });
getWebhooks(); getWebhooks();
} catch (error) { } catch (error) {
handleError(error); handleError(error);
@ -162,7 +165,7 @@ export default function Webhooks() {
try { try {
const newHook = await fetchData(CREATE_WEBHOOK, { const newHook = await fetchData(CREATE_WEBHOOK, {
method: 'POST', method: 'POST',
data: { url: url, events: events }, data: { url, events },
}); });
setWebhooks(webhooks.concat(newHook)); setWebhooks(webhooks.concat(newHook));
} catch (error) { } catch (error) {
@ -170,11 +173,6 @@ export default function Webhooks() {
} }
} }
function handleError(error) {
console.error('error', error);
alert(error);
}
const showCreateModal = () => { const showCreateModal = () => {
setIsModalVisible(true); setIsModalVisible(true);
}; };
@ -194,7 +192,7 @@ export default function Webhooks() {
<Paragraph> <Paragraph>
A webhook is a callback made to an external API in response to an event that takes place 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 within Owncast. This can be used to build chat bots or sending automatic notifications that
you've started streaming. you&apos;ve started streaming.
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
Read more about how to use webhooks, with examples, at{' '} Read more about how to use webhooks, with examples, at{' '}

View File

@ -1,6 +1,5 @@
// GENERAL ANT OVERRIDES // GENERAL ANT OVERRIDES
// RESET BG, TEXT COLORS // RESET BG, TEXT COLORS
.ant-layout, .ant-layout,
.ant-layout-header, .ant-layout-header,
@ -9,6 +8,9 @@
.ant-card, .ant-card,
.ant-collapse, .ant-collapse,
.ant-collapse-content, .ant-collapse-content,
.ant-statistic,
.ant-statistic-title,
.ant-statistic-content,
.ant-table, .ant-table,
.ant-table-thead > tr > th, .ant-table-thead > tr > th,
.ant-table-small .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-submenu > .ant-menu,
.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected { .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: transparent; background-color: transparent;
color: var(--default-text-color) color: var(--default-text-color);
} }
h1.ant-typography,
h1.ant-typography, h2.ant-typography,
h2.ant-typography, h3.ant-typography,
h3.ant-typography, h4.ant-typography,
h4.ant-typography, h5.ant-typography,
h5.ant-typography,
.ant-typography, .ant-typography,
.ant-typography h1, .ant-typography h1,
.ant-typography h2, .ant-typography h2,
.ant-typography h3, .ant-typography h3,
.ant-typography h4, .ant-typography h4,
.ant-typography h5 { .ant-typography h5 {
color: var(--default-text-color); color: var(--white);
font-weight: 500; 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 { .ant-typography h1,
color: rgba(255,255,255,.85); h1.ant-typography {
font-weight: 400; 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, .ant-progress-text,
@ -49,8 +75,6 @@ h5.ant-typography,
color: var(--default-text-color); color: var(--default-text-color);
} }
// ANT MENU // ANT MENU
// menu base // menu base
.ant-menu-item { .ant-menu-item {
@ -58,7 +82,7 @@ h5.ant-typography,
.anticon { .anticon {
transition-duration: var(--ant-transition-duration); transition-duration: var(--ant-transition-duration);
color: var(--nav-text); color: var(--nav-text);
} }
a { a {
@ -66,21 +90,26 @@ h5.ant-typography,
color: var(--nav-text); color: var(--nav-text);
&:hover { &:hover {
color: white; color: var(--white);
} }
} }
&:hover { &:hover {
background-color: rgba(0,0,0,.15); background-color: var(--black-50);
color: var(--white);
.anticon { .anticon {
color: white; color: var(--white);
} }
} }
} }
.ant-menu-item:active,
.ant-menu-submenu-title:active {
background-color: var(--black-50);
}
// menu item selected // menu item selected
.ant-menu-item-selected, .ant-menu-item-selected,
.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected { .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: black; background-color: var(--black);
a { a {
color: var(--nav-selected-text); color: var(--nav-selected-text);
} }
@ -96,14 +125,14 @@ h5.ant-typography,
} }
// submenu items // submenu items
.ant-menu-submenu { .ant-menu-submenu {
&> .ant-menu { & > .ant-menu {
border-left: 1px solid rgba(255,255,255,.4); border-left: 1px solid var(--white-50);
background-color: rgba(0,0,0,.15); background-color: var(--black-35);
} }
.ant-menu-submenu-title { .ant-menu-submenu-title {
transition-duration: var(--ant-transition-duration); transition-duration: var(--ant-transition-duration);
color: var(--nav-text); color: var(--nav-text);
.anticon { .anticon {
color: var(--nav-text); color: var(--nav-text);
} }
@ -117,30 +146,28 @@ h5.ant-typography,
} }
&:hover { &:hover {
.ant-menu-submenu-title { .ant-menu-submenu-title {
color: white; color: var(--white);
.anticon { .anticon {
color: white; color: var(--white);
} }
.ant-menu-submenu-arrow { .ant-menu-submenu-arrow {
&:before, &:before,
&:after { &:after {
background-image: linear-gradient(to right, white, white); background-image: linear-gradient(to right, var(--white), var(--white));
} }
} }
} }
} }
} }
// ANT RESULT // ANT RESULT
.ant-result-title { .ant-result-title {
color: var(--default-text-color); color: var(--default-text-color);
} }
.ant-result-subtitle { .ant-result-subtitle {
color: var(--default-text-color); color: var(--white-75);
} }
// ANT CARD // ANT CARD
.ant-card { .ant-card {
border-radius: var(--container-border-radius); border-radius: var(--container-border-radius);
@ -148,16 +175,30 @@ h5.ant-typography,
color: var(--default-text-color); color: var(--default-text-color);
} }
.ant-card-bordered { .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 { .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 // 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-affix-wrapper,
.ant-input-number { .ant-input-number {
background-color: var(--textfield-bg); background-color: var(--textfield-bg);
@ -165,66 +206,90 @@ h5.ant-typography,
input, input,
textarea { textarea {
background-color: transparent; background-color: transparent;
color: rgba(255,255,255,.85); color: var(--white-88);
border-color: rgba(0,0,0,1); border-color: var(--black);
&::placeholder { &::placeholder {
color: var(--textfield-border); color: var(--owncast-purple-50);
} }
&:-webkit-autofill { &:-webkit-autofill {
background-color: transparent; background-color: transparent;
} }
} }
} }
.ant-input:hover,
.ant-input-number:hover, .ant-input-number:hover,
.ant-input-affix-wrapper:hover { .ant-input-affix-wrapper:hover {
border-color: var(--owncast-purple-highlight); border-color: var(--owncast-purple);
input, input,
textarea { textarea {
border-color: var(--owncast-purple-highlight); border-color: var(--owncast-purple);
} }
} }
.ant-input,
.ant-input-number:focus, .ant-input-number:focus,
.ant-input-affix-wrapper:focus, .ant-input-affix-wrapper:focus,
.ant-input-affix-wrapper-focused { .ant-input-affix-wrapper-focused {
border-color: var(--owncast-purple); border-color: var(--owncast-purple);
input, input,
textarea { textarea {
color: white; color: var(--white);
border-color: var(--owncast-purple); border-color: var(--owncast-purple);
} }
} }
.ant-input-textarea-clear-icon,
.ant-input-clear-icon {
color: rgba(255,255,255,.5);
}
textarea.ant-input { textarea.ant-input {
padding-right: 25px; 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 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 { .ant-btn-primary {
background-color: var(--owncast-purple); background-color: var(--owncast-purple);
border-color: var(--owncast-purple); border-color: var(--owncast-purple);
} }
.ant-btn-primary:hover, .ant-btn-primary:hover,
.ant-btn-primary:focus { .ant-btn-primary:focus {
background-color: var(--form-focused); background-color: var(--button-focused);
border-color: var(--form-focused); color: var(--white);
} }
.ant-btn.ant-btn-primary:hover { .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] { .ant-btn-primary[disabled] {
background-color: rgba(255,255,255,.2); background-color: var(--white-25);
border-color: rgba(255,255,255,.2); border-color: var(--white-25);
color: white; color: var(--white-50);
&:hover { &:hover {
background-color: rgba(255,255,255,.2); background-color: var(--white-35);
border-color: rgba(255,255,255,.2); border-color: var(--white-35);
} }
} }
.ant-input-affix-wrapper, .ant-input-affix-wrapper,
@ -233,30 +298,31 @@ textarea.ant-input {
transition-duration: 0.15s; transition-duration: 0.15s;
} }
// ANT TABLE // ANT TABLE
.ant-table-thead > tr > th, .ant-table-thead > tr > th,
.ant-table-small .ant-table-thead > tr > th { .ant-table-small .ant-table-thead > tr > th {
transition-duration: var(--ant-transition-duration); transition-duration: var(--ant-transition-duration);
background-color: #112; background-color: var(--purple-dark);
font-weight: 500; font-weight: 500;
color: var(--owncast-purple); color: var(--white);
} }
.ant-table-tbody > tr > td, .ant-table-tbody > tr > td,
.ant-table-thead > tr > th, .ant-table-thead > tr > th,
.ant-table-small .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 { .ant-table-tbody > tr > td {
transition-duration: var(--ant-transition-duration); 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 { .ant-table-tbody > tr.ant-table-row:hover > td {
background-color: var(--textfield-bg); background-color: var(--gray-dark);
} }
.ant-empty { .ant-empty {
color: white; color: var(--white-75);
opacity: .75;
} }
.ant-table-empty .ant-table-tbody > tr.ant-table-placeholder { .ant-table-empty .ant-table-tbody > tr.ant-table-placeholder {
&:hover > td { &:hover > td {
@ -269,18 +335,26 @@ textarea.ant-input {
background-color: var(--textfield-border); background-color: var(--textfield-border);
} }
} }
.ant-table-thead th.ant-table-column-sort {
background-color: var(--owncast-purple-25);
opacity: 0.75;
}
// MODAL // MODAL
.ant-modal,
.ant-modal-body {
font-size: 1em;
}
.ant-modal-content { .ant-modal-content {
border-radius: var(--container-border-radius); 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 { .ant-modal-header {
border-radius: var(--container-border-radius) var(--container-border-radius) 0 0; border-radius: var(--container-border-radius) var(--container-border-radius) 0 0;
} }
.ant-modal-close-x { .ant-modal-close-x {
color: white; color: var(--white);
} }
.ant-modal-title { .ant-modal-title {
font-weight: 500; font-weight: 500;
@ -288,79 +362,151 @@ textarea.ant-input {
color: var(--nav-selected-text); color: var(--nav-selected-text);
} }
.ant-modal-body { .ant-modal-body {
background-color: var(--nav-bg-color); background-color: var(--gray);
color: var(--default-text-color); color: var(--default-text-color);
} }
.ant-modal-header, .ant-modal-header,
.ant-modal-footer { .ant-modal-footer {
background-color: black; background: var(--black);
} }
.ant-modal-content, .ant-modal-content,
.ant-modal-header, .ant-modal-header,
.ant-modal-footer { .ant-modal-footer {
border-color: #333; border-color: var(--white-50);
} }
// SELECT // SELECT
.ant-select-dropdown { .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 // SLIDER
// .ant-slider-with-marks { // .ant-slider-with-marks {
// margin-right: 2em; // margin-right: 2em;
// } // }
.ant-slider-mark-text { .ant-slider-mark-text {
font-size: .85em; font-size: 0.85em;
white-space: nowrap; 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
.ant-switch { .ant-switch {
background-color: #666; background-color: var(--gray-medium);
} }
.ant-switch-checked { .ant-switch-checked {
background-color: var(--ant-success); background-color: var(--ant-success);
.ant-switch-inner { .ant-switch-inner {
color: white; color: var(--white);
} }
} }
// ANT COLLAPSE // ANT COLLAPSE
.ant-collapse { .ant-collapse {
font-size: 1em;
border-color: transparent; border-color: transparent;
&> .ant-collapse-item, & > .ant-collapse-item,
.ant-collapse-content { .ant-collapse-content {
border-color: transparent; border-color: transparent;
&> .ant-collapse-header { & > .ant-collapse-header {
border-radius: var(--container-border-radius);
border-color: transparent; border-color: transparent;
background-color: var(--textfield-bg); background-color: var(--purple-dark);
color: var(--nav-text); color: var(--white);
font-weight: 500; font-weight: 500;
} }
} }
} }
.ant-collapse-content { .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 {
}
.ant-popover-inner { .ant-popover-inner {
background-color: black; background-color: var(--gray);
} }
.ant-popover-message, .ant-popover-message,
.ant-popover-inner-content { .ant-popover-inner-content {
color: var(--default-text-color); color: var(--default-text-color);
} }
.ant-popover-placement-topLeft > .ant-popover-content > .ant-popover-arrow { .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;
}
}

View File

@ -8,11 +8,11 @@
} }
.ant-table-row.hidden { .ant-table-row.hidden {
.ant-table-cell { .ant-table-cell {
color: rgba(0,0,0,.25) color: var(--black-35)
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.ant-table-cell { .ant-table-cell {
color: rgba(255,255,255,.25) color: var(--white-25);
} }
} }
} }
@ -47,29 +47,21 @@
.bulk-editor { .bulk-editor {
margin: .5rem 0; margin: .5rem 0;
padding: .5rem; padding: .5rem;
border: 1px solid #ccc; border: 1px solid var(--textfield-border);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
border-radius: 4px; border-radius: 4px;
opacity: .5;
&.active { &.active {
opacity: 1;
.label { .label {
color: #000; color: var(--black);
}
@media (prefers-color-scheme: dark) {
.label {
color: #fff;
}
} }
} }
.label { .label {
font-size: .75rem; font-size: .75rem;
color: #666; color: var(--white-50);
margin-right: .5rem; margin-right: .5rem;
} }
@ -112,11 +104,6 @@
} }
} }
.ant-btn-text:hover { .ant-btn-text:hover {
background-color: rgba(0,0,0,.1) background-color: var(--black-35)
}
@media (prefers-color-scheme: dark) {
.ant-btn-text:hover {
background-color: rgba(255,255,255,.3)
}
} }
} }

View File

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

View File

@ -38,14 +38,21 @@
} }
.instance-details-container { .instance-details-container {
width: 100%; width: 100%;
.logo-preview {
display: inline-block;
margin: -1em 0 1em 11em;
height: 120px;
border: 1px solid var(--white-25);
}
} }
.social-items-container { .social-items-container {
background-color: var(--container-bg-color-alt); background-color: var(--container-bg-color-alt);
padding: 0 .75em; padding: 0 0.75em;
margin-left: 1em; margin-left: 1em;
max-width: 450px; max-width: 450px;
.form-module { .form-module {
background-color: #000; background-color: var(--black);
} }
.social-handles-container { .social-handles-container {
@ -62,4 +69,8 @@
height: 6em !important; height: 6em !important;
} }
} }
} }
.other-field-container {
margin: 0.5em 0;
}

View File

@ -8,7 +8,7 @@
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
padding: .25em; padding: 0.25em;
line-height: normal; line-height: normal;
.option-icon { .option-icon {
@ -31,9 +31,9 @@
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
color: rgba(255,255,255,.85); color: var(--white-75);
.option-icon { .option-icon {
height: 2em; height: 2em;
width: 2em; width: 2em;
line-height: normal; line-height: normal;
@ -43,7 +43,7 @@
flex-direction: column; flex-direction: column;
margin: 0 0 0 1em; margin: 0 0 0 1em;
line-height: 2; line-height: 2;
font-size: .85em; font-size: 0.85em;
} }
} }
.actions { .actions {
@ -54,4 +54,4 @@
width: 6em; width: 6em;
} }
} }
} }

View File

@ -22,29 +22,37 @@
} }
// Do something special for the stream key field .edit-server-details-container {
.field-streamkey-container {
margin-bottom: 1.5em; // Do something special for the stream key field
.field-tip { .field-streamkey-container {
color: var(--ant-warning); margin-bottom: 1.5em;
} .field-tip {
.left-side { color: var(--ant-warning);
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) { .left-side {
margin-top: 2em; 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;
}
}

View File

@ -15,12 +15,12 @@
margin-left: .3rem; margin-left: .3rem;
padding: 2px; padding: 2px;
border-radius: 5rem; border-radius: 5rem;
color: black; color: var(--black);
border: 1px solid #000; border: 1px solid var(--black);
transition-duration: var(--ant-transition-duration); transition-duration: var(--ant-transition-duration);
&:hover { &:hover {
border-color: #5a67d8; border-color: var(--owncast-purple);
background-color: white; background-color: var(--white);
svg { svg {
fill: black; fill: black;
transition: fill var(--ant-transition-duration); transition: fill var(--ant-transition-duration);

View File

@ -1,17 +1,13 @@
// styles for Video variant editor (table + modal) // styles for Video variant editor (table + modal)
.config-video-variants { .config-video-variants {
.variants-table { .variants-table {
margin-top: 2em; margin-top: 2em;
} }
.variants-table-module { .variants-table-module {
min-width: 48%; min-width: 400px;
max-width: 600px;
margin-right: 1em
} }
} }
// modal content // modal content
@ -20,84 +16,16 @@
margin-top: 0; margin-top: 0;
} }
.cpu-usage-container,
.bitrate-container {
height: 20em;
}
.advanced-settings { .advanced-settings {
width: 48%; margin-top: 1em;
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;
} }
} }
.variants-table { .variants-table {
.actions { .actions {
display: flex; display: flex;
@ -105,11 +33,11 @@
justify-content: center; justify-content: center;
} }
.delete-button { .delete-button {
margin-left: .5em; margin-left: 0.5em;
opacity: .8; opacity: 0.8;
} }
} }
.advanced-settings { .read-more-subtext {
margin-top: 2em; font-size: 0.8rem;
} }

View File

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

View File

@ -30,7 +30,7 @@
/* TIP CONTAINER BASE */ /* TIP CONTAINER BASE */
.field-tip { .field-tip {
font-size: .8em; 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 { .field-container {
padding: .85em 0 .5em; 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 { .segment-slider-container {
width: 90%; width: 100%;
margin: auto; margin: auto;
padding: 1em 2em .75em; padding: 1em 2em .75em;
background-color: var(--textfield-border); background-color: var(--owncast-purple-25);
border-radius: 1em; border-radius: var(--container-border-radius);
.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;
}
.status-container { .status-container {
width: 100%; width: 100%;
margin: .5em auto; margin: .5em auto;
text-align: center; 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);
}
} }

View File

@ -6,16 +6,16 @@ body {
margin: 0; 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-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; background-color: var(--default-bg-color);
color: var(--default-text-color);; color: var(--default-text-color);
} }
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
color: var(--owncast-purple); color: var(--default-link-color);
&:hover { &:hover {
color: var(--default-text-color); color: var(--default-text-color);
@ -25,19 +25,29 @@ a {
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
p {
p,
p.description,
.ant-typography {
font-weight: 300; font-weight: 300;
margin: 1em 0;
color: var(--white-75);
} }
pre { pre {
display: block; display: block;
padding: 1rem; padding: 1rem;
margin: .5rem 0; margin: .5rem 0;
background-color: rgb(44, 44, 44); background-color: var(--code-bg-color);
color:lightgrey; color: var(--white-50);
} }
code { 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; width: 2rem;
} }
p.description {
margin: 1em 0;
color: #ccc;
}
.line-chart-container { .line-chart-container {
margin: 2em auto; 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 { .form-module {
// width: 100%;
// max-width: 500px;
// min-width: 300px;
margin: 1em 0; margin: 1em 0;
background-color: var(--container-bg-color); background-color: var(--container-bg-color);
padding: 2em; padding: 2em;
border-radius: var(--container-border-radius); border-radius: var(--container-border-radius);
h3 {
&:first-of-type {
margin-top: 0;
}
}
} }
.row { .row {

View File

@ -1,137 +1,51 @@
.home-container { .home-container {
max-width: 1000px;
.statistics-list {
li {
margin-left: -.5em;
}
}
.section {
margin: 1rem 0;
.ant-statistic-content {
font-size: 1rem;
}
}
.online-status-section { .online-status-section {
> .ant-card { margin-bottom: 1em;
box-shadow: 0px 1px 10px 2px rgba(0, 22, 40, 0.1); .online-details-card {
border-color: var(--online-color);
} }
.ant-statistic {
.ant-card-head { text-align: center;
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-title { .ant-statistic-title {
font-size: .88rem; color: var(--white-50);
}
.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;
}
} }
} }
.ant-card-head {
color: var(--online-color);
}
.stream-details-section { .stream-details-item-container {
display: flex; margin: 1em 0;
flex-direction: row; &:first-of-type {
justify-content: space-between; margin-top: 0;
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;
}
}
} }
.server-detail { }
.ant-card-body { .ant-statistic.stream-details-item {
display: flex; background-color: var(--black-50);
flex-direction: row; padding: 1em;
justify-content: space-between; .ant-statistic-title {
align-items: flex-start; color: var(--blue);
}
.ant-card { .ant-statistic-content {
width: 45%; font-size: 1.25em;
text-align: center; white-space: nowrap;
}
}
.ant-card-head {
background-color: #669;
color: #fff;
}
} }
} }
.outbound-details,
@media (max-width: 800px) { .inbound-details {
.online-status-section{ > .ant-card-bordered {
.ant-card-body { border-color: rgba(255, 255, 255, 0.1);
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%;
}
} }
} }
} }
.offline-content { .offline-content {
max-width: 1000px; max-width: 1000px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
width: 100%;
.logo-section { .logo-section {
width: 50%;
.ant-result-title { .ant-result-title {
font-size: 2rem; font-size: 2rem;
} }
@ -144,36 +58,20 @@
} }
} }
.list-section { .list-section {
width: 50%; background-color: var(--container-bg-color-alt);
border-radius: var(--container-border-radius);
padding: 1em;
> .ant-card { > .ant-card {
margin-bottom: 1rem; background-color: var(--black);
.ant-card-head { margin-bottom: 1em;
background-color: #dde;
}
.ant-card-head-title {
font-size: 1rem;
}
.ant-card-meta-avatar { .ant-card-meta-avatar {
margin-top: .25rem; margin-top: 0.25rem;
svg { svg {
height: 1.25rem; height: 1.5em;
width: 1.25rem; 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%
}
}
} }

View File

@ -1,5 +1,4 @@
.app-container { .app-container {
.side-nav { .side-nav {
position: fixed; position: fixed;
height: 100vh; height: 100vh;
@ -20,20 +19,20 @@
align-items: center; align-items: center;
.logo-container { .logo-container {
background-color: #fff; background-color: var(--white);
padding: .35rem; padding: 0.35rem;
border-radius: 9999px; border-radius: 9999px;
} }
.title-label { .title-label {
display: inline-block; display: inline-block;
margin-left: 1rem; margin-left: 1rem;
color: rgba(203,213,224, 1); color: var(--white);
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 200; font-weight: 200;
text-transform: uppercase; text-transform: uppercase;
line-height: normal; line-height: normal;
letter-spacing: .05em; letter-spacing: 0.05em;
} }
} }
@ -48,17 +47,17 @@
background-color: var(--nav-bg-color); background-color: var(--nav-bg-color);
} }
.main-content-container { .main-content-container {
padding: 3em; padding: 2em 3em 3em;
max-width: 1024px;
min-width: 50vw;
margin: auto;
} }
.footer-container { .footer-container {
text-align: center; text-align: center;
} }
.online-status-indicator { .online-status-indicator {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -70,21 +69,21 @@
} }
.status-label { .status-label {
color: #fff; color: var(--white);
text-transform: uppercase; text-transform: uppercase;
font-size: .75rem; font-size: 0.75rem;
display: inline-block; display: inline-block;
margin-right: .5rem; margin-right: 0.5rem;
color: #999; color: var(--offline-color);
} }
.status-icon { .status-icon {
font-size: 1.5rem; font-size: 1.5rem;
svg { svg {
fill: #999; fill: var(--offline-color);
} }
} }
} }
.online { &.online {
.online-status-indicator { .online-status-indicator {
.status-icon { .status-icon {
svg { svg {
@ -92,13 +91,13 @@
} }
} }
.status-label { .status-label {
white-space: nowrap;
color: var(--online-color); color: var(--online-color);
} }
} }
} }
} }
// stream title form field in header // stream title form field in header
.global-stream-title-container { .global-stream-title-container {
display: flex; display: flex;
@ -111,8 +110,21 @@
align-items: center; align-items: center;
margin-bottom: 0; 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 { .input-side {
width: 400px; width: 400px;
@media (max-width: 800px) {
width: auto;
}
} }
.label-side { .label-side {
@ -135,10 +147,9 @@
} }
.update-button-container { .update-button-container {
margin: 0; margin: 0;
margin-left: .5em; margin-left: 0.5em;
line-height: 1; line-height: 1;
} }
} }
} }
} }

View File

@ -1,26 +1,38 @@
// markdown editor overrides // markdown editor overrides
.rc-virtual-list-scrollbar { .rc-virtual-list-scrollbar {
display: block !important; display: block !important;
} }
.rc-md-editor { .rc-md-editor {
border-color: black !important; border-color: var(--black) !important;
border: 1px solid black; border: 1px solid var(--black);
background-color: black !important; background-color: var(--black) !important;
.rc-md-navigation { .rc-md-navigation {
background-color: black; background-color: var(--black);
border-color: black; border-color: var(--black);
} }
// Set the background color of the preview container // Set the background color of the preview container
.editor-container { .editor-container {
color: rgba(45,55,72,1); p {
background-color: rgba(226,232,240, 1) !important; 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 // Custom CSS for formatting the preview text
.markdown-editor-preview-pane { .markdown-editor-preview-pane {
color: var(--black-75);
a { a {
color: var(--owncast-purple); color: var(--owncast-purple);
} }
@ -31,23 +43,29 @@
// Custom CSS class used to format the text of the editor // Custom CSS class used to format the text of the editor
.markdown-editor-pane { .markdown-editor-pane {
color: rgba(255,255,255,.85) !important; color: rgba(255, 255, 255, 0.85) !important;
border-color: black !important; border-color: black !important;
background-color: black; background-color: black;
font-family: monospace; font-family: monospace;
} }
// Set the background color of the editor text input // Set the background color of the editor text input
textarea { textarea {
background-color: #223 !important; background-color: var(--gray) !important;
color: rgba(255,255,255,.5) !important; color: rgba(255, 255, 255, 0.5) !important;
overflow: auto;
} }
// Hide extra toolbar buttons. // 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; display: none !important;
} }
} }

20
web/styles/pages.scss Normal file
View 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
View 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);
}