0

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 (
<div className="config-video-segements-conatiner">
<Title level={3} className="section-title">
CPU Usage
</Title>
<div className="config-video-cpu-container">
<Title level={3}>CPU Usage</Title>
<p className="description">
There are trade-offs when considering CPU usage blah blah more wording here.
Reduce the to improve server performance, or increase it to improve video quality.
</p>
<br />
<div className="segment-slider-container">
<Slider
tipFormatter={value => TOOLTIPS[value]}
@ -63,6 +59,7 @@ export default function CPUUsageSelector({ defaultValue, onChange }: Props) {
defaultValue={selectedOption}
value={selectedOption}
/>
<p className="selected-value-note">Selected: {TOOLTIPS[selectedOption]}</p>
</div>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
// This is a wrapper for the Ant Switch component.
// onChange of the switch, it will automatically post a change to the config api.
// This one is styled to match the form-textfield component.
// If `useSubmit` is true then it will automatically post to the config API onChange.
import React, { useState, useContext } from 'react';
import { Switch } from 'antd';
@ -17,9 +18,9 @@ import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constan
import { ServerStatusContext } from '../../utils/server-status-context';
interface ToggleSwitchProps {
apiPath: string;
fieldName: string;
apiPath?: string;
checked?: boolean;
configPath?: string;
disabled?: boolean;
@ -106,6 +107,7 @@ export default function ToggleSwitch(props: ToggleSwitchProps) {
}
ToggleSwitch.defaultProps = {
apiPath: '',
checked: false,
configPath: '',
disabled: false,

View File

@ -1,27 +1,46 @@
import { Popconfirm, Button, Typography } from 'antd';
import { useContext } from 'react';
import { useContext, useState } from 'react';
import { AlertMessageContext } from '../../utils/alert-message-context';
import { API_YP_RESET, fetchData } from '../../utils/apis';
import { RESET_TIMEOUT } from '../../utils/config-constants';
import {
createInputStatus,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
export default function ResetYP() {
const { setMessage } = useContext(AlertMessageContext);
const { Title } = Typography;
const [submitStatus, setSubmitStatus] = useState(null);
let resetTimer = null;
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
const resetDirectoryRegistration = async () => {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
try {
await fetchData(API_YP_RESET);
setMessage('');
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
} catch (error) {
alert(error);
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${error}`));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
};
return (
<>
<Title level={3} className="section-title">
<Typography.Title level={3} className="section-title">
Reset Directory
</Title>
</Typography.Title>
<p className="description">
If you are experiencing issues with your listing on the Owncast Directory and were asked to
&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>
</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
your own name. A logo will not be provided.
</p>
<p className="description">
If you DO have a logo, drop it in to the <code>/webroot/img/platformicons</code> directory
and update the <code>/socialHandle.go</code> list. Then restart the server and it will show
up in the list.
</p>
<Select
style={{ width: 240 }}
className="social-dropdown"
placeholder="Social platform..."
defaultValue={inititalSelected}
value={inititalSelected}
onSelect={handleSelected}
>
{iconList.map(item => {
const { platform, icon, key } = item;
return (
<Select.Option className="social-option" key={`platform-${key}`} value={key}>
<span className="option-icon">
<img src={`${NEXT_PUBLIC_API_HOST}${icon}`} alt="" className="option-icon" />
</span>
<span className="option-label">{platform}</span>
<div className="formfield-container">
<div className="label-side">
<span className="formfield-label">Social Platform</span>
</div>
<div className="input-side">
<Select
style={{ width: 240 }}
className="social-dropdown"
placeholder="Social platform..."
defaultValue={inititalSelected}
value={inititalSelected}
onSelect={handleSelected}
>
{iconList.map(item => {
const { platform, icon, key } = item;
return (
<Select.Option className="social-option" key={`platform-${key}`} value={key}>
<span className="option-icon">
<img src={`${NEXT_PUBLIC_API_HOST}${icon}`} alt="" className="option-icon" />
</span>
<span className="option-label">{platform}</span>
</Select.Option>
);
})}
<Select.Option
className="social-option"
key={`platform-${OTHER_SOCIAL_HANDLE_OPTION}`}
value={OTHER_SOCIAL_HANDLE_OPTION}
>
Other...
</Select.Option>
);
})}
<Select.Option
className="social-option"
key={`platform-${OTHER_SOCIAL_HANDLE_OPTION}`}
value={OTHER_SOCIAL_HANDLE_OPTION}
>
Other...
</Select.Option>
</Select>
</Select>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

91
web/docs/README.md Normal file
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": {
"dev": "next dev",
"build": "next build && next export",
"start": "next start"
"start": "next start",
"lint": "eslint --ext .js,.ts,.tsx types/ pages/ components/"
},
"dependencies": {
"@ant-design/icons": "^4.2.2",

View File

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

View File

@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react';
import { Table, Tag, Space, Button, Modal, Checkbox, Input, Typography, Tooltip } from 'antd';
import { DeleteOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
const { Title, Paragraph, Text } = Typography;
import { DeleteOutlined } from '@ant-design/icons';
import format from 'date-fns/format';
import { fetchData, ACCESS_TOKENS, DELETE_ACCESS_TOKEN, CREATE_ACCESS_TOKEN } from '../utils/apis';
const { Title, Paragraph } = Typography;
const availableScopes = {
CAN_SEND_SYSTEM_MESSAGES: {
name: 'System messages',
@ -39,11 +40,17 @@ function convertScopeStringToTag(scopeString) {
);
}
function NewTokenModal(props) {
interface Props {
onCancel: () => void;
onOk: any; // todo: make better type
visible: boolean;
}
function NewTokenModal(props: Props) {
const { onOk, onCancel, visible } = props;
const [selectedScopes, setSelectedScopes] = useState([]);
const [name, setName] = useState('');
const scopes = Object.keys(availableScopes).map(function (key) {
const scopes = Object.keys(availableScopes).map(key => {
return { value: key, label: availableScopes[key].description };
});
@ -52,7 +59,7 @@ function NewTokenModal(props) {
}
function saveToken() {
props.onOk(name, selectedScopes);
onOk(name, selectedScopes);
// Clear the modal
setSelectedScopes([]);
@ -70,9 +77,9 @@ function NewTokenModal(props) {
return (
<Modal
title="Create New Access token"
visible={props.visible}
visible={visible}
onOk={saveToken}
onCancel={props.onCancel}
onCancel={onCancel}
okButtonProps={okButtonProps}
>
<p>
@ -84,12 +91,16 @@ function NewTokenModal(props) {
</p>
<p>
Select the permissions this access token will have. It cannot be edited after it's created.
Select the permissions this access token will have. It cannot be edited after it&apos;s
created.
</p>
<Checkbox.Group options={scopes} value={selectedScopes} onChange={onChange} />
<Button type="text" size="small" onClick={selectAll}>
Select all
</Button>
<p>
<Button type="primary" onClick={selectAll}>
Select all
</Button>
</p>
</Modal>
);
}
@ -98,6 +109,47 @@ export default function AccessTokens() {
const [tokens, setTokens] = useState([]);
const [isTokenModalVisible, setIsTokenModalVisible] = useState(false);
function handleError(error) {
console.error('error', error);
alert(error);
}
async function getAccessTokens() {
try {
const result = await fetchData(ACCESS_TOKENS);
setTokens(result);
} catch (error) {
handleError(error);
}
}
useEffect(() => {
getAccessTokens();
}, []);
async function handleDeleteToken(token) {
try {
await fetchData(DELETE_ACCESS_TOKEN, {
method: 'POST',
data: { token },
});
getAccessTokens();
} catch (error) {
handleError(error);
}
}
async function handleSaveToken(name: string, scopes: string[]) {
try {
const newToken = await fetchData(CREATE_ACCESS_TOKEN, {
method: 'POST',
data: { name, scopes },
});
setTokens(tokens.concat(newToken));
} catch (error) {
handleError(error);
}
}
const columns = [
{
title: '',
@ -117,7 +169,7 @@ export default function AccessTokens() {
title: 'Token',
dataIndex: 'token',
key: 'token',
render: (text, record) => <Input.Password size="small" bordered={false} value={text} />,
render: text => <Input.Password size="small" bordered={false} value={text} />,
},
{
title: 'Scopes',
@ -145,48 +197,6 @@ export default function AccessTokens() {
},
];
const getAccessTokens = async () => {
try {
const result = await fetchData(ACCESS_TOKENS);
setTokens(result);
} catch (error) {
handleError(error);
}
};
useEffect(() => {
getAccessTokens();
}, []);
async function handleDeleteToken(token) {
try {
const result = await fetchData(DELETE_ACCESS_TOKEN, {
method: 'POST',
data: { token: token },
});
getAccessTokens();
} catch (error) {
handleError(error);
}
}
async function handleSaveToken(name: string, scopes: string[]) {
try {
const newToken = await fetchData(CREATE_ACCESS_TOKEN, {
method: 'POST',
data: { name: name, scopes: scopes },
});
setTokens(tokens.concat(newToken));
} catch (error) {
handleError(error);
}
}
function handleError(error) {
console.error('error', error);
alert(error);
}
const showCreateTokenModal = () => {
setIsTokenModalVisible(true);
};

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 (
<div className="chat-messages">
<Title level={2}>Chat Messages</Title>
<Title>Chat Messages</Title>
<p>Manage the messages from viewers that show up on your stream.</p>
<div className={bulkDivClasses}>
<span className="label">Check multiple messages to change their visibility to: </span>

View File

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

View File

@ -7,9 +7,7 @@ const { Title } = Typography;
export default function ConfigServerDetails() {
return (
<div className="config-server-details-form">
<Title level={2} className="page-title">
Server Settings
</Title>
<Title>Server Settings</Title>
<p className="description">
You should change your stream key from the default and keep it safe. For most people
it&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() {
return (
<div className="config-social-items">
<Title level={2}>Social Items</Title>
<Title>Social Items</Title>
<EditSocialLinks />
</div>

View File

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

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Typography } from 'antd';
import { Typography, Row, Col } from 'antd';
import VideoVariantsTable from '../components/config/video-variants-table';
import VideoLatency from '../components/config/video-latency';
@ -9,24 +9,27 @@ const { Title } = Typography;
export default function ConfigVideoSettings() {
return (
<div className="config-video-variants">
<Title level={2} className="page-title">
Video configuration
</Title>
<Title>Video configuration</Title>
<p className="description">
Before changing your video configuration{' '}
<a href="https://owncast.online/docs/encoding">visit the video documentation</a> to learn
how it impacts your stream performance.
<a href="https://owncast.online/docs/video">visit the video documentation</a> to learn how
it impacts your stream performance. The general rule is to start conservatively by having
one middle quality stream output variant and experiment with adding more of varied
qualities.
</p>
<div className="row">
<div className="form-module variants-table-module">
<VideoVariantsTable />
</div>
<div className="form-module latency-module">
<VideoLatency />
</div>
</div>
<Row gutter={[16, 16]}>
<Col md={24} lg={12}>
<div className="form-module variants-table-module">
<VideoVariantsTable />
</div>
</Col>
<Col md={24} lg={12}>
<div className="form-module latency-module">
<VideoLatency />
</div>
</Col>
</Row>
</div>
);
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
// GENERAL ANT OVERRIDES
// RESET BG, TEXT COLORS
.ant-layout,
.ant-layout-header,
@ -9,6 +8,9 @@
.ant-card,
.ant-collapse,
.ant-collapse-content,
.ant-statistic,
.ant-statistic-title,
.ant-statistic-content,
.ant-table,
.ant-table-thead > tr > th,
.ant-table-small .ant-table-thead > tr > th,
@ -20,28 +22,52 @@ td.ant-table-column-sort,
.ant-menu-submenu > .ant-menu,
.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: transparent;
color: var(--default-text-color)
color: var(--default-text-color);
}
h1.ant-typography,
h2.ant-typography,
h3.ant-typography,
h4.ant-typography,
h5.ant-typography,
h1.ant-typography,
h2.ant-typography,
h3.ant-typography,
h4.ant-typography,
h5.ant-typography,
.ant-typography,
.ant-typography h1,
.ant-typography h2,
.ant-typography h3,
.ant-typography h4,
.ant-typography h5 {
color: var(--default-text-color);
font-weight: 500;
color: var(--white);
font-weight: 400;
margin: 0.5em 0;
}
.ant-typography.ant-typography-secondary {
color: var(--white);
font-weight: 400;
}
.ant-typography {
font-weight: 300;
color: var(--white-75);
a {
color: var(--owncast-purple);
}
}
.ant-typography.ant-typography-secondary {
color: rgba(255,255,255,.85);
font-weight: 400;
.ant-typography h1,
h1.ant-typography {
font-size: 1.75em;
color: var(--pink);
&:first-of-type {
margin-top: 0;
}
}
.ant-typography h2,
h2.ant-typography {
font-size: 1.5em;
}
.ant-typography h3,
h3.ant-typography {
font-size: 1.25em;
}
.ant-progress-text,
@ -49,8 +75,6 @@ h5.ant-typography,
color: var(--default-text-color);
}
// ANT MENU
// menu base
.ant-menu-item {
@ -58,7 +82,7 @@ h5.ant-typography,
.anticon {
transition-duration: var(--ant-transition-duration);
color: var(--nav-text);
color: var(--nav-text);
}
a {
@ -66,21 +90,26 @@ h5.ant-typography,
color: var(--nav-text);
&:hover {
color: white;
color: var(--white);
}
}
&:hover {
background-color: rgba(0,0,0,.15);
background-color: var(--black-50);
color: var(--white);
.anticon {
color: white;
color: var(--white);
}
}
}
.ant-menu-item:active,
.ant-menu-submenu-title:active {
background-color: var(--black-50);
}
// menu item selected
.ant-menu-item-selected,
.ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: black;
background-color: var(--black);
a {
color: var(--nav-selected-text);
}
@ -96,14 +125,14 @@ h5.ant-typography,
}
// submenu items
.ant-menu-submenu {
&> .ant-menu {
border-left: 1px solid rgba(255,255,255,.4);
background-color: rgba(0,0,0,.15);
& > .ant-menu {
border-left: 1px solid var(--white-50);
background-color: var(--black-35);
}
.ant-menu-submenu-title {
transition-duration: var(--ant-transition-duration);
color: var(--nav-text);
.anticon {
color: var(--nav-text);
}
@ -117,30 +146,28 @@ h5.ant-typography,
}
&:hover {
.ant-menu-submenu-title {
color: white;
color: var(--white);
.anticon {
color: white;
color: var(--white);
}
.ant-menu-submenu-arrow {
&:before,
&:after {
background-image: linear-gradient(to right, white, white);
background-image: linear-gradient(to right, var(--white), var(--white));
}
}
}
}
}
// ANT RESULT
.ant-result-title {
color: var(--default-text-color);
}
.ant-result-subtitle {
color: var(--default-text-color);
color: var(--white-75);
}
// ANT CARD
.ant-card {
border-radius: var(--container-border-radius);
@ -148,16 +175,30 @@ h5.ant-typography,
color: var(--default-text-color);
}
.ant-card-bordered {
border-color: rgba(255,255,255,.25);
border-color: var(--white-25);
}
.ant-card-meta-title {
color: var(--white);
}
.ant-card-meta-title,
.ant-card-meta-description {
color: white;
color: var(--white-75);
}
.ant-card-type-inner .ant-card-head {
background-color: var(--black);
color: var(--white-88);
border-color: var(--white-25);
}
// ANT INPUT
input.ant-input,
textarea.ant-input {
background-color: var(--textfield-bg);
color: var(--white-88);
border-color: var(--black);
&::placeholder {
color: var(--owncast-purple-50);
}
}
.ant-input-affix-wrapper,
.ant-input-number {
background-color: var(--textfield-bg);
@ -165,66 +206,90 @@ h5.ant-typography,
input,
textarea {
background-color: transparent;
color: rgba(255,255,255,.85);
border-color: rgba(0,0,0,1);
color: var(--white-88);
border-color: var(--black);
&::placeholder {
color: var(--textfield-border);
color: var(--owncast-purple-50);
}
&:-webkit-autofill {
background-color: transparent;
}
}
}
.ant-input:hover,
.ant-input-number:hover,
.ant-input-affix-wrapper:hover {
border-color: var(--owncast-purple-highlight);
border-color: var(--owncast-purple);
input,
textarea {
border-color: var(--owncast-purple-highlight);
border-color: var(--owncast-purple);
}
}
.ant-input,
.ant-input-number:focus,
.ant-input-affix-wrapper:focus,
.ant-input-affix-wrapper:focus,
.ant-input-affix-wrapper-focused {
border-color: var(--owncast-purple);
input,
textarea {
color: white;
color: var(--white);
border-color: var(--owncast-purple);
}
}
.ant-input-textarea-clear-icon,
.ant-input-clear-icon {
color: rgba(255,255,255,.5);
}
textarea.ant-input {
padding-right: 25px;
}
.ant-input-affix-wrapper {
color: transparent;
}
.ant-input-suffix,
.ant-input-clear-icon,
.ant-input-textarea-clear-icon,
.ant-input-password-icon {
color: var(--white-50);
&:hover {
color: var(--white);
}
}
// ANT BUTTON
.ant-btn {
background-color: var(--owncast-purple-25);
border-color: var(--owncast-purple-25);
color: var(--white-75);
&:hover,
&:focus {
background-color: var(--button-focused);
color: var(--white);
}
}
.ant-btn-primary {
background-color: var(--owncast-purple);
border-color: var(--owncast-purple);
}
.ant-btn-primary:hover,
.ant-btn-primary:hover,
.ant-btn-primary:focus {
background-color: var(--form-focused);
border-color: var(--form-focused);
background-color: var(--button-focused);
color: var(--white);
}
.ant-btn.ant-btn-primary:hover {
border-color: white;
border-color: var(--white);
}
.ant-btn:focus,
.ant-btn-primary:focus {
border-color: var(--white);
}
.ant-btn-primary[disabled] {
background-color: rgba(255,255,255,.2);
border-color: rgba(255,255,255,.2);
color: white;
background-color: var(--white-25);
border-color: var(--white-25);
color: var(--white-50);
&:hover {
background-color: rgba(255,255,255,.2);
border-color: rgba(255,255,255,.2);
background-color: var(--white-35);
border-color: var(--white-35);
}
}
.ant-input-affix-wrapper,
@ -233,30 +298,31 @@ textarea.ant-input {
transition-duration: 0.15s;
}
// ANT TABLE
// ANT TABLE
.ant-table-thead > tr > th,
.ant-table-small .ant-table-thead > tr > th {
transition-duration: var(--ant-transition-duration);
background-color: #112;
background-color: var(--purple-dark);
font-weight: 500;
color: var(--owncast-purple);
color: var(--white);
}
.ant-table-tbody > tr > td,
.ant-table-thead > tr > th,
.ant-table-small .ant-table-thead > tr > th {
border-color: var(--textfield-border);
border-color: var(--white-15);
}
.ant-table-tbody > tr > td {
transition-duration: var(--ant-transition-duration);
background-color: var(--textfield-bg);
background-color: #222325;
color: var(--white-75);
}
.ant-table-tbody > tr:nth-child(odd) > td {
background-color: var(--textfield-bg);
.ant-table-tbody > tr.ant-table-row:hover > td {
background-color: var(--gray-dark);
}
.ant-empty {
color: white;
opacity: .75;
color: var(--white-75);
}
.ant-table-empty .ant-table-tbody > tr.ant-table-placeholder {
&:hover > td {
@ -269,18 +335,26 @@ textarea.ant-input {
background-color: var(--textfield-border);
}
}
.ant-table-thead th.ant-table-column-sort {
background-color: var(--owncast-purple-25);
opacity: 0.75;
}
// MODAL
.ant-modal,
.ant-modal-body {
font-size: 1em;
}
.ant-modal-content {
border-radius: var(--container-border-radius);
border: 1px solid var(--owncast-purple-highlight);
border: 1px solid var(--owncast-purple);
background-color: var(--black);
}
.ant-modal-header {
border-radius: var(--container-border-radius) var(--container-border-radius) 0 0;
}
.ant-modal-close-x {
color: white;
color: var(--white);
}
.ant-modal-title {
font-weight: 500;
@ -288,79 +362,151 @@ textarea.ant-input {
color: var(--nav-selected-text);
}
.ant-modal-body {
background-color: var(--nav-bg-color);
background-color: var(--gray);
color: var(--default-text-color);
}
.ant-modal-header,
.ant-modal-footer {
background-color: black;
background: var(--black);
}
.ant-modal-content,
.ant-modal-header,
.ant-modal-footer {
border-color: #333;
border-color: var(--white-50);
}
// SELECT
.ant-select-dropdown {
background-color: #334;
background-color: var(--black);
}
.ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
background-color: var(--black);
border-color: var(--owncast-purple-50);
}
.ant-select-arrow {
color: var(--owncast-purple);
}
.ant-select-selection-placeholder {
color: var(--owncast-purple-50);
}
.ant-select {
color: var(--white);
}
.ant-select-item {
background-color: var(--gray-dark);
color: var(--white-88);
}
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
background-color: var(--gray);
color: var(--white-75);
}
// SLIDER
// .ant-slider-with-marks {
// margin-right: 2em;
// }
.ant-slider-mark-text {
font-size: .85em;
font-size: 0.85em;
white-space: nowrap;
color: var(--white);
opacity: 0.5;
}
.ant-slider-handle {
border-color: var(--blue);
}
.ant-slider:hover .ant-slider-track {
background-color: var(--blue);
}
.ant-slider-rail {
background-color: var(--black);
}
.ant-slider-track {
background-color: var(--nav-text);
}
.ant-slider-mark-text-active {
opacity: 1;
}
// ANT SWITCH
.ant-switch {
background-color: #666;
background-color: var(--gray-medium);
}
.ant-switch-checked {
background-color: var(--ant-success);
.ant-switch-inner {
color: white;
color: var(--white);
}
}
// ANT COLLAPSE
.ant-collapse {
font-size: 1em;
border-color: transparent;
&> .ant-collapse-item,
& > .ant-collapse-item,
.ant-collapse-content {
border-color: transparent;
&> .ant-collapse-header {
& > .ant-collapse-header {
border-radius: var(--container-border-radius);
border-color: transparent;
background-color: var(--textfield-bg);
color: var(--nav-text);
background-color: var(--purple-dark);
color: var(--white);
font-weight: 500;
}
}
}
.ant-collapse-content {
background-color: #181231;
background-color: var(--black-35); //#181231;
}
.ant-collapse > .ant-collapse-item:last-child,
.ant-collapse > .ant-collapse-item:last-child > .ant-collapse-header {
border-radius: var(--container-border-radius) var(--container-border-radius) 0 0;
}
.ant-collapse-item:last-child > .ant-collapse-content {
border-radius: 0 0 var(--container-border-radius) var(--container-border-radius);
}
// ANT POPOVER
.ant-popover {
}
.ant-popover-inner {
background-color: black;
background-color: var(--gray);
}
.ant-popover-message,
.ant-popover-inner-content {
color: var(--default-text-color);
}
.ant-popover-placement-topLeft > .ant-popover-content > .ant-popover-arrow {
border-color: black;
border-color: var(--gray);
}
// ANT TAGS
.ant-tag-red,
.ant-tag-orange,
.ant-tag-green,
.ant-tag-purple,
.ant-tag-blue {
background-color: var(--black);
}
// ANT PAGINATOR
.ant-pagination-item-active {
color: var(--white);
background-color: var(--default-link-color);
border-color: var(--default-link-color);
a {
color: var(--white);
&:hover {
color: var(--white);
opacity: 0.75;
}
}
}
// ANT CHECKBOX
.ant-checkbox-wrapper {
color: var(--white-75);
margin: 0.5em 0;
}
.ant-checkbox-group {
.ant-checkbox-group-item {
display: block;
}
}

View File

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

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

View File

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

View File

@ -22,29 +22,37 @@
}
// Do something special for the stream key field
.field-streamkey-container {
margin-bottom: 1.5em;
.field-tip {
color: var(--ant-warning);
}
.left-side {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.textfield-with-submit-container {
margin-bottom: 0;
}
.streamkey-actions {
white-space: nowrap;
button {
margin: .25em;
.edit-server-details-container {
// Do something special for the stream key field
.field-streamkey-container {
margin-bottom: 1.5em;
.field-tip {
color: var(--ant-warning);
}
@media (max-width: 800px) {
margin-top: 2em;
.left-side {
display: flex;
flex-direction: row;
align-items: flex-start;
}
.textfield-with-submit-container {
margin-bottom: 0;
}
.streamkey-actions {
white-space: nowrap;
button {
margin: .25em;
}
@media (max-width: 800px) {
margin-top: 2em;
}
}
}
}
.advanced-settings {
max-width: 800px;
}
}

View File

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

View File

@ -1,17 +1,13 @@
// styles for Video variant editor (table + modal)
.config-video-variants {
.variants-table {
margin-top: 2em;
}
.variants-table-module {
min-width: 48%;
max-width: 600px;
margin-right: 1em
min-width: 400px;
}
}
// modal content
@ -20,84 +16,16 @@
margin-top: 0;
}
.cpu-usage-container,
.bitrate-container {
height: 20em;
}
.advanced-settings {
width: 48%;
margin-left: 2em;
}
.blurb {
margin: 1em;
opacity: .75;
}
.note {
display: inline-block;
margin-left: 1em;
font-size: .75em;
opacity: .5;
font-style: italic;
}
// .field {
// margin-bottom: 2em;
// display: flex;
// flex-direction: row;
// justify-content: center;
// align-items: flex-start;
// transform: opacity .15s;
// &.disabled {
// opacity: .25;
// }
// .label {
// width: 40%;
// text-align: right;
// padding-right: 2em;
// font-weight: bold;
// color: var(--owncast-purple);
// }
// .info-tip {
// margin-right: 1em;
// }
// .form-component {
// width: 60%;
// .selected-value-note {
// font-size: .85em;
// display: inline-block;
// text-align: center;
// }
// }
// }
// .ant-collapse {
// border: none;
// border-radius: 6px;
// }
// .ant-collapse > .ant-collapse-item:last-child,
// .ant-collapse > .ant-collapse-item:last-child > .ant-collapse-header {
// border: none;
// background-color: rgba(0,0,0,.25);
// border-radius: 6px;
// }
// .ant-collapse-content {
// background-color: rgba(0,0,0,.1);
// }
}
.config-video-segements-conatiner {
// display: flex;
// flex-direction: row;
// justify-content: center;
// align-items: flex-start;
.status-message {
text-align: center;
margin-top: 1em;
}
}
.variants-table {
.actions {
display: flex;
@ -105,11 +33,11 @@
justify-content: center;
}
.delete-button {
margin-left: .5em;
opacity: .8;
margin-left: 0.5em;
opacity: 0.8;
}
}
.advanced-settings {
margin-top: 2em;
}
.read-more-subtext {
font-size: 0.8rem;
}

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 */
.field-tip {
font-size: .8em;
color: rgba(255,255,255,.5)
color: var(--white-50);
}
@ -39,38 +39,34 @@ Ideal for wrapping each Textfield on a page with many text fields in a row. This
*/
.field-container {
padding: .85em 0 .5em;
// &:nth-child(even) {
// background-color: rgba(0,0,0,.25);
// }
}
/* SEGMENT SLIDER */
/* SEGMENT SLIDER GROUP WITH SELECTED NOTE, OR STATUS */
.segment-slider-container {
width: 90%;
width: 100%;
margin: auto;
padding: 1em 2em .75em;
background-color: var(--textfield-border);
border-radius: 1em;
.ant-slider-rail {
background-color: black;
}
.ant-slider-track {
background-color: var(--nav-text);
}
.ant-slider-mark-text,
.ant-slider-mark-text-active {
color: white;
opacity: .5;
}
.ant-slider-mark-text-active {
opacity: 1;
}
background-color: var(--owncast-purple-25);
border-radius: var(--container-border-radius);
.status-container {
width: 100%;
margin: .5em auto;
text-align: center;
}
.selected-value-note {
width: 100%;
margin: 3em auto 0;
text-align: center;
font-size: .75em;
line-height: normal;
color: var(--white);
padding: 1em;
border-radius: var(--container-border-radius);
background-color: var(--black-35);
}
}

View File

@ -6,16 +6,16 @@ body {
margin: 0;
font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
font-size: 14px;
font-size: 16px;
background-color: #000;
color: var(--default-text-color);;
background-color: var(--default-bg-color);
color: var(--default-text-color);
}
a {
color: inherit;
text-decoration: none;
color: var(--owncast-purple);
color: var(--default-link-color);
&:hover {
color: var(--default-text-color);
@ -25,19 +25,29 @@ a {
* {
box-sizing: border-box;
}
p {
p,
p.description,
.ant-typography {
font-weight: 300;
margin: 1em 0;
color: var(--white-75);
}
pre {
display: block;
padding: 1rem;
margin: .5rem 0;
background-color: rgb(44, 44, 44);
color:lightgrey;
background-color: var(--code-bg-color);
color: var(--white-50);
}
code {
color: var(--owncast-purple);
color: var(--code-color);
background-color: var(--white-15);
display: inline-block;
padding: 2px 4px;
border-radius: 4px;
font-size: .88em;
}
@ -46,37 +56,28 @@ code {
width: 2rem;
}
p.description {
margin: 1em 0;
color: #ccc;
}
.line-chart-container {
margin: 2em auto;
padding: 1em;
border: 1px solid var(--gray-dark);
}
h2.ant-typography.page-title,
h3.ant-typography.page-title
{
font-weight: 400;
font-size: 1.5em;
color: var(--nav-selected-text);
}
h2.section-title,
h3.section-title {
font-weight: 400;
font-size: 1.25em;
}
.form-module {
// width: 100%;
// max-width: 500px;
// min-width: 300px;
margin: 1em 0;
background-color: var(--container-bg-color);
padding: 2em;
border-radius: var(--container-border-radius);
h3 {
&:first-of-type {
margin-top: 0;
}
}
}
.row {

View File

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

View File

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

View File

@ -1,26 +1,38 @@
// markdown editor overrides
.rc-virtual-list-scrollbar {
display: block !important;
}
.rc-md-editor {
border-color: black !important;
border: 1px solid black;
background-color: black !important;
border-color: var(--black) !important;
border: 1px solid var(--black);
background-color: var(--black) !important;
.rc-md-navigation {
background-color: black;
border-color: black;
background-color: var(--black);
border-color: var(--black);
}
// Set the background color of the preview container
.editor-container {
color: rgba(45,55,72,1);
background-color: rgba(226,232,240, 1) !important;
p {
color: var(--black-75);
}
background-color: rgba(226, 232, 240, 1) !important;
.sec-html {
background-color: white;
pre,
code {
background-color: #eee;
color: #900;
}
}
}
// Custom CSS for formatting the preview text
.markdown-editor-preview-pane {
color: var(--black-75);
a {
color: var(--owncast-purple);
}
@ -31,23 +43,29 @@
// Custom CSS class used to format the text of the editor
.markdown-editor-pane {
color: rgba(255,255,255,.85) !important;
border-color: black !important;
color: rgba(255, 255, 255, 0.85) !important;
border-color: black !important;
background-color: black;
font-family: monospace;
}
// Set the background color of the editor text input
textarea {
background-color: #223 !important;
color: rgba(255,255,255,.5) !important;
background-color: var(--gray) !important;
color: rgba(255, 255, 255, 0.5) !important;
overflow: auto;
}
// Hide extra toolbar buttons.
.button-type-undo, .button-type-redo, .button-type-clear, .button-type-image, .button-type-wrap, .button-type-quote, .button-type-strikethrough, .button-type-code-inline, .button-type-code-block {
.button-type-undo,
.button-type-redo,
.button-type-clear,
.button-type-image,
.button-type-wrap,
.button-type-quote,
.button-type-strikethrough,
.button-type-code-inline,
.button-type-code-block {
display: none !important;
}
}

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