Merge remote-tracking branch 'admin/ui-components' into webv2

This commit is contained in:
Gabe Kangas 2022-04-25 18:48:07 -07:00
commit 23a009d011
No known key found for this signature in database
GPG key ID: 9A56337728BC81EA
181 changed files with 69288 additions and 0 deletions

8
web/.editorconfig Normal file
View file

@ -0,0 +1,8 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

3
web/.env.development Normal file
View file

@ -0,0 +1,3 @@
NEXT_PUBLIC_ADMIN_USERNAME=admin
NEXT_PUBLIC_ADMIN_STREAMKEY=abc123
NEXT_PUBLIC_API_HOST=http://localhost:8080/

1
web/.env.production Normal file
View file

@ -0,0 +1 @@
NEXT_PUBLIC_API_HOST=/

3
web/.eslintignore Normal file
View file

@ -0,0 +1,3 @@
# Ignore artifacts:
node_modules
out

66
web/.eslintrc.js Normal file
View file

@ -0,0 +1,66 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
'plugin:react/recommended',
'airbnb',
'prettier',
'plugin:@next/next/recommended',
'plugin:storybook/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['react', 'prettier', '@typescript-eslint'],
ignorePatterns: ['!./storybook/**'],
rules: {
'prettier/prettier': 'error',
'react/react-in-jsx-scope': 'off',
'react/jsx-filename-extension': [
1,
{
extensions: ['.js', '.jsx', '.tsx'],
},
],
'react/jsx-props-no-spreading': 'off',
'react/jsx-no-bind': 'off',
'react/function-component-definition': 'off',
'@next/next/no-img-element': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'error',
'no-console': 'off',
'no-use-before-define': [0],
'@typescript-eslint/no-use-before-define': [1],
'react/jsx-no-target-blank': [
1,
{
allowReferrer: false,
enforceDynamicLinks: 'always',
},
],
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
};

27
web/.github/stale.yml vendored Normal file
View file

@ -0,0 +1,27 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- backlog
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
exemptMilestones: true
# Since old PRs are less useful than old issues ping them sooner.
pulls:
daysUntilStale: 30
markComment: >
This pull request has not had any activity in 30 days. Since things move fast it's best
to get PRs merged in, or to allow somebody else to work on it so the change can get in.
This PR will be closed if no further activity occurs. Thank you for your contributions!
exemptLabels:
- bot

12
web/.github/workflows/build-next.yml vendored Normal file
View file

@ -0,0 +1,12 @@
name: Build admin app
on: [push, pull_request]
jobs:
run:
name: npm run build
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v2
- name: Install dependencies
run: npm install && npm run build

16
web/.github/workflows/bundle-admin.yml vendored Normal file
View file

@ -0,0 +1,16 @@
name: Bundle admin into core
on:
push:
branches:
- develop
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Dispatch event to owncast/owncast
uses: peter-evans/repository-dispatch@v1
with:
token: ${{ secrets.ADMIN_BUNDLE_PAT }}
repository: owncast/owncast
event-type: bundle-admin-event
client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}"}'

18
web/.github/workflows/linter.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: linter
# This action works with pull requests and pushes
on: [push, pull_request]
jobs:
run-test:
runs-on: ubuntu-latest
name: Run linter on changed files
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install Dependencies
run: npm install
- name: Lint
run: npm run lint

25
web/.github/workflows/prettier.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: javascript-formatter
on: [push]
jobs:
format:
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Prettier
uses: creyD/prettier_action@v4.2
with:
# This part is also where you can pass other options, for example:
prettier_options: --write **/*.{js,tsx,jsx,css,scss}
ref: ${{ github.head_ref }}
only_changed: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

8
web/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules
.env*.local
.next
out
lefthook.yml
storybook-static

3
web/.prettierignore Normal file
View file

@ -0,0 +1,3 @@
# Ignore artifacts:
node_modules
out

8
web/.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"useTabs": false,
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "avoid"
}

34
web/.storybook/main.js Normal file
View file

@ -0,0 +1,34 @@
module.exports = {
core: {
builder: 'webpack5',
},
stories: ['../stories/**/*.stories.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/preset-scss',
'@storybook/addon-postcss',
'@storybook/addon-a11y',
'@storybook/addon-viewport',
'storybook-dark-mode',
'addon-screen-reader',
],
webpackFinal: async (config, { configType }) => {
config.module.rules.push({
test: /\.less$/,
use: [
require.resolve('style-loader'),
require.resolve('css-loader'),
{
loader: require.resolve('less-loader'),
options: {
lessOptions: { javascriptEnabled: true },
},
},
],
});
return config;
},
framework: '@storybook/react',
};

14
web/.storybook/preview.js Normal file
View file

@ -0,0 +1,14 @@
import '../styles/variables.scss';
import '../styles/global.less';
import '../styles/theme.less';
import '../stories/preview.scss';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};

13
web/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,13 @@
{
"cSpell.words": [
"Owncast",
"antd",
"bitrates",
"chartkick",
"framerates",
"kbps",
"linkify",
"paypal",
"toggleswitch"
]
}

21
web/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Owncast
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

57
web/README.md Normal file
View file

@ -0,0 +1,57 @@
# Owncast Web
## Owncast Web Frontend
The Owncast web frontend is a [Next.js](https://nextjs.org/) project with [React](https://reactjs.org/) components, [TypeScript](https://www.typescriptlang.org/), [Sass](https://sass-lang.com/) styling, using [Ant Design](https://ant.design/) UI components.
### Getting Started
**First**, install the dependencies.
```npm install```
### Run the web project
Make sure you're running an instance of Owncast on localhost:8080, as your copy of the admin will look to use that as the API.
**Next**, start the web project with npm.
```npm run dev```
### Update the project
You can add or edit a pages by modifying `pages/something.js`. The page auto-updates as you edit the file.
[Routes](https://nextjs.org/docs/api-reference/next/router) will automatically be available for this new page components.
Since this project hits API endpoints you should make requests in [`componentDidMount`](https://reactjs.org/docs/react-component.html#componentdidmount), and not in [`getStaticProps`](https://nextjs.org/docs/basic-features/data-fetching), since they're not static and we don't want to fetch them at build time, but instead at runtime.
A list of API end points can be found here:
https://owncast.online/api/development/
### Admin Authentication
The pages until `/admin` require authentication to make API calls.
Auth: HTTP Basic
username: admin
pw: [your streamkey]
### Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Style guide and components
We are currently experimenting with using [Storybook](https://storybook.js.org/) to build components, experiment with styling, and have a single place to find colors, fonts, and other styles.
To work with Storybook:
```npm run storybook```

View file

@ -0,0 +1,85 @@
import { Modal, Button } from 'antd';
import { ExclamationCircleFilled, QuestionCircleFilled, StopTwoTone } from '@ant-design/icons';
import { USER_ENABLED_TOGGLE, fetchData } from '../utils/apis';
import { User } from '../types/chat';
interface BanUserButtonProps {
user: User;
isEnabled: Boolean; // = this user's current status
label?: string;
onClick?: () => void;
}
export default function BanUserButton({ user, isEnabled, label, onClick }: BanUserButtonProps) {
async function buttonClicked({ id }): Promise<Boolean> {
const data = {
userId: id,
enabled: !isEnabled, // set user to this value
};
try {
const result = await fetchData(USER_ENABLED_TOGGLE, {
data,
method: 'POST',
auth: true,
});
return result.success;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
return false;
}
const actionString = isEnabled ? 'ban' : 'unban';
const icon = isEnabled ? (
<ExclamationCircleFilled style={{ color: 'var(--ant-error)' }} />
) : (
<QuestionCircleFilled style={{ color: 'var(--ant-warning)' }} />
);
const content = (
<>
Are you sure you want to {actionString} <strong>{user.displayName}</strong>
{isEnabled ? ' and remove their messages?' : '?'}
</>
);
const confirmBlockAction = () => {
Modal.confirm({
title: `Confirm ${actionString}`,
content,
onCancel: () => {},
onOk: () =>
new Promise((resolve, reject) => {
const result = buttonClicked(user);
if (result) {
// wait a bit before closing so the user/client tables repopulate
// GW: TODO: put users/clients data in global app context instead, then call a function here to update that state. (current in another branch)
setTimeout(() => {
resolve(result);
onClick?.();
}, 3000);
} else {
reject();
}
}),
okType: 'danger',
okText: isEnabled ? 'Absolutely' : null,
icon,
});
};
return (
<Button
onClick={confirmBlockAction}
size="small"
icon={isEnabled ? <StopTwoTone twoToneColor="#ff4d4f" /> : null}
className="block-user-button"
>
{label || actionString}
</Button>
);
}
BanUserButton.defaultProps = {
label: '',
onClick: null,
};

View file

@ -0,0 +1,75 @@
import { Table, Button } from 'antd';
import format from 'date-fns/format';
import { SortOrder } from 'antd/lib/table/interface';
import React from 'react';
import { StopTwoTone } from '@ant-design/icons';
import { User } from '../types/chat';
import { BANNED_IP_REMOVE, fetchData } from '../utils/apis';
function formatDisplayDate(date: string | Date) {
return format(new Date(date), 'MMM d H:mma');
}
async function removeIPAddressBan(ipAddress: String) {
try {
await fetchData(BANNED_IP_REMOVE, {
data: { value: ipAddress },
method: 'POST',
auth: true,
});
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
}
export default function BannedIPsTable({ data }: UserTableProps) {
const columns = [
{
title: 'IP Address',
dataIndex: 'ipAddress',
key: 'ipAddress',
},
{
title: 'Reason',
dataIndex: 'notes',
key: 'notes',
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: Date) => formatDisplayDate(date),
sorter: (a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
},
{
title: '',
key: 'block',
className: 'actions-col',
render: (_, ip) => (
<Button
title="Remove IP Address Ban"
onClick={() => removeIPAddressBan(ip.ipAddress)}
icon={<StopTwoTone twoToneColor="#ff4d4f" />}
className="block-user-button"
/>
),
},
];
return (
<Table
pagination={{ hideOnSinglePage: true }}
className="table-container"
columns={columns}
dataSource={data}
size="large"
rowKey="ipAddress"
/>
);
}
interface UserTableProps {
data: User[];
}

98
web/components/chart.tsx Normal file
View file

@ -0,0 +1,98 @@
import ChartJs from 'chart.js/auto';
import Chartkick from 'chartkick';
import format from 'date-fns/format';
import { LineChart } from 'react-chartkick';
// from https://github.com/ankane/chartkick.js/blob/master/chart.js/chart.esm.js
Chartkick.use(ChartJs);
interface TimedValue {
time: Date;
value: number;
}
interface ChartProps {
data?: TimedValue[];
title?: string;
color: string;
unit: string;
yFlipped?: boolean;
yLogarithmic?: boolean;
dataCollections?: any[];
}
function createGraphDataset(dataArray) {
const dataValues = {};
dataArray.forEach(item => {
const dateObject = new Date(item.time);
const dateString = format(dateObject, 'H:mma');
dataValues[dateString] = item.value;
});
return dataValues;
}
export default function Chart({
data,
title,
color,
unit,
dataCollections,
yFlipped,
yLogarithmic,
}: ChartProps) {
const renderData = [];
if (data && data.length > 0) {
renderData.push({
name: title,
color,
data: createGraphDataset(data),
});
}
dataCollections.forEach(collection => {
renderData.push({
name: collection.name,
data: createGraphDataset(collection.data),
color: collection.color,
dataset: collection.options,
});
});
// ChartJs.defaults.scales.linear.reverse = true;
const options = {
scales: {
y: { reverse: false, type: 'linear' },
x: {
type: 'time',
},
},
};
options.scales.y.reverse = yFlipped;
options.scales.y.type = yLogarithmic ? 'logarithmic' : 'linear';
return (
<div className="line-chart-container">
<LineChart
xtitle="Time"
ytitle={title}
suffix={unit}
legend="bottom"
color={color}
data={renderData}
download={title}
library={options}
/>
</div>
);
}
Chart.defaultProps = {
dataCollections: [],
data: [],
title: '',
yFlipped: false,
yLogarithmic: false,
};

View file

@ -0,0 +1,98 @@
import { Input, Table } from 'antd';
import { FilterDropdownProps, SortOrder } from 'antd/lib/table/interface';
import { ColumnsType } from 'antd/es/table';
import { SearchOutlined } from '@ant-design/icons';
import { formatDistanceToNow } from 'date-fns';
import { Client } from '../types/chat';
import UserPopover from './user-popover';
import BanUserButton from './ban-user-button';
import { formatUAstring } from '../utils/format';
export default function ClientTable({ data }: ClientTableProps) {
const columns: ColumnsType<Client> = [
{
title: 'Display Name',
key: 'username',
// eslint-disable-next-line react/destructuring-assignment
render: (client: Client) => {
const { user, connectedAt, messageCount, userAgent } = client;
const connectionInfo = { connectedAt, messageCount, userAgent };
return (
<UserPopover user={user} connectionInfo={connectionInfo}>
<span className="display-name">{user.displayName}</span>
</UserPopover>
);
},
sorter: (a: any, b: any) => b.user.displayName.localeCompare(a.user.displayName),
filterIcon: <SearchOutlined />,
// eslint-disable-next-line react/no-unstable-nested-components
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm }: FilterDropdownProps) => (
<div style={{ padding: 8 }}>
<Input
placeholder="Search display names..."
value={selectedKeys[0]}
onChange={e => {
setSelectedKeys(e.target.value ? [e.target.value] : []);
confirm({ closeDropdown: false });
}}
/>
</div>
),
onFilter: (value: string, record: Client) => record.user.displayName.includes(value),
sortDirections: ['descend', 'ascend'] as SortOrder[],
},
{
title: 'Messages sent',
dataIndex: 'messageCount',
key: 'messageCount',
className: 'number-col',
width: '12%',
sorter: (a: any, b: any) => a.messageCount - b.messageCount,
sortDirections: ['descend', 'ascend'] as SortOrder[],
render: (count: number) => <div style={{ textAlign: 'center' }}>{count}</div>,
},
{
title: 'Connected Time',
dataIndex: 'connectedAt',
key: 'connectedAt',
defaultSortOrder: 'ascend',
render: (time: Date) => formatDistanceToNow(new Date(time)),
sorter: (a: any, b: any) =>
new Date(b.connectedAt).getTime() - new Date(a.connectedAt).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
},
{
title: 'User Agent',
dataIndex: 'userAgent',
key: 'userAgent',
render: (ua: string) => formatUAstring(ua),
},
{
title: 'Location',
dataIndex: 'geo',
key: 'geo',
render: geo => (geo ? `${geo.regionName}, ${geo.countryCode}` : '-'),
},
{
title: '',
key: 'block',
className: 'actions-col',
render: (_, row) => <BanUserButton user={row.user} isEnabled={!row.user.disabledAt} />,
},
];
return (
<Table
className="table-container"
pagination={{ hideOnSinglePage: true }}
columns={columns}
dataSource={data}
size="small"
rowKey="id"
/>
);
}
interface ClientTableProps {
data: Client[];
}

View file

@ -0,0 +1,82 @@
import React, { useState } from 'react';
import { Button, Space, Input, Modal } from 'antd';
import { STATUS_ERROR, STATUS_SUCCESS } from '../utils/input-statuses';
import { fetchData, FEDERATION_MESSAGE_SEND } from '../utils/apis';
const { TextArea } = Input;
interface ComposeFederatedPostProps {
visible: boolean;
handleClose: () => void;
}
export default function ComposeFederatedPost({ visible, handleClose }: ComposeFederatedPostProps) {
const [content, setContent] = useState('');
const [postPending, setPostPending] = useState(false);
const [postSuccessState, setPostSuccessState] = useState(null);
function handleEditorChange(e) {
setContent(e.target.value);
}
function close() {
setPostPending(false);
setPostSuccessState(null);
handleClose();
}
async function sendButtonClicked() {
setPostPending(true);
const data = {
value: content,
};
try {
await fetchData(FEDERATION_MESSAGE_SEND, {
data,
method: 'POST',
auth: true,
});
setPostSuccessState(STATUS_SUCCESS);
setTimeout(close, 1000);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
setPostSuccessState(STATUS_ERROR);
}
setPostPending(false);
}
return (
<Modal
destroyOnClose
width={600}
title="Post to Followers"
visible={visible}
onCancel={handleClose}
footer={[
<Button onClick={() => handleClose()}>Cancel</Button>,
<Button
type="primary"
onClick={sendButtonClicked}
disabled={postPending || postSuccessState}
loading={postPending}
>
{postSuccessState?.toUpperCase() || 'Post'}
</Button>,
]}
>
<Space id="fediverse-post-container" direction="vertical">
<TextArea
placeholder="Tell the world about your streaming plans..."
size="large"
showCount
maxLength={500}
style={{ height: '150px' }}
onChange={handleEditorChange}
/>
</Space>
</Modal>
);
}

View file

@ -0,0 +1,117 @@
// EDIT CUSTOM CSS STYLES
import React, { useState, useEffect, useContext } from 'react';
import { Typography, Button } from 'antd';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
postConfigUpdateToAPI,
RESET_TIMEOUT,
API_CUSTOM_CSS_STYLES,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
import TextField, { TEXTFIELD_TYPE_TEXTAREA } from './form-textfield';
import { UpdateArgs } from '../../types/config-section';
const { Title } = Typography;
export default function EditCustomStyles() {
const [content, setContent] = useState('');
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [hasChanged, setHasChanged] = useState(false);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { customStyles: initialContent } = instanceDetails;
let resetTimer = null;
function handleFieldChange({ value }: UpdateArgs) {
setContent(value);
if (value !== initialContent && !hasChanged) {
setHasChanged(true);
} else if (value === initialContent && hasChanged) {
setHasChanged(false);
}
}
// Clear out any validation states and messaging
const resetStates = () => {
setSubmitStatus(null);
setHasChanged(false);
clearTimeout(resetTimer);
resetTimer = null;
};
// posts all the tags at once as an array obj
async function handleSave() {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath: API_CUSTOM_CSS_STYLES,
data: { value: content },
onSuccess: (message: string) => {
setFieldInConfigState({
fieldName: 'customStyles',
value: content,
path: 'instanceDetails',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, message));
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
},
});
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
useEffect(() => {
setContent(initialContent);
}, [instanceDetails]);
return (
<div className="edit-custom-css">
<Title level={3} className="section-title">
Customize your page styling with CSS
</Title>
<p className="description">
Customize the look and feel of your Owncast instance by overriding the CSS styles of various
components on the page. Refer to the{' '}
<a href="https://owncast.online/docs/website/" rel="noopener noreferrer" target="_blank">
CSS &amp; Components guide
</a>
.
</p>
<p className="description">
Please input plain CSS text, as this will be directly injected onto your page during load.
</p>
<TextField
fieldName="customStyles"
type={TEXTFIELD_TYPE_TEXTAREA}
value={content}
maxLength={null}
onChange={handleFieldChange}
placeholder="/* Enter custom CSS here */"
/>
<br />
<div className="page-content-actions">
{hasChanged && (
<Button type="primary" onClick={handleSave}>
Save
</Button>
)}
<FormStatusIndicator status={submitStatus} />
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
// Note: references to "yp" in the app are likely related to Owncast Directory
import React, { useState, useContext, useEffect } from 'react';
import { Typography } from 'antd';
import ToggleSwitch from './form-toggleswitch';
import { ServerStatusContext } from '../../utils/server-status-context';
import { FIELD_PROPS_NSFW, FIELD_PROPS_YP } from '../../utils/config-constants';
const { Title } = Typography;
export default function EditYPDetails() {
const [formDataValues, setFormDataValues] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { yp, instanceDetails } = serverConfig;
const { nsfw } = instanceDetails;
const { enabled, instanceUrl } = yp;
useEffect(() => {
setFormDataValues({
...yp,
enabled,
nsfw,
});
}, [yp, instanceDetails]);
const hasInstanceUrl = instanceUrl !== '';
if (!formDataValues) {
return null;
}
return (
<div className="config-directory-details-form">
<Title level={3} className="section-title">
Owncast Directory Settings
</Title>
<p className="description">
Would you like to appear in the{' '}
<a href="https://directory.owncast.online" target="_blank" rel="noreferrer">
<strong>Owncast Directory</strong>
</a>
?
</p>
<p style={{ backgroundColor: 'black', fontSize: '.75rem', padding: '5px' }}>
<em>
NOTE: You will need to have a URL specified in the <code>Instance URL</code> field to be
able to use this.
</em>
</p>
<div className="config-yp-container">
<ToggleSwitch
fieldName="enabled"
{...FIELD_PROPS_YP}
checked={formDataValues.enabled}
disabled={!hasInstanceUrl}
/>
<ToggleSwitch
fieldName="nsfw"
{...FIELD_PROPS_NSFW}
checked={formDataValues.nsfw}
disabled={!hasInstanceUrl}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
import React, { useState, useContext, useEffect } from 'react';
import { Typography } from 'antd';
import TextFieldWithSubmit, {
TEXTFIELD_TYPE_TEXTAREA,
TEXTFIELD_TYPE_URL,
} from './form-textfield-with-submit';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
postConfigUpdateToAPI,
TEXTFIELD_PROPS_INSTANCE_URL,
TEXTFIELD_PROPS_SERVER_NAME,
TEXTFIELD_PROPS_SERVER_SUMMARY,
API_YP_SWITCH,
FIELD_PROPS_YP,
FIELD_PROPS_NSFW,
} from '../../utils/config-constants';
import { UpdateArgs } from '../../types/config-section';
import ToggleSwitch from './form-toggleswitch';
import EditLogo from './edit-logo';
const { Title } = Typography;
export default function EditInstanceDetails() {
const [formDataValues, setFormDataValues] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { instanceDetails, yp } = serverConfig;
const { instanceUrl } = yp;
useEffect(() => {
setFormDataValues({
...instanceDetails,
...yp,
});
}, [instanceDetails, yp]);
if (!formDataValues) {
return null;
}
// if instanceUrl is empty, we should also turn OFF the `enabled` field of directory.
const handleSubmitInstanceUrl = () => {
if (formDataValues.instanceUrl === '') {
if (yp.enabled === true) {
postConfigUpdateToAPI({
apiPath: API_YP_SWITCH,
data: { value: false },
});
}
}
};
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
};
const hasInstanceUrl = instanceUrl !== '';
return (
<div className="edit-general-settings">
<Title level={3} className="section-title">
Configure Instance Details
</Title>
<br />
<TextFieldWithSubmit
fieldName="name"
{...TEXTFIELD_PROPS_SERVER_NAME}
value={formDataValues.name}
initialValue={instanceDetails.name}
onChange={handleFieldChange}
/>
<TextFieldWithSubmit
fieldName="instanceUrl"
{...TEXTFIELD_PROPS_INSTANCE_URL}
value={formDataValues.instanceUrl}
initialValue={yp.instanceUrl}
type={TEXTFIELD_TYPE_URL}
onChange={handleFieldChange}
onSubmit={handleSubmitInstanceUrl}
/>
<TextFieldWithSubmit
fieldName="summary"
{...TEXTFIELD_PROPS_SERVER_SUMMARY}
type={TEXTFIELD_TYPE_TEXTAREA}
value={formDataValues.summary}
initialValue={instanceDetails.summary}
onChange={handleFieldChange}
/>
{/* Logo section */}
<EditLogo />
<br />
<p className="description">
Increase your audience by appearing in the{' '}
<a href="https://directory.owncast.online" target="_blank" rel="noreferrer">
<strong>Owncast Directory</strong>
</a>
. This is an external service run by the Owncast project.{' '}
<a
href="https://owncast.online/docs/directory/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Learn more
</a>
.
</p>
{!yp.instanceUrl && (
<p className="description">
You must set your <strong>Server URL</strong> above to enable the directory.
</p>
)}
<div className="config-yp-container">
<ToggleSwitch
fieldName="enabled"
useSubmit
{...FIELD_PROPS_YP}
checked={formDataValues.enabled}
disabled={!hasInstanceUrl}
/>
<ToggleSwitch
fieldName="nsfw"
useSubmit
{...FIELD_PROPS_NSFW}
checked={formDataValues.nsfw}
disabled={!hasInstanceUrl}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,128 @@
import { Button, Upload } from 'antd';
import { RcFile } from 'antd/lib/upload/interface';
import { LoadingOutlined, UploadOutlined } from '@ant-design/icons';
import React, { useState, useContext } from 'react';
import FormStatusIndicator from './form-status-indicator';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
postConfigUpdateToAPI,
RESET_TIMEOUT,
TEXTFIELD_PROPS_LOGO,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
const ACCEPTED_FILE_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
function getBase64(img: File | Blob, callback: (imageUrl: string | ArrayBuffer) => void) {
const reader = new FileReader();
reader.addEventListener('load', () => callback(reader.result));
reader.readAsDataURL(img);
}
export default function EditLogo() {
const [logoUrl, setlogoUrl] = useState(null);
const [loading, setLoading] = useState(false);
const [logoCachedbuster, setLogoCacheBuster] = useState(0);
const serverStatusData = useContext(ServerStatusContext);
const { setFieldInConfigState, serverConfig } = serverStatusData || {};
const currentLogo = serverConfig?.instanceDetails?.logo;
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
let resetTimer = null;
const { apiPath, tip } = TEXTFIELD_PROPS_LOGO;
// Clear out any validation states and messaging
const resetStates = () => {
setSubmitStatus(null);
clearTimeout(resetTimer);
resetTimer = null;
};
// validate file type and create base64 encoded img
const beforeUpload = (file: RcFile) => {
setLoading(true);
// eslint-disable-next-line consistent-return
return new Promise<void>((res, rej) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) {
const msg = `File type is not supported: ${file.type}`;
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
setLoading(false);
// eslint-disable-next-line no-promise-executor-return
return rej();
}
getBase64(file, (url: string) => {
setlogoUrl(url);
return res();
});
});
};
// Post new logo to api
const handleLogoUpdate = async () => {
if (logoUrl !== currentLogo) {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath,
data: { value: logoUrl },
onSuccess: () => {
setFieldInConfigState({ fieldName: 'logo', value: logoUrl, path: '' });
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
setLoading(false);
setLogoCacheBuster(Math.floor(Math.random() * 100)); // Force logo to re-load
},
onError: (msg: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`));
setLoading(false);
},
});
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
};
const logoDisplayUrl = `${NEXT_PUBLIC_API_HOST}logo?random=${logoCachedbuster}`;
return (
<div className="formfield-container logo-upload-container">
<div className="label-side">
<span className="formfield-label">Logo</span>
</div>
<div className="input-side">
<div className="input-group">
<img src={logoDisplayUrl} alt="avatar" className="logo-preview" />
<Upload
name="logo"
listType="picture"
className="avatar-uploader"
showUploadList={false}
accept={ACCEPTED_FILE_TYPES.join(',')}
beforeUpload={beforeUpload}
customRequest={handleLogoUpdate}
disabled={loading}
>
{loading ? (
<LoadingOutlined style={{ color: 'white' }} />
) : (
<Button icon={<UploadOutlined />} />
)}
</Upload>
</div>
<FormStatusIndicator status={submitStatus} />
<p className="field-tip">{tip}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,125 @@
// EDIT CUSTOM DETAILS ON YOUR PAGE
import React, { useState, useEffect, useContext } from 'react';
import { Typography, Button } from 'antd';
import dynamic from 'next/dynamic';
import MarkdownIt from 'markdown-it';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
postConfigUpdateToAPI,
RESET_TIMEOUT,
API_CUSTOM_CONTENT,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
import 'react-markdown-editor-lite/lib/index.css';
const mdParser = new MarkdownIt(/* Markdown-it options */);
const MdEditor = dynamic(() => import('react-markdown-editor-lite'), {
ssr: false,
});
const { Title } = Typography;
export default function EditPageContent() {
const [content, setContent] = useState('');
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [hasChanged, setHasChanged] = useState(false);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { extraPageContent: initialContent } = instanceDetails;
let resetTimer = null;
function handleEditorChange({ text }) {
setContent(text);
if (text !== initialContent && !hasChanged) {
setHasChanged(true);
} else if (text === initialContent && hasChanged) {
setHasChanged(false);
}
}
// Clear out any validation states and messaging
const resetStates = () => {
setSubmitStatus(null);
setHasChanged(false);
clearTimeout(resetTimer);
resetTimer = null;
};
// posts all the tags at once as an array obj
async function handleSave() {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath: API_CUSTOM_CONTENT,
data: { value: content },
onSuccess: (message: string) => {
setFieldInConfigState({
fieldName: 'extraPageContent',
value: content,
path: 'instanceDetails',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, message));
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
},
});
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
useEffect(() => {
setContent(initialContent);
}, [instanceDetails]);
return (
<div className="edit-page-content">
<Title level={3} className="section-title">
Custom Page Content
</Title>
<p className="description">
Edit the content of your page by using simple{' '}
<a
href="https://www.markdownguide.org/basic-syntax/"
target="_blank"
rel="noopener noreferrer"
>
Markdown syntax
</a>
.
</p>
<MdEditor
style={{ height: '30em' }}
value={content}
renderHTML={(c: string) => mdParser.render(c)}
onChange={handleEditorChange}
config={{
htmlClass: 'markdown-editor-preview-pane',
markdownClass: 'markdown-editor-pane',
}}
/>
<br />
<div className="page-content-actions">
{hasChanged && (
<Button type="primary" onClick={handleSave}>
Save
</Button>
)}
<FormStatusIndicator status={submitStatus} />
</div>
</div>
);
}

View file

@ -0,0 +1,167 @@
import React, { useState, useContext, useEffect } from 'react';
import { Button, Tooltip, Collapse, Typography } from 'antd';
import { CopyOutlined, RedoOutlined } from '@ant-design/icons';
import {
TEXTFIELD_TYPE_NUMBER,
TEXTFIELD_TYPE_PASSWORD,
TEXTFIELD_TYPE_URL,
} from './form-textfield';
import TextFieldWithSubmit from './form-textfield-with-submit';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import {
TEXTFIELD_PROPS_FFMPEG,
TEXTFIELD_PROPS_RTMP_PORT,
TEXTFIELD_PROPS_SOCKET_HOST_OVERRIDE,
TEXTFIELD_PROPS_STREAM_KEY,
TEXTFIELD_PROPS_WEB_PORT,
} from '../../utils/config-constants';
import { UpdateArgs } from '../../types/config-section';
import ResetYP from './reset-yp';
const { Panel } = Collapse;
export default function EditInstanceDetails() {
const [formDataValues, setFormDataValues] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { setMessage } = useContext(AlertMessageContext);
const { serverConfig } = serverStatusData || {};
const { streamKey, ffmpegPath, rtmpServerPort, webServerPort, yp, socketHostOverride } =
serverConfig;
const [copyIsVisible, setCopyVisible] = useState(false);
const COPY_TOOLTIP_TIMEOUT = 3000;
useEffect(() => {
setFormDataValues({
streamKey,
ffmpegPath,
rtmpServerPort,
webServerPort,
socketHostOverride,
});
}, [serverConfig]);
if (!formDataValues) {
return null;
}
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
};
const showConfigurationRestartMessage = () => {
setMessage('Updating server settings requires a restart of your Owncast server.');
};
const showStreamKeyChangeMessage = () => {
setMessage(
'Changing your stream key will log you out of the admin and block you from streaming until you change the key in your broadcasting software.',
);
};
const showFfmpegChangeMessage = () => {
if (serverStatusData.online) {
setMessage('The updated ffmpeg path will be used when starting your next live stream.');
}
};
function generateStreamKey() {
let key = '';
for (let i = 0; i < 3; i += 1) {
key += Math.random().toString(36).substring(2);
}
handleFieldChange({ fieldName: 'streamKey', value: key });
}
function copyStreamKey() {
navigator.clipboard.writeText(formDataValues.streamKey).then(() => {
setCopyVisible(true);
setTimeout(() => setCopyVisible(false), COPY_TOOLTIP_TIMEOUT);
});
}
return (
<div className="edit-server-details-container">
<div className="field-container field-streamkey-container">
<div className="left-side">
<TextFieldWithSubmit
fieldName="streamKey"
{...TEXTFIELD_PROPS_STREAM_KEY}
value={formDataValues.streamKey}
initialValue={streamKey}
type={TEXTFIELD_TYPE_PASSWORD}
onChange={handleFieldChange}
onSubmit={showStreamKeyChangeMessage}
/>
<div className="streamkey-actions">
<Tooltip title="Generate a stream key">
<Button icon={<RedoOutlined />} size="small" onClick={generateStreamKey} />
</Tooltip>
<Tooltip
className="copy-tooltip"
title={copyIsVisible ? 'Copied!' : 'Copy to clipboard'}
>
<Button icon={<CopyOutlined />} size="small" onClick={copyStreamKey} />
</Tooltip>
</div>
</div>
</div>
<TextFieldWithSubmit
fieldName="ffmpegPath"
{...TEXTFIELD_PROPS_FFMPEG}
value={formDataValues.ffmpegPath}
initialValue={ffmpegPath}
onChange={handleFieldChange}
onSubmit={showFfmpegChangeMessage}
/>
<TextFieldWithSubmit
fieldName="webServerPort"
{...TEXTFIELD_PROPS_WEB_PORT}
value={formDataValues.webServerPort}
initialValue={webServerPort}
type={TEXTFIELD_TYPE_NUMBER}
onChange={handleFieldChange}
onSubmit={showConfigurationRestartMessage}
/>
<TextFieldWithSubmit
fieldName="rtmpServerPort"
{...TEXTFIELD_PROPS_RTMP_PORT}
value={formDataValues.rtmpServerPort}
initialValue={rtmpServerPort}
type={TEXTFIELD_TYPE_NUMBER}
onChange={handleFieldChange}
onSubmit={showConfigurationRestartMessage}
/>
<Collapse className="advanced-settings">
<Panel header="Advanced Settings" key="1">
<Typography.Paragraph>
If you have a CDN in front of your entire Owncast instance, specify your origin server
here for the websocket to connect to. Most people will never need to set this.
</Typography.Paragraph>
<TextFieldWithSubmit
fieldName="socketHostOverride"
{...TEXTFIELD_PROPS_SOCKET_HOST_OVERRIDE}
value={formDataValues.socketHostOverride}
initialValue={socketHostOverride || ''}
type={TEXTFIELD_TYPE_URL}
onChange={handleFieldChange}
/>
{yp.enabled && <ResetYP />}
</Panel>
</Collapse>
</div>
);
}

View file

@ -0,0 +1,319 @@
import React, { useState, useContext, useEffect } from 'react';
import { Typography, Table, Button, Modal, Input } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { DeleteOutlined } from '@ant-design/icons';
import SocialDropdown from './social-icons-dropdown';
import { fetchData, SOCIAL_PLATFORMS_LIST, NEXT_PUBLIC_API_HOST } from '../../utils/apis';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
API_SOCIAL_HANDLES,
postConfigUpdateToAPI,
RESET_TIMEOUT,
DEFAULT_SOCIAL_HANDLE,
OTHER_SOCIAL_HANDLE_OPTION,
} from '../../utils/config-constants';
import { SocialHandle, UpdateArgs } from '../../types/config-section';
import isValidUrl, { DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/urls';
import TextField from './form-textfield';
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
const { Title } = Typography;
export default function EditSocialLinks() {
const [availableIconsList, setAvailableIconsList] = useState([]);
const [currentSocialHandles, setCurrentSocialHandles] = useState([]);
const [displayModal, setDisplayModal] = useState(false);
const [displayOther, setDisplayOther] = useState(false);
const [modalProcessing, setModalProcessing] = useState(false);
const [editId, setEditId] = useState(-1);
// current data inside modal
const [modalDataState, setModalDataState] = useState(DEFAULT_SOCIAL_HANDLE);
const [submitStatus, setSubmitStatus] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { socialHandles: initialSocialHandles } = instanceDetails;
let resetTimer = null;
const PLACEHOLDERS = {
mastodon: 'https://mastodon.social/@username',
twitter: 'https://twitter.com/username',
};
const getAvailableIcons = async () => {
try {
const result = await fetchData(SOCIAL_PLATFORMS_LIST, { auth: false });
const list = Object.keys(result).map(item => ({
key: item,
...result[item],
}));
setAvailableIconsList(list);
} catch (error) {
console.log(error);
// do nothing
}
};
const isPredefinedSocial = (platform: string) =>
availableIconsList.find(item => item.key === platform) || false;
const selectedOther =
modalDataState.platform !== '' &&
!availableIconsList.find(item => item.key === modalDataState.platform);
useEffect(() => {
getAvailableIcons();
}, []);
useEffect(() => {
if (instanceDetails.socialHandles) {
setCurrentSocialHandles(initialSocialHandles);
}
}, [instanceDetails]);
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
const resetModal = () => {
setDisplayModal(false);
setEditId(-1);
setDisplayOther(false);
setModalProcessing(false);
setModalDataState({ ...DEFAULT_SOCIAL_HANDLE });
};
const handleModalCancel = () => {
resetModal();
};
const updateModalState = (fieldName: string, value: string) => {
setModalDataState({
...modalDataState,
[fieldName]: value,
});
};
const handleDropdownSelect = (value: string) => {
if (value === OTHER_SOCIAL_HANDLE_OPTION) {
setDisplayOther(true);
updateModalState('platform', '');
} else {
setDisplayOther(false);
updateModalState('platform', value);
}
};
const handleOtherNameChange = event => {
const { value } = event.target;
updateModalState('platform', value);
};
const handleUrlChange = ({ value }: UpdateArgs) => {
updateModalState('url', value);
};
// posts all the variants at once as an array obj
const postUpdateToAPI = async (postValue: any) => {
await postConfigUpdateToAPI({
apiPath: API_SOCIAL_HANDLES,
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'socialHandles',
value: postValue,
path: 'instanceDetails',
});
// close modal
setModalProcessing(false);
handleModalCancel();
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
setModalProcessing(false);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
// on Ok, send all of dataState to api
// show loading
// close modal when api is done
const handleModalOk = () => {
setModalProcessing(true);
const postData = currentSocialHandles.length ? [...currentSocialHandles] : [];
if (editId === -1) {
postData.push(modalDataState);
} else {
postData.splice(editId, 1, modalDataState);
}
postUpdateToAPI(postData);
};
const handleDeleteItem = (index: number) => {
const postData = [...currentSocialHandles];
postData.splice(index, 1);
postUpdateToAPI(postData);
};
const socialHandlesColumns: ColumnsType<SocialHandle> = [
{
title: 'Social Link',
dataIndex: '',
key: 'combo',
render: (data, record) => {
const { platform, url } = record;
const platformInfo = isPredefinedSocial(platform);
// custom platform case
if (!platformInfo) {
return (
<div className="social-handle-cell">
<p className="option-label">
<strong>{platform}</strong>
<span className="handle-url" title={url}>
{url}
</span>
</p>
</div>
);
}
const { icon, platform: platformName } = platformInfo;
const iconUrl = `${NEXT_PUBLIC_API_HOST}${icon.slice(1)}`;
return (
<div className="social-handle-cell">
<span className="option-icon">
<img src={iconUrl} alt="" className="option-icon" />
</span>
<p className="option-label">
<strong>{platformName}</strong>
<span className="handle-url" title={url}>
{url}
</span>
</p>
</div>
);
},
},
{
title: '',
dataIndex: '',
key: 'edit',
render: (data, record, index) => (
<div className="actions">
<Button
size="small"
onClick={() => {
const platformInfo = currentSocialHandles[index];
setEditId(index);
setModalDataState({ ...platformInfo });
setDisplayModal(true);
if (!isPredefinedSocial(platformInfo.platform)) {
setDisplayOther(true);
}
}}
>
Edit
</Button>
<Button
className="delete-button"
icon={<DeleteOutlined />}
size="small"
onClick={() => handleDeleteItem(index)}
/>
</div>
),
},
];
const okButtonProps = {
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">
Your Social Handles
</Title>
<p className="description">
Add all your social media handles and links to your other profiles here.
</p>
<FormStatusIndicator status={submitStatus} />
<Table
className="social-handles-table"
pagination={false}
size="small"
rowKey={record => `${record.platform}-${record.url}`}
columns={socialHandlesColumns}
dataSource={currentSocialHandles}
/>
<Modal
title="Edit Social Handle"
visible={displayModal}
onOk={handleModalOk}
onCancel={handleModalCancel}
confirmLoading={modalProcessing}
okButtonProps={okButtonProps}
>
<div className="social-handle-modal-content">
<SocialDropdown
iconList={availableIconsList}
selectedOption={selectedOther ? OTHER_SOCIAL_HANDLE_OPTION : modalDataState.platform}
onSelected={handleDropdownSelect}
/>
{displayOther && otherField}
<br />
<TextField
fieldName="social-url"
label="URL"
placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
value={modalDataState.url}
onChange={handleUrlChange}
useTrim
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
/>
<FormStatusIndicator status={submitStatus} />
</div>
</Modal>
<br />
<Button
type="primary"
onClick={() => {
resetModal();
setDisplayModal(true);
}}
>
Add a new social link
</Button>
</div>
);
}

View file

@ -0,0 +1,257 @@
import { Button, Collapse } from 'antd';
import classNames from 'classnames';
import React, { useContext, useState, useEffect } from 'react';
import { UpdateArgs } from '../../types/config-section';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import {
postConfigUpdateToAPI,
API_S3_INFO,
RESET_TIMEOUT,
S3_TEXT_FIELDS_INFO,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import TextField from './form-textfield';
import FormStatusIndicator from './form-status-indicator';
import isValidUrl from '../../utils/urls';
import ToggleSwitch from './form-toggleswitch';
const { Panel } = Collapse;
// we could probably add more detailed checks here
// `currentValues` is what's currently in the global store and in the db
function checkSaveable(formValues: any, currentValues: any) {
const {
endpoint,
accessKey,
secret,
bucket,
region,
enabled,
servingEndpoint,
acl,
forcePathStyle,
} = formValues;
// if fields are filled out and different from what's in store, then return true
if (enabled) {
if (!!endpoint && isValidUrl(endpoint) && !!accessKey && !!secret && !!bucket && !!region) {
if (
enabled !== currentValues.enabled ||
endpoint !== currentValues.endpoint ||
accessKey !== currentValues.accessKey ||
secret !== currentValues.secret ||
region !== currentValues.region ||
(!currentValues.servingEndpoint && servingEndpoint !== '') ||
(!!currentValues.servingEndpoint && servingEndpoint !== currentValues.servingEndpoint) ||
(!currentValues.acl && acl !== '') ||
(!!currentValues.acl && acl !== currentValues.acl) ||
forcePathStyle !== currentValues.forcePathStyle
) {
return true;
}
}
} else if (enabled !== currentValues.enabled) {
return true;
}
return false;
}
export default function EditStorage() {
const [formDataValues, setFormDataValues] = useState(null);
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [shouldDisplayForm, setShouldDisplayForm] = useState(false);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { setMessage: setAlertMessage } = useContext(AlertMessageContext);
const { s3 } = serverConfig;
const {
accessKey = '',
acl = '',
bucket = '',
enabled = false,
endpoint = '',
region = '',
secret = '',
servingEndpoint = '',
forcePathStyle = false,
} = s3;
useEffect(() => {
setFormDataValues({
accessKey,
acl,
bucket,
enabled,
endpoint,
region,
secret,
servingEndpoint,
forcePathStyle,
});
setShouldDisplayForm(enabled);
}, [s3]);
if (!formDataValues) {
return null;
}
let resetTimer = null;
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
// update individual values in state
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
};
// posts the whole state
const handleSave = async () => {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
const postValue = formDataValues;
await postConfigUpdateToAPI({
apiPath: API_S3_INFO,
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({ fieldName: 's3', value: postValue, path: '' });
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
setAlertMessage(
'Changing your storage configuration will take place the next time you start a new stream.',
);
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
// toggle switch.
const handleSwitchChange = (storageEnabled: boolean) => {
setShouldDisplayForm(storageEnabled);
handleFieldChange({ fieldName: 'enabled', value: storageEnabled });
};
const handleForcePathStyleSwitchChange = (forcePathStyleEnabled: boolean) => {
handleFieldChange({ fieldName: 'forcePathStyle', value: forcePathStyleEnabled });
};
const containerClass = classNames({
'edit-storage-container': true,
'form-module': true,
enabled: shouldDisplayForm,
});
const isSaveable = checkSaveable(formDataValues, s3);
return (
<div className={containerClass}>
<div className="enable-switch">
<ToggleSwitch
apiPath=""
fieldName="enabled"
label="Use S3 Storage Provider"
checked={formDataValues.enabled}
onChange={handleSwitchChange}
/>
{/* <Switch
checked={formDataValues.enabled}
defaultChecked={formDataValues.enabled}
onChange={handleSwitchChange}
checkedChildren="ON"
unCheckedChildren="OFF"
/>{' '}
Enabled */}
</div>
<div className="form-fields">
<div className="field-container">
<TextField
{...S3_TEXT_FIELDS_INFO.endpoint}
value={formDataValues.endpoint}
onChange={handleFieldChange}
/>
</div>
<div className="field-container">
<TextField
{...S3_TEXT_FIELDS_INFO.accessKey}
value={formDataValues.accessKey}
onChange={handleFieldChange}
/>
</div>
<div className="field-container">
<TextField
{...S3_TEXT_FIELDS_INFO.secret}
value={formDataValues.secret}
onChange={handleFieldChange}
/>
</div>
<div className="field-container">
<TextField
{...S3_TEXT_FIELDS_INFO.bucket}
value={formDataValues.bucket}
onChange={handleFieldChange}
/>
</div>
<div className="field-container">
<TextField
{...S3_TEXT_FIELDS_INFO.region}
value={formDataValues.region}
onChange={handleFieldChange}
/>
</div>
<Collapse className="advanced-section">
<Panel header="Optional Settings" key="1">
<div className="field-container">
<TextField
{...S3_TEXT_FIELDS_INFO.acl}
value={formDataValues.acl}
onChange={handleFieldChange}
/>
</div>
<div className="field-container">
<TextField
{...S3_TEXT_FIELDS_INFO.servingEndpoint}
value={formDataValues.servingEndpoint}
onChange={handleFieldChange}
/>
</div>
<div className="enable-switch">
<ToggleSwitch
{...S3_TEXT_FIELDS_INFO.forcePathStyle}
fieldName="forcePathStyle"
checked={formDataValues.forcePathStyle}
onChange={handleForcePathStyleSwitchChange}
/>
</div>
</Panel>
</Collapse>
</div>
<div className="button-container">
<Button type="primary" onClick={handleSave} disabled={!isSaveable}>
Save
</Button>
<FormStatusIndicator status={submitStatus} />
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
/* eslint-disable react/no-array-index-key */
import React, { useState } from 'react';
import { Typography, Tag } from 'antd';
import TextField from './form-textfield';
import { UpdateArgs } from '../../types/config-section';
import { StatusState } from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
const { Title } = Typography;
export const TAG_COLOR = '#5a67d8';
interface EditStringArrayProps {
title: string;
description?: string;
placeholder: string;
maxLength?: number;
values: string[];
submitStatus?: StatusState;
continuousStatusMessage?: StatusState;
handleDeleteIndex: (index: number) => void;
handleCreateString: (arg: string) => void;
}
export default function EditValueArray(props: EditStringArrayProps) {
const [newStringInput, setNewStringInput] = useState<string>('');
const {
title,
description,
placeholder,
maxLength,
values,
handleDeleteIndex,
handleCreateString,
submitStatus,
continuousStatusMessage,
} = props;
const handleInputChange = ({ value }: UpdateArgs) => {
setNewStringInput(value);
};
const handleSubmitNewString = () => {
const newString = newStringInput.trim();
handleCreateString(newString);
setNewStringInput('');
};
return (
<div className="edit-string-array-container">
<Title level={3} className="section-title">
{title}
</Title>
<p className="description">{description}</p>
<div className="edit-current-strings">
{values?.map((tag, index) => {
const handleClose = () => {
handleDeleteIndex(index);
};
return (
<Tag closable onClose={handleClose} color={TAG_COLOR} key={`tag-${tag}-${index}`}>
{tag}
</Tag>
);
})}
</div>
{continuousStatusMessage && (
<div className="continuous-status-section">
<FormStatusIndicator status={continuousStatusMessage} />
</div>
)}
<div className="add-new-string-section">
<TextField
fieldName="string-input"
value={newStringInput}
onChange={handleInputChange}
onPressEnter={handleSubmitNewString}
maxLength={maxLength}
placeholder={placeholder}
status={submitStatus}
/>
</div>
</div>
);
}
EditValueArray.defaultProps = {
maxLength: 50,
description: null,
submitStatus: null,
continuousStatusMessage: null,
};

View file

@ -0,0 +1,139 @@
/* eslint-disable react/no-array-index-key */
import React, { useContext, useState, useEffect } from 'react';
import { Typography, Tag } from 'antd';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
FIELD_PROPS_TAGS,
RESET_TIMEOUT,
postConfigUpdateToAPI,
} from '../../utils/config-constants';
import TextField from './form-textfield';
import { UpdateArgs } from '../../types/config-section';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
STATUS_WARNING,
} from '../../utils/input-statuses';
import { TAG_COLOR } from './edit-string-array';
const { Title } = Typography;
export default function EditInstanceTags() {
const [newTagInput, setNewTagInput] = useState<string>('');
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { tags = [] } = instanceDetails;
const { apiPath, maxLength, placeholder, configPath } = FIELD_PROPS_TAGS;
let resetTimer = null;
useEffect(
() => () => {
clearTimeout(resetTimer);
},
[],
);
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
// posts all the tags at once as an array obj
const postUpdateToAPI = async (postValue: any) => {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath,
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({ fieldName: 'tags', value: postValue, path: configPath });
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Tags updated.'));
setNewTagInput('');
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
const handleInputChange = ({ value }: UpdateArgs) => {
if (!submitStatus) {
setSubmitStatus(null);
}
setNewTagInput(value);
};
// send to api and do stuff
const handleSubmitNewTag = () => {
resetStates();
const newTag = newTagInput.trim();
if (newTag === '') {
setSubmitStatus(createInputStatus(STATUS_WARNING, 'Please enter a tag'));
return;
}
if (tags.some(tag => tag.toLowerCase() === newTag.toLowerCase())) {
setSubmitStatus(createInputStatus(STATUS_WARNING, 'This tag is already used!'));
return;
}
const updatedTags = [...tags, newTag];
postUpdateToAPI(updatedTags);
};
const handleDeleteTag = index => {
resetStates();
const updatedTags = [...tags];
updatedTags.splice(index, 1);
postUpdateToAPI(updatedTags);
};
return (
<div className="tag-editor-container">
<Title level={3} className="section-title">
Add Tags
</Title>
<p className="description">
This is a great way to categorize your Owncast server on the Directory!
</p>
<div className="edit-current-strings">
{tags.map((tag, index) => {
const handleClose = () => {
handleDeleteTag(index);
};
return (
<Tag closable onClose={handleClose} color={TAG_COLOR} key={`tag-${tag}-${index}`}>
{tag}
</Tag>
);
})}
</div>
<div className="add-new-string-section">
<TextField
fieldName="tag-input"
value={newTagInput}
className="new-tag-input"
onChange={handleInputChange}
onPressEnter={handleSubmitNewTag}
maxLength={maxLength}
placeholder={placeholder}
status={submitStatus}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import React from 'react';
import classNames from 'classnames';
import { StatusState } from '../../utils/input-statuses';
interface FormStatusIndicatorProps {
status: StatusState;
}
export default function FormStatusIndicator({ status }: FormStatusIndicatorProps) {
const { type, icon, message } = status || {};
const classes = classNames({
'status-container': true,
[`status-${type}`]: type,
empty: !message,
});
return (
<span className={classes}>
{icon ? <span className="status-icon">{icon}</span> : null}
{message ? <span className="status-message">{message}</span> : null}
</span>
);
}

View file

@ -0,0 +1,158 @@
import { Button } from 'antd';
import classNames from 'classnames';
import React, { useContext, useEffect, useState } from 'react';
import { UpdateArgs } from '../../types/config-section';
import { postConfigUpdateToAPI, RESET_TIMEOUT } from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { ServerStatusContext } from '../../utils/server-status-context';
import FormStatusIndicator from './form-status-indicator';
import TextField, { TextFieldProps } from './form-textfield';
export const TEXTFIELD_TYPE_TEXT = 'default';
export const TEXTFIELD_TYPE_PASSWORD = 'password'; // Input.Password
export const TEXTFIELD_TYPE_NUMBER = 'numeric';
export const TEXTFIELD_TYPE_TEXTAREA = 'textarea';
export const TEXTFIELD_TYPE_URL = 'url';
interface TextFieldWithSubmitProps extends TextFieldProps {
apiPath: string;
configPath?: string;
initialValue?: string;
}
export default function TextFieldWithSubmit(props: TextFieldWithSubmitProps) {
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [hasChanged, setHasChanged] = useState(false);
const serverStatusData = useContext(ServerStatusContext);
const { setFieldInConfigState } = serverStatusData || {};
let resetTimer = null;
const {
apiPath,
configPath = '',
initialValue,
useTrim,
useTrimLead,
...textFieldProps // rest of props
} = props;
const { fieldName, required, tip, status, value, onChange, onSubmit } = textFieldProps;
// Clear out any validation states and messaging
const resetStates = () => {
setSubmitStatus(null);
setHasChanged(false);
clearTimeout(resetTimer);
resetTimer = null;
};
useEffect(() => {
// TODO: Add native validity checks here, somehow
// https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
// const hasValidity = (type !== TEXTFIELD_TYPE_NUMBER && e.target.validity.valid) || type === TEXTFIELD_TYPE_NUMBER ;
if ((required && (value === '' || value === null)) || value === initialValue) {
setHasChanged(false);
} else {
// show submit button
resetStates();
setHasChanged(true);
}
}, [value]);
// if field is required but value is empty, or equals initial value, then don't show submit/update button. otherwise clear out any result messaging and display button.
const handleChange = ({ fieldName: changedFieldName, value: changedValue }: UpdateArgs) => {
if (onChange) {
let newValue: string = changedValue;
if (useTrim) {
newValue = changedValue.trim();
} else if (useTrimLead) {
newValue = changedValue.replace(/^\s+/g, '');
}
onChange({
fieldName: changedFieldName,
value: newValue,
});
}
};
// if you blur a required field with an empty value, restore its original value in state (parent's state), if an onChange from parent is available.
const handleBlur = ({ value: changedValue }: UpdateArgs) => {
if (onChange && required && changedValue === '') {
onChange({ fieldName, value: initialValue });
}
};
// how to get current value of input
const handleSubmit = async () => {
if ((required && value !== '') || value !== initialValue) {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath,
data: { value },
onSuccess: () => {
setFieldInConfigState({ fieldName, value, path: configPath });
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
},
});
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
// if an extra onSubmit handler was sent in as a prop, let's run that too.
if (onSubmit) {
onSubmit();
}
}
};
const textfieldContainerClass = classNames({
'textfield-with-submit-container': true,
submittable: hasChanged,
});
return (
<div className={textfieldContainerClass}>
<div className="textfield-component">
<TextField
{...textFieldProps}
onSubmit={null}
onBlur={handleBlur}
onChange={handleChange}
/>
</div>
<div className="formfield-container lower-container">
<p className="label-spacer" />
<div className="lower-content">
<div className="field-tip">{tip}</div>
<FormStatusIndicator status={status || submitStatus} />
<div className="update-button-container">
<Button
type="primary"
size="small"
className="submit-button"
onClick={handleSubmit}
disabled={!hasChanged}
>
Update
</Button>
</div>
</div>
</div>
</div>
);
}
TextFieldWithSubmit.defaultProps = {
configPath: '',
initialValue: '',
};

View file

@ -0,0 +1,172 @@
import React from 'react';
import classNames from 'classnames';
import { Input, InputNumber } from 'antd';
import { FieldUpdaterFunc } from '../../types/config-section';
// import InfoTip from '../info-tip';
import { StatusState } from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
export const TEXTFIELD_TYPE_TEXT = 'default';
export const TEXTFIELD_TYPE_PASSWORD = 'password'; // Input.Password
export const TEXTFIELD_TYPE_NUMBER = 'numeric'; // InputNumber
export const TEXTFIELD_TYPE_TEXTAREA = 'textarea'; // Input.TextArea
export const TEXTFIELD_TYPE_URL = 'url';
export interface TextFieldProps {
fieldName: string;
onSubmit?: () => void;
onPressEnter?: () => void;
className?: string;
disabled?: boolean;
label?: string;
maxLength?: number;
pattern?: string;
placeholder?: string;
required?: boolean;
status?: StatusState;
tip?: string;
type?: string;
useTrim?: boolean;
useTrimLead?: boolean;
value?: string | number;
onBlur?: FieldUpdaterFunc;
onChange?: FieldUpdaterFunc;
}
export default function TextField(props: TextFieldProps) {
const {
className,
disabled,
fieldName,
label,
maxLength,
onBlur,
onChange,
onPressEnter,
pattern,
placeholder,
required,
status,
tip,
type,
useTrim,
value,
} = props;
const handleChange = (e: any) => {
// if an extra onChange handler was sent in as a prop, let's run that too.
if (onChange) {
const val = type === TEXTFIELD_TYPE_NUMBER ? e : e.target.value;
onChange({ fieldName, value: useTrim ? val.trim() : val });
}
};
// if you blur a required field with an empty value, restore its original value in state (parent's state), if an onChange from parent is available.
const handleBlur = (e: any) => {
const val = e.target.value;
if (onBlur) {
onBlur({ value: val });
}
};
const handlePressEnter = () => {
if (onPressEnter) {
onPressEnter();
}
};
// display the appropriate Ant text field
let Field = Input as
| typeof Input
| typeof InputNumber
| typeof Input.TextArea
| typeof Input.Password;
let fieldProps = {};
if (type === TEXTFIELD_TYPE_TEXTAREA) {
Field = Input.TextArea;
fieldProps = {
autoSize: true,
};
} else if (type === TEXTFIELD_TYPE_PASSWORD) {
Field = Input.Password;
fieldProps = {
visibilityToggle: true,
};
} else if (type === TEXTFIELD_TYPE_NUMBER) {
Field = InputNumber;
fieldProps = {
type: 'number',
min: 1,
max: 10 ** maxLength - 1,
};
} else if (type === TEXTFIELD_TYPE_URL) {
fieldProps = {
type: 'url',
pattern,
};
}
const fieldId = `field-${fieldName}`;
const { type: statusType } = status || {};
const containerClass = classNames({
'formfield-container': true,
'textfield-container': true,
[`type-${type}`]: true,
required,
[`status-${statusType}`]: status,
});
return (
<div className={containerClass}>
{label ? (
<div className="label-side">
<label htmlFor={fieldId} className="formfield-label">
{label}
</label>
</div>
) : null}
<div className="input-side">
<div className="input-group">
<Field
id={fieldId}
className={`field ${className} ${fieldId}`}
{...fieldProps}
{...(type !== TEXTFIELD_TYPE_NUMBER && { allowClear: true })}
placeholder={placeholder}
maxLength={maxLength}
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handlePressEnter}
disabled={disabled}
value={value as number | (readonly string[] & number)}
/>
</div>
<FormStatusIndicator status={status} />
<p className="field-tip">{tip}</p>
</div>
</div>
);
}
TextField.defaultProps = {
className: '',
disabled: false,
label: '',
maxLength: 255,
placeholder: '',
required: false,
status: null,
tip: '',
type: TEXTFIELD_TYPE_TEXT,
value: '',
onSubmit: () => {},
onBlur: () => {},
onChange: () => {},
onPressEnter: () => {},
};

View file

@ -0,0 +1,122 @@
// This is a wrapper for the Ant Switch component.
// 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';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
import { RESET_TIMEOUT, postConfigUpdateToAPI } from '../../utils/config-constants';
import { ServerStatusContext } from '../../utils/server-status-context';
interface ToggleSwitchProps {
fieldName: string;
apiPath?: string;
checked?: boolean;
reversed?: boolean;
configPath?: string;
disabled?: boolean;
label?: string;
tip?: string;
useSubmit?: boolean;
onChange?: (arg: boolean) => void;
}
export default function ToggleSwitch(props: ToggleSwitchProps) {
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
let resetTimer = null;
const serverStatusData = useContext(ServerStatusContext);
const { setFieldInConfigState } = serverStatusData || {};
const {
apiPath,
checked,
reversed = false,
configPath = '',
disabled = false,
fieldName,
label,
tip,
useSubmit,
onChange,
} = props;
const resetStates = () => {
setSubmitStatus(null);
clearTimeout(resetTimer);
resetTimer = null;
};
const handleChange = async (isChecked: boolean) => {
if (useSubmit) {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
const isCheckedSend = reversed ? !isChecked : isChecked;
await postConfigUpdateToAPI({
apiPath,
data: { value: isCheckedSend },
onSuccess: () => {
setFieldInConfigState({ fieldName, value: isCheckedSend, path: configPath });
setSubmitStatus(createInputStatus(STATUS_SUCCESS));
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${message}`));
},
});
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
if (onChange) {
onChange(isChecked);
}
};
const loading = submitStatus !== null && submitStatus.type === STATUS_PROCESSING;
return (
<div className="formfield-container toggleswitch-container">
{label && (
<div className="label-side">
<span className="formfield-label">{label}</span>
</div>
)}
<div className="input-side">
<div className="input-group">
<Switch
className={`switch field-${fieldName}`}
loading={loading}
onChange={handleChange}
defaultChecked={checked}
checked={checked}
checkedChildren="ON"
unCheckedChildren="OFF"
disabled={disabled}
/>
<FormStatusIndicator status={submitStatus} />
</div>
<p className="field-tip">{tip}</p>
</div>
</div>
);
}
ToggleSwitch.defaultProps = {
apiPath: '',
checked: false,
reversed: false,
configPath: '',
disabled: false,
label: '',
tip: '',
useSubmit: false,
onChange: null,
};

View file

@ -0,0 +1,129 @@
import { Button, Typography } from 'antd';
import React, { useState, useContext, useEffect } from 'react';
import { ServerStatusContext } from '../../../utils/server-status-context';
import TextField, { TEXTFIELD_TYPE_TEXTAREA } from '../form-textfield';
import {
postConfigUpdateToAPI,
RESET_TIMEOUT,
BROWSER_PUSH_CONFIG_FIELDS,
} from '../../../utils/config-constants';
import ToggleSwitch from '../form-toggleswitch';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_SUCCESS,
} from '../../../utils/input-statuses';
import { UpdateArgs } from '../../../types/config-section';
import FormStatusIndicator from '../form-status-indicator';
const { Title } = Typography;
export default function ConfigNotify() {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { notifications } = serverConfig || {};
const { browser } = notifications || {};
const { enabled, goLiveMessage } = browser || {};
const [formDataValues, setFormDataValues] = useState<any>({});
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [enableSaveButton, setEnableSaveButton] = useState<boolean>(false);
useEffect(() => {
setFormDataValues({
enabled,
goLiveMessage,
});
}, [notifications, browser]);
const canSave = (): boolean => true;
// update individual values in state
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
console.log(fieldName, value);
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
setEnableSaveButton(canSave());
};
// toggle switch.
const handleSwitchChange = (switchEnabled: boolean) => {
// setShouldDisplayForm(storageEnabled);
handleFieldChange({ fieldName: 'enabled', value: switchEnabled });
};
let resetTimer = null;
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
const save = async () => {
const postValue = formDataValues;
await postConfigUpdateToAPI({
apiPath: '/notifications/browser',
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'browser',
value: postValue,
path: 'notifications',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
return (
<>
<Title>Browser Alerts</Title>
<p className="description reduced-margins">
Viewers can opt into being notified when you go live with their browser.
</p>
<p className="description reduced-margins">Not all browsers support this.</p>
<ToggleSwitch
apiPath=""
fieldName="enabled"
label="Enable browser notifications"
onChange={handleSwitchChange}
checked={formDataValues.enabled}
/>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<TextField
{...BROWSER_PUSH_CONFIG_FIELDS.goLiveMessage}
required
type={TEXTFIELD_TYPE_TEXTAREA}
value={formDataValues.goLiveMessage}
onChange={handleFieldChange}
/>
</div>
<Button
type="primary"
style={{
display: enableSaveButton ? 'inline-block' : 'none',
position: 'relative',
marginLeft: 'auto',
right: '0',
marginTop: '20px',
}}
onClick={save}
>
Save
</Button>
<FormStatusIndicator status={submitStatus} />
</>
);
}

View file

@ -0,0 +1,153 @@
import { Button, Typography } from 'antd';
import React, { useState, useContext, useEffect } from 'react';
import { ServerStatusContext } from '../../../utils/server-status-context';
import TextField from '../form-textfield';
import FormStatusIndicator from '../form-status-indicator';
import {
postConfigUpdateToAPI,
RESET_TIMEOUT,
DISCORD_CONFIG_FIELDS,
} from '../../../utils/config-constants';
import ToggleSwitch from '../form-toggleswitch';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_SUCCESS,
} from '../../../utils/input-statuses';
import { UpdateArgs } from '../../../types/config-section';
const { Title } = Typography;
export default function ConfigNotify() {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { notifications } = serverConfig || {};
const { discord } = notifications || {};
const { enabled, webhook, goLiveMessage } = discord || {};
const [formDataValues, setFormDataValues] = useState<any>({});
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [enableSaveButton, setEnableSaveButton] = useState<boolean>(false);
useEffect(() => {
setFormDataValues({
enabled,
webhook,
goLiveMessage,
});
}, [notifications, discord]);
const canSave = (): boolean => {
if (webhook === '' || goLiveMessage === '') {
return false;
}
return true;
};
// update individual values in state
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
setEnableSaveButton(canSave());
};
let resetTimer = null;
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
const save = async () => {
const postValue = formDataValues;
await postConfigUpdateToAPI({
apiPath: '/notifications/discord',
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'discord',
value: postValue,
path: 'notifications',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
// toggle switch.
const handleSwitchChange = (switchEnabled: boolean) => {
// setShouldDisplayForm(storageEnabled);
handleFieldChange({ fieldName: 'enabled', value: switchEnabled });
};
return (
<>
<Title>Discord</Title>
<p className="description reduced-margins">
Let your Discord channel know each time you go live.
</p>
<p className="description reduced-margins">
<a
href="https://support.discord.com/hc/en-us/articles/228383668"
target="_blank"
rel="noreferrer"
>
Create a webhook
</a>{' '}
under <i>Edit Channel / Integrations</i> on your Discord channel and provide it below.
</p>
<ToggleSwitch
apiPath=""
fieldName="discordEnabled"
label="Enable Discord"
checked={formDataValues.enabled}
onChange={handleSwitchChange}
/>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<TextField
{...DISCORD_CONFIG_FIELDS.webhookUrl}
required
value={formDataValues.webhook}
onChange={handleFieldChange}
/>
</div>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<TextField
{...DISCORD_CONFIG_FIELDS.goLiveMessage}
required
value={formDataValues.goLiveMessage}
onChange={handleFieldChange}
/>
</div>
<Button
type="primary"
onClick={save}
style={{
display: enableSaveButton ? 'inline-block' : 'none',
position: 'relative',
marginLeft: 'auto',
right: '0',
marginTop: '20px',
}}
>
Save
</Button>
<FormStatusIndicator status={submitStatus} />
</>
);
}

View file

@ -0,0 +1,51 @@
import { Button, Typography } from 'antd';
import React, { useState, useContext, useEffect } from 'react';
import Link from 'next/link';
import { ServerStatusContext } from '../../../utils/server-status-context';
const { Title } = Typography;
export default function ConfigNotify() {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { federation } = serverConfig || {};
const { enabled } = federation || {};
const [formDataValues, setFormDataValues] = useState<any>({});
useEffect(() => {
setFormDataValues({
enabled,
});
}, [enabled]);
return (
<>
<Title>Fediverse Social</Title>
<p className="description">
Enabling the Fediverse social features will not just alert people to when you go live, but
also enable other functionality.
</p>
<p>
Fediverse social features:{' '}
<span style={{ color: federation.enabled ? 'green' : 'red' }}>
{formDataValues.enabled ? 'Enabled' : 'Disabled'}
</span>
</p>
<Link passHref href="/config-federation">
<Button
type="primary"
style={{
position: 'relative',
marginLeft: 'auto',
right: '0',
marginTop: '20px',
}}
>
Configure
</Button>
</Link>
</>
);
}

View file

@ -0,0 +1,225 @@
import { Button, Typography } from 'antd';
import React, { useState, useContext, useEffect } from 'react';
import { ServerStatusContext } from '../../../utils/server-status-context';
import TextField, { TEXTFIELD_TYPE_PASSWORD } from '../form-textfield';
import FormStatusIndicator from '../form-status-indicator';
import {
postConfigUpdateToAPI,
RESET_TIMEOUT,
TWITTER_CONFIG_FIELDS,
} from '../../../utils/config-constants';
import ToggleSwitch from '../form-toggleswitch';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_SUCCESS,
} from '../../../utils/input-statuses';
import { UpdateArgs } from '../../../types/config-section';
import { TEXTFIELD_TYPE_TEXT } from '../form-textfield-with-submit';
const { Title } = Typography;
export default function ConfigNotify() {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { notifications } = serverConfig || {};
const { twitter } = notifications || {};
const [formDataValues, setFormDataValues] = useState<any>({});
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [enableSaveButton, setEnableSaveButton] = useState<boolean>(false);
useEffect(() => {
const {
enabled,
apiKey,
apiSecret,
accessToken,
accessTokenSecret,
bearerToken,
goLiveMessage,
} = twitter || {};
setFormDataValues({
enabled,
apiKey,
apiSecret,
accessToken,
accessTokenSecret,
bearerToken,
goLiveMessage,
});
}, [twitter]);
const canSave = (): boolean => {
const {
enabled,
apiKey,
apiSecret,
accessToken,
accessTokenSecret,
bearerToken,
goLiveMessage,
} = formDataValues;
return (
enabled &&
!!apiKey &&
!!apiSecret &&
!!accessToken &&
!!accessTokenSecret &&
!!bearerToken &&
!!goLiveMessage
);
};
// update individual values in state
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
setEnableSaveButton(canSave());
};
// toggle switch.
const handleSwitchChange = (switchEnabled: boolean) => {
const previouslySaved = formDataValues.enabled;
handleFieldChange({ fieldName: 'enabled', value: switchEnabled });
return switchEnabled !== previouslySaved;
};
let resetTimer = null;
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
setEnableSaveButton(false);
};
const save = async () => {
const postValue = formDataValues;
await postConfigUpdateToAPI({
apiPath: '/notifications/twitter',
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'twitter',
value: postValue,
path: 'notifications',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
return (
<>
<Title>Twitter</Title>
<p className="description reduced-margins">
Let your Twitter followers know each time you go live.
</p>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<p className="description reduced-margins">
<a href="https://owncast.online/docs/notifications" target="_blank" rel="noreferrer">
Read how to configure your Twitter account
</a>{' '}
to support posting from Owncast.
</p>
<p className="description reduced-margins">
<a
href="https://developer.twitter.com/en/portal/dashboard"
target="_blank"
rel="noreferrer"
>
And then get your Twitter developer credentials
</a>{' '}
to fill in below.
</p>
</div>
<ToggleSwitch
apiPath=""
fieldName="enabled"
label="Enable Twitter"
onChange={handleSwitchChange}
checked={formDataValues.enabled}
/>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<TextField
{...TWITTER_CONFIG_FIELDS.apiKey}
required
value={formDataValues.apiKey}
onChange={handleFieldChange}
/>
</div>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<TextField
{...TWITTER_CONFIG_FIELDS.apiSecret}
type={TEXTFIELD_TYPE_PASSWORD}
required
value={formDataValues.apiSecret}
onChange={handleFieldChange}
/>
</div>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<TextField
{...TWITTER_CONFIG_FIELDS.accessToken}
required
value={formDataValues.accessToken}
onChange={handleFieldChange}
/>
</div>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<TextField
{...TWITTER_CONFIG_FIELDS.accessTokenSecret}
type={TEXTFIELD_TYPE_PASSWORD}
required
value={formDataValues.accessTokenSecret}
onChange={handleFieldChange}
/>
</div>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<TextField
{...TWITTER_CONFIG_FIELDS.bearerToken}
required
value={formDataValues.bearerToken}
onChange={handleFieldChange}
/>
</div>
<div style={{ display: formDataValues.enabled ? 'block' : 'none' }}>
<TextField
{...TWITTER_CONFIG_FIELDS.goLiveMessage}
type={TEXTFIELD_TYPE_TEXT}
required
value={formDataValues.goLiveMessage}
onChange={handleFieldChange}
/>
</div>
<Button
type="primary"
onClick={save}
style={{
display: enableSaveButton ? 'inline-block' : 'none',
position: 'relative',
marginLeft: 'auto',
right: '0',
marginTop: '20px',
}}
>
Save
</Button>
<FormStatusIndicator status={submitStatus} />
</>
);
}

View file

@ -0,0 +1,64 @@
import { Popconfirm, Button, Typography } from 'antd';
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 [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) {
setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${error}`));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
}
};
return (
<>
<Typography.Title level={3} className="section-title">
Reset Directory
</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
live it will try and re-register your server with the directory from scratch.
</p>
<Popconfirm
placement="topLeft"
title="Are you sure you want to reset your connection to the Owncast directory?"
onConfirm={resetDirectoryRegistration}
okText="Yes"
cancelText="No"
>
<Button type="primary">Reset Directory Connection</Button>
</Popconfirm>
<p>
<FormStatusIndicator status={submitStatus} />
</p>
</>
);
}

View file

@ -0,0 +1,65 @@
import React from 'react';
import { Select } from 'antd';
import { SocialHandleDropdownItem } from '../../types/config-section';
import { NEXT_PUBLIC_API_HOST } from '../../utils/apis';
import { OTHER_SOCIAL_HANDLE_OPTION } from '../../utils/config-constants';
interface DropdownProps {
iconList: SocialHandleDropdownItem[];
selectedOption: string;
onSelected: any;
}
export default function SocialDropdown({ iconList, selectedOption, onSelected }: DropdownProps) {
const handleSelected = (value: string) => {
if (onSelected) {
onSelected(value);
}
};
const inititalSelected = selectedOption === '' ? null : selectedOption;
return (
<div className="social-dropdown-container">
<p className="description">
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>
<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;
const iconUrl = `${NEXT_PUBLIC_API_HOST}${icon.slice(1)}`;
return (
<Select.Option className="social-option" key={`platform-${key}`} value={key}>
<span className="option-icon">
<img src={iconUrl} 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>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,173 @@
import { Popconfirm, Select, Typography } from 'antd';
import React, { useContext, useEffect, useState } from 'react';
import { AlertMessageContext } from '../../utils/alert-message-context';
import {
API_VIDEO_CODEC,
postConfigUpdateToAPI,
RESET_TIMEOUT,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { ServerStatusContext } from '../../utils/server-status-context';
import FormStatusIndicator from './form-status-indicator';
// eslint-disable-next-line react/function-component-definition
export default function CodecSelector() {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { videoCodec, supportedCodecs } = serverConfig || {};
const { Title } = Typography;
const { Option } = Select;
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const { setMessage } = useContext(AlertMessageContext);
const [selectedCodec, setSelectedCodec] = useState(videoCodec);
const [pendingSaveCodec, setPendingSavecodec] = useState(videoCodec);
const [confirmPopupVisible, setConfirmPopupVisible] = React.useState(false);
let resetTimer = null;
useEffect(() => {
setSelectedCodec(videoCodec);
}, [videoCodec]);
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
function handleChange(value) {
setPendingSavecodec(value);
setConfirmPopupVisible(true);
}
async function save() {
setSelectedCodec(pendingSaveCodec);
setPendingSavecodec('');
setConfirmPopupVisible(false);
await postConfigUpdateToAPI({
apiPath: API_VIDEO_CODEC,
data: { value: pendingSaveCodec },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'videoCodec',
value: pendingSaveCodec,
path: 'videoSettings',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Video codec updated.'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
if (serverStatusData.online) {
setMessage(
'Your latency buffer setting will take effect the next time you begin a live stream.',
);
}
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
}
const items = supportedCodecs.map(codec => {
let title = codec;
if (title === 'libx264') {
title = 'Default (libx264)';
} else if (title === 'h264_nvenc') {
title = 'NVIDIA GPU acceleration';
} else if (title === 'h264_vaapi') {
title = 'VA-API hardware encoding';
} else if (title === 'h264_qsv') {
title = 'Intel QuickSync';
} else if (title === 'h264_v4l2m2m') {
title = 'Video4Linux hardware encoding';
} else if (title === 'h264_omx') {
title = 'OpenMax (omx) for Raspberry Pi';
} else if (title === 'h264_videotoolbox') {
title = 'Apple VideoToolbox (hardware)';
}
return (
<Option key={codec} value={codec}>
{title}
</Option>
);
});
let description = '';
if (selectedCodec === 'libx264') {
description =
'libx264 is the default codec and generally the only working choice for shared VPS environments. This is likely what you should be using unless you know you have set up other options.';
} else if (selectedCodec === 'h264_nvenc') {
description =
'You can use your NVIDIA GPU for encoding if you have a modern NVIDIA card with encoding cores.';
} else if (selectedCodec === 'h264_vaapi') {
description =
'VA-API may be supported by your NVIDIA proprietary drivers, Mesa open-source drivers for AMD or Intel graphics.';
} else if (selectedCodec === 'h264_qsv') {
description =
"Quick Sync Video is Intel's brand for its dedicated video encoding and decoding hardware. It may be an option if you have a modern Intel CPU with integrated graphics.";
} else if (selectedCodec === 'h264_v4l2m2m') {
description =
'Video4Linux is an interface to multiple different hardware encoding platforms such as Intel and AMD.';
} else if (selectedCodec === 'h264_omx') {
description = 'OpenMax is a codec most often used with a Raspberry Pi.';
} else if (selectedCodec === 'h264_videotoolbox') {
description =
'Apple VideoToolbox is a low-level framework that provides direct access to hardware encoders and decoders.';
}
return (
<>
<Title level={3} className="section-title">
Video Codec
</Title>
<div className="description">
If you have access to specific hardware with the drivers and software installed for them,
you may be able to improve your video encoding performance.
<p>
<a
href="https://owncast.online/docs/codecs?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Read the documentation about this setting before changing it or you may make your stream
unplayable.
</a>
</p>
</div>
<div className="segment-slider-container">
<Popconfirm
title={`Are you sure you want to change your video codec to ${pendingSaveCodec} and understand what this means?`}
visible={confirmPopupVisible}
placement="leftBottom"
onConfirm={save}
onCancel={() => setConfirmPopupVisible(false)}
okText="Yes"
cancelText="No"
>
<Select
defaultValue={selectedCodec}
value={selectedCodec}
style={{ width: '100%' }}
onChange={handleChange}
>
{items}
</Select>
</Popconfirm>
<FormStatusIndicator status={submitStatus} />
<p id="selected-codec-note" className="selected-value-note">
{description}
</p>
</div>
</>
);
}

View file

@ -0,0 +1,133 @@
import React, { useContext, useState, useEffect } from 'react';
import { Typography, Slider } from 'antd';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import {
API_VIDEO_SEGMENTS,
RESET_TIMEOUT,
postConfigUpdateToAPI,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
const { Title } = Typography;
const SLIDER_MARKS = {
0: 'Lowest',
1: '',
2: '',
3: '',
4: 'Highest',
};
const SLIDER_COMMENTS = {
0: 'Lowest latency, lowest error tolerance (Not recommended, may not work for all content/configurations.)',
1: 'Low latency, low error tolerance',
2: 'Medium latency, medium error tolerance (Default)',
3: 'High latency, high error tolerance',
4: 'Highest latency, highest error tolerance',
};
export default function VideoLatency() {
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [selectedOption, setSelectedOption] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { setMessage } = useContext(AlertMessageContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { videoSettings } = serverConfig || {};
let resetTimer = null;
if (!videoSettings) {
return null;
}
useEffect(() => {
setSelectedOption(videoSettings.latencyLevel);
}, [videoSettings]);
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
// posts all the variants at once as an array obj
const postUpdateToAPI = async (postValue: any) => {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath: API_VIDEO_SEGMENTS,
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'latencyLevel',
value: postValue,
path: 'videoSettings',
});
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Latency buffer level updated.'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
if (serverStatusData.online) {
setMessage(
'Your latency buffer setting will take effect the next time you begin a live stream.',
);
}
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
const handleChange = value => {
postUpdateToAPI(value);
};
return (
<div className="config-video-latency-container">
<Title level={3} className="section-title">
Latency Buffer
</Title>
<p className="description">
While it&apos;s natural to want to keep your latency as low as possible, you may experience
reduced error tolerance and stability the lower you go. The lowest setting is not
recommended.
</p>
<p className="description">
For interactive live streams you may want to experiment with a lower latency, for
non-interactive broadcasts you may want to increase it.{' '}
<a
href="https://owncast.online/docs/encoding#latency-buffer?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Read to learn more.
</a>
</p>
<div className="segment-slider-container">
<Slider
tipFormatter={value => SLIDER_COMMENTS[value]}
onChange={handleChange}
min={0}
max={4}
marks={SLIDER_MARKS}
defaultValue={selectedOption}
value={selectedOption}
/>
<p className="selected-value-note">{SLIDER_COMMENTS[selectedOption]}</p>
<FormStatusIndicator status={submitStatus} />
</div>
</div>
);
}

View file

@ -0,0 +1,317 @@
// This content populates the video variant modal, which is spawned from the variants table. This relies on the `dataState` prop fed in by the table.
import React from 'react';
import { Popconfirm, Row, Col, Slider, Collapse, Typography } from 'antd';
import { ExclamationCircleFilled } from '@ant-design/icons';
import classNames from 'classnames';
import { FieldUpdaterFunc, VideoVariant, UpdateArgs } from '../../types/config-section';
import TextField from './form-textfield';
import {
DEFAULT_VARIANT_STATE,
VIDEO_VARIANT_SETTING_DEFAULTS,
VIDEO_NAME_DEFAULTS,
ENCODER_PRESET_SLIDER_MARKS,
ENCODER_PRESET_TOOLTIPS,
VIDEO_BITRATE_DEFAULTS,
VIDEO_BITRATE_SLIDER_MARKS,
FRAMERATE_SLIDER_MARKS,
FRAMERATE_DEFAULTS,
FRAMERATE_TOOLTIPS,
} from '../../utils/config-constants';
import ToggleSwitch from './form-toggleswitch';
const { Panel } = Collapse;
interface VideoVariantFormProps {
dataState: VideoVariant;
onUpdateField: FieldUpdaterFunc;
}
export default function VideoVariantForm({
dataState = DEFAULT_VARIANT_STATE,
onUpdateField,
}: VideoVariantFormProps) {
const videoPassthroughEnabled = dataState.videoPassthrough;
const handleFramerateChange = (value: number) => {
onUpdateField({ fieldName: 'framerate', value });
};
const handleVideoBitrateChange = (value: number) => {
onUpdateField({ fieldName: 'videoBitrate', value });
};
const handleVideoCpuUsageLevelChange = (value: number) => {
onUpdateField({ fieldName: 'cpuUsageLevel', value });
};
const handleScaledWidthChanged = (args: UpdateArgs) => {
const value = Number(args.value);
// eslint-disable-next-line no-restricted-globals
if (isNaN(value)) {
return;
}
onUpdateField({ fieldName: 'scaledWidth', value: value || 0 });
};
const handleScaledHeightChanged = (args: UpdateArgs) => {
const value = Number(args.value);
// eslint-disable-next-line no-restricted-globals
if (isNaN(value)) {
return;
}
onUpdateField({ fieldName: 'scaledHeight', value: value || 0 });
};
// Video passthrough handling
const handleVideoPassConfirm = () => {
onUpdateField({ fieldName: 'videoPassthrough', value: true });
};
// If passthrough is currently on, set it back to false on toggle.
// Else let the Popconfirm turn it on.
const handleVideoPassthroughToggle = (value: boolean) => {
if (videoPassthroughEnabled) {
onUpdateField({ fieldName: 'videoPassthrough', value });
}
};
const handleNameChanged = (args: UpdateArgs) => {
onUpdateField({ fieldName: 'name', value: args.value });
};
// Slider notes
const selectedVideoBRnote = () => {
if (videoPassthroughEnabled) {
return 'Bitrate selection is disabled when Video Passthrough is enabled.';
}
let note = `${dataState.videoBitrate}${VIDEO_BITRATE_DEFAULTS.unit}`;
if (dataState.videoBitrate < 2000) {
note = `${note} - Good for low bandwidth environments.`;
} else if (dataState.videoBitrate < 3500) {
note = `${note} - Good for most bandwidth environments.`;
} else {
note = `${note} - Good for high bandwidth environments.`;
}
return note;
};
const selectedFramerateNote = () => {
if (videoPassthroughEnabled) {
return 'Framerate selection is disabled when Video Passthrough is enabled.';
}
return FRAMERATE_TOOLTIPS[dataState.framerate] || '';
};
const cpuUsageNote = () => {
if (videoPassthroughEnabled) {
return 'CPU usage selection is disabled when Video Passthrough is enabled.';
}
return ENCODER_PRESET_TOOLTIPS[dataState.cpuUsageLevel] || '';
};
const classes = classNames({
'config-variant-form': true,
'video-passthrough-enabled': videoPassthroughEnabled,
});
return (
<div className={classes}>
<p className="description">
<a
href="https://owncast.online/docs/video?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Learn more
</a>{' '}
about how each of these settings can impact the performance of your server.
</p>
{videoPassthroughEnabled && (
<p className="passthrough-warning">
NOTE: Video Passthrough for this output stream variant is <em>enabled</em>, disabling the
below video encoding settings.
</p>
)}
<Row gutter={16}>
<TextField
maxLength="10"
{...VIDEO_NAME_DEFAULTS}
value={dataState.name}
onChange={handleNameChanged}
/>
<Col sm={24} md={12}>
<div className="form-module cpu-usage-container">
<Typography.Title level={3}>CPU or GPU Utilization</Typography.Title>
<p className="description">
Reduce to improve server performance, or increase it to improve video quality.
</p>
<div className="segment-slider-container">
<Slider
tipFormatter={value => ENCODER_PRESET_TOOLTIPS[value]}
onChange={handleVideoCpuUsageLevelChange}
min={1}
max={Object.keys(ENCODER_PRESET_SLIDER_MARKS).length}
marks={ENCODER_PRESET_SLIDER_MARKS}
defaultValue={dataState.cpuUsageLevel}
value={dataState.cpuUsageLevel}
disabled={dataState.videoPassthrough}
/>
<p className="selected-value-note">{cpuUsageNote()}</p>
</div>
<p className="read-more-subtext">
This could mean GPU or CPU usage depending on your server environment.{' '}
<a
href="https://owncast.online/docs/video/?source=admin#cpu-usage"
target="_blank"
rel="noopener noreferrer"
>
Read more about hardware performance.
</a>
</p>
</div>
</Col>
<Col sm={24} md={12}>
{/* VIDEO BITRATE FIELD */}
<div
className={`form-module bitrate-container ${
dataState.videoPassthrough ? 'disabled' : ''
}`}
>
<Typography.Title level={3}>Video Bitrate</Typography.Title>
<p className="description">{VIDEO_BITRATE_DEFAULTS.tip}</p>
<div className="segment-slider-container">
<Slider
tipFormatter={value => `${value} ${VIDEO_BITRATE_DEFAULTS.unit}`}
disabled={dataState.videoPassthrough}
defaultValue={dataState.videoBitrate}
value={dataState.videoBitrate}
onChange={handleVideoBitrateChange}
step={VIDEO_BITRATE_DEFAULTS.incrementBy}
min={VIDEO_BITRATE_DEFAULTS.min}
max={VIDEO_BITRATE_DEFAULTS.max}
marks={VIDEO_BITRATE_SLIDER_MARKS}
/>
<p className="selected-value-note">{selectedVideoBRnote()}</p>
</div>
<p className="read-more-subtext">
<a
href="https://owncast.online/docs/video/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Read more about bitrates.
</a>
</p>
</div>
</Col>
</Row>
<Collapse className="advanced-settings">
<Panel header="Advanced Settings" key="1">
<Row gutter={16}>
<Col sm={24} md={12}>
<div className="form-module resolution-module">
<Typography.Title level={3}>Resolution</Typography.Title>
<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/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Read more about resolutions.
</a>
</p>
<br />
<TextField
type="number"
{...VIDEO_VARIANT_SETTING_DEFAULTS.scaledWidth}
value={dataState.scaledWidth}
onChange={handleScaledWidthChanged}
disabled={dataState.videoPassthrough}
/>
<TextField
type="number"
{...VIDEO_VARIANT_SETTING_DEFAULTS.scaledHeight}
value={dataState.scaledHeight}
onChange={handleScaledHeightChanged}
disabled={dataState.videoPassthrough}
/>
</div>
</Col>
<Col sm={24} md={12}>
{/* VIDEO PASSTHROUGH FIELD */}
<div className="form-module video-passthrough-module">
<Typography.Title level={3}>Video Passthrough</Typography.Title>
<div className="description">
<p>
Enabling video passthrough may allow for less hardware utilization, but may also
make your stream <strong>unplayable</strong>.
</p>
<p>
All other settings for this stream output will be disabled if passthrough is
used.
</p>
<p>
<a
href="https://owncast.online/docs/video/?source=admin#video-passthrough"
target="_blank"
rel="noopener noreferrer"
>
Read the documentation before enabling, as it impacts your stream.
</a>
</p>
</div>
<Popconfirm
disabled={dataState.videoPassthrough === true}
title="Did you read the documentation about video passthrough and understand the risks involved with enabling it?"
icon={<ExclamationCircleFilled />}
onConfirm={handleVideoPassConfirm}
okText="Yes"
cancelText="No"
>
{/* adding an <a> tag to force Popcofirm to register click on toggle */}
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a href="#">
<ToggleSwitch
label="Use Video Passthrough?"
fieldName="video-passthrough"
tip={VIDEO_VARIANT_SETTING_DEFAULTS.videoPassthrough.tip}
checked={dataState.videoPassthrough}
onChange={handleVideoPassthroughToggle}
/>
</a>
</Popconfirm>
</div>
</Col>
</Row>
{/* FRAME RATE FIELD */}
<div className="form-module frame-rate-module">
<Typography.Title level={3}>Frame rate</Typography.Title>
<p className="description">{FRAMERATE_DEFAULTS.tip}</p>
<div className="segment-slider-container">
<Slider
tipFormatter={value => `${value} ${FRAMERATE_DEFAULTS.unit}`}
defaultValue={dataState.framerate}
value={dataState.framerate}
onChange={handleFramerateChange}
step={FRAMERATE_DEFAULTS.incrementBy}
min={FRAMERATE_DEFAULTS.min}
max={FRAMERATE_DEFAULTS.max}
marks={FRAMERATE_SLIDER_MARKS}
disabled={dataState.videoPassthrough}
/>
<p className="selected-value-note">{selectedFramerateNote()}</p>
</div>
<p className="read-more-subtext">
<a
href="https://owncast.online/docs/video/?source=admin#framerate"
target="_blank"
rel="noopener noreferrer"
>
Read more about framerates.
</a>
</p>
</div>
</Panel>
</Collapse>
</div>
);
}

View file

@ -0,0 +1,245 @@
// Updating a variant will post ALL the variants in an array as an update to the API.
import React, { useContext, useState } from 'react';
import { Typography, Table, Modal, Button, Alert } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { DeleteOutlined } from '@ant-design/icons';
import { ServerStatusContext } from '../../utils/server-status-context';
import { AlertMessageContext } from '../../utils/alert-message-context';
import { UpdateArgs, VideoVariant } from '../../types/config-section';
import VideoVariantForm from './video-variant-form';
import {
API_VIDEO_VARIANTS,
DEFAULT_VARIANT_STATE,
RESET_TIMEOUT,
postConfigUpdateToAPI,
ENCODER_PRESET_TOOLTIPS,
ENCODER_RECOMMENDATION_THRESHOLD,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator';
const { Title } = Typography;
export default function CurrentVariantsTable() {
const [displayModal, setDisplayModal] = useState(false);
const [modalProcessing, setModalProcessing] = useState(false);
const [editId, setEditId] = useState(0);
const { setMessage } = useContext(AlertMessageContext);
// current data inside modal
const [modalDataState, setModalDataState] = useState(DEFAULT_VARIANT_STATE);
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { videoSettings } = serverConfig || {};
const { videoQualityVariants } = videoSettings || {};
let resetTimer = null;
if (!videoSettings) {
return null;
}
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
const handleModalCancel = () => {
setDisplayModal(false);
setEditId(-1);
setModalDataState(DEFAULT_VARIANT_STATE);
};
// posts all the variants at once as an array obj
const postUpdateToAPI = async (postValue: any) => {
setSubmitStatus(createInputStatus(STATUS_PROCESSING));
await postConfigUpdateToAPI({
apiPath: API_VIDEO_VARIANTS,
data: { value: postValue },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'videoQualityVariants',
value: postValue,
path: 'videoSettings',
});
// close modal
setModalProcessing(false);
handleModalCancel();
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Variants updated'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
if (serverStatusData.online) {
setMessage(
'Updating your video configuration will take effect the next time you begin a new stream.',
);
}
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
setModalProcessing(false);
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
};
// on Ok, send all of dataState to api
// show loading
// close modal when api is done
const handleModalOk = () => {
setModalProcessing(true);
const postData = [...videoQualityVariants];
if (editId === -1) {
postData.push(modalDataState);
} else {
postData.splice(editId, 1, modalDataState);
}
postUpdateToAPI(postData);
};
const handleDeleteVariant = (index: number) => {
const postData = [...videoQualityVariants];
postData.splice(index, 1);
postUpdateToAPI(postData);
};
const handleUpdateField = ({ fieldName, value }: UpdateArgs) => {
setModalDataState({
...modalDataState,
[fieldName]: value,
});
};
const videoQualityColumns: ColumnsType<VideoVariant> = [
{
title: 'Name',
dataIndex: 'name',
render: (name: string) => (!name ? 'No name' : name),
},
{
title: 'Video bitrate',
dataIndex: 'videoBitrate',
key: 'videoBitrate',
render: (bitrate: number, variant: VideoVariant) =>
!bitrate || variant.videoPassthrough ? 'Same as source' : `${bitrate} kbps`,
},
{
title: 'CPU Usage',
dataIndex: 'cpuUsageLevel',
key: 'cpuUsageLevel',
render: (level: string, variant: VideoVariant) =>
!level || variant.videoPassthrough ? 'n/a' : ENCODER_PRESET_TOOLTIPS[level].split(' ')[0],
},
{
title: '',
dataIndex: '',
key: 'edit',
render: ({ key }: VideoVariant) => {
const index = key - 1;
return (
<span className="actions">
<Button
size="small"
onClick={() => {
setEditId(index);
setModalDataState(videoQualityVariants[index]);
setDisplayModal(true);
}}
>
Edit
</Button>
<Button
className="delete-button"
icon={<DeleteOutlined />}
size="small"
disabled={videoQualityVariants.length === 1}
onClick={() => {
handleDeleteVariant(index);
}}
/>
</span>
);
},
},
];
const videoQualityVariantData = videoQualityVariants.map((variant, index) => ({
key: index + 1,
...variant,
}));
const showSecondVariantRecommendation = (): boolean => {
if (videoQualityVariants.length !== 1) {
return false;
}
const [variant] = videoQualityVariants;
return (
ENCODER_RECOMMENDATION_THRESHOLD.VIDEO_HEIGHT <= variant.scaledHeight ||
ENCODER_RECOMMENDATION_THRESHOLD.VIDEO_BITRATE <= variant.videoBitrate
);
};
return (
<>
<Title level={3} className="section-title">
Stream output
</Title>
{showSecondVariantRecommendation() && (
<Alert message={ENCODER_RECOMMENDATION_THRESHOLD.HELP_TEXT} type="info" closable />
)}
<FormStatusIndicator status={submitStatus} />
<Table
className="variants-table"
pagination={false}
size="small"
columns={videoQualityColumns}
dataSource={videoQualityVariantData}
/>
<Modal
title="Edit Video Variant Details"
visible={displayModal}
onOk={handleModalOk}
onCancel={handleModalCancel}
confirmLoading={modalProcessing}
width={900}
>
<VideoVariantForm dataState={{ ...modalDataState }} onUpdateField={handleUpdateField} />
<FormStatusIndicator status={submitStatus} />
</Modal>
<br />
<Button
type="primary"
onClick={() => {
setEditId(-1);
setModalDataState(DEFAULT_VARIANT_STATE);
setDisplayModal(true);
}}
>
Add a new variant
</Button>
</>
);
}

View file

@ -0,0 +1,20 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd';
interface InfoTipProps {
tip: string | null;
}
export default function InfoTip({ tip }: InfoTipProps) {
if (tip === '' || tip === null) {
return null;
}
return (
<span className="info-tip">
<Tooltip title={tip}>
<InfoCircleOutlined />
</Tooltip>
</span>
);
}

View file

@ -0,0 +1,30 @@
import { Table, Typography } from 'antd';
const { Title } = Typography;
export default function KeyValueTable({ title, data }: KeyValueTableProps) {
const columns = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
},
];
return (
<>
<Title level={2}>{title}</Title>
<Table pagination={false} columns={columns} dataSource={data} rowKey="name" />
</>
);
}
interface KeyValueTableProps {
title: string;
data: any;
}

View file

@ -0,0 +1,11 @@
import { AppProps } from 'next/app';
function SimpleLayout({ Component, pageProps }: AppProps) {
return (
<div>
<Component {...pageProps} />
</div>
);
}
export default SimpleLayout;

View file

@ -0,0 +1,88 @@
import React from 'react';
import { Table, Tag, Typography } from 'antd';
import Linkify from 'react-linkify';
import { SortOrder } from 'antd/lib/table/interface';
import format from 'date-fns/format';
const { Title } = Typography;
function renderColumnLevel(text, entry) {
let color = 'black';
if (entry.level === 'warning') {
color = 'orange';
} else if (entry.level === 'error') {
color = 'red';
}
return <Tag color={color}>{text}</Tag>;
}
function renderMessage(text) {
return <Linkify>{text}</Linkify>;
}
interface Props {
logs: object[];
pageSize: number;
}
export default function LogTable({ logs, pageSize }: Props) {
if (!logs?.length) {
return null;
}
const columns = [
{
title: 'Level',
dataIndex: 'level',
key: 'level',
filters: [
{
text: 'Info',
value: 'info',
},
{
text: 'Warning',
value: 'warning',
},
{
text: 'Error',
value: 'Error',
},
],
onFilter: (level, row) => row.level.indexOf(level) === 0,
render: renderColumnLevel,
},
{
title: 'Timestamp',
dataIndex: 'time',
key: 'time',
render: timestamp => {
const dateObject = new Date(timestamp);
return format(dateObject, 'pp P');
},
sorter: (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
defaultSortOrder: 'descend' as SortOrder,
},
{
title: 'Message',
dataIndex: 'message',
key: 'message',
render: renderMessage,
},
];
return (
<div className="logs-section">
<Title>Logs</Title>
<Table
size="middle"
dataSource={logs}
columns={columns}
rowKey={row => row.time}
pagination={{ pageSize: pageSize || 20 }}
/>
</div>
);
}

159
web/components/logo.tsx Normal file
View file

@ -0,0 +1,159 @@
import React from 'react';
export default function Logo() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 95.68623352050781 104.46271514892578"
className="logo-svg"
>
<g transform="matrix(1 0 0 1 -37.08803939819336 -18.940391540527344)">
<g>
<g>
<g>
<g transform="matrix(1.0445680396949917 0 0 1.0445679172996596 36.34559138380523 18.877718021903796)">
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient120"
gradientTransform="rotate(-90 .5 .5)"
>
<stop offset="0" stopColor="#1f2022" stopOpacity="1" />
<stop offset="1" stopColor="#635e69" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient120)"
d="M91.5 75.35Q93.05 71.15 91.65 67.7 90.35 64.5 86.65 62.3 83.2 60.3 78.3 59.4 73.85 58.6 68.6 58.7 63.55 58.85 58.8 59.8 54.25 60.75 50.8 62.2 47.4 63.65 45.5 65.35 43.6 67.15 43.5 69.05 43.35 71.3 45.8 73.9 48.05 76.3 52.1 78.6 56.15 80.9 61.05 82.55 66.3 84.3 71.4 84.8 74.7 85.1 77.55 84.9 80.65 84.6 83.3 83.6 86.15 82.5 88.15 80.55 90.4 78.4 91.5 75.35M70.6 67.5Q72.3 68.4 73.1 69.7 73.9 71.15 73.45 73 73.1 74.3 72.3 75.25 71.55 76.1 70.3 76.6 69.25 77.05 67.75 77.25 66.3 77.4 64.85 77.3 62.3 77.15 59.25 76.3 56.6 75.5 54.15 74.3 51.9 73.2 50.45 72 49.05 70.75 49.1 69.8 49.2 69 50.25 68.25 51.3 67.55 53.15 67 55 66.4 57.25 66.1 59.8 65.8 62.1 65.8 64.65 65.85 66.7 66.2 68.9 66.65 70.6 67.5Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient121"
gradientTransform="rotate(-180 .5 .5)"
>
<stop offset="0" stopColor="#2087e2" stopOpacity="1" />
<stop offset="1" stopColor="#b63fff" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient121)"
d="M66.6 15.05Q66.4 9.65 63.9 6.05 61.25 2.1 56.1 0.65 54.95 0.3 53.65 0.15 52.5 0 51.3 0.1 50.2 0.1 49.1 0.35 48.15 0.55 47 1 43.3 2.45 40.3 6.1 37.5 9.4 35.5 14.3 33.75 18.45 32.7 23.4 31.7 28.05 31.35 32.85 31.05 37.2 31.3 41.2 31.6 45.15 32.4 48.35 34 54.9 37.3 56.4 37.6 56.55 37.9 56.65L39.2 56.85Q39.45 56.85 39.95 56.8 42.05 56.6 44.7 55.05 47.25 53.5 50.05 50.8 53.05 47.9 55.85 44.05 58.8 40.05 61.1 35.6 63.8 30.35 65.25 25.3 66.75 19.75 66.6 15.05M47.55 23.15Q48.05 23.25 48.4 23.4 52.45 24.8 52.55 29.85 52.6 34 50 39.4 47.85 43.9 44.85 47.3 42.05 50.5 40.15 50.7L39.9 50.75 39.45 50.7 39.2 50.6Q37.8 49.95 37.25 46.35 36.7 42.7 37.3 38 37.95 32.75 39.75 28.8 41.9 24.1 45.05 23.25 45.6 23.1 45.85 23.1 46.25 23.05 46.65 23.05 47.05 23.05 47.55 23.15Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient122"
gradientTransform="rotate(-90 .5 .5)"
>
<stop offset="0" stopColor="#100f0f" stopOpacity="1" />
<stop offset="1" stopColor="#49261F" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient122)"
d="M2.7 33.6Q2.1 34.4 1.7 35.35 1.25 36.5 1.05 37.7 0 42.6 2.2 47.2 4 51 8 54.35 11.55 57.3 16 59.15 20.5 61 23.85 60.85 24.5 60.85 25.25 60.7 26 60.55 26.5 60.3 27 60.05 27.45 59.65 27.9 59.25 28.15 58.75 29.35 56.45 27.5 51.65 25.6 47 21.75 42.1 17.75 37 13.4 34.05 8.7 30.9 5.45 31.7 4.65 31.9 3.95 32.4 3.25 32.85 2.7 33.6M10.1 43.55Q10.35 43.1 10.6 42.85 10.85 42.6 11.2 42.4 11.6 42.25 11.9 42.2 13.5 41.9 15.95 43.6 18.15 45.05 20.35 47.7 22.35 50.1 23.55 52.4 24.7 54.75 24.25 55.7 24.15 55.9 24 56 23.85 56.2 23.65 56.25 23.55 56.35 23.25 56.4L22.7 56.5Q21.1 56.6 18.55 55.6 16.05 54.6 13.85 52.95 11.5 51.2 10.35 49.15 9.05 46.8 9.75 44.45 9.9 43.95 10.1 43.55Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient123"
gradientTransform="rotate(-180 .5 .5)"
>
<stop offset="0" stopColor="#222020" stopOpacity="1" />
<stop offset="1" stopColor="#49261F" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient123)"
d="M34.95 74.2L34.75 74.2Q33.2 74.15 31.9 75.25 30.7 76.3 29.85 78.25 29.1 80 28.8 82.2 28.5 84.4 28.7 86.65 29.1 91.4 31.5 94.7 34.3 98.5 39.3 99.7L39.4 99.7 39.7 99.8 39.85 99.8Q45.3 100.85 47.15 97.75 48 96.3 48 94.05 47.95 91.9 47.2 89.35 46.45 86.75 45.1 84.15 43.75 81.5 42.05 79.35 40.25 77.1 38.45 75.75 36.55 74.35 34.95 74.2M33.55 80.4Q34.35 78.2 35.6 78.3L35.65 78.3Q36.9 78.45 38.6 80.9 40.3 83.35 41.15 86.05 42.1 89 41.55 90.75 40.9 92.6 38.35 92.25L38.3 92.25 38.25 92.2 38.1 92.2Q35.6 91.7 34.25 89.6 33.1 87.7 32.95 85 32.8 82.35 33.55 80.4Z"
/>
</g>
<g transform="matrix(0.9999999999999999 0 0 1 0 5.684341886080802e-14)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient124"
gradientTransform="rotate(-180 .5 .5)"
>
{' '}
<stop offset="0" stopColor="#1e1c1c" stopOpacity="1" />
<stop offset="1" stopColor="#49261F" stopOpacity="1" />
</linearGradient>
</defs>
<path
fill="url(#gradient124)"
d="M22.7 69.65Q22.25 69.3 21.6 69.05 20.95 68.8 20.25 68.7 19.6 68.55 18.85 68.5 16.7 68.45 14.65 69.15 12.65 69.8 11.4 71.1 10.15 72.5 10.2 74.2 10.25 76.05 11.95 78.2 12.4 78.75 13.05 79.4 13.55 79.9 14.2 80.3 14.7 80.6 15.3 80.85 16 81.1 16.4 81.1 18.2 81.35 19.9 80.35 21.55 79.4 22.75 77.65 24 75.85 24.3 73.95 24.6 71.85 23.55 70.5 23.15 70 22.7 69.65M21.7 71.7Q22.15 72.3 21.9 73.3 21.7 74.25 21 75.25 20.3 76.2 19.4 76.75 18.45 77.35 17.55 77.25L17 77.15Q16.7 77.05 16.45 76.85 16.25 76.75 15.9 76.45 15.7 76.25 15.4 75.9 14.5 74.75 14.7 73.8 14.8 72.95 15.75 72.3 16.6 71.7 17.8 71.4 19 71.1 20.1 71.15L20.65 71.2 21.1 71.3Q21.3 71.4 21.45 71.5L21.7 71.7Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient125"
gradientTransform="rotate(-360 .5 .5)"
>
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0.5" />
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0.2" />
</linearGradient>
</defs>
<path
fill="url(#gradient125)"
d="M52.6 19.25Q59.6 19.25 66.2 20.95 66.7 17.8 66.6 15.05 66.4 9.65 63.9 6.05 61.25 2.1 56.1 0.65 54.95 0.3 53.65 0.15 52.5 0 51.3 0.1 50.2 0.1 49.1 0.35 48.15 0.55 47 1 43.3 2.45 40.3 6.1 37.5 9.4 35.5 14.3 33.85 18.3 32.8 22.85 42.25 19.25 52.6 19.25Z"
/>
</g>
<g transform="matrix(1 0 0 1 0 0)">
<defs>
<linearGradient
x1="0"
y1="0"
x2="0"
y2="1"
id="gradient126"
gradientTransform="rotate(-360 .5 .5)"
>
<stop offset="0" stopColor="#FFFFFF" stopOpacity="0.5" />
<stop offset="1" stopColor="#FFFFFF" stopOpacity="0.2" />
</linearGradient>
</defs>
<path
fill="url(#gradient126)"
d="M1.05 37.7Q0 42.6 2.2 47.2 2.95 48.8 4.05 50.25 7.55 41.65 14.4 34.75 14 34.45 13.4 34.05 8.7 30.9 5.45 31.7 4.65 31.9 3.95 32.4 3.25 32.85 2.7 33.6 2.1 34.4 1.7 35.35 1.25 36.5 1.05 37.7Z"
/>
</g>
</g>
</g>
<g transform="matrix(1.219512230276127 0 0 1.2195122143630526 32.82519274395008 88.56945194723018)">
<path fill="#000000" fillOpacity="1" d="" />
</g>
</g>
</g>
</g>
</svg>
);
}

View file

@ -0,0 +1,294 @@
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import Link from 'next/link';
import Head from 'next/head';
import { differenceInSeconds } from 'date-fns';
import { useRouter } from 'next/router';
import { Layout, Menu, Popover, Alert, Typography, Button, Space, Tooltip } from 'antd';
import {
SettingOutlined,
HomeOutlined,
LineChartOutlined,
ToolOutlined,
PlayCircleFilled,
MinusSquareFilled,
QuestionCircleOutlined,
MessageOutlined,
ExperimentOutlined,
EditOutlined,
} from '@ant-design/icons';
import classNames from 'classnames';
import { upgradeVersionAvailable } from '../utils/apis';
import { parseSecondsToDurationString } from '../utils/format';
import OwncastLogo from './logo';
import { ServerStatusContext } from '../utils/server-status-context';
import { AlertMessageContext } from '../utils/alert-message-context';
import TextFieldWithSubmit from './config/form-textfield-with-submit';
import { TEXTFIELD_PROPS_STREAM_TITLE } from '../utils/config-constants';
import ComposeFederatedPost from './compose-federated-post';
import { UpdateArgs } from '../types/config-section';
// eslint-disable-next-line react/function-component-definition
export default function MainLayout(props) {
const { children } = props;
const context = useContext(ServerStatusContext);
const { serverConfig, online, broadcaster, versionNumber } = context || {};
const { instanceDetails, chatDisabled, federation } = serverConfig;
const { enabled: federationEnabled } = federation;
const [currentStreamTitle, setCurrentStreamTitle] = useState('');
const [postModalDisplayed, setPostModalDisplayed] = useState(false);
const alertMessage = useContext(AlertMessageContext);
const router = useRouter();
const { route } = router || {};
const { Header, Footer, Content, Sider } = Layout;
const { SubMenu } = Menu;
const [upgradeVersion, setUpgradeVersion] = useState('');
const checkForUpgrade = async () => {
try {
const result = await upgradeVersionAvailable(versionNumber);
setUpgradeVersion(result);
} catch (error) {
console.log('==== error', error);
}
};
useEffect(() => {
checkForUpgrade();
}, [versionNumber]);
useEffect(() => {
setCurrentStreamTitle(instanceDetails.streamTitle);
}, [instanceDetails]);
const handleStreamTitleChanged = ({ value }: UpdateArgs) => {
setCurrentStreamTitle(value);
};
const handleCreatePostButtonPressed = () => {
setPostModalDisplayed(true);
};
const appClass = classNames({
'app-container': true,
online,
});
const upgradeMenuItemStyle = upgradeVersion ? 'block' : 'none';
const upgradeVersionString = `${upgradeVersion}` || '';
const upgradeMessage = `Upgrade to v${upgradeVersionString}`;
const chatMenuItemStyle = chatDisabled ? 'none' : 'block';
const openMenuItems = upgradeVersion ? ['utilities-menu'] : [];
const clearAlertMessage = () => {
alertMessage.setMessage(null);
};
const headerAlertMessage = alertMessage.message ? (
<Alert message={alertMessage.message} afterClose={clearAlertMessage} banner closable />
) : null;
// status indicator items
const streamDurationString = broadcaster
? parseSecondsToDurationString(differenceInSeconds(new Date(), new Date(broadcaster.time)))
: '';
const currentThumbnail = online ? (
<img src="/thumbnail.jpg" className="online-thumbnail" alt="current thumbnail" width="1rem" />
) : null;
const statusIcon = online ? <PlayCircleFilled /> : <MinusSquareFilled />;
const statusMessage = online ? `Online ${streamDurationString}` : 'Offline';
const popoverTitle = <Typography.Text>Thumbnail</Typography.Text>;
const statusIndicator = (
<div className="online-status-indicator">
<span className="status-label">{statusMessage}</span>
<span className="status-icon">{statusIcon}</span>
</div>
);
const statusIndicatorWithThumb = online ? (
<Popover content={currentThumbnail} title={popoverTitle} trigger="hover">
{statusIndicator}
</Popover>
) : (
statusIndicator
);
return (
<Layout className={appClass}>
<Head>
<title>Owncast Admin</title>
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png" />
</Head>
<Sider width={240} className="side-nav">
<h1 className="owncast-title">
<span className="logo-container">
<OwncastLogo />
</span>
<span className="title-label">Owncast Admin</span>
</h1>
<Menu
defaultSelectedKeys={[route.substring(1) || 'home']}
defaultOpenKeys={openMenuItems}
mode="inline"
className="menu-container"
>
<Menu.Item key="home" icon={<HomeOutlined />}>
<Link href="/admin">Home</Link>
</Menu.Item>
<Menu.Item key="viewer-info" icon={<LineChartOutlined />} title="Current stream">
<Link href="/admin/viewer-info">Viewers</Link>
</Menu.Item>
<SubMenu
key="chat-config"
title="Chat &amp; Users"
icon={<MessageOutlined />}
style={{ display: chatMenuItemStyle }}
>
<Menu.Item key="messages" title="Chat utilities">
<Link href="/admin/chat/messages">Messages</Link>
</Menu.Item>
<Menu.Item key="chat-users" title="Chat utilities">
<Link href="/admin/chat/users">Users</Link>
</Menu.Item>
</SubMenu>
<Menu.Item
style={{ display: federationEnabled ? 'block' : 'none' }}
key="federation-followers"
title="Fediverse followers"
icon={
<img
alt="fediverse icon"
src="/admin/fediverse-white.png"
width="15rem"
style={{ opacity: 0.6, position: 'relative', top: '-1px' }}
/>
}
>
<Link href="/admin/federation/followers">Followers</Link>
</Menu.Item>
<SubMenu key="configuration" title="Configuration" icon={<SettingOutlined />}>
<Menu.Item key="config-public-details">
<Link href="/admin/config-public-details">General</Link>
</Menu.Item>
<Menu.Item key="config-server-details">
<Link href="/admin/config-server-details">Server Setup</Link>
</Menu.Item>
<Menu.Item key="config-video">
<Link href="/admin/config-video">Video</Link>
</Menu.Item>
<Menu.Item key="config-chat">
<Link href="/admin/config-chat">Chat</Link>
</Menu.Item>
<Menu.Item key="config-federation">
<Link href="/admin/config-federation">Social</Link>
</Menu.Item>
<Menu.Item key="config-notify">
<Link href="/admin/config-notify">Notifications</Link>
</Menu.Item>
<Menu.Item key="config-storage">
<Link href="/admin/config-storage">S3 Storage</Link>
</Menu.Item>
</SubMenu>
<SubMenu key="utilities-menu" icon={<ToolOutlined />} title="Utilities">
<Menu.Item key="hardware-info">
<Link href="/admin/hardware-info">Hardware</Link>
</Menu.Item>
<Menu.Item key="stream-health">
<Link href="/admin/stream-health">Stream Health</Link>
</Menu.Item>
<Menu.Item key="logs">
<Link href="/admin/logs">Logs</Link>
</Menu.Item>
<Menu.Item
key="federation-activities"
title="Social Actions"
style={{ display: federationEnabled ? 'block' : 'none' }}
>
<Link href="/admin/federation/actions">Social Actions</Link>
</Menu.Item>
<Menu.Item key="upgrade" style={{ display: upgradeMenuItemStyle }}>
<Link href="/upgrade">{upgradeMessage}</Link>
</Menu.Item>
</SubMenu>
<SubMenu key="integrations-menu" icon={<ExperimentOutlined />} title="Integrations">
<Menu.Item key="webhooks">
<Link href="/admin/webhooks">Webhooks</Link>
</Menu.Item>
<Menu.Item key="access-tokens">
<Link href="/admin/access-tokens">Access Tokens</Link>
</Menu.Item>
<Menu.Item key="actions">
<Link href="/admin/actions">External Actions</Link>
</Menu.Item>
</SubMenu>
<Menu.Item key="help" icon={<QuestionCircleOutlined />} title="Help">
<Link href="/admin/help">Help</Link>
</Menu.Item>
</Menu>
</Sider>
<Layout className="layout-main">
<Header className="layout-header">
<Space direction="horizontal">
<Tooltip title="Compose post to your followers">
<Button
type="primary"
shape="circle"
icon={<EditOutlined />}
size="large"
onClick={handleCreatePostButtonPressed}
style={{ display: federationEnabled ? 'block' : 'none' }}
/>
</Tooltip>
</Space>
<div className="global-stream-title-container">
<TextFieldWithSubmit
fieldName="streamTitle"
{...TEXTFIELD_PROPS_STREAM_TITLE}
placeholder="What are you streaming now"
value={currentStreamTitle}
initialValue={instanceDetails.streamTitle}
onChange={handleStreamTitleChanged}
/>
</div>
<Space direction="horizontal">{statusIndicatorWithThumb}</Space>
</Header>
{headerAlertMessage}
<Content className="main-content-container">{children}</Content>
<Footer className="footer-container">
<a href="https://owncast.online/?source=admin" target="_blank" rel="noopener noreferrer">
About Owncast v{versionNumber}
</a>
</Footer>
</Layout>
<ComposeFederatedPost
visible={postModalDisplayed}
handleClose={() => setPostModalDisplayed(false)}
/>
</Layout>
);
}
MainLayout.propTypes = {
children: PropTypes.element.isRequired,
};

View file

@ -0,0 +1,92 @@
// Custom component for AntDesign Button that makes an api call, then displays a confirmation icon upon
import React, { useState, useEffect } from 'react';
import { Button, Tooltip } from 'antd';
import {
EyeOutlined,
EyeInvisibleOutlined,
CheckCircleFilled,
ExclamationCircleFilled,
} from '@ant-design/icons';
import { fetchData, UPDATE_CHAT_MESSGAE_VIZ } from '../utils/apis';
import { MessageType } from '../types/chat';
import { OUTCOME_TIMEOUT } from '../pages/admin/chat/messages';
import { isEmptyObject } from '../utils/format';
interface MessageToggleProps {
isVisible: boolean;
message: MessageType;
setMessage: (message: MessageType) => void;
}
export default function MessageVisiblityToggle({
isVisible,
message,
setMessage,
}: MessageToggleProps) {
if (!message || isEmptyObject(message)) {
return null;
}
let outcomeTimeout = null;
const [outcome, setOutcome] = useState(0);
const { id: messageId } = message || {};
const resetOutcome = () => {
outcomeTimeout = setTimeout(() => {
setOutcome(0);
}, OUTCOME_TIMEOUT);
};
useEffect(() => () => {
clearTimeout(outcomeTimeout);
});
const updateChatMessage = async () => {
clearTimeout(outcomeTimeout);
setOutcome(0);
const result = await fetchData(UPDATE_CHAT_MESSGAE_VIZ, {
auth: true,
method: 'POST',
data: {
visible: !isVisible,
idArray: [messageId],
},
});
if (result.success && result.message === 'changed') {
setMessage({ ...message, visible: !isVisible });
setOutcome(1);
} else {
setMessage({ ...message, visible: isVisible });
setOutcome(-1);
}
resetOutcome();
};
let outcomeIcon = <CheckCircleFilled style={{ color: 'transparent' }} />;
if (outcome) {
outcomeIcon =
outcome > 0 ? (
<CheckCircleFilled style={{ color: 'var(--ant-success)' }} />
) : (
<ExclamationCircleFilled style={{ color: 'var(--ant-warning)' }} />
);
}
const toolTipMessage = `Click to ${isVisible ? 'hide' : 'show'} this message`;
return (
<div className={`toggle-switch ${isVisible ? '' : 'hidden'}`}>
<span className="outcome-icon">{outcomeIcon}</span>
<Tooltip title={toolTipMessage} placement="topRight">
<Button
shape="circle"
size="small"
type="text"
icon={isVisible ? <EyeOutlined /> : <EyeInvisibleOutlined />}
onClick={updateChatMessage}
/>
</Tooltip>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { Modal, Button } from 'antd';
import {
ExclamationCircleFilled,
QuestionCircleFilled,
StopTwoTone,
SafetyCertificateTwoTone,
} from '@ant-design/icons';
import { USER_SET_MODERATOR, fetchData } from '../utils/apis';
import { User } from '../types/chat';
interface ModeratorUserButtonProps {
user: User;
onClick?: () => void;
}
export default function ModeratorUserButton({ user, onClick }: ModeratorUserButtonProps) {
async function buttonClicked({ id }, setAsModerator: Boolean): Promise<Boolean> {
const data = {
userId: id,
isModerator: setAsModerator,
};
try {
const result = await fetchData(USER_SET_MODERATOR, {
data,
method: 'POST',
auth: true,
});
return result.success;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
return false;
}
const isModerator = user.scopes?.includes('MODERATOR');
const actionString = isModerator ? 'remove moderator' : 'add moderator';
const icon = isModerator ? (
<ExclamationCircleFilled style={{ color: 'var(--ant-error)' }} />
) : (
<QuestionCircleFilled style={{ color: 'var(--ant-warning)' }} />
);
const content = (
<>
Are you sure you want to {actionString} <strong>{user.displayName}</strong>?
</>
);
const confirmBlockAction = () => {
Modal.confirm({
title: `Confirm ${actionString}`,
content,
onCancel: () => {},
onOk: () =>
new Promise((resolve, reject) => {
const result = buttonClicked(user, !isModerator);
if (result) {
// wait a bit before closing so the user/client tables repopulate
// GW: TODO: put users/clients data in global app context instead, then call a function here to update that state. (current in another branch)
setTimeout(() => {
resolve(result);
onClick?.();
}, 3000);
} else {
reject();
}
}),
okType: 'danger',
okText: isModerator ? 'Yup!' : null,
icon,
});
};
return (
<Button
onClick={confirmBlockAction}
size="small"
icon={
isModerator ? (
<StopTwoTone twoToneColor="#ff4d4f" />
) : (
<SafetyCertificateTwoTone twoToneColor="#22bb44" />
)
}
className="block-user-button"
>
{actionString}
</Button>
);
}
ModeratorUserButton.defaultProps = {
onClick: null,
};

View file

@ -0,0 +1,78 @@
/* eslint-disable camelcase */
/* eslint-disable react/no-danger */
import React, { useState, useEffect } from 'react';
import { Collapse, Typography, Skeleton } from 'antd';
import format from 'date-fns/format';
import { fetchExternalData } from '../utils/apis';
const { Panel } = Collapse;
const { Title, Link } = Typography;
const OWNCAST_FEED_URL = 'https://owncast.online/news/index.json';
const OWNCAST_BASE_URL = 'https://owncast.online';
interface Article {
title: string;
url: string;
content_html: string;
date_published: string;
}
function ArticleItem({ title, url, content_html: content, date_published: date }: Article) {
const dateObject = new Date(date);
const dateString = format(dateObject, 'MMM dd, yyyy, HH:mm');
return (
<article>
<Collapse>
<Panel header={title} key={url}>
<p className="timestamp">
{dateString} (
<Link href={`${OWNCAST_BASE_URL}${url}`} target="_blank" rel="noopener noreferrer">
Link
</Link>
)
</p>
<div dangerouslySetInnerHTML={{ __html: content }} />
</Panel>
</Collapse>
</article>
);
}
export default function NewsFeed() {
const [feed, setFeed] = useState<Article[]>([]);
const [loading, setLoading] = useState<Boolean>(true);
const getFeed = async () => {
setLoading(false);
try {
const result = await fetchExternalData(OWNCAST_FEED_URL);
if (result?.items.length > 0) {
setFeed(result.items);
}
} catch (error) {
console.log('==== error', error);
}
};
useEffect(() => {
getFeed();
}, []);
const loadingSpinner = loading ? <Skeleton loading active /> : null;
const noNews = !loading && feed.length === 0 ? <div>No news.</div> : null;
return (
<section className="news-feed form-module">
<Title level={2}>News &amp; Updates from Owncast</Title>
{loadingSpinner}
{feed.map(item => (
<ArticleItem {...item} key={item.url} />
))}
{noNews}
</section>
);
}

View file

@ -0,0 +1,152 @@
import { BookTwoTone, MessageTwoTone, PlaySquareTwoTone, ProfileTwoTone } from '@ant-design/icons';
import { Card, Col, Row, Typography } from 'antd';
import Link from 'next/link';
import { useContext } from 'react';
import LogTable from './log-table';
import OwncastLogo from './logo';
import NewsFeed from './news-feed';
import { ConfigDetails } from '../types/config-section';
import { ServerStatusContext } from '../utils/server-status-context';
const { Paragraph, Text } = Typography;
const { Title } = Typography;
const { Meta } = Card;
function generateStreamURL(serverURL, rtmpServerPort) {
return `rtmp://${serverURL.replace(/(^\w+:|^)\/\//, '')}:${rtmpServerPort}/live`;
}
type OfflineProps = {
logs: any[];
config: ConfigDetails;
};
export default function Offline({ logs = [], config }: OfflineProps) {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { streamKey, rtmpServerPort } = serverConfig;
const instanceUrl = global.window?.location.hostname || '';
let rtmpURL;
if (instanceUrl && rtmpServerPort) {
rtmpURL = generateStreamURL(instanceUrl, rtmpServerPort);
}
const data = [
{
icon: <BookTwoTone twoToneColor="#6f42c1" />,
title: 'Use your broadcasting software',
content: (
<div>
<a
href="https://owncast.online/docs/broadcasting/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Learn how to point your existing software to your new server and start streaming your
content.
</a>
<div className="stream-info-container">
<Text strong className="stream-info-label">
Streaming URL:
</Text>
{rtmpURL && (
<Paragraph className="stream-info-box" copyable>
{rtmpURL}
</Paragraph>
)}
<Text strong className="stream-info-label">
Stream Key:
</Text>
<Paragraph className="stream-info-box" copyable={{ text: streamKey }}>
*********************
</Paragraph>
</div>
</div>
),
},
{
icon: <PlaySquareTwoTone twoToneColor="#f9826c" />,
title: 'Embed your video onto other sites',
content: (
<div>
<a
href="https://owncast.online/docs/embed?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Learn how you can add your Owncast stream to other sites you control.
</a>
</div>
),
},
];
if (!config?.chatDisabled) {
data.push({
icon: <MessageTwoTone twoToneColor="#0366d6" />,
title: 'Chat is disabled',
content: <span>Chat will continue to be disabled until you begin a live stream.</span>,
});
}
if (!config?.yp?.enabled) {
data.push({
icon: <ProfileTwoTone twoToneColor="#D18BFE" />,
title: 'Find an audience on the Owncast Directory',
content: (
<div>
List yourself in the Owncast Directory and show off your stream. Enable it in{' '}
<Link href="/config-public-details">settings.</Link>
</div>
),
});
}
if (!config?.federation?.enabled) {
data.push({
icon: <img alt="fediverse" width="20px" src="fediverse-white.png" />,
title: 'Add your Owncast instance to the Fediverse',
content: (
<div>
<Link href="/config-federation">Enable Owncast social</Link> features to have your
instance join the Fediverse, allowing people to follow, share and engage with your live
stream.
</div>
),
});
}
return (
<>
<Row>
<Col span={12} offset={6}>
<div className="offline-intro">
<span className="logo">
<OwncastLogo />
</span>
<div>
<Title level={2}>No stream is active</Title>
<p>You should start one.</p>
</div>
</div>
</Col>
</Row>
<Row gutter={[16, 16]} className="offline-content">
<Col span={12} xs={24} sm={24} md={24} lg={12} className="list-section">
{data.map(item => (
<Card key={item.title} size="small" bordered={false}>
<Meta avatar={item.icon} title={item.title} description={item.content} />
</Card>
))}
</Col>
<Col span={12} xs={24} sm={24} md={24} lg={12}>
<NewsFeed />
</Col>
</Row>
<LogTable logs={logs} pageSize={5} />
</>
);
}

View file

@ -0,0 +1,79 @@
/* 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;
interface StatisticItemProps {
title?: string;
value?: any;
prefix?: any;
suffix?: string;
color?: string;
progress?: boolean;
centered?: boolean;
formatter?: any;
}
const defaultProps = {
title: '',
value: 0,
prefix: null,
suffix: null,
color: '',
progress: false,
centered: false,
formatter: null,
};
function ProgressView({ title, value, prefix, suffix, color }: StatisticItemProps) {
const endColor = value > 90 ? 'red' : color;
const content = (
<div>
{prefix}
<div>
<Text type="secondary">{title}</Text>
</div>
<div>
<Text type="secondary">
{value}
{suffix || '%'}
</Text>
</div>
</div>
);
return (
<Progress
type="dashboard"
percent={value}
width={120}
strokeColor={{
'0%': color,
'90%': endColor,
}}
format={() => content}
/>
);
}
ProgressView.defaultProps = defaultProps;
function StatisticView({ title, value, prefix, formatter }: StatisticItemProps) {
return <Statistic title={title} value={value} prefix={prefix} formatter={formatter} />;
}
StatisticView.defaultProps = defaultProps;
export default function StatisticItem(props: StatisticItemProps) {
const { progress, centered } = props;
const View = progress ? ProgressView : StatisticView;
const style = centered ? { display: 'flex', alignItems: 'center', justifyContent: 'center' } : {};
return (
<Card type="inner">
<div style={style}>
<View {...props} />
</div>
</Card>
);
}
StatisticItem.defaultProps = defaultProps;

View file

@ -0,0 +1,86 @@
import { CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { Alert, Button, Col, Row, Statistic, Typography } from 'antd';
import Link from 'next/link';
import React, { useContext } from 'react';
import { ServerStatusContext } from '../utils/server-status-context';
interface StreamHealthOverviewProps {
showTroubleshootButton?: Boolean;
}
export default function StreamHealthOverview({
showTroubleshootButton,
}: StreamHealthOverviewProps) {
const serverStatusData = useContext(ServerStatusContext);
const { health } = serverStatusData;
if (!health) {
return null;
}
const { healthy, healthPercentage, message, representation } = health;
let color = '#3f8600';
let icon: 'success' | 'info' | 'warning' | 'error' = 'info';
if (healthPercentage < 80) {
color = '#cf000f';
icon = 'error';
} else if (healthPercentage < 30) {
color = '#f0ad4e';
icon = 'error';
}
return (
<div>
<Row gutter={8}>
<Col span={12}>
<Statistic
title="Healthy Stream"
value={healthy ? 'Yes' : 'No'}
valueStyle={{ color }}
prefix={healthy ? <CheckCircleOutlined /> : <ExclamationCircleOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="Playback Health"
value={healthPercentage}
valueStyle={{ color }}
suffix="%"
/>
</Col>
</Row>
<Row style={{ display: representation < 100 && representation !== 0 ? 'grid' : 'none' }}>
<Typography.Text
type="secondary"
style={{ textAlign: 'center', fontSize: '0.7em', opacity: '0.3' }}
>
Stream health represents {representation}% of all known players. Other player status is
unknown.
</Typography.Text>
</Row>
<Row
gutter={16}
style={{ width: '100%', display: message ? 'grid' : 'none', marginTop: '10px' }}
>
<Col span={24}>
<Alert
message={message}
type={icon}
showIcon
action={
showTroubleshootButton && (
<Link passHref href="/stream-health">
<Button size="small" type="text" style={{ color: 'black' }}>
TROUBLESHOOT
</Button>
</Link>
)
}
/>
</Col>
</Row>
</div>
);
}
StreamHealthOverview.defaultProps = {
showTroubleshootButton: true,
};

View file

@ -0,0 +1,151 @@
// This displays a clickable user name (or whatever children element you provide), and displays a simple tooltip of created time. OnClick a modal with more information about the user is displayed.
import { useState, ReactNode } from 'react';
import { Divider, Modal, Tooltip, Typography, Row, Col, Space } from 'antd';
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import format from 'date-fns/format';
import { uniq } from 'lodash';
import BlockUserbutton from './ban-user-button';
import ModeratorUserbutton from './moderator-user-button';
import { User, UserConnectionInfo } from '../types/chat';
import { formatDisplayDate } from './user-table';
import { formatUAstring } from '../utils/format';
interface UserPopoverProps {
user: User;
connectionInfo?: UserConnectionInfo | null;
children: ReactNode;
}
export default function UserPopover({ user, connectionInfo, children }: UserPopoverProps) {
const [isModalVisible, setIsModalVisible] = useState(false);
const handleShowModal = () => {
setIsModalVisible(true);
};
const handleCloseModal = () => {
setIsModalVisible(false);
};
const { displayName, createdAt, previousNames, nameChangedAt, disabledAt } = user;
const { connectedAt, messageCount, userAgent } = connectionInfo || {};
let lastNameChangeDate = null;
const nameList = previousNames && [...previousNames];
if (previousNames && previousNames.length > 1 && nameChangedAt) {
lastNameChangeDate = new Date(nameChangedAt);
// reverse prev names for display purposes
nameList.reverse();
}
const dateObject = new Date(createdAt);
const createdAtDate = format(dateObject, 'PP pp');
const lastNameChangeDuration = lastNameChangeDate
? formatDistanceToNow(lastNameChangeDate)
: null;
return (
<>
<Tooltip
title={
<>
Created at: {createdAtDate}.
<br /> Click for more info.
</>
}
placement="bottomLeft"
>
<button
type="button"
aria-label="Display more details about this user"
className="user-item-container"
onClick={handleShowModal}
>
{children}
</button>
</Tooltip>
<Modal
destroyOnClose
width={600}
cancelText="Close"
okButtonProps={{ style: { display: 'none' } }}
title={`User details: ${displayName}`}
visible={isModalVisible}
onOk={handleCloseModal}
onCancel={handleCloseModal}
>
<div className="user-details">
<Typography.Title level={4}>{displayName}</Typography.Title>
<p className="created-at">User created at {createdAtDate}.</p>
<Row gutter={16}>
{connectionInfo && (
<Col md={lastNameChangeDate ? 12 : 24}>
<Typography.Title level={5}>
This user is currently connected to Chat.
</Typography.Title>
<ul className="connection-info">
<li>
<strong>Active for:</strong> {formatDistanceToNow(new Date(connectedAt))}
</li>
<li>
<strong>Messages sent:</strong> {messageCount}
</li>
<li>
<strong>User Agent:</strong>
<br />
{formatUAstring(userAgent)}
</li>
</ul>
</Col>
)}
{lastNameChangeDate && (
<Col md={connectionInfo ? 12 : 24}>
<Typography.Title level={5}>This user is also seen as:</Typography.Title>
<ul className="previous-names-list">
{uniq(nameList).map((name, index) => (
<li className={index === 0 ? 'latest' : ''}>
<span className="user-name-item">{name}</span>
{index === 0 ? ` (Changed ${lastNameChangeDuration} ago)` : ''}
</li>
))}
</ul>
</Col>
)}
</Row>
<Divider />
<Space direction="horizontal">
{disabledAt ? (
<>
This user was banned on <code>{formatDisplayDate(disabledAt)}</code>.
<br />
<br />
<BlockUserbutton
label="Unban this user"
user={user}
isEnabled={false}
onClick={handleCloseModal}
/>
</>
) : (
<BlockUserbutton
label="Ban this user"
user={user}
isEnabled
onClick={handleCloseModal}
/>
)}
<ModeratorUserbutton user={user} onClick={handleCloseModal} />
</Space>
</div>
</Modal>
</>
);
}
UserPopover.defaultProps = {
connectionInfo: null,
};

View file

@ -0,0 +1,64 @@
import { Table } from 'antd';
import format from 'date-fns/format';
import { SortOrder } from 'antd/lib/table/interface';
import { User } from '../types/chat';
import UserPopover from './user-popover';
import BanUserButton from './ban-user-button';
export function formatDisplayDate(date: string | Date) {
return format(new Date(date), 'MMM d H:mma');
}
export default function UserTable({ data }: UserTableProps) {
const columns = [
{
title: 'Last Known Display Name',
dataIndex: 'displayName',
key: 'displayName',
// eslint-disable-next-line react/destructuring-assignment
render: (displayName: string, user: User) => (
<UserPopover user={user}>
<span className="display-name">{displayName}</span>
</UserPopover>
),
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: Date) => formatDisplayDate(date),
sorter: (a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
},
{
title: 'Disabled at',
dataIndex: 'disabledAt',
key: 'disabledAt',
defaultSortOrder: 'descend' as SortOrder,
render: (date: Date) => (date ? formatDisplayDate(date) : null),
sorter: (a: any, b: any) =>
new Date(a.disabledAt).getTime() - new Date(b.disabledAt).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
},
{
title: '',
key: 'block',
className: 'actions-col',
render: (_, user) => <BanUserButton user={user} isEnabled={!user.disabledAt} />,
},
];
return (
<Table
pagination={{ hideOnSinglePage: true }}
className="table-container"
columns={columns}
dataSource={data}
size="small"
rowKey="id"
/>
);
}
interface UserTableProps {
data: User[];
}

View file

@ -0,0 +1,50 @@
import { Table } from 'antd';
import format from 'date-fns/format';
import { SortOrder } from 'antd/lib/table/interface';
import { formatDistanceToNow } from 'date-fns';
import { User } from '../types/chat';
import { formatUAstring } from '../utils/format';
export function formatDisplayDate(date: string | Date) {
return format(new Date(date), 'MMM d H:mma');
}
export default function ViewerTable({ data }: ViewerTableProps) {
const columns = [
{
title: 'User Agent',
dataIndex: 'userAgent',
key: 'userAgent',
render: (ua: string) => formatUAstring(ua),
},
{
title: 'Location',
dataIndex: 'geo',
key: 'geo',
render: geo => (geo ? `${geo.regionName}, ${geo.countryCode}` : '-'),
},
{
title: 'Watch Time',
dataIndex: 'firstSeen',
key: 'firstSeen',
defaultSortOrder: 'ascend' as SortOrder,
render: (time: Date) => formatDistanceToNow(new Date(time)),
sorter: (a: any, b: any) => new Date(a.firstSeen).getTime() - new Date(b.firstSeen).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
},
];
return (
<Table
pagination={{ hideOnSinglePage: true }}
className="table-container"
columns={columns}
dataSource={data}
size="small"
rowKey="id"
/>
);
}
interface ViewerTableProps {
data: User[];
}

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.

BIN
web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

5
web/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

5
web/next.config.js Normal file
View file

@ -0,0 +1,5 @@
const withLess = require('next-with-less');
module.exports = withLess({
trailingSlash: true,
});

55337
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

87
web/package.json Normal file
View file

@ -0,0 +1,87 @@
{
"name": "owncast-admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build && next export",
"start": "next start",
"lint": "eslint --ext .js,.ts,.tsx types/ pages/ components/ stories/",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
},
"dependencies": {
"@ant-design/icons": "4.7.0",
"@storybook/react": "^6.4.22",
"antd": "4.18.9",
"autoprefixer": "^10.4.4",
"chart.js": "3.7.0",
"chartkick": "4.1.1",
"classnames": "2.3.1",
"date-fns": "2.28.0",
"lodash": "4.17.21",
"markdown-it": "12.3.2",
"next": "^12.1.5",
"next-with-less": "^2.0.5",
"postcss-flexbugs-fixes": "^5.0.2",
"prop-types": "15.8.1",
"rc-overflow": "1.2.4",
"rc-util": "5.17.0",
"react": "17.0.2",
"react-chartkick": "0.5.2",
"react-dom": "17.0.2",
"react-linkify": "1.0.0-alpha",
"react-markdown": "8.0.0",
"react-markdown-editor-lite": "1.3.2",
"ua-parser-js": "1.0.2"
},
"devDependencies": {
"@babel/core": "^7.17.9",
"@storybook/addon-a11y": "^6.4.22",
"@storybook/addon-actions": "^6.4.22",
"@storybook/addon-docs": "^6.4.22",
"@storybook/addon-essentials": "^6.4.22",
"@storybook/addon-interactions": "^6.4.22",
"@storybook/addon-links": "^6.4.22",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/addon-viewport": "^6.4.22",
"@storybook/builder-webpack5": "^6.4.22",
"@storybook/manager-webpack5": "^6.4.22",
"@storybook/preset-scss": "^1.0.3",
"@storybook/testing-library": "^0.0.9",
"@types/chart.js": "2.9.35",
"@types/classnames": "2.3.1",
"@types/markdown-it": "12.2.3",
"@types/node": "17.0.0",
"@types/prop-types": "15.7.4",
"@types/react": "^18.0.5",
"@types/react-linkify": "1.0.1",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.10.2",
"@typescript-eslint/parser": "5.10.2",
"addon-screen-reader": "^1.8.6",
"babel-loader": "^8.2.4",
"css-loader": "^5.2.7",
"eslint": "8.8.0",
"eslint-config-airbnb": "19.0.4",
"eslint-config-next": "12.0.10",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-jsx-a11y": "6.5.1",
"eslint-plugin-prettier": "4.0.0",
"eslint-plugin-react": "7.28.0",
"eslint-plugin-react-hooks": "4.3.0",
"eslint-plugin-storybook": "^0.5.10",
"html-webpack-plugin": "^5.5.0",
"less": "^4.1.2",
"less-loader": "^10.2.0",
"prettier": "2.5.1",
"sass": "^1.50.0",
"sass-loader": "^10.1.1",
"sb": "^6.4.22",
"storybook-dark-mode": "^1.0.9",
"storybook-preset-less": "^1.1.2",
"style-loader": "^2.0.0",
"typescript": "4.5.5"
}
}

37
web/pages/_app.tsx Normal file
View file

@ -0,0 +1,37 @@
// order matters!
import '../styles/variables.scss';
import '../styles/global.less';
import '../styles/globals.scss';
// import '../styles/ant-overrides.scss';
import '../styles/markdown-editor.scss';
import '../styles/main-layout.scss';
import '../styles/form-textfields.scss';
import '../styles/form-misc-elements.scss';
import '../styles/config-socialhandles.scss';
import '../styles/config-storage.scss';
import '../styles/config-edit-string-tags.scss';
import '../styles/config-video-variants.scss';
import '../styles/config-public-details.scss';
import '../styles/home.scss';
import '../styles/chat.scss';
import '../styles/pages.scss';
import '../styles/offline-notice.scss';
import { AppProps } from 'next/app';
import { useRouter } from 'next/router';
import AdminLayout from './admin/admin-layout';
import SimpleLayout from '../components/layouts/simple-layout';
function App({ Component, pageProps }: AppProps) {
const router = useRouter();
if (router.pathname.startsWith('/admin')) {
return <AdminLayout pageProps={pageProps} Component={Component} router={router} />;
}
return <SimpleLayout pageProps={pageProps} Component={Component} router={router} />;
}
export default App;

View file

@ -0,0 +1,267 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Tag,
Space,
Button,
Modal,
Checkbox,
Input,
Typography,
Tooltip,
Row,
Col,
} from 'antd';
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',
description: 'Can send official messages on behalf of the system.',
color: 'purple',
},
CAN_SEND_MESSAGES: {
name: 'User chat messages',
description: 'Can send chat messages on behalf of the owner of this token.',
color: 'green',
},
HAS_ADMIN_ACCESS: {
name: 'Has admin access',
description: 'Can perform administrative actions such as moderation, get server statuses, etc.',
color: 'red',
},
};
function convertScopeStringToTag(scopeString: string) {
if (!scopeString || !availableScopes[scopeString]) {
return null;
}
const scope = availableScopes[scopeString];
return (
<Tooltip key={scopeString} title={scope.description}>
<Tag color={scope.color}>{scope.name}</Tag>
</Tooltip>
);
}
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(key => ({
value: key,
label: availableScopes[key].description,
}));
function onChange(checkedValues) {
setSelectedScopes(checkedValues);
}
function saveToken() {
onOk(name, selectedScopes);
// Clear the modal
setSelectedScopes([]);
setName('');
}
const okButtonProps = {
disabled: selectedScopes.length === 0 || name === '',
};
function selectAll() {
setSelectedScopes(Object.keys(availableScopes));
}
const checkboxes = scopes.map(singleEvent => (
<Col span={8} key={singleEvent.value}>
<Checkbox value={singleEvent.value}>{singleEvent.label}</Checkbox>
</Col>
));
return (
<Modal
title="Create New Access token"
visible={visible}
onOk={saveToken}
onCancel={onCancel}
okButtonProps={okButtonProps}
>
<p>
<p>
The name will be displayed as the chat user when sending messages with this access token.
</p>
<Input
value={name}
placeholder="Name of bot, service, or integration"
onChange={input => setName(input.currentTarget.value)}
/>
</p>
<p>
Select the permissions this access token will have. It cannot be edited after it&apos;s
created.
</p>
<Checkbox.Group style={{ width: '100%' }} value={selectedScopes} onChange={onChange}>
<Row>{checkboxes}</Row>
</Checkbox.Group>
<p>
<Button type="primary" onClick={selectAll}>
Select all
</Button>
</p>
</Modal>
);
}
export default function AccessTokens() {
const [tokens, setTokens] = useState([]);
const [isTokenModalVisible, setIsTokenModalVisible] = useState(false);
function handleError(error) {
console.error('error', 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: '',
key: 'delete',
render: (text, record) => (
<Space size="middle">
<Button onClick={() => handleDeleteToken(record.accessToken)} icon={<DeleteOutlined />} />
</Space>
),
},
{
title: 'Name',
dataIndex: 'displayName',
key: 'displayName',
},
{
title: 'Token',
dataIndex: 'accessToken',
key: 'accessToken',
render: text => <Input.Password size="small" bordered={false} value={text} />,
},
{
title: 'Scopes',
dataIndex: 'scopes',
key: 'scopes',
// eslint-disable-next-line react/destructuring-assignment
render: scopes => <>{scopes.map(scope => convertScopeStringToTag(scope))}</>,
},
{
title: 'Last Used',
dataIndex: 'lastUsed',
key: 'lastUsed',
render: lastUsed => {
if (!lastUsed) {
return 'Never';
}
const dateObject = new Date(lastUsed);
return format(dateObject, 'P p');
},
},
];
const showCreateTokenModal = () => {
setIsTokenModalVisible(true);
};
const handleTokenModalSaveButton = (name, scopes) => {
setIsTokenModalVisible(false);
handleSaveToken(name, scopes);
};
const handleTokenModalCancel = () => {
setIsTokenModalVisible(false);
};
return (
<div>
<Title>Access Tokens</Title>
<Paragraph>
Access tokens are used to allow external, 3rd party tools to perform specific actions on
your Owncast server. They should be kept secure and never included in client code, instead
they should be kept on a server that you control.
</Paragraph>
<Paragraph>
Read more about how to use these tokens, with examples, at{' '}
<a
href="https://owncast.online/docs/integrations/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
our documentation
</a>
.
</Paragraph>
<Table rowKey="token" columns={columns} dataSource={tokens} pagination={false} />
<br />
<Button type="primary" onClick={showCreateTokenModal}>
Create Access Token
</Button>
<NewTokenModal
visible={isTokenModalVisible}
onOk={handleTokenModalSaveButton}
onCancel={handleTokenModalCancel}
/>
</div>
);
}

313
web/pages/admin/actions.tsx Normal file
View file

@ -0,0 +1,313 @@
import { DeleteOutlined } from '@ant-design/icons';
import { Button, Checkbox, Input, Modal, Space, Table, Typography } from 'antd';
import React, { useContext, useEffect, useState } from 'react';
import FormStatusIndicator from '../../components/config/form-status-indicator';
import {
API_EXTERNAL_ACTIONS,
postConfigUpdateToAPI,
RESET_TIMEOUT,
} from '../../utils/config-constants';
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
import { ServerStatusContext } from '../../utils/server-status-context';
import isValidUrl, { DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/urls';
const { Title, Paragraph } = Typography;
let resetTimer = null;
interface Props {
onCancel: () => void;
onOk: any; // todo: make better type
visible: boolean;
}
function NewActionModal(props: Props) {
const { onOk, onCancel, visible } = props;
const [actionUrl, setActionUrl] = useState('');
const [actionTitle, setActionTitle] = useState('');
const [actionDescription, setActionDescription] = useState('');
const [actionIcon, setActionIcon] = useState('');
const [actionColor, setActionColor] = useState('');
const [openExternally, setOpenExternally] = useState(false);
function save() {
onOk(actionUrl, actionTitle, actionDescription, actionIcon, actionColor, openExternally);
setActionUrl('');
setActionTitle('');
setActionDescription('');
setActionIcon('');
setActionColor('');
setOpenExternally(false);
}
function canSave(): Boolean {
try {
const validationObject = new URL(actionUrl);
if (validationObject.protocol !== 'https:') {
return false;
}
} catch {
return false;
}
return isValidUrl(actionUrl) && actionTitle !== '';
}
const okButtonProps = {
disabled: !canSave(),
};
const onOpenExternallyChanged = checkbox => {
setOpenExternally(checkbox.target.checked);
};
return (
<Modal
title="Create New Action"
visible={visible}
onOk={save}
onCancel={onCancel}
okButtonProps={okButtonProps}
>
<div>
Add the URL for the external action you want to present.{' '}
<strong>Only HTTPS urls are supported.</strong>
<p>
<a
href="https://owncast.online/thirdparty/actions/"
target="_blank"
rel="noopener noreferrer"
>
Read more about external actions.
</a>
</p>
<p>
<Input
value={actionUrl}
required
placeholder="https://myserver.com/action (required)"
onChange={input => setActionUrl(input.currentTarget.value.trim())}
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
/>
</p>
<p>
<Input
value={actionTitle}
required
placeholder="Your action title (required)"
onChange={input => setActionTitle(input.currentTarget.value)}
/>
</p>
<p>
<Input
value={actionDescription}
placeholder="Optional description"
onChange={input => setActionDescription(input.currentTarget.value)}
/>
</p>
<p>
<Input
value={actionIcon}
placeholder="https://myserver.com/action/icon.png (optional)"
onChange={input => setActionIcon(input.currentTarget.value)}
/>
</p>
<p>
<Input
type="color"
value={actionColor}
onChange={input => setActionColor(input.currentTarget.value)}
/>
Optional background color of the action button.
</p>
<Checkbox
checked={openExternally}
defaultChecked={openExternally}
onChange={onOpenExternallyChanged}
>
Open in a new tab instead of within your page.
</Checkbox>
</div>
</Modal>
);
}
export default function Actions() {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { externalActions } = serverConfig;
const [actions, setActions] = useState([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const [submitStatus, setSubmitStatus] = useState(null);
const resetStates = () => {
setSubmitStatus(null);
resetTimer = null;
clearTimeout(resetTimer);
};
useEffect(() => {
setActions(externalActions || []);
}, [externalActions]);
async function save(actionsData) {
await postConfigUpdateToAPI({
apiPath: API_EXTERNAL_ACTIONS,
data: { value: actionsData },
onSuccess: () => {
setFieldInConfigState({ fieldName: 'externalActions', value: actionsData, path: '' });
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
onError: (message: string) => {
console.log(message);
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
},
});
}
async function handleDelete(action) {
const actionsData = [...actions];
const index = actions.findIndex(item => item.url === action.url);
actionsData.splice(index, 1);
try {
setActions(actionsData);
save(actionsData);
} catch (error) {
console.error(error);
}
}
async function handleSave(
url: string,
title: string,
description: string,
icon: string,
color: string,
openExternally: boolean,
) {
try {
const actionsData = [...actions];
const updatedActions = actionsData.concat({
url,
title,
description,
icon,
color,
openExternally,
});
setActions(updatedActions);
await save(updatedActions);
} catch (error) {
console.error(error);
}
}
const showCreateModal = () => {
setIsModalVisible(true);
};
const handleModalSaveButton = (
actionUrl: string,
actionTitle: string,
actionDescription: string,
actionIcon: string,
actionColor: string,
openExternally: boolean,
) => {
setIsModalVisible(false);
handleSave(actionUrl, actionTitle, actionDescription, actionIcon, actionColor, openExternally);
};
const handleModalCancelButton = () => {
setIsModalVisible(false);
};
const columns = [
{
title: '',
key: 'delete',
render: (text, record) => (
<Space size="middle">
<Button onClick={() => handleDelete(record)} icon={<DeleteOutlined />} />
</Space>
),
},
{
title: 'Name',
dataIndex: 'title',
key: 'title',
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
},
{
title: 'URL',
dataIndex: 'url',
key: 'url',
},
{
title: 'Icon',
dataIndex: 'icon',
key: 'icon',
render: (url: string) => (url ? <img style={{ width: '2vw' }} src={url} alt="" /> : null),
},
{
title: 'Color',
dataIndex: 'color',
key: 'color',
render: (color: string) =>
color ? <div style={{ backgroundColor: color, height: '30px' }}>{color}</div> : null,
},
{
title: 'Opens',
dataIndex: 'openExternally',
key: 'openExternally',
render: (openExternally: boolean) => (openExternally ? 'In a new tab' : 'In a modal'),
},
];
return (
<div>
<Title>External Actions</Title>
<Paragraph>
External action URLs are 3rd party UI you can display, embedded, into your Owncast page when
a user clicks on a button to launch your action.
</Paragraph>
<Paragraph>
Read more about how to use actions, with examples, at{' '}
<a
href="https://owncast.online/thirdparty/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
our documentation
</a>
.
</Paragraph>
<Table
rowKey={record => `${record.title}-${record.url}`}
columns={columns}
dataSource={actions}
pagination={false}
/>
<br />
<Button type="primary" onClick={showCreateModal}>
Create New Action
</Button>
<FormStatusIndicator status={submitStatus} />
<NewActionModal
visible={isModalVisible}
onOk={handleModalSaveButton}
onCancel={handleModalCancelButton}
/>
</div>
);
}

View file

@ -0,0 +1,18 @@
import { AppProps } from 'next/app';
import ServerStatusProvider from '../../utils/server-status-context';
import AlertMessageProvider from '../../utils/alert-message-context';
import MainLayout from '../../components/main-layout';
function AdminLayout({ Component, pageProps }: AppProps) {
return (
<ServerStatusProvider>
<AlertMessageProvider>
<MainLayout>
<Component {...pageProps} />
</MainLayout>
</AlertMessageProvider>
</ServerStatusProvider>
);
}
export default AdminLayout;

View file

@ -0,0 +1,253 @@
import React, { useState, useEffect } from 'react';
import { Table, Typography, Button } from 'antd';
import { CheckCircleFilled, ExclamationCircleFilled } from '@ant-design/icons';
import classNames from 'classnames';
import { ColumnsType } from 'antd/es/table';
import format from 'date-fns/format';
import { MessageType } from '../../../types/chat';
import {
CHAT_HISTORY,
fetchData,
FETCH_INTERVAL,
UPDATE_CHAT_MESSGAE_VIZ,
} from '../../../utils/apis';
import { isEmptyObject } from '../../../utils/format';
import MessageVisiblityToggle from '../../../components/message-visiblity-toggle';
import UserPopover from '../../../components/user-popover';
const { Title } = Typography;
function createUserNameFilters(messages: MessageType[]) {
const filtered = messages.reduce((acc, curItem) => {
const curAuthor = curItem.user.id;
if (!acc.some(item => item.text === curAuthor)) {
acc.push({ text: curAuthor, value: curAuthor });
}
return acc;
}, []);
// sort by name
return filtered.sort((a, b) => {
const nameA = a.text.toUpperCase(); // ignore upper and lowercase
const nameB = b.text.toUpperCase(); // ignore upper and lowercase
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
// names must be equal
return 0;
});
}
export const OUTCOME_TIMEOUT = 3000;
export default function Chat() {
const [messages, setMessages] = useState([]);
const [selectedRowKeys, setSelectedRows] = useState([]);
const [bulkProcessing, setBulkProcessing] = useState(false);
const [bulkOutcome, setBulkOutcome] = useState(null);
const [bulkAction, setBulkAction] = useState('');
let outcomeTimeout = null;
let chatReloadInterval = null;
const getInfo = async () => {
try {
const result = await fetchData(CHAT_HISTORY, { auth: true });
if (isEmptyObject(result)) {
setMessages([]);
} else {
setMessages(result);
}
} catch (error) {
console.log('==== error', error);
}
};
useEffect(() => {
getInfo();
chatReloadInterval = setInterval(() => {
getInfo();
}, FETCH_INTERVAL);
return () => {
clearTimeout(outcomeTimeout);
clearTimeout(chatReloadInterval);
};
}, []);
const nameFilters = createUserNameFilters(messages);
const rowSelection = {
selectedRowKeys,
onChange: (selectedKeys: string[]) => {
setSelectedRows(selectedKeys);
},
};
const updateMessage = message => {
const messageIndex = messages.findIndex(m => m.id === message.id);
messages.splice(messageIndex, 1, message);
setMessages([...messages]);
};
const resetBulkOutcome = () => {
outcomeTimeout = setTimeout(() => {
setBulkOutcome(null);
setBulkAction('');
}, OUTCOME_TIMEOUT);
};
const handleSubmitBulk = async bulkVisibility => {
setBulkProcessing(true);
const result = await fetchData(UPDATE_CHAT_MESSGAE_VIZ, {
auth: true,
method: 'POST',
data: {
visible: bulkVisibility,
idArray: selectedRowKeys,
},
});
if (result.success && result.message === 'changed') {
setBulkOutcome(<CheckCircleFilled />);
resetBulkOutcome();
// update messages
const updatedList = [...messages];
selectedRowKeys.map(key => {
const messageIndex = updatedList.findIndex(m => m.id === key);
const newMessage = { ...messages[messageIndex], visible: bulkVisibility };
updatedList.splice(messageIndex, 1, newMessage);
return null;
});
setMessages(updatedList);
setSelectedRows([]);
} else {
setBulkOutcome(<ExclamationCircleFilled />);
resetBulkOutcome();
}
setBulkProcessing(false);
};
const handleSubmitBulkShow = () => {
setBulkAction('show');
handleSubmitBulk(true);
};
const handleSubmitBulkHide = () => {
setBulkAction('hide');
handleSubmitBulk(false);
};
const chatColumns: ColumnsType<MessageType> = [
{
title: 'Time',
dataIndex: 'timestamp',
key: 'timestamp',
className: 'timestamp-col',
defaultSortOrder: 'descend',
render: timestamp => {
const dateObject = new Date(timestamp);
return format(dateObject, 'PP pp');
},
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
width: 90,
},
{
title: 'User',
dataIndex: 'user',
key: 'user',
className: 'name-col',
filters: nameFilters,
onFilter: (value, record) => record.user.id === value,
sorter: (a, b) => a.user.displayName.localeCompare(b.user.displayName),
sortDirections: ['ascend', 'descend'],
ellipsis: true,
render: user => {
const { displayName } = user;
return <UserPopover user={user}>{displayName}</UserPopover>;
},
width: 110,
},
{
title: 'Message',
dataIndex: 'body',
key: 'body',
className: 'message-col',
width: 320,
render: body => (
<div
className="message-contents"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: body }}
/>
),
},
{
title: '',
dataIndex: 'hiddenAt',
key: 'hiddenAt',
className: 'toggle-col',
filters: [
{ text: 'Visible messages', value: true },
{ text: 'Hidden messages', value: false },
],
onFilter: (value, record) => record.visible === value,
render: (hiddenAt, record) => (
<MessageVisiblityToggle isVisible={!hiddenAt} message={record} setMessage={updateMessage} />
),
width: 30,
},
];
const bulkDivClasses = classNames({
'bulk-editor': true,
active: selectedRowKeys.length,
});
return (
<div className="chat-messages">
<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>
<Button
type="primary"
size="small"
shape="round"
className="button"
loading={bulkAction === 'show' && bulkProcessing}
icon={bulkAction === 'show' && bulkOutcome}
disabled={!selectedRowKeys.length || (bulkAction && bulkAction !== 'show')}
onClick={handleSubmitBulkShow}
>
Show
</Button>
<Button
type="primary"
size="small"
shape="round"
className="button"
loading={bulkAction === 'hide' && bulkProcessing}
icon={bulkAction === 'hide' && bulkOutcome}
disabled={!selectedRowKeys.length || (bulkAction && bulkAction !== 'hide')}
onClick={handleSubmitBulkHide}
>
Hide
</Button>
</div>
<Table
size="small"
className="table-container"
pagination={{ defaultPageSize: 100, showSizeChanger: true }}
scroll={{ y: 540 }}
rowClassName={record => (record.hiddenAt ? 'hidden' : '')}
dataSource={messages}
columns={chatColumns}
rowKey={row => row.id}
rowSelection={rowSelection}
/>
</div>
);
}

View file

@ -0,0 +1,107 @@
import React, { useState, useEffect, useContext } from 'react';
import { Tabs } from 'antd';
import { ServerStatusContext } from '../../../utils/server-status-context';
import {
CONNECTED_CLIENTS,
fetchData,
DISABLED_USERS,
MODERATORS,
BANNED_IPS,
} from '../../../utils/apis';
import UserTable from '../../../components/user-table';
import ClientTable from '../../../components/client-table';
import BannedIPsTable from '../../../components/banned-ips-table';
const { TabPane } = Tabs;
export const FETCH_INTERVAL = 10 * 1000; // 10 sec
export default function ChatUsers() {
const context = useContext(ServerStatusContext);
const { online } = context || {};
const [disabledUsers, setDisabledUsers] = useState([]);
const [ipBans, setIPBans] = useState([]);
const [clients, setClients] = useState([]);
const [moderators, setModerators] = useState([]);
const getInfo = async () => {
try {
const result = await fetchData(DISABLED_USERS);
setDisabledUsers(result);
} catch (error) {
console.log('==== error', error);
}
try {
const result = await fetchData(CONNECTED_CLIENTS);
setClients(result);
} catch (error) {
console.log('==== error', error);
}
try {
const result = await fetchData(MODERATORS);
setModerators(result);
} catch (error) {
console.error('error fetching moderators', error);
}
try {
const result = await fetchData(BANNED_IPS);
setIPBans(result);
} catch (error) {
console.error('error fetching banned ips', error);
}
};
useEffect(() => {
let getStatusIntervalId = null;
getInfo();
getStatusIntervalId = setInterval(getInfo, FETCH_INTERVAL);
// returned function will be called on component unmount
return () => {
clearInterval(getStatusIntervalId);
};
}, [online]);
const connectedUsers = online ? (
<>
<ClientTable data={clients} />
<p className="description">
Visit the{' '}
<a
href="https://owncast.online/docs/viewers/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>{' '}
to configure additional details about your viewers.
</p>
</>
) : (
<p className="description">
When a stream is active and chat is enabled, connected chat clients will be displayed here.
</p>
);
return (
<Tabs defaultActiveKey="1">
<TabPane tab={<span>Connected {online ? `(${clients.length})` : '(offline)'}</span>} key="1">
{connectedUsers}
</TabPane>
<TabPane tab={<span>Banned Users ({disabledUsers.length})</span>} key="2">
<UserTable data={disabledUsers} />
</TabPane>
<TabPane tab={<span>IP Bans ({ipBans.length})</span>} key="3">
<BannedIPsTable data={ipBans} />
</TabPane>
<TabPane tab={<span>Moderators ({moderators.length})</span>} key="4">
<UserTable data={moderators} />
</TabPane>
</Tabs>
);
}

View file

@ -0,0 +1,222 @@
import { Typography } from 'antd';
import React, { useContext, useEffect, useState } from 'react';
import { TEXTFIELD_TYPE_TEXTAREA } from '../../components/config/form-textfield';
import TextFieldWithSubmit from '../../components/config/form-textfield-with-submit';
import ToggleSwitch from '../../components/config/form-toggleswitch';
import EditValueArray from '../../components/config/edit-string-array';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { UpdateArgs } from '../../types/config-section';
import {
API_CHAT_FORBIDDEN_USERNAMES,
API_CHAT_SUGGESTED_USERNAMES,
FIELD_PROPS_CHAT_JOIN_MESSAGES_ENABLED,
CHAT_ESTABLISHED_USER_MODE,
FIELD_PROPS_DISABLE_CHAT,
postConfigUpdateToAPI,
RESET_TIMEOUT,
TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES,
TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES,
TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE,
} from '../../utils/config-constants';
import { ServerStatusContext } from '../../utils/server-status-context';
export default function ConfigChat() {
const { Title } = Typography;
const [formDataValues, setFormDataValues] = useState(null);
const [forbiddenUsernameSaveState, setForbiddenUsernameSaveState] = useState<StatusState>(null);
const [suggestedUsernameSaveState, setSuggestedUsernameSaveState] = useState<StatusState>(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const {
chatDisabled,
chatJoinMessagesEnabled,
forbiddenUsernames,
instanceDetails,
suggestedUsernames,
chatEstablishedUserMode,
} = serverConfig;
const { welcomeMessage } = instanceDetails;
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
};
function handleChatDisableChange(disabled: boolean) {
handleFieldChange({ fieldName: 'chatDisabled', value: !disabled });
}
function handleChatJoinMessagesEnabledChange(enabled: boolean) {
handleFieldChange({ fieldName: 'chatJoinMessagesEnabled', value: enabled });
}
function handleEstablishedUserModeChange(enabled: boolean) {
handleFieldChange({ fieldName: 'chatEstablishedUserMode', value: enabled });
}
function resetForbiddenUsernameState() {
setForbiddenUsernameSaveState(null);
}
function saveForbiddenUsernames() {
postConfigUpdateToAPI({
apiPath: API_CHAT_FORBIDDEN_USERNAMES,
data: { value: formDataValues.forbiddenUsernames },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'forbiddenUsernames',
value: formDataValues.forbiddenUsernames,
});
setForbiddenUsernameSaveState(createInputStatus(STATUS_SUCCESS));
setTimeout(resetForbiddenUsernameState, RESET_TIMEOUT);
},
onError: (message: string) => {
setForbiddenUsernameSaveState(createInputStatus(STATUS_ERROR, message));
setTimeout(resetForbiddenUsernameState, RESET_TIMEOUT);
},
});
}
function handleDeleteForbiddenUsernameIndex(index: number) {
formDataValues.forbiddenUsernames.splice(index, 1);
saveForbiddenUsernames();
}
function handleCreateForbiddenUsername(tag: string) {
formDataValues.forbiddenUsernames.push(tag);
handleFieldChange({
fieldName: 'forbiddenUsernames',
value: formDataValues.forbiddenUsernames,
});
saveForbiddenUsernames();
}
function resetSuggestedUsernameState() {
setSuggestedUsernameSaveState(null);
}
function saveSuggestedUsernames() {
postConfigUpdateToAPI({
apiPath: API_CHAT_SUGGESTED_USERNAMES,
data: { value: formDataValues.suggestedUsernames },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'suggestedUsernames',
value: formDataValues.suggestedUsernames,
});
setSuggestedUsernameSaveState(createInputStatus(STATUS_SUCCESS));
setTimeout(resetSuggestedUsernameState, RESET_TIMEOUT);
},
onError: (message: string) => {
setForbiddenUsernameSaveState(createInputStatus(STATUS_ERROR, message));
setTimeout(resetSuggestedUsernameState, RESET_TIMEOUT);
},
});
}
function handleDeleteSuggestedUsernameIndex(index: number) {
formDataValues.suggestedUsernames.splice(index, 1);
saveSuggestedUsernames();
}
function handleCreateSuggestedUsername(tag: string) {
formDataValues.suggestedUsernames.push(tag);
handleFieldChange({
fieldName: 'suggestedUsernames',
value: formDataValues.suggestedUsernames,
});
saveSuggestedUsernames();
}
function getSuggestedUsernamesLimitWarning(length: number): StatusState | null {
if (length === 0)
return createInputStatus('success', TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.no_entries);
if (length > 0 && length < 10)
return createInputStatus('warning', TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.min_not_reached);
return null;
}
useEffect(() => {
setFormDataValues({
chatDisabled,
chatJoinMessagesEnabled,
forbiddenUsernames,
suggestedUsernames,
welcomeMessage,
chatEstablishedUserMode,
});
}, [serverConfig]);
if (!formDataValues) {
return null;
}
return (
<div className="config-server-details-form">
<Title>Chat Settings</Title>
<div className="form-module config-server-details-container">
<ToggleSwitch
fieldName="chatDisabled"
{...FIELD_PROPS_DISABLE_CHAT}
checked={!formDataValues.chatDisabled}
reversed
onChange={handleChatDisableChange}
/>
<ToggleSwitch
fieldName="chatJoinMessagesEnabled"
{...FIELD_PROPS_CHAT_JOIN_MESSAGES_ENABLED}
checked={formDataValues.chatJoinMessagesEnabled}
onChange={handleChatJoinMessagesEnabledChange}
/>
<ToggleSwitch
fieldName="establishedUserMode"
{...CHAT_ESTABLISHED_USER_MODE}
checked={formDataValues.chatEstablishedUserMode}
onChange={handleEstablishedUserModeChange}
/>
<TextFieldWithSubmit
fieldName="welcomeMessage"
{...TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE}
type={TEXTFIELD_TYPE_TEXTAREA}
value={formDataValues.welcomeMessage}
initialValue={welcomeMessage}
onChange={handleFieldChange}
/>
<br />
<br />
<EditValueArray
title={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.label}
placeholder={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.placeholder}
description={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.tip}
values={formDataValues.forbiddenUsernames}
handleDeleteIndex={handleDeleteForbiddenUsernameIndex}
handleCreateString={handleCreateForbiddenUsername}
submitStatus={forbiddenUsernameSaveState}
/>
<br />
<br />
<EditValueArray
title={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.label}
placeholder={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.placeholder}
description={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.tip}
values={formDataValues.suggestedUsernames}
handleDeleteIndex={handleDeleteSuggestedUsernameIndex}
handleCreateString={handleCreateSuggestedUsername}
submitStatus={suggestedUsernameSaveState}
continuousStatusMessage={getSuggestedUsernamesLimitWarning(
formDataValues.suggestedUsernames.length,
)}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,334 @@
/* eslint-disable react/no-unescaped-entities */
import { Typography, Modal, Button, Row, Col, Alert } from 'antd';
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
TEXTFIELD_TYPE_TEXT,
TEXTFIELD_TYPE_TEXTAREA,
TEXTFIELD_TYPE_URL,
} from '../../components/config/form-textfield';
import TextFieldWithSubmit from '../../components/config/form-textfield-with-submit';
import ToggleSwitch from '../../components/config/form-toggleswitch';
import EditValueArray from '../../components/config/edit-string-array';
import { UpdateArgs } from '../../types/config-section';
import {
FIELD_PROPS_ENABLE_FEDERATION,
TEXTFIELD_PROPS_FEDERATION_LIVE_MESSAGE,
TEXTFIELD_PROPS_FEDERATION_DEFAULT_USER,
FIELD_PROPS_FEDERATION_IS_PRIVATE,
FIELD_PROPS_SHOW_FEDERATION_ENGAGEMENT,
TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL,
FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS,
postConfigUpdateToAPI,
RESET_TIMEOUT,
API_FEDERATION_BLOCKED_DOMAINS,
FIELD_PROPS_FEDERATION_NSFW,
} from '../../utils/config-constants';
import { ServerStatusContext } from '../../utils/server-status-context';
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
function FederationInfoModal({ cancelPressed, okPressed }) {
return (
<Modal
width="70%"
title="Enable Social Features"
visible
onCancel={cancelPressed}
footer={
<div>
<Button onClick={cancelPressed}>Do not enable</Button>
<Button type="primary" onClick={okPressed}>
Enable Social Features
</Button>
</div>
}
>
<Typography.Title level={3}>How do Owncast's social features work?</Typography.Title>
<Typography.Paragraph>
Owncast's social features are accomplished by having your server join The{' '}
<a href="https://en.wikipedia.org/wiki/Fediverse" rel="noopener noreferrer" target="_blank">
Fediverse
</a>
, a decentralized, open, collection of independent servers, like yours.
</Typography.Paragraph>
Please{' '}
<a href="https://owncast.online/docs/social" rel="noopener noreferrer" target="_blank">
read more
</a>{' '}
about these features, the details behind them, and how they work.
<Typography.Paragraph />
<Typography.Title level={3}>What do you need to know?</Typography.Title>
<ul>
<li>
These features are brand new. Given the variability of interfacing with the rest of the
world, bugs are possible. Please report anything that you think isn't working quite right.
</li>
<li>You must always host your Owncast server with SSL using a https url.</li>
<li>
You should not change your server name URL or social username once people begin following
you, as your server will be seen as a completely different user on the Fediverse, and the
old user will disappear.
</li>
<li>
Turning on <i>Private mode</i> will allow you to manually approve each follower and limit
the visibility of your posts to followers only.
</li>
</ul>
<Typography.Title level={3}>Learn more about The Fediverse</Typography.Title>
<Typography.Paragraph>
If these concepts are new you should discover more about what this functionality has to
offer. Visit{' '}
<a href="https://owncast.online/docs/social" rel="noopener noreferrer" target="_blank">
our documentation
</a>{' '}
to be pointed at some resources that will help get you started on The Fediverse.
</Typography.Paragraph>
</Modal>
);
}
FederationInfoModal.propTypes = {
cancelPressed: PropTypes.func.isRequired,
okPressed: PropTypes.func.isRequired,
};
export default function ConfigFederation() {
const { Title } = Typography;
const [formDataValues, setFormDataValues] = useState(null);
const [isInfoModalOpen, setIsInfoModalOpen] = useState(false);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const [blockedDomainSaveState, setBlockedDomainSaveState] = useState(null);
const { federation, yp, instanceDetails } = serverConfig;
const { enabled, isPrivate, username, goLiveMessage, showEngagement, blockedDomains } =
federation;
const { instanceUrl } = yp;
const { nsfw } = instanceDetails;
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
};
const handleEnabledSwitchChange = (value: boolean) => {
if (!value) {
setFormDataValues({
...formDataValues,
enabled: false,
});
} else {
setIsInfoModalOpen(true);
}
};
// if instanceUrl is empty, we should also turn OFF the `enabled` field of directory.
const handleSubmitInstanceUrl = () => {
const hasInstanceUrl = formDataValues.instanceUrl !== '';
const isInstanceUrlSecure = formDataValues.instanceUrl.startsWith('https://');
if (!hasInstanceUrl || !isInstanceUrlSecure) {
postConfigUpdateToAPI({
apiPath: FIELD_PROPS_ENABLE_FEDERATION.apiPath,
data: { value: false },
});
setFormDataValues({
...formDataValues,
enabled: false,
});
}
};
function federationInfoModalCancelPressed() {
setIsInfoModalOpen(false);
setFormDataValues({
...formDataValues,
enabled: false,
});
}
function federationInfoModalOkPressed() {
setIsInfoModalOpen(false);
setFormDataValues({
...formDataValues,
enabled: true,
});
}
function resetBlockedDomainsSaveState() {
setBlockedDomainSaveState(null);
}
function saveBlockedDomains() {
try {
postConfigUpdateToAPI({
apiPath: API_FEDERATION_BLOCKED_DOMAINS,
data: { value: formDataValues.blockedDomains },
onSuccess: () => {
setFieldInConfigState({
fieldName: 'forbiddenUsernames',
value: formDataValues.forbiddenUsernames,
});
setBlockedDomainSaveState(STATUS_SUCCESS);
setTimeout(resetBlockedDomainsSaveState, RESET_TIMEOUT);
},
onError: (message: string) => {
setBlockedDomainSaveState(createInputStatus(STATUS_ERROR, message));
setTimeout(resetBlockedDomainsSaveState, RESET_TIMEOUT);
},
});
} catch (e) {
console.error(e);
setBlockedDomainSaveState(STATUS_ERROR);
}
}
function handleDeleteBlockedDomain(index: number) {
formDataValues.blockedDomains.splice(index, 1);
saveBlockedDomains();
}
function handleCreateBlockedDomain(domain: string) {
let newDomain;
try {
const u = new URL(domain);
newDomain = u.host;
} catch (_) {
newDomain = domain;
}
formDataValues.blockedDomains.push(newDomain);
handleFieldChange({
fieldName: 'blockedDomains',
value: formDataValues.blockedDomains,
});
saveBlockedDomains();
}
useEffect(() => {
setFormDataValues({
enabled,
isPrivate,
username,
goLiveMessage,
showEngagement,
blockedDomains,
nsfw,
instanceUrl: yp.instanceUrl,
});
}, [serverConfig, yp]);
if (!formDataValues) {
return null;
}
const hasInstanceUrl = instanceUrl !== '';
const isInstanceUrlSecure = instanceUrl.startsWith('https://');
const configurationWarning = !isInstanceUrlSecure && (
<>
<Alert
message="You must set your server URL before you can enable this feature."
type="warning"
showIcon
/>
<br />
<TextFieldWithSubmit
fieldName="instanceUrl"
{...TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL}
value={formDataValues.instanceUrl}
initialValue={yp.instanceUrl}
type={TEXTFIELD_TYPE_URL}
onChange={handleFieldChange}
onSubmit={handleSubmitInstanceUrl}
required
/>
</>
);
return (
<div>
<Title>Configure Social Features</Title>
<p>
Owncast provides the ability for people to follow and engage with your instance. It's a
great way to promote alerting, sharing and engagement of your stream.
</p>
<p>
Once enabled you'll alert your followers when you go live as well as gain the ability to
compose custom posts to share any information you like.
</p>
<p>
<a href="https://owncast.online/docs/social" rel="noopener noreferrer" target="_blank">
Read more about the specifics of these social features.
</a>
</p>
<Row>
<Col span={15} className="form-module" style={{ marginRight: '15px' }}>
{configurationWarning}
<ToggleSwitch
fieldName="enabled"
onChange={handleEnabledSwitchChange}
{...FIELD_PROPS_ENABLE_FEDERATION}
checked={formDataValues.enabled}
disabled={!hasInstanceUrl || !isInstanceUrlSecure}
/>
<ToggleSwitch
fieldName="isPrivate"
{...FIELD_PROPS_FEDERATION_IS_PRIVATE}
checked={formDataValues.isPrivate}
disabled={!enabled}
/>
<ToggleSwitch
fieldName="nsfw"
useSubmit
{...FIELD_PROPS_FEDERATION_NSFW}
checked={formDataValues.nsfw}
disabled={!hasInstanceUrl}
/>
<TextFieldWithSubmit
required
fieldName="username"
type={TEXTFIELD_TYPE_TEXT}
{...TEXTFIELD_PROPS_FEDERATION_DEFAULT_USER}
value={formDataValues.username}
initialValue={username}
onChange={handleFieldChange}
disabled={!enabled}
/>
<TextFieldWithSubmit
fieldName="goLiveMessage"
{...TEXTFIELD_PROPS_FEDERATION_LIVE_MESSAGE}
type={TEXTFIELD_TYPE_TEXTAREA}
value={formDataValues.goLiveMessage}
initialValue={goLiveMessage}
onChange={handleFieldChange}
disabled={!enabled}
/>
<ToggleSwitch
fieldName="showEngagement"
{...FIELD_PROPS_SHOW_FEDERATION_ENGAGEMENT}
checked={formDataValues.showEngagement}
disabled={!enabled}
/>
</Col>
<Col span={8} className="form-module">
<EditValueArray
title={FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS.label}
placeholder={FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS.placeholder}
description={FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS.tip}
values={formDataValues.blockedDomains}
handleDeleteIndex={handleDeleteBlockedDomain}
handleCreateString={handleCreateBlockedDomain}
submitStatus={createInputStatus(blockedDomainSaveState)}
/>
</Col>
</Row>
{isInfoModalOpen && (
<FederationInfoModal
cancelPressed={federationInfoModalCancelPressed}
okPressed={federationInfoModalOkPressed}
/>
)}
</div>
);
}

View file

@ -0,0 +1,148 @@
import { Alert, Button, Col, Row, Typography } from 'antd';
import React, { useContext, useEffect, useState } from 'react';
import Link from 'next/link';
import Discord from '../../components/config/notification/discord';
import Browser from '../../components/config/notification/browser';
import Twitter from '../../components/config/notification/twitter';
import Federation from '../../components/config/notification/federation';
import TextFieldWithSubmit, {
TEXTFIELD_TYPE_URL,
} from '../../components/config/form-textfield-with-submit';
import { TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL } from '../../utils/config-constants';
import { ServerStatusContext } from '../../utils/server-status-context';
import { UpdateArgs } from '../../types/config-section';
import isValidUrl from '../utils/urls';
const { Title } = Typography;
export default function ConfigNotify() {
const [formDataValues, setFormDataValues] = useState(null);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { yp } = serverConfig;
const { instanceUrl } = yp;
const [urlValid, setUrlValid] = useState(false);
useEffect(() => {
setFormDataValues({
instanceUrl,
});
}, [yp]);
const handleSubmitInstanceUrl = () => {
if (!urlValid) {
return;
}
setFormDataValues({
...formDataValues,
enabled: false,
});
};
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setUrlValid(isValidUrl(value));
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
};
const enabled = instanceUrl !== '';
const configurationWarning = !enabled && (
<>
<Alert
message="You must set your server URL before you can enable this feature."
type="warning"
showIcon
/>
<br />
<TextFieldWithSubmit
fieldName="instanceUrl"
{...TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL}
value={formDataValues?.instanceUrl || ''}
initialValue={yp.instanceUrl}
type={TEXTFIELD_TYPE_URL}
onChange={handleFieldChange}
onSubmit={handleSubmitInstanceUrl}
required
/>
</>
);
return (
<>
<Title>Notifications</Title>
<p className="description">
Let your viewers know when you go live by supporting any of the below notification channels.{' '}
<a
href="https://owncast.online/docs/notifications/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Learn more about live notifications.
</a>
</p>
{configurationWarning}
<Row>
<Col
span={10}
className={`form-module ${enabled ? '' : 'disabled'}`}
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
>
<Browser />
</Col>
<Col
span={10}
className={`form-module ${enabled ? '' : 'disabled'}`}
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
>
<Twitter />
</Col>
<Col
span={10}
className={`form-module ${enabled ? '' : 'disabled'}`}
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
>
<Discord />
</Col>
<Col
span={10}
className={`form-module ${enabled ? '' : 'disabled'}`}
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
>
<Federation />
</Col>
<Col
span={10}
className={`form-module ${enabled ? '' : 'disabled'}`}
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
>
<Title>Custom</Title>
<p className="description">Build your own notifications by using custom webhooks.</p>
<Link passHref href="/webhooks">
<Button
type="primary"
style={{
position: 'relative',
marginLeft: 'auto',
right: '0',
marginTop: '20px',
}}
>
Create
</Button>
</Link>
</Col>
</Row>
</>
);
}

View file

@ -0,0 +1,50 @@
import React from 'react';
import { Typography } from 'antd';
import EditInstanceDetails from '../../components/config/edit-instance-details';
import EditInstanceTags from '../../components/config/edit-tags';
import EditSocialLinks from '../../components/config/edit-social-links';
import EditPageContent from '../../components/config/edit-page-content';
import EditCustomStyles from '../../components/config/edit-custom-css';
const { Title } = Typography;
export default function PublicFacingDetails() {
return (
<div className="config-public-details-page">
<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/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Learn more.
</a>
</p>
<div className="top-container">
<div className="form-module instance-details-container">
<EditInstanceDetails />
</div>
<div className="form-module social-items-container ">
<div className="form-module tags-module">
<EditInstanceTags />
</div>
<div className="form-module social-handles-container">
<EditSocialLinks />
</div>
</div>
</div>
<div className="form-module page-content-module">
<EditPageContent />
</div>
<div className="form-module page-content-module">
<EditCustomStyles />
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
import React from 'react';
import { Typography } from 'antd';
import EditServerDetails from '../../components/config/edit-server-details';
const { Title } = Typography;
export default function ConfigServerDetails() {
return (
<div className="config-server-details-form">
<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.
</p>
<div className="form-module config-server-details-container">
<EditServerDetails />
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import React from 'react';
import { Typography } from 'antd';
import EditSocialLinks from '../../components/config/edit-social-links';
const { Title } = Typography;
export default function ConfigSocialThings() {
return (
<div className="config-social-items">
<Title>Social Items</Title>
<EditSocialLinks />
</div>
);
}

View file

@ -0,0 +1,34 @@
import { Typography } from 'antd';
import React from 'react';
import EditStorage from '../../components/config/edit-storage';
const { Title } = Typography;
export default function ConfigStorageInfo() {
return (
<>
<Title>Storage</Title>
<p className="description">
Owncast supports optionally using external storage providers to stream your video. Learn
more about this by visiting our{' '}
<a
href="https://owncast.online/docs/storage/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
Storage Documentation
</a>
.
</p>
<p className="description">
Configuring this incorrectly will likely cause your video to be unplayable. Double check the
documentation for your storage provider on how to configure the bucket you created for
Owncast.
</p>
<p className="description">
Keep in mind this is for live streaming, not for archival, recording or VOD purposes.
</p>
<EditStorage />
</>
);
}

View file

@ -0,0 +1,50 @@
import { Col, Collapse, Row, Typography } from 'antd';
import React from 'react';
import VideoCodecSelector from '../../components/config/video-codec-selector';
import VideoLatency from '../../components/config/video-latency';
import VideoVariantsTable from '../../components/config/video-variants-table';
const { Panel } = Collapse;
const { Title } = Typography;
export default function ConfigVideoSettings() {
return (
<div className="config-video-variants">
<Title>Video configuration</Title>
<p className="description">
Before changing your video configuration{' '}
<a
href="https://owncast.online/docs/video?source=admin"
target="_blank"
rel="noopener noreferrer"
>
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>
<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>
<Collapse className="advanced-settings codec-module">
<Panel header="Advanced Settings" key="1">
<div className="form-module variants-table-module">
<VideoCodecSelector />
</div>
</Panel>
</Collapse>
</Col>
</Row>
</div>
);
}

View file

@ -0,0 +1,136 @@
import React, { useEffect, useState } from 'react';
import { Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table/interface';
import format from 'date-fns/format';
import { FEDERATION_ACTIONS, fetchData } from '../../../utils/apis';
import { isEmptyObject } from '../../../utils/format';
const { Title, Paragraph } = Typography;
export interface Action {
iri: string;
actorIRI: string;
type: string;
timestamp: Date;
}
export default function FediverseActions() {
const [actions, setActions] = useState<Action[]>([]);
const [totalCount, setTotalCount] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(0);
const getActions = async () => {
try {
const limit = 50;
const offset = currentPage * limit;
const u = `${FEDERATION_ACTIONS}?offset=${offset}&limit=${limit}`;
const result = await fetchData(u, { auth: true });
const { results, total } = result;
setTotalCount(total);
if (isEmptyObject(results)) {
setActions([]);
} else {
setActions(results);
}
} catch (error) {
console.log('==== error', error);
}
};
useEffect(() => {
getActions();
}, [currentPage]);
const columns: ColumnsType<Action> = [
{
title: 'Action',
dataIndex: 'type',
key: 'type',
width: 50,
render: (_, record) => {
let image;
let title;
switch (record.type) {
case 'FEDIVERSE_ENGAGEMENT_REPOST':
image = '/img/repost.svg';
title = 'Share';
break;
case 'FEDIVERSE_ENGAGEMENT_LIKE':
image = '/img/like.svg';
title = 'Like';
break;
case 'FEDIVERSE_ENGAGEMENT_FOLLOW':
image = '/img/follow.svg';
title = 'Follow';
break;
default:
image = '';
}
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<img src={image} width="70%" alt={title} title={title} />
<div style={{ fontSize: '0.7rem' }}>{title}</div>
</div>
);
},
},
{
title: 'From',
dataIndex: 'actorIRI',
key: 'from',
render: (_, record) => <a href={record.actorIRI}>{record.actorIRI}</a>,
},
{
title: 'When',
dataIndex: 'timestamp',
key: 'timestamp',
render: (_, record) => {
const dateObject = new Date(record.timestamp);
return format(dateObject, 'P pp');
},
},
];
function makeTable(data: Action[], tableColumns: ColumnsType<Action>) {
return (
<Table
dataSource={data}
columns={tableColumns}
size="small"
rowKey={row => row.iri}
pagination={{
pageSize: 50,
hideOnSinglePage: true,
showSizeChanger: false,
total: totalCount,
}}
onChange={pagination => {
const page = pagination.current;
setCurrentPage(page);
}}
/>
);
}
return (
<div>
<Title level={3}>Fediverse Actions</Title>
<Paragraph>
Below is a list of actions that were taken by others in response to your posts as well as
people who requested to follow you.
</Paragraph>
{makeTable(actions, columns)}
</div>
);
}

View file

@ -0,0 +1,336 @@
import React, { useEffect, useState, useContext } from 'react';
import { Table, Avatar, Button, Tabs } from 'antd';
import { ColumnsType, SortOrder } from 'antd/lib/table/interface';
import format from 'date-fns/format';
import { UserAddOutlined, UserDeleteOutlined } from '@ant-design/icons';
import { ServerStatusContext } from '../../../utils/server-status-context';
import {
FOLLOWERS,
FOLLOWERS_PENDING,
SET_FOLLOWER_APPROVAL,
FOLLOWERS_BLOCKED,
fetchData,
} from '../../../utils/apis';
import { isEmptyObject } from '../../../utils/format';
const { TabPane } = Tabs;
export interface Follower {
link: string;
username: string;
image: string;
name: string;
timestamp: Date;
approved: Date;
}
export default function FediverseFollowers() {
const [followersPending, setFollowersPending] = useState<Follower[]>([]);
const [followersBlocked, setFollowersBlocked] = useState<Follower[]>([]);
const [followers, setFollowers] = useState<Follower[]>([]);
const [totalCount, setTotalCount] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(0);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { federation } = serverConfig;
const { isPrivate } = federation;
const getFollowers = async () => {
try {
const limit = 50;
const offset = currentPage * limit;
const u = `${FOLLOWERS}?offset=${offset}&limit=${limit}`;
// Active followers
const result = await fetchData(u, { auth: true });
const { results, total } = result;
if (isEmptyObject(results)) {
setFollowers([]);
} else {
setTotalCount(total);
setFollowers(results);
}
// Pending follow requests
const pendingFollowersResult = await fetchData(FOLLOWERS_PENDING, { auth: true });
if (isEmptyObject(pendingFollowersResult)) {
setFollowersPending([]);
} else {
setFollowersPending(pendingFollowersResult);
}
// Blocked/rejected followers
const blockedFollowersResult = await fetchData(FOLLOWERS_BLOCKED, { auth: true });
if (isEmptyObject(followersBlocked)) {
setFollowersBlocked([]);
} else {
setFollowersBlocked(blockedFollowersResult);
}
} catch (error) {
console.log('==== error', error);
}
};
useEffect(() => {
getFollowers();
}, []);
const columns: ColumnsType<Follower> = [
{
title: '',
dataIndex: 'image',
key: 'image',
width: 90,
render: image => <Avatar size={40} src={image || '/img/logo.svg'} />,
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (_, follower) => (
<a href={follower.link} target="_blank" rel="noreferrer">
{follower.name || follower.username}
</a>
),
},
{
title: 'URL',
dataIndex: 'link',
key: 'link',
render: (_, follower) => (
<a href={follower.link} target="_blank" rel="noreferrer">
{follower.link}
</a>
),
},
];
function makeTable(data: Follower[], tableColumns: ColumnsType<Follower>) {
return (
<Table
dataSource={data}
columns={tableColumns}
size="small"
rowKey={row => row.link}
pagination={{
pageSize: 50,
hideOnSinglePage: true,
showSizeChanger: false,
total: totalCount,
}}
onChange={pagination => {
const page = pagination.current;
setCurrentPage(page);
}}
/>
);
}
async function approveFollowRequest(request) {
try {
await fetchData(SET_FOLLOWER_APPROVAL, {
auth: true,
method: 'POST',
data: {
actorIRI: request.link,
approved: true,
},
});
// Refetch and update the current data.
getFollowers();
} catch (err) {
console.error(err);
}
}
async function rejectFollowRequest(request) {
try {
await fetchData(SET_FOLLOWER_APPROVAL, {
auth: true,
method: 'POST',
data: {
actorIRI: request.link,
approved: false,
},
});
// Refetch and update the current data.
getFollowers();
} catch (err) {
console.error(err);
}
}
const pendingColumns: ColumnsType<Follower> = [...columns];
pendingColumns.unshift(
{
title: 'Approve',
dataIndex: null,
key: null,
render: request => (
<Button
type="primary"
icon={<UserAddOutlined />}
onClick={() => {
approveFollowRequest(request);
}}
/>
),
width: 50,
},
{
title: 'Reject',
dataIndex: null,
key: null,
render: request => (
<Button
type="primary"
danger
icon={<UserDeleteOutlined />}
onClick={() => {
rejectFollowRequest(request);
}}
/>
),
width: 50,
},
);
pendingColumns.push({
title: 'Requested',
dataIndex: 'timestamp',
key: 'requested',
width: 200,
render: timestamp => {
const dateObject = new Date(timestamp);
return <>{format(dateObject, 'P')}</>;
},
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
defaultSortOrder: 'descend' as SortOrder,
});
const blockedColumns: ColumnsType<Follower> = [...columns];
blockedColumns.unshift({
title: 'Approve',
dataIndex: null,
key: null,
render: request => (
<Button
type="primary"
icon={<UserAddOutlined />}
size="large"
onClick={() => {
approveFollowRequest(request);
}}
/>
),
width: 50,
});
blockedColumns.push(
{
title: 'Requested',
dataIndex: 'timestamp',
key: 'requested',
width: 200,
render: timestamp => {
const dateObject = new Date(timestamp);
return <>{format(dateObject, 'P')}</>;
},
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
defaultSortOrder: 'descend' as SortOrder,
},
{
title: 'Rejected/Blocked',
dataIndex: 'timestamp',
key: 'disabled_at',
width: 200,
render: timestamp => {
const dateObject = new Date(timestamp);
return <>{format(dateObject, 'P')}</>;
},
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
defaultSortOrder: 'descend' as SortOrder,
},
);
const followersColumns: ColumnsType<Follower> = [...columns];
followersColumns.push(
{
title: 'Added',
dataIndex: 'timestamp',
key: 'timestamp',
width: 200,
render: timestamp => {
const dateObject = new Date(timestamp);
return <>{format(dateObject, 'P')}</>;
},
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
sortDirections: ['descend', 'ascend'] as SortOrder[],
defaultSortOrder: 'descend' as SortOrder,
},
{
title: 'Remove',
dataIndex: null,
key: null,
render: request => (
<Button
type="primary"
danger
icon={<UserDeleteOutlined />}
onClick={() => {
rejectFollowRequest(request);
}}
/>
),
width: 50,
},
);
const pendingRequestsTab = isPrivate && (
<TabPane
tab={<span>Requests {followersPending.length > 0 && `(${followersPending.length})`}</span>}
key="2"
>
<p>
The following people are requesting to follow your Owncast server on the{' '}
<a href="https://en.wikipedia.org/wiki/Fediverse" target="_blank" rel="noopener noreferrer">
Fediverse
</a>{' '}
and be alerted to when you go live. Each must be approved.
</p>
{makeTable(followersPending, pendingColumns)}
</TabPane>
);
return (
<div className="followers-section">
<Tabs defaultActiveKey="1">
<TabPane
tab={<span>Followers {followers.length > 0 && `(${followers.length})`}</span>}
key="1"
>
<p>The following accounts get notified when you go live or send a post.</p>
{makeTable(followers, followersColumns)}{' '}
</TabPane>
{pendingRequestsTab}
<TabPane
tab={<span>Blocked {followersBlocked.length > 0 && `(${followersBlocked.length})`}</span>}
key="3"
>
<p>
The following people were either rejected or blocked by you. You can approve them as a
follower.
</p>
<p>{makeTable(followersBlocked, blockedColumns)}</p>
</TabPane>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,111 @@
import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons';
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';
// 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>(),
message: '',
});
const getHardwareStatus = async () => {
try {
const result = await fetchData(HARDWARE_STATS);
setHardwareStatus({ ...result });
} catch (error) {
setHardwareStatus({ ...hardwareStatus, message: error.message });
}
};
useEffect(() => {
let getStatusIntervalId = null;
getHardwareStatus();
getStatusIntervalId = setInterval(getHardwareStatus, FETCH_INTERVAL); // runs every 1 min.
// returned function will be called on component unmount
return () => {
clearInterval(getStatusIntervalId);
};
}, []);
if (!hardwareStatus.cpu) {
return null;
}
const currentCPUUsage = hardwareStatus.cpu[hardwareStatus.cpu.length - 1]?.value;
const currentRamUsage = hardwareStatus.memory[hardwareStatus.memory.length - 1]?.value;
const currentDiskUsage = hardwareStatus.disk[hardwareStatus.disk.length - 1]?.value;
const series = [
{
name: 'CPU',
color: '#B63FFF',
data: hardwareStatus.cpu,
},
{
name: 'Memory',
color: '#2087E2',
data: hardwareStatus.memory,
},
{
name: 'Disk',
color: '#FF7700',
data: hardwareStatus.disk,
},
];
return (
<>
<Typography.Title>Hardware Info</Typography.Title>
<br />
<div>
<Row gutter={[16, 16]} justify="space-around">
<Col>
<StatisticItem
title={series[0].name}
value={`${Math.round(currentCPUUsage) || 0}`}
prefix={<LaptopOutlined style={{ color: series[0].color }} />}
color={series[0].color}
progress
centered
/>
</Col>
<Col>
<StatisticItem
title={series[1].name}
value={`${Math.round(currentRamUsage) || 0}`}
prefix={<BulbOutlined style={{ color: series[1].color }} />}
color={series[1].color}
progress
centered
/>
</Col>
<Col>
<StatisticItem
title={series[2].name}
value={`${Math.round(currentDiskUsage) || 0}`}
prefix={<SaveOutlined style={{ color: series[2].color }} />}
color={series[2].color}
progress
centered
/>
</Col>
</Row>
<Chart title="% used" dataCollections={series} color="#FF7700" unit="%" />
</div>
</>
);
}

228
web/pages/admin/help.tsx Normal file
View file

@ -0,0 +1,228 @@
import { Button, Card, Col, Divider, Result, Row } from 'antd';
import Meta from 'antd/lib/card/Meta';
import Title from 'antd/lib/typography/Title';
import {
ApiTwoTone,
BugTwoTone,
CameraTwoTone,
DatabaseTwoTone,
EditTwoTone,
Html5TwoTone,
LinkOutlined,
QuestionCircleTwoTone,
SettingTwoTone,
SlidersTwoTone,
} from '@ant-design/icons';
import React from 'react';
export default function Help() {
const questions = [
{
icon: <SettingTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to configure my owncast instance',
content: (
<div>
<a
href="https://owncast.online/docs/configuration/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
<LinkOutlined /> Learn more
</a>
</div>
),
},
{
icon: <CameraTwoTone style={{ fontSize: '24px' }} />,
title: 'Help configuring my broadcasting software',
content: (
<div>
<a
href="https://owncast.online/docs/broadcasting/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
<LinkOutlined /> Learn more
</a>
</div>
),
},
{
icon: <Html5TwoTone style={{ fontSize: '24px' }} />,
title: 'I want to embed my stream into another site',
content: (
<div>
<a
href="https://owncast.online/docs/embed/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
<LinkOutlined /> Learn more
</a>
</div>
),
},
{
icon: <EditTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to customize my website',
content: (
<div>
<a
href="https://owncast.online/docs/website/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
<LinkOutlined /> Learn more
</a>
</div>
),
},
{
icon: <SlidersTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to tweak my video output',
content: (
<div>
<a
href="https://owncast.online/docs/encoding/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
<LinkOutlined /> Learn more
</a>
</div>
),
},
{
icon: <DatabaseTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to use an external storage provider',
content: (
<div>
<a
href="https://owncast.online/docs/storage/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
<LinkOutlined /> Learn more
</a>
</div>
),
},
];
const otherResources = [
{
icon: <BugTwoTone style={{ fontSize: '24px' }} />,
title: 'I found a bug',
content: (
<div>
If you found a bug, then please
<a
href="https://github.com/owncast/owncast/issues/new/choose"
target="_blank"
rel="noopener noreferrer"
>
{' '}
let us know
</a>
</div>
),
},
{
icon: <QuestionCircleTwoTone style={{ fontSize: '24px' }} />,
title: 'I have a general question',
content: (
<div>
Most general questions are answered in our
<a
href="https://owncast.online/docs/faq/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
{' '}
FAQ
</a>{' '}
or exist in our{' '}
<a
href="https://github.com/owncast/owncast/discussions"
target="_blank"
rel="noopener noreferrer"
>
discussions
</a>
</div>
),
},
{
icon: <ApiTwoTone style={{ fontSize: '24px' }} />,
title: 'I want to build add-ons for Owncast',
content: (
<div>
You can build your own bots, overlays, tools and add-ons with our
<a
href="https://owncast.online/thirdparty?source=admin"
target="_blank"
rel="noopener noreferrer"
>
&nbsp;developer APIs.&nbsp;
</a>
</div>
),
},
];
return (
<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' }}>
<Result status="500" />
<Title level={2}>Troubleshooting</Title>
<Button
target="_blank"
rel="noopener noreferrer"
href="https://owncast.online/docs/troubleshooting/?source=admin"
icon={<LinkOutlined />}
type="primary"
>
Fix your problems
</Button>
</Col>
<Col xs={24} lg={12} style={{ textAlign: 'center' }}>
<Result status="404" />
<Title level={2}>Documentation</Title>
<Button
target="_blank"
rel="noopener noreferrer"
href="https://owncast.online/docs?source=admin"
icon={<LinkOutlined />}
type="primary"
>
Read the Docs
</Button>
</Col>
</Row>
<Divider />
<Title level={2}>Common tasks</Title>
<Row gutter={[16, 16]}>
{questions.map(question => (
<Col xs={24} lg={12} key={question.title}>
<Card>
<Meta avatar={question.icon} title={question.title} description={question.content} />
</Card>
</Col>
))}
</Row>
<Divider />
<Title level={2}>Other</Title>
<Row gutter={[16, 16]}>
{otherResources.map(question => (
<Col xs={24} lg={12} key={question.title}>
<Card>
<Meta avatar={question.icon} title={question.title} description={question.content} />
</Card>
</Col>
))}
</Row>
</div>
);
}

180
web/pages/admin/index.tsx Normal file
View file

@ -0,0 +1,180 @@
import React, { useState, useEffect, useContext } from 'react';
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 LogTable from '../../components/log-table';
import Offline from '../../components/offline-notice';
import StreamHealthOverview from '../../components/stream-health-overview';
import { LOGS_WARN, fetchData, FETCH_INTERVAL } from '../../utils/apis';
import { formatIPAddress, isEmptyObject } from '../../utils/format';
import NewsFeed from '../../components/news-feed';
function streamDetailsFormatter(streamDetails) {
return (
<ul className="statistics-list">
<li>
{streamDetails.videoCodec || 'Unknown'} @ {streamDetails.videoBitrate || 'Unknown'} kbps
</li>
<li>{streamDetails.framerate || 'Unknown'} fps</li>
<li>
{streamDetails.width} x {streamDetails.height}
</li>
</ul>
);
}
export default function Home() {
const serverStatusData = useContext(ServerStatusContext);
const { broadcaster, serverConfig: configData } = serverStatusData || {};
const { remoteAddr, streamDetails } = broadcaster || {};
const encoder = streamDetails?.encoder || 'Unknown encoder';
const [logsData, setLogs] = useState([]);
const getLogs = async () => {
try {
const result = await fetchData(LOGS_WARN);
setLogs(result);
} catch (error) {
console.log('==== error', error);
}
};
const getMoreStats = () => {
getLogs();
};
useEffect(() => {
getMoreStats();
let intervalId = null;
intervalId = setInterval(getMoreStats, FETCH_INTERVAL);
return () => {
clearInterval(intervalId);
};
}, []);
if (isEmptyObject(configData) || isEmptyObject(serverStatusData)) {
return (
<>
<Skeleton active />
<Skeleton active />
<Skeleton active />
</>
);
}
if (!broadcaster) {
return <Offline logs={logsData} config={configData} />;
}
// map out settings
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 videoSetting = videoPassthrough
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${
streamDetails.width
} x ${streamDetails.height}`
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
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;
const streamAudioDetailString = `${streamDetails.audioCodec}, ${
streamDetails.audioBitrate || 'Unknown'
} kbps`;
const broadcastDate = new Date(broadcaster.time);
return (
<div className="home-container">
<div className="sections-container">
<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>
<StreamHealthOverview />
</Card>
</div>
<Row gutter={[16, 16]} className="section stream-details-section">
<Col className="stream-details" span={12} sm={24} md={24} lg={12}>
<Card
size="small"
title="Outbound Stream Details"
type="inner"
className="outbound-details"
>
{videoQualitySettings}
</Card>
<Card size="small" title="Inbound Stream Details" type="inner">
<Statistic
className="stream-details-item"
title="Input"
value={`${encoder} ${formatIPAddress(remoteAddr)}`}
/>
<Statistic
className="stream-details-item"
title="Inbound Video Stream"
value={streamDetails}
formatter={streamDetailsFormatter}
/>
<Statistic
className="stream-details-item"
title="Inbound Audio Stream"
value={streamAudioDetailString}
/>
</Card>
</Col>
<Col span={12} xs={24} sm={24} md={24} lg={12}>
<NewsFeed />
</Col>
</Row>
</div>
<br />
<LogTable logs={logsData} pageSize={5} />
</div>
);
}

34
web/pages/admin/logs.tsx Normal file
View file

@ -0,0 +1,34 @@
import React, { useState, useEffect } from 'react';
import LogTable from '../../components/log-table';
import { LOGS_ALL, fetchData } from '../../utils/apis';
const FETCH_INTERVAL = 5 * 1000; // 5 sec
export default function Logs() {
const [logs, setLogs] = useState([]);
const getInfo = async () => {
try {
const result = await fetchData(LOGS_ALL);
setLogs(result);
} catch (error) {
console.log('==== error', error);
}
};
useEffect(() => {
let getStatusIntervalId = null;
setInterval(getInfo, FETCH_INTERVAL);
getInfo();
getStatusIntervalId = setInterval(getInfo, FETCH_INTERVAL);
// returned function will be called on component unmount
return () => {
clearInterval(getStatusIntervalId);
};
}, []);
return <LogTable logs={logs} pageSize={20} />;
}

View file

@ -0,0 +1,412 @@
/* eslint-disable react/no-unescaped-entities */
// import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons';
import { Row, Col, Typography, Space, Statistic, Card, Alert, Spin } from 'antd';
import React, { ReactNode, useEffect, useState } from 'react';
import { ClockCircleOutlined, WarningOutlined, WifiOutlined } from '@ant-design/icons';
import { fetchData, FETCH_INTERVAL, API_STREAM_HEALTH_METRICS } from '../../utils/apis';
import Chart from '../../components/chart';
import StreamHealthOverview from '../../components/stream-health-overview';
interface TimedValue {
time: Date;
value: Number;
}
interface DescriptionBoxProps {
title: String;
description: ReactNode;
}
function DescriptionBox({ title, description }: DescriptionBoxProps) {
return (
<div className="description-box">
<Typography.Title>{title}</Typography.Title>
<Typography.Paragraph>{description}</Typography.Paragraph>
</div>
);
}
export default function StreamHealth() {
const [errors, setErrors] = useState<TimedValue[]>([]);
const [qualityVariantChanges, setQualityVariantChanges] = useState<TimedValue[]>([]);
const [lowestLatency, setLowestLatency] = useState<TimedValue[]>();
const [highestLatency, setHighestLatency] = useState<TimedValue[]>();
const [medianLatency, setMedianLatency] = useState<TimedValue[]>([]);
const [medianSegmentDownloadDurations, setMedianSegmentDownloadDurations] = useState<
TimedValue[]
>([]);
const [maximumSegmentDownloadDurations, setMaximumSegmentDownloadDurations] = useState<
TimedValue[]
>([]);
const [minimumSegmentDownloadDurations, setMinimumSegmentDownloadDurations] = useState<
TimedValue[]
>([]);
const [minimumPlayerBitrate, setMinimumPlayerBitrate] = useState<TimedValue[]>([]);
const [medianPlayerBitrate, setMedianPlayerBitrate] = useState<TimedValue[]>([]);
const [maximumPlayerBitrate, setMaximumPlayerBitrate] = useState<TimedValue[]>([]);
const [availableBitrates, setAvailableBitrates] = useState<Number[]>([]);
const [segmentLength, setSegmentLength] = useState(0);
const getMetrics = async () => {
try {
const result = await fetchData(API_STREAM_HEALTH_METRICS);
setErrors(result.errors);
setQualityVariantChanges(result.qualityVariantChanges);
setHighestLatency(result.highestLatency);
setLowestLatency(result.lowestLatency);
setMedianLatency(result.medianLatency);
setMedianSegmentDownloadDurations(result.medianSegmentDownloadDuration);
setMaximumSegmentDownloadDurations(result.maximumSegmentDownloadDuration);
setMinimumSegmentDownloadDurations(result.minimumSegmentDownloadDuration);
setMinimumPlayerBitrate(result.minPlayerBitrate);
setMedianPlayerBitrate(result.medianPlayerBitrate);
setMaximumPlayerBitrate(result.maxPlayerBitrate);
setAvailableBitrates(result.availableBitrates);
setSegmentLength(result.segmentLength - 0.3);
} catch (error) {
console.error(error);
}
};
useEffect(() => {
let getStatusIntervalId = null;
getMetrics();
getStatusIntervalId = setInterval(getMetrics, FETCH_INTERVAL); // runs every 1 min.
// returned function will be called on component unmount
return () => {
clearInterval(getStatusIntervalId);
};
}, []);
const noData = (
<div>
<Typography.Title>Stream Performance</Typography.Title>
<Alert
type="info"
message="
Data has not yet been collected. Once a stream has begun and viewers are watching this page
will be available."
/>
<Spin size="large">
<div style={{ marginTop: '50px', height: '100px' }} />
</Spin>
</div>
);
if (!errors?.length) {
return noData;
}
if (!medianLatency?.length) {
return noData;
}
if (!medianSegmentDownloadDurations?.length) {
return noData;
}
const errorChart = [
{
name: 'Errors',
color: '#B63FFF',
options: { radius: 3 },
data: errors,
},
{
name: 'Quality changes',
color: '#2087E2',
options: { radius: 2 },
data: qualityVariantChanges,
},
];
const latencyChart = [
{
name: 'Median stream latency',
color: '#00FFFF',
options: { radius: 2 },
data: medianLatency,
},
{
name: 'Lowest stream latency',
color: '#02FD0D',
options: { radius: 2 },
data: lowestLatency,
},
{
name: 'Highest stream latency',
color: '#B63FFF',
options: { radius: 2 },
data: highestLatency,
},
];
const segmentDownloadDurationChart = [
{
name: 'Max download duration',
color: '#B63FFF',
options: { radius: 2 },
data: maximumSegmentDownloadDurations,
},
{
name: 'Median download duration',
color: '#00FFFF',
options: { radius: 2 },
data: medianSegmentDownloadDurations,
},
{
name: 'Min download duration',
color: '#02FD0D',
options: { radius: 2 },
data: minimumSegmentDownloadDurations,
},
{
name: `Approximate limit`,
color: '#003FFF',
data: medianSegmentDownloadDurations.map(item => ({
time: item.time,
value: segmentLength,
})),
options: { radius: 0 },
},
];
const bitrateChart = [
{
name: 'Lowest player speed',
color: '#B63FFF',
data: minimumPlayerBitrate,
options: { radius: 2 },
},
{
name: 'Median player speed',
color: '#00FFFF',
data: medianPlayerBitrate,
options: { radius: 2 },
},
{
name: 'Maximum player speed',
color: '#02FD0D',
data: maximumPlayerBitrate,
options: { radius: 2 },
},
];
availableBitrates.forEach(bitrate => {
bitrateChart.push({
name: `Available bitrate`,
color: '#003FFF',
data: minimumPlayerBitrate.map(item => ({
time: item.time,
value: bitrate,
})),
options: { radius: 0 },
});
});
const currentSpeed = bitrateChart[0]?.data[bitrateChart[0].data.length - 1]?.value;
const currentDownloadSeconds =
medianSegmentDownloadDurations[medianSegmentDownloadDurations.length - 1]?.value;
const lowestVariant = availableBitrates[0]; // TODO: get lowest bitrate from available bitrates
const latencyMedian = medianLatency[medianLatency.length - 1]?.value || 0;
const latencyMax = highestLatency[highestLatency.length - 1]?.value || 0;
const latencyMin = lowestLatency[lowestLatency.length - 1]?.value || 0;
const latencyStat = (Number(latencyMax) + Number(latencyMin) + Number(latencyMedian)) / 3;
let recentErrorCount = 0;
const errorValueCount = errorChart[0]?.data.length || 0;
if (errorValueCount > 5) {
const values = errorChart[0].data.slice(-5);
recentErrorCount = values.reduce((acc, curr) => acc + Number(curr.value), 0);
} else {
recentErrorCount = errorChart[0].data.reduce((acc, curr) => acc + Number(curr.value), 0);
}
const showStats = currentSpeed > 0 || currentDownloadSeconds > 0 || recentErrorCount > 0;
let bitrateError = null;
let speedError = null;
if (currentSpeed !== 0 && currentSpeed < lowestVariant) {
bitrateError = `One of your viewers is playing your stream at ${currentSpeed}kbps, slower than ${lowestVariant}kbps, the lowest quality you made available. Consider adding a lower quality with a lower bitrate if the errors over time warrant this.`;
}
if (currentDownloadSeconds > segmentLength) {
speedError =
'Your viewers may be consuming your video slower than required. This may be due to slow networks or your latency configuration. You need to decrease the amount of time viewers are taking to consume your video. Consider adding a lower quality with a lower bitrate or experiment with increasing the latency buffer setting.';
}
const errorStatColor = recentErrorCount > 0 ? '#B63FFF' : '#FFFFFF';
const statStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '80px',
};
return (
<>
<Typography.Title>Stream Performance</Typography.Title>
<Typography.Paragraph>
This tool hopes to help you identify and troubleshoot problems you may be experiencing with
your stream. It aims to aggregate experiences across your viewers, meaning one viewer with
an exceptionally bad experience may throw off numbers for the whole, especially with a low
number of viewers.
</Typography.Paragraph>
<Typography.Paragraph>
The data is only collected by those using the Owncast web interface and is unable to gain
insight into external players people may be using such as VLC, MPV, QuickTime, etc.
</Typography.Paragraph>
<Space direction="vertical" size="middle">
<Row justify="space-around">
<Col style={{ width: '100%' }}>
<Card type="inner">
<StreamHealthOverview showTroubleshootButton={false} />
</Card>
</Col>
</Row>
<Row
gutter={[16, 16]}
justify="space-around"
style={{ display: showStats ? 'flex' : 'none' }}
>
<Col>
<Card type="inner">
<div style={statStyle}>
<Statistic
title="Viewer Playback Speed"
value={`${currentSpeed}`}
prefix={<WifiOutlined style={{ marginRight: '5px' }} />}
precision={0}
suffix="kbps"
/>
</div>
</Card>
</Col>
<Col>
<Card type="inner">
<div style={statStyle}>
<Statistic
title="Viewer Latency"
value={`${latencyStat}`}
prefix={<ClockCircleOutlined style={{ marginRight: '5px' }} />}
precision={0}
suffix="seconds"
/>
</div>
</Card>
</Col>
<Col>
<Card type="inner">
<div style={statStyle}>
<Statistic
title="Recent Playback Errors"
value={`${recentErrorCount || 0}`}
valueStyle={{ color: errorStatColor }}
prefix={<WarningOutlined style={{ marginRight: '5px' }} />}
suffix=""
/>
</div>
</Card>
</Col>
</Row>
<Card>
<DescriptionBox
title="Video Segment Download"
description={
<>
<Typography.Paragraph>
Once a video segment takes too long to download a viewer will experience
buffering. If you see slow downloads you should offer a lower quality for your
viewers, or find other ways, possibly an external storage provider, a CDN or a
faster network, to improve your stream quality. Increasing your latency buffer can
also help for some viewers.
</Typography.Paragraph>
<Typography.Paragraph>
In short, once the pink line consistently gets near the blue line, your stream is
likely experiencing problems for viewers.
</Typography.Paragraph>
</>
}
/>
{speedError && (
<Alert message="Slow downloads" description={speedError} type="error" showIcon />
)}
<Chart
title="Seconds"
dataCollections={segmentDownloadDurationChart}
color="#FF7700"
unit="s"
yLogarithmic
/>
</Card>
<Card>
<DescriptionBox
title="Player Network Speed"
description={
<>
<Typography.Paragraph>
The playback bitrate of your viewers. Once somebody's bitrate drops below the
lowest video variant bitrate they will experience buffering. If you see viewers
with slow connections trying to play your video you should consider offering an
additional, lower quality.
</Typography.Paragraph>
<Typography.Paragraph>
In short, once the pink line gets near the lowest blue line, your stream is likely
experiencing problems for at least one of your viewers.
</Typography.Paragraph>
</>
}
/>
{bitrateError && (
<Alert
message="Low bandwidth viewers"
description={bitrateError}
type="error"
showIcon
/>
)}
<Chart
title="Lowest Player Bitrate"
dataCollections={bitrateChart}
color="#FF7700"
unit="kbps"
yLogarithmic
/>
</Card>
<Card>
<DescriptionBox
title="Errors and Quality Changes"
description={
<>
<Typography.Paragraph>
Recent number of errors, including buffering, and quality changes from across all
your viewers. Errors can occur for many reasons, including browser issues,
plugins, wifi problems, and they don't all represent fatal issues or something you
have control over.
</Typography.Paragraph>
A quality change is not necessarily a negative thing, but if it's excessive and
coinciding with errors you should consider adding another quality variant.
<Typography.Paragraph />
</>
}
/>
<Chart title="#" dataCollections={errorChart} color="#FF7700" unit="" />
</Card>
<Card>
<DescriptionBox
title="Viewer Latency"
description="An approximate number of seconds that your viewers are behind your live video. The largest cause of latency spikes is buffering. High latency itself is not a problem, and optimizing for low latency can result in buffering, resulting in even higher latency."
/>
<Chart title="Seconds" dataCollections={latencyChart} color="#FF7700" unit="s" />
</Card>
</Space>
</>
);
}

View file

@ -0,0 +1,74 @@
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { Table, Typography } from 'antd';
import { getGithubRelease } from '../../utils/apis';
const { Title } = Typography;
function AssetTable(assets) {
const data = Object.values(assets) as object[];
const columns = [
{
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`,
},
];
return (
<Table
dataSource={data}
columns={columns}
rowKey={record => record.id}
size="large"
pagination={false}
/>
);
}
export default function Logs() {
const [release, setRelease] = useState({
html_url: '',
name: '',
created_at: null,
body: '',
assets: [],
});
const getRelease = async () => {
try {
const result = await getGithubRelease();
setRelease(result);
} catch (error) {
console.log('==== error', error);
}
};
useEffect(() => {
getRelease();
}, []);
if (!release) {
return null;
}
return (
<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>
<AssetTable {...release.assets} />
</div>
);
}

View file

@ -0,0 +1,149 @@
import React, { useState, useEffect, useContext } from 'react';
import { Row, Col, Typography, Menu, Dropdown, Spin, Alert } from 'antd';
import { DownOutlined, UserOutlined } from '@ant-design/icons';
import { getUnixTime, sub } from 'date-fns';
import Chart from '../../components/chart';
import StatisticItem from '../../components/statistic';
import ViewerTable from '../../components/viewer-table';
import { ServerStatusContext } from '../../utils/server-status-context';
import { VIEWERS_OVER_TIME, ACTIVE_VIEWER_DETAILS, fetchData } from '../../utils/apis';
const FETCH_INTERVAL = 60 * 1000; // 1 min
export default function ViewersOverTime() {
const context = useContext(ServerStatusContext);
const { online, broadcaster, viewerCount, overallPeakViewerCount, sessionPeakViewerCount } =
context || {};
let streamStart;
if (broadcaster && broadcaster.time) {
streamStart = new Date(broadcaster.time);
}
const times = [
{ title: 'Current stream', start: streamStart },
{ title: 'Last 12 hours', start: sub(new Date(), { hours: 12 }) },
{ title: 'Last 24 hours', start: sub(new Date(), { hours: 24 }) },
{ title: 'Last 7 days', start: sub(new Date(), { days: 7 }) },
{ title: 'Last 30 days', start: sub(new Date(), { days: 30 }) },
{ title: 'Last 3 months', start: sub(new Date(), { months: 3 }) },
{ title: 'Last 6 months', start: sub(new Date(), { months: 6 }) },
];
const [loadingChart, setLoadingChart] = useState(true);
const [viewerInfo, setViewerInfo] = useState([]);
const [viewerDetails, setViewerDetails] = useState([]);
const [timeWindowStart, setTimeWindowStart] = useState(times[1]);
const getInfo = async () => {
try {
const url = `${VIEWERS_OVER_TIME}?windowStart=${getUnixTime(timeWindowStart.start)}`;
const result = await fetchData(url);
setViewerInfo(result);
setLoadingChart(false);
} catch (error) {
console.log('==== error', error);
}
try {
const result = await fetchData(ACTIVE_VIEWER_DETAILS);
setViewerDetails(result);
} catch (error) {
console.log('==== error', error);
}
};
useEffect(() => {
let getStatusIntervalId = null;
getInfo();
if (online) {
getStatusIntervalId = setInterval(getInfo, FETCH_INTERVAL);
// returned function will be called on component unmount
return () => {
clearInterval(getStatusIntervalId);
};
}
return () => [];
}, [online, timeWindowStart]);
const onTimeWindowSelect = ({ key }) => {
setTimeWindowStart(times[key]);
};
const menu = (
<Menu>
{online && streamStart && (
<Menu.Item key="0" onClick={onTimeWindowSelect}>
{times[0].title}
</Menu.Item>
)}
{times.slice(1).map((time, index) => (
// The array is hard coded, so it's safe to use the index as a key.
// eslint-disable-next-line react/no-array-index-key
<Menu.Item key={index + 1} onClick={onTimeWindowSelect}>
{time.title}
</Menu.Item>
))}
</Menu>
);
return (
<>
<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={online ? 'Max viewers this stream' : 'Max viewers last stream'}
value={sessionPeakViewerCount.toString()}
prefix={<UserOutlined />}
/>
</Col>
<Col md={online ? 8 : 12}>
<StatisticItem
title="All-time max viewers"
value={overallPeakViewerCount.toString()}
prefix={<UserOutlined />}
/>
</Col>
</Row>
{!viewerInfo.length && (
<Alert
style={{ marginTop: '10px' }}
banner
message="Please wait"
description="No viewer data has been collected yet."
type="info"
/>
)}
<Spin spinning={!viewerInfo.length || loadingChart}>
<Dropdown overlay={menu} trigger={['click']}>
<button
type="button"
style={{ float: 'right', background: 'transparent', border: 'unset' }}
>
{timeWindowStart.title} <DownOutlined />
</button>
</Dropdown>
{viewerInfo.length > 0 && (
<Chart title="Viewers" data={viewerInfo} color="#2087E2" unit="" />
)}
<ViewerTable data={viewerDetails} />
</Spin>
</>
);
}

View file

@ -0,0 +1,250 @@
/* eslint-disable react/destructuring-assignment */
import { DeleteOutlined } from '@ant-design/icons';
import {
Button,
Checkbox,
Col,
Input,
Modal,
Row,
Space,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import React, { useEffect, useState } from 'react';
import { CREATE_WEBHOOK, DELETE_WEBHOOK, fetchData, WEBHOOKS } from '../../utils/apis';
import isValidUrl, { DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/urls';
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' },
NAME_CHANGE: {
name: 'User name changed',
description: 'When a user changes their name',
color: 'blue',
},
'VISIBILITY-UPDATE': {
name: 'Message visibility changed',
description: 'When a message visibility changes, likely due to moderation',
color: 'red',
},
STREAM_STARTED: { name: 'Stream started', description: 'When a stream starts', color: 'orange' },
STREAM_STOPPED: { name: 'Stream stopped', description: 'When a stream stops', color: 'cyan' },
};
function convertEventStringToTag(eventString: string) {
if (!eventString || !availableEvents[eventString]) {
return null;
}
const event = availableEvents[eventString];
return (
<Tooltip key={eventString} title={event.description}>
<Tag color={event.color}>{event.name}</Tag>
</Tooltip>
);
}
interface Props {
onCancel: () => void;
onOk: any; // todo: make better type
visible: boolean;
}
function NewWebhookModal(props: Props) {
const { onOk, onCancel, visible } = props;
const [selectedEvents, setSelectedEvents] = useState([]);
const [webhookUrl, setWebhookUrl] = useState('');
const events = Object.keys(availableEvents).map(key => ({
value: key,
label: availableEvents[key].description,
}));
function onChange(checkedValues) {
setSelectedEvents(checkedValues);
}
function selectAll() {
setSelectedEvents(Object.keys(availableEvents));
}
function save() {
onOk(webhookUrl, selectedEvents);
// Reset the modal
setWebhookUrl('');
setSelectedEvents(null);
}
const okButtonProps = {
disabled: selectedEvents?.length === 0 || !isValidUrl(webhookUrl),
};
const checkboxes = events.map(singleEvent => (
<Col span={8} key={singleEvent.value}>
<Checkbox value={singleEvent.value}>{singleEvent.label}</Checkbox>
</Col>
));
return (
<Modal
title="Create New Webhook"
visible={visible}
onOk={save}
onCancel={onCancel}
okButtonProps={okButtonProps}
>
<div>
<Input
value={webhookUrl}
placeholder="https://myserver.com/webhook"
onChange={input => setWebhookUrl(input.currentTarget.value.trim())}
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
/>
</div>
<p>Select the events that will be sent to this webhook.</p>
<Checkbox.Group style={{ width: '100%' }} value={selectedEvents} onChange={onChange}>
<Row>{checkboxes}</Row>
</Checkbox.Group>
<p>
<Button type="primary" onClick={selectAll}>
Select all
</Button>
</p>
</Modal>
);
}
export default function Webhooks() {
const [webhooks, setWebhooks] = useState([]);
const [isModalVisible, setIsModalVisible] = useState(false);
function handleError(error) {
console.error('error', error);
}
async function getWebhooks() {
try {
const result = await fetchData(WEBHOOKS);
setWebhooks(result);
} catch (error) {
handleError(error);
}
}
useEffect(() => {
getWebhooks();
}, []);
async function handleDelete(id) {
try {
await fetchData(DELETE_WEBHOOK, { method: 'POST', data: { id } });
getWebhooks();
} catch (error) {
handleError(error);
}
}
async function handleSave(url: string, events: string[]) {
try {
const newHook = await fetchData(CREATE_WEBHOOK, {
method: 'POST',
data: { url, events },
});
setWebhooks(webhooks.concat(newHook));
} catch (error) {
handleError(error);
}
}
const showCreateModal = () => {
setIsModalVisible(true);
};
const handleModalSaveButton = (url, events) => {
setIsModalVisible(false);
handleSave(url, events);
};
const handleModalCancelButton = () => {
setIsModalVisible(false);
};
const columns = [
{
title: '',
key: 'delete',
render: (text, record) => (
<Space size="middle">
<Button onClick={() => handleDelete(record.id)} icon={<DeleteOutlined />} />
</Space>
),
},
{
title: 'URL',
dataIndex: 'url',
key: 'url',
},
{
title: 'Events',
dataIndex: 'events',
key: 'events',
render: events => (
<>
{
// eslint-disable-next-line arrow-body-style
events.map(event => {
return convertEventStringToTag(event);
})
}
</>
),
},
];
return (
<div>
<Title>Webhooks</Title>
<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&apos;ve started streaming.
</Paragraph>
<Paragraph>
Read more about how to use webhooks, with examples, at{' '}
<a
href="https://owncast.online/docs/integrations/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
our documentation
</a>
.
</Paragraph>
<Table
rowKey={record => record.id}
columns={columns}
dataSource={webhooks}
pagination={false}
/>
<br />
<Button type="primary" onClick={showCreateModal}>
Create Webhook
</Button>
<NewWebhookModal
visible={isModalVisible}
onOk={handleModalSaveButton}
onCancel={handleModalCancelButton}
/>
</div>
);
}

8
web/pages/index.tsx Normal file
View file

@ -0,0 +1,8 @@
export default function Home() {
return (
<div>
This is where v2 of the Owncast web UI will be built. Begin with the layout component
https://ant.design/components/layout/ and edit pages/index.tsx.
</div>
);
}

3
web/postcss.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
plugins: ['postcss-flexbugs-fixes', 'autoprefixer'],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more