mirror of
https://github.com/owncast/owncast.git
synced 2024-11-23 05:14:20 +03:00
Merge remote-tracking branch 'admin/ui-components' into webv2
This commit is contained in:
commit
23a009d011
181 changed files with 69288 additions and 0 deletions
8
web/.editorconfig
Normal file
8
web/.editorconfig
Normal 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
3
web/.env.development
Normal 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
1
web/.env.production
Normal file
|
@ -0,0 +1 @@
|
|||
NEXT_PUBLIC_API_HOST=/
|
3
web/.eslintignore
Normal file
3
web/.eslintignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Ignore artifacts:
|
||||
node_modules
|
||||
out
|
66
web/.eslintrc.js
Normal file
66
web/.eslintrc.js
Normal 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
27
web/.github/stale.yml
vendored
Normal 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
12
web/.github/workflows/build-next.yml
vendored
Normal 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
16
web/.github/workflows/bundle-admin.yml
vendored
Normal 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
18
web/.github/workflows/linter.yml
vendored
Normal 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
25
web/.github/workflows/prettier.yml
vendored
Normal 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
8
web/.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.env*.local
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
lefthook.yml
|
||||
storybook-static
|
3
web/.prettierignore
Normal file
3
web/.prettierignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Ignore artifacts:
|
||||
node_modules
|
||||
out
|
8
web/.prettierrc
Normal file
8
web/.prettierrc
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"useTabs": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "avoid"
|
||||
}
|
34
web/.storybook/main.js
Normal file
34
web/.storybook/main.js
Normal 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
14
web/.storybook/preview.js
Normal 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
13
web/.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"Owncast",
|
||||
"antd",
|
||||
"bitrates",
|
||||
"chartkick",
|
||||
"framerates",
|
||||
"kbps",
|
||||
"linkify",
|
||||
"paypal",
|
||||
"toggleswitch"
|
||||
]
|
||||
}
|
21
web/LICENSE
Normal file
21
web/LICENSE
Normal 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
57
web/README.md
Normal 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```
|
85
web/components/ban-user-button.tsx
Normal file
85
web/components/ban-user-button.tsx
Normal 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,
|
||||
};
|
75
web/components/banned-ips-table.tsx
Normal file
75
web/components/banned-ips-table.tsx
Normal 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
98
web/components/chart.tsx
Normal 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,
|
||||
};
|
98
web/components/client-table.tsx
Normal file
98
web/components/client-table.tsx
Normal 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[];
|
||||
}
|
82
web/components/compose-federated-post.tsx
Normal file
82
web/components/compose-federated-post.tsx
Normal 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>
|
||||
);
|
||||
}
|
117
web/components/config/edit-custom-css.tsx
Normal file
117
web/components/config/edit-custom-css.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
71
web/components/config/edit-directory.tsx
Normal file
71
web/components/config/edit-directory.tsx
Normal 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>
|
||||
);
|
||||
}
|
143
web/components/config/edit-instance-details.tsx
Normal file
143
web/components/config/edit-instance-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
128
web/components/config/edit-logo.tsx
Normal file
128
web/components/config/edit-logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
125
web/components/config/edit-page-content.tsx
Normal file
125
web/components/config/edit-page-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
167
web/components/config/edit-server-details.tsx
Normal file
167
web/components/config/edit-server-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
319
web/components/config/edit-social-links.tsx
Normal file
319
web/components/config/edit-social-links.tsx
Normal 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>
|
||||
);
|
||||
}
|
257
web/components/config/edit-storage.tsx
Normal file
257
web/components/config/edit-storage.tsx
Normal 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>
|
||||
);
|
||||
}
|
94
web/components/config/edit-string-array.tsx
Normal file
94
web/components/config/edit-string-array.tsx
Normal 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,
|
||||
};
|
139
web/components/config/edit-tags.tsx
Normal file
139
web/components/config/edit-tags.tsx
Normal file
|
@ -0,0 +1,139 @@
|
|||
/* eslint-disable react/no-array-index-key */
|
||||
import React, { useContext, useState, useEffect } from 'react';
|
||||
import { Typography, Tag } from 'antd';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import {
|
||||
FIELD_PROPS_TAGS,
|
||||
RESET_TIMEOUT,
|
||||
postConfigUpdateToAPI,
|
||||
} from '../../utils/config-constants';
|
||||
import TextField from './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>
|
||||
);
|
||||
}
|
22
web/components/config/form-status-indicator.tsx
Normal file
22
web/components/config/form-status-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
158
web/components/config/form-textfield-with-submit.tsx
Normal file
158
web/components/config/form-textfield-with-submit.tsx
Normal 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: '',
|
||||
};
|
172
web/components/config/form-textfield.tsx
Normal file
172
web/components/config/form-textfield.tsx
Normal 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: () => {},
|
||||
};
|
122
web/components/config/form-toggleswitch.tsx
Normal file
122
web/components/config/form-toggleswitch.tsx
Normal 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,
|
||||
};
|
129
web/components/config/notification/browser.tsx
Normal file
129
web/components/config/notification/browser.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
153
web/components/config/notification/discord.tsx
Normal file
153
web/components/config/notification/discord.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
51
web/components/config/notification/federation.tsx
Normal file
51
web/components/config/notification/federation.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
225
web/components/config/notification/twitter.tsx
Normal file
225
web/components/config/notification/twitter.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
64
web/components/config/reset-yp.tsx
Normal file
64
web/components/config/reset-yp.tsx
Normal 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
|
||||
"reset" 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>
|
||||
</>
|
||||
);
|
||||
}
|
65
web/components/config/social-icons-dropdown.tsx
Normal file
65
web/components/config/social-icons-dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
173
web/components/config/video-codec-selector.tsx
Normal file
173
web/components/config/video-codec-selector.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
133
web/components/config/video-latency.tsx
Normal file
133
web/components/config/video-latency.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
317
web/components/config/video-variant-form.tsx
Normal file
317
web/components/config/video-variant-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
245
web/components/config/video-variants-table.tsx
Normal file
245
web/components/config/video-variants-table.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
20
web/components/info-tip.tsx
Normal file
20
web/components/info-tip.tsx
Normal 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>
|
||||
);
|
||||
}
|
30
web/components/key-value-table.tsx
Normal file
30
web/components/key-value-table.tsx
Normal 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;
|
||||
}
|
11
web/components/layouts/simple-layout.tsx
Normal file
11
web/components/layouts/simple-layout.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { AppProps } from 'next/app';
|
||||
|
||||
function SimpleLayout({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<div>
|
||||
<Component {...pageProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimpleLayout;
|
88
web/components/log-table.tsx
Normal file
88
web/components/log-table.tsx
Normal 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
159
web/components/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
294
web/components/main-layout.tsx
Normal file
294
web/components/main-layout.tsx
Normal 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 & 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,
|
||||
};
|
92
web/components/message-visiblity-toggle.tsx
Normal file
92
web/components/message-visiblity-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
93
web/components/moderator-user-button.tsx
Normal file
93
web/components/moderator-user-button.tsx
Normal 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,
|
||||
};
|
78
web/components/news-feed.tsx
Normal file
78
web/components/news-feed.tsx
Normal 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 & Updates from Owncast</Title>
|
||||
{loadingSpinner}
|
||||
{feed.map(item => (
|
||||
<ArticleItem {...item} key={item.url} />
|
||||
))}
|
||||
|
||||
{noNews}
|
||||
</section>
|
||||
);
|
||||
}
|
152
web/components/offline-notice.tsx
Normal file
152
web/components/offline-notice.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
79
web/components/statistic.tsx
Normal file
79
web/components/statistic.tsx
Normal 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;
|
86
web/components/stream-health-overview.tsx
Normal file
86
web/components/stream-health-overview.tsx
Normal 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,
|
||||
};
|
151
web/components/user-popover.tsx
Normal file
151
web/components/user-popover.tsx
Normal 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,
|
||||
};
|
64
web/components/user-table.tsx
Normal file
64
web/components/user-table.tsx
Normal 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[];
|
||||
}
|
50
web/components/viewer-table.tsx
Normal file
50
web/components/viewer-table.tsx
Normal 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
91
web/docs/README.md
Normal file
|
@ -0,0 +1,91 @@
|
|||
# Tips for creating a new Admin form
|
||||
|
||||
### Layout
|
||||
- Give your page or form a title. Feel free to use Ant Design's `<Title>` component.
|
||||
- Give your form a description inside of a `<p className="description" />` tag.
|
||||
|
||||
- Use some Ant Design `Row` and `Col`'s to layout your forms if you want to spread them out into responsive columns. If you use an `<Row>`s, be sure to use `<Col>`s with them too!
|
||||
|
||||
- Use the `form-module` CSS class if you want to add a visual separation to a grouping of items.
|
||||
|
||||
|
||||
|
||||
### Form fields
|
||||
- Feel free to use the pre-styled `<TextField>` text form field or the `<ToggleSwitch>` compnent, in a group of form fields together. These have been styled and laid out to match each other.
|
||||
|
||||
- `Slider`'s - If your form uses an Ant Slider component, follow this recommended markup of CSS classes to maintain a consistent look and feel to other Sliders in the app.
|
||||
```
|
||||
<div className="segment-slider-container">
|
||||
<Slider ...props />
|
||||
<p className="selected-value-note">{selected value}</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Submit Statuses
|
||||
- It would be nice to display indicators of success/warnings to let users know if something has been successfully updated on the server. It has a lot of steps (sorry, but it could probably be optimized), but it'll provide a consistent way to display messaging.
|
||||
|
||||
- See `reset-yp.tsx` for an example of using `submitStatus` with `useState()` and the `<FormStatusIndicator>` component to achieve this.
|
||||
|
||||
### Styling
|
||||
- This admin site chooses to have a generally Dark color palette, but with colors that are different from Ant design's _dark_ stylesheet, so that style sheet is not included. This results in a very large `ant-overrides.scss` file to reset colors on frequently used Ant components in the system. If you find yourself a new Ant Component that has not yet been used in this app, feel free to add a reset style for that component to the overrides stylesheet.
|
||||
|
||||
- Take a look at `variables.scss` CSS file if you want to give some elements custom css colors.
|
||||
|
||||
|
||||
---
|
||||
---
|
||||
# Creating Admin forms the Config section
|
||||
First things first..
|
||||
|
||||
## General Config data flow in this React app
|
||||
|
||||
- When the Admin app loads, the `ServerStatusContext` (in addition to checking server `/status` on a timer) makes a call to the `/serverconfig` API to get your config details. This data will be stored as **`serverConfig`** in app state, and _provided_ to the app via `useContext` hook.
|
||||
|
||||
- The `serverConfig` in state is be the central source of data that pre-populates the forms.
|
||||
|
||||
- The `ServerStatusContext` also provides a method for components to update the serverConfig state, called `setFieldInConfigState()`.
|
||||
|
||||
- After you have updated a config value in a form field, and successfully submitted it through its endpoint, you should call `setFieldInConfigState` to update the global state with the new value.
|
||||
|
||||
|
||||
## Suggested Config Form Flow
|
||||
- *NOTE: Each top field of the serverConfig has its own API update endpoint.*
|
||||
|
||||
|
||||
There many steps here, but they are highly suggested to ensure that Config values are updated and displayed properly throughout the entire admin form.
|
||||
|
||||
For each form input (or group of inputs) you make, you should:
|
||||
1. Get the field values that you want out of `serverConfig` from ServerStatusContext with `useContext`.
|
||||
2. Next we'll have to put these field values of interest into a `useState` in each grouping. This will help you edit the form.
|
||||
3. Because ths config data is populated asynchronously, Use a `useEffect` to check when that data has arrived before putting it into state.
|
||||
4. You will be using the state's value to populate the `defaultValue` and the `value` props of each Ant input component (`Input`, `Toggle`, `Switch`, `Select`, `Slider` are currently used).
|
||||
5. When an `onChange` event fires for each type of input component, you will update the local state of each page with the changed value.
|
||||
6. Depending on the form, an `onChange` of the input component, or a subsequent `onClick` of a submit button will take the value from local state and POST the field's API.
|
||||
7. `onSuccess` of the post, you should update the global app state with the new value.
|
||||
|
||||
There are also a variety of other local states to manage the display of error/success messaging.
|
||||
|
||||
- It is recommended that you use `form-textfield-with-submit` and `form-toggleswitch`(with `useSubmit=true`) Components to edit Config fields.
|
||||
|
||||
Examples of Config form groups where individual form fields submitting to the update API include:
|
||||
- `edit-instance-details.tsx`
|
||||
- `edit-server-details.tsx`
|
||||
|
||||
Examples of Config form groups where there is 1 submit button for the entire group include:
|
||||
- `edit-storage.tsx`
|
||||
|
||||
|
||||
---
|
||||
#### Notes about `form-textfield-with-submit` and `form-togglefield` (with useSubmit=true)
|
||||
- The text field is intentionally designed to make it difficult for the user to submit bad data.
|
||||
- If you make a change on a field, a Submit buttton will show up that you have to click to update. That will be the only way you can update it.
|
||||
- If you clear out a field that is marked as Required, then exit/blur the field, it will repopulate with its original value.
|
||||
|
||||
- Both of these elements are specifically meant to be used with updating `serverConfig` fields, since each field requires its own endpoint.
|
||||
|
||||
- Give these fields a bunch of props, and they will display labelling, some helpful UI around tips, validation messaging, as well as submit the update for you.
|
||||
|
||||
- (currently undergoing re-styling and TS cleanup)
|
||||
|
||||
- NOTE: you don't have to use these components. Some form groups may require a customized UX flow where you're better off using the Ant components straight up.
|
||||
|
BIN
web/favicon.ico
Normal file
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
5
web/next-env.d.ts
vendored
Normal 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
5
web/next.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
const withLess = require('next-with-less');
|
||||
|
||||
module.exports = withLess({
|
||||
trailingSlash: true,
|
||||
});
|
55337
web/package-lock.json
generated
Normal file
55337
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
87
web/package.json
Normal file
87
web/package.json
Normal 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
37
web/pages/_app.tsx
Normal 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;
|
267
web/pages/admin/access-tokens.tsx
Normal file
267
web/pages/admin/access-tokens.tsx
Normal 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'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
313
web/pages/admin/actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
18
web/pages/admin/admin-layout.tsx
Normal file
18
web/pages/admin/admin-layout.tsx
Normal 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;
|
253
web/pages/admin/chat/messages.tsx
Normal file
253
web/pages/admin/chat/messages.tsx
Normal 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>
|
||||
);
|
||||
}
|
107
web/pages/admin/chat/users.tsx
Normal file
107
web/pages/admin/chat/users.tsx
Normal 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>
|
||||
);
|
||||
}
|
222
web/pages/admin/config-chat.tsx
Normal file
222
web/pages/admin/config-chat.tsx
Normal 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>
|
||||
);
|
||||
}
|
334
web/pages/admin/config-federation.tsx
Normal file
334
web/pages/admin/config-federation.tsx
Normal 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>
|
||||
);
|
||||
}
|
148
web/pages/admin/config-notify.tsx
Normal file
148
web/pages/admin/config-notify.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
50
web/pages/admin/config-public-details.tsx
Normal file
50
web/pages/admin/config-public-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
web/pages/admin/config-server-details.tsx
Normal file
20
web/pages/admin/config-server-details.tsx
Normal 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's likely the other settings will not need to be changed.
|
||||
</p>
|
||||
<div className="form-module config-server-details-container">
|
||||
<EditServerDetails />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
15
web/pages/admin/config-social-items.tsx
Normal file
15
web/pages/admin/config-social-items.tsx
Normal 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>
|
||||
);
|
||||
}
|
34
web/pages/admin/config-storage.tsx
Normal file
34
web/pages/admin/config-storage.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
50
web/pages/admin/config-video.tsx
Normal file
50
web/pages/admin/config-video.tsx
Normal 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>
|
||||
);
|
||||
}
|
136
web/pages/admin/federation/actions.tsx
Normal file
136
web/pages/admin/federation/actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
336
web/pages/admin/federation/followers.tsx
Normal file
336
web/pages/admin/federation/followers.tsx
Normal 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>
|
||||
);
|
||||
}
|
111
web/pages/admin/hardware-info.tsx
Normal file
111
web/pages/admin/hardware-info.tsx
Normal 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
228
web/pages/admin/help.tsx
Normal 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"
|
||||
>
|
||||
developer APIs.
|
||||
</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
180
web/pages/admin/index.tsx
Normal 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
34
web/pages/admin/logs.tsx
Normal 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} />;
|
||||
}
|
412
web/pages/admin/stream-health.tsx
Normal file
412
web/pages/admin/stream-health.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
74
web/pages/admin/upgrade.tsx
Normal file
74
web/pages/admin/upgrade.tsx
Normal 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>
|
||||
);
|
||||
}
|
149
web/pages/admin/viewer-info.tsx
Normal file
149
web/pages/admin/viewer-info.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
250
web/pages/admin/webhooks.tsx
Normal file
250
web/pages/admin/webhooks.tsx
Normal 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'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
8
web/pages/index.tsx
Normal 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
3
web/postcss.config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
plugins: ['postcss-flexbugs-fixes', 'autoprefixer'],
|
||||
};
|
BIN
web/public/fediverse-white.png
Normal file
BIN
web/public/fediverse-white.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4 KiB |
BIN
web/public/fonts/inter/Inter-Black.woff
Normal file
BIN
web/public/fonts/inter/Inter-Black.woff
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/Inter-Black.woff2
Normal file
BIN
web/public/fonts/inter/Inter-Black.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/Inter-BlackItalic.woff
Normal file
BIN
web/public/fonts/inter/Inter-BlackItalic.woff
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/Inter-BlackItalic.woff2
Normal file
BIN
web/public/fonts/inter/Inter-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
web/public/fonts/inter/Inter-Bold.woff
Normal file
BIN
web/public/fonts/inter/Inter-Bold.woff
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue