Merge branch 'webv2' into fix/ImplementPasswordRules

This commit is contained in:
Jambaldorj Ochirpurev
2023-01-29 11:31:36 +01:00
committed by GitHub
519 changed files with 6047 additions and 3281 deletions

View File

@@ -0,0 +1,118 @@
import React, { useState, useEffect, useContext, FC } from 'react';
import { Typography, Button } from 'antd';
import CodeMirror from '@uiw/react-codemirror';
import { bbedit } from '@uiw/codemirror-theme-bbedit';
import { javascript } from '@codemirror/lang-javascript';
import { ServerStatusContext } from '../../utils/server-status-context';
import {
postConfigUpdateToAPI,
RESET_TIMEOUT,
API_CUSTOM_JAVASCRIPT,
} from '../../utils/config-constants';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_PROCESSING,
STATUS_SUCCESS,
} from '../../utils/input-statuses';
import { FormStatusIndicator } from './FormStatusIndicator';
const { Title } = Typography;
// eslint-disable-next-line import/prefer-default-export
export const EditCustomJavascript: FC = () => {
const [content, setContent] = useState('/* Enter custom Javascript here */');
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
const [hasChanged, setHasChanged] = useState(false);
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
const { instanceDetails } = serverConfig;
const { customJavascript: initialContent } = instanceDetails;
let resetTimer = null;
// 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_JAVASCRIPT,
data: { value: content },
onSuccess: (message: string) => {
setFieldInConfigState({
fieldName: 'customJavascript',
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]);
const onCSSValueChange = React.useCallback(value => {
setContent(value);
if (value !== initialContent && !hasChanged) {
setHasChanged(true);
} else if (value === initialContent && hasChanged) {
setHasChanged(false);
}
}, []);
return (
<div className="edit-custom-css">
<Title level={3} className="section-title">
Customize your page styling with CSS
</Title>
<p className="description">
Customize the look and feel of your Owncast instance by overriding the CSS styles of various
components on the page. Refer to the{' '}
<a href="https://owncast.online/docs/website/" rel="noopener noreferrer" target="_blank">
CSS &amp; Components guide
</a>
.
</p>
<p className="description">
Please input plain CSS text, as this will be directly injected onto your page during load.
</p>
<CodeMirror
value={content}
placeholder="/* Enter custom Javascript here */"
theme={bbedit}
height="200px"
extensions={[javascript()]}
onChange={onCSSValueChange}
/>
<br />
<div className="page-content-actions">
{hasChanged && (
<Button type="primary" onClick={handleSave}>
Save
</Button>
)}
<FormStatusIndicator status={submitStatus} />
</div>
</div>
);
};

View File

@@ -261,7 +261,7 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
},
upgradeVersion && {
key: 'upgrade',
label: <Link href="/upgrade">{upgradeMessage}</Link>,
label: <Link href="/admin/upgrade">{upgradeMessage}</Link>,
},
{
key: 'help',

View File

@@ -125,7 +125,7 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
if (!config?.federation?.enabled) {
data.push({
icon: <img alt="fediverse" width="20px" src="fediverse-white.png" />,
icon: <img alt="fediverse" width="20px" src="/img/fediverse-color.png" />,
title: 'Add your Owncast instance to the Fediverse',
content: (
<div>

View File

@@ -284,6 +284,8 @@ export const VideoVariantForm: FC<VideoVariantFormProps> = ({
onConfirm={handleVideoPassConfirm}
okText="Yes"
cancelText="No"
getPopupContainer={triggerNode => triggerNode}
placement="topLeft"
>
{/* adding an <a> tag to force Popcofirm to register click on toggle */}
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}

View File

@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { FC, useContext, useEffect, useState } from 'react';
import { Button, Col, Collapse, Row, Slider, Space } from 'antd';
import Paragraph from 'antd/lib/typography/Paragraph';
@@ -35,49 +35,39 @@ const chatColorVariables = [
{ name: 'theme-color-users-7', description: '' },
];
const paletteVariables = [
{ name: 'theme-color-palette-0', description: '' },
{ name: 'theme-color-palette-1', description: '' },
{ name: 'theme-color-palette-2', description: '' },
{ name: 'theme-color-palette-3', description: '' },
{ name: 'theme-color-palette-4', description: '' },
{ name: 'theme-color-palette-5', description: '' },
{ name: 'theme-color-palette-6', description: '' },
{ name: 'theme-color-palette-7', description: '' },
{ name: 'theme-color-palette-8', description: '' },
{ name: 'theme-color-palette-9', description: '' },
{ name: 'theme-color-palette-10', description: '' },
{ name: 'theme-color-palette-11', description: '' },
{ name: 'theme-color-palette-12', description: '' },
];
const componentColorVariables = [
{ name: 'theme-color-background-main', description: 'Background' },
{ name: 'theme-color-action', description: 'Action' },
{ name: 'theme-color-action-hover', description: 'Action Hover' },
{ name: 'theme-color-components-primary-button-border', description: 'Primary Button Border' },
{ name: 'theme-color-components-primary-button-text', description: 'Primary Button Text' },
{ name: 'theme-color-components-chat-background', description: 'Chat Background' },
{ name: 'theme-color-components-chat-text', description: 'Text: Chat' },
{ name: 'theme-color-components-text-on-dark', description: 'Text: Light' },
{ name: 'theme-color-components-text-on-light', description: 'Text: Dark' },
{ name: 'theme-color-background-header', description: 'Header/Footer' },
{ name: 'theme-color-components-content-background', description: 'Page Content' },
{ name: 'theme-color-components-scrollbar-background', description: 'Scrollbar Background' },
{ name: 'theme-color-components-scrollbar-thumb', description: 'Scrollbar Thumb' },
{
name: 'theme-color-components-video-status-bar-background',
description: 'Video Status Bar Background',
},
{
name: 'theme-color-components-video-status-bar-foreground',
description: 'Video Status Bar Foreground',
},
];
const others = [{ name: 'theme-rounded-corners', description: 'Corner radius' }];
// Create an object so these vars can be indexed by name.
const allAvailableValues = [
...paletteVariables,
...componentColorVariables,
...chatColorVariables,
...others,
].reduce((obj, val) => {
// eslint-disable-next-line no-param-reassign
obj[val.name] = { name: val.name, description: val.description };
return obj;
}, {});
const allAvailableValues = [...componentColorVariables, ...chatColorVariables, ...others].reduce(
(obj, val) => {
// eslint-disable-next-line no-param-reassign
obj[val.name] = { name: val.name, description: val.description };
return obj;
},
{},
);
// eslint-disable-next-line react/function-component-definition
function ColorPicker({
@@ -106,6 +96,7 @@ function ColorPicker({
</Col>
);
}
// eslint-disable-next-line react/function-component-definition
export default function Appearance() {
const serverStatusData = useContext(ServerStatusContext);
@@ -113,7 +104,9 @@ export default function Appearance() {
const { instanceDetails } = serverConfig;
const { appearanceVariables } = instanceDetails;
const [colors, setColors] = useState<Record<string, AppearanceVariable>>();
const [defaultValues, setDefaultValues] = useState<Record<string, AppearanceVariable>>();
const [customValues, setCustomValues] = useState<Record<string, AppearanceVariable>>();
const [submitStatus, setSubmitStatus] = useState<StatusState>(null);
let resetTimer = null;
@@ -123,39 +116,37 @@ export default function Appearance() {
clearTimeout(resetTimer);
};
const setColorDefaults = () => {
const setDefaults = () => {
const c = {};
[...paletteVariables, ...componentColorVariables, ...chatColorVariables, ...others].forEach(
color => {
const resolvedColor = getComputedStyle(document.documentElement).getPropertyValue(
`--${color.name}`,
);
c[color.name] = { value: resolvedColor.trim(), description: color.description };
},
);
setColors(c);
[...componentColorVariables, ...chatColorVariables, ...others].forEach(color => {
const resolvedColor = getComputedStyle(document.documentElement).getPropertyValue(
`--${color.name}`,
);
c[color.name] = { value: resolvedColor.trim(), description: color.description };
});
setDefaultValues(c);
};
useEffect(() => {
setColorDefaults();
setDefaults();
}, []);
useEffect(() => {
if (Object.keys(appearanceVariables).length === 0) return;
const c = colors || {};
const c = {};
Object.keys(appearanceVariables).forEach(key => {
c[key] = {
value: appearanceVariables[key],
description: allAvailableValues[key]?.description || '',
};
});
setColors(c);
setCustomValues(c);
}, [appearanceVariables]);
const updateColor = (variable: string, color: string, description: string) => {
setColors({
...colors,
setCustomValues({
...customValues,
[variable]: { value: color, description },
});
};
@@ -167,7 +158,7 @@ export default function Appearance() {
onSuccess: () => {
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
setColorDefaults();
setCustomValues(null);
},
onError: (message: string) => {
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
@@ -178,8 +169,8 @@ export default function Appearance() {
const save = async () => {
const c = {};
Object.keys(colors).forEach(color => {
c[color] = colors[color].value;
Object.keys(customValues).forEach(color => {
c[color] = customValues[color].value;
});
await postConfigUpdateToAPI({
@@ -202,7 +193,31 @@ export default function Appearance() {
updateColor(variableName, `${value.toString()}px`, '');
};
if (!colors) {
type ColorCollectionProps = {
variables: { name; description }[];
};
// eslint-disable-next-line react/no-unstable-nested-components
const ColorCollection: FC<ColorCollectionProps> = ({ variables }) => {
const cc = variables.map(colorVar => {
const source = customValues?.[colorVar.name] ? customValues : defaultValues;
const { name, description } = colorVar;
const { value } = source[name];
return (
<ColorPicker
key={name}
value={value}
name={name}
description={description}
onChange={updateColor}
/>
);
});
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{cc}</>;
};
if (!defaultValues) {
return <div>Loading...</div>;
}
@@ -217,56 +232,15 @@ export default function Appearance() {
Certain sections of the interface can be customized by selecting new colors for them.
</p>
<Row gutter={[16, 16]}>
{componentColorVariables.map(colorVar => {
const { name } = colorVar;
const c = colors[name];
return (
<ColorPicker
key={name}
value={c.value}
name={name}
description={c.description}
onChange={updateColor}
/>
);
})}
<ColorCollection variables={componentColorVariables} />
</Row>
</Panel>
<Panel header={<Title level={3}>Chat User Colors</Title>} key="2">
<Row gutter={[16, 16]}>
{chatColorVariables.map(colorVar => {
const { name } = colorVar;
const c = colors[name];
return (
<ColorPicker
key={name}
value={c.value}
name={name}
description={c.description}
onChange={updateColor}
/>
);
})}
</Row>
</Panel>
<Panel header={<Title level={3}>Theme Colors</Title>} key="3">
<Row gutter={[16, 16]}>
{paletteVariables.map(colorVar => {
const { name } = colorVar;
const c = colors[name];
return (
<ColorPicker
key={name}
value={c.value}
name={name}
description={c.description}
onChange={updateColor}
/>
);
})}
<ColorCollection variables={chatColorVariables} />
</Row>
</Panel>
<Panel header={<Title level={3}>Other Settings</Title>} key="4">
How rounded should corners be?
<Row gutter={[16, 16]}>
@@ -278,7 +252,9 @@ export default function Appearance() {
onChange={v => {
onBorderRadiusChange(v);
}}
value={Number(colors['theme-rounded-corners']?.value?.replace('px', '') || 0)}
value={Number(
defaultValues['theme-rounded-corners']?.value?.replace('px', '') || 0,
)}
/>
</Col>
<Col span={4}>
@@ -286,7 +262,7 @@ export default function Appearance() {
style={{
width: '100px',
height: '30px',
borderRadius: `${colors['theme-rounded-corners']?.value}`,
borderRadius: `${defaultValues['theme-rounded-corners']?.value}`,
backgroundColor: 'var(--theme-color-palette-7)',
}}
/>

View File

@@ -1,220 +0,0 @@
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 '../TextField';
import { FormStatusIndicator } from '../FormStatusIndicator';
import {
postConfigUpdateToAPI,
RESET_TIMEOUT,
TWITTER_CONFIG_FIELDS,
} from '../../../utils/config-constants';
import { ToggleSwitch } from '../ToggleSwitch';
import {
createInputStatus,
StatusState,
STATUS_ERROR,
STATUS_SUCCESS,
} from '../../../utils/input-statuses';
import { UpdateArgs } from '../../../types/config-section';
import { TEXTFIELD_TYPE_TEXT } from '../TextFieldWithSubmit';
const { Title } = Typography;
export const 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 { apiKey, apiSecret, accessToken, accessTokenSecret, bearerToken, goLiveMessage } =
formDataValues;
return (
!!apiKey &&
!!apiSecret &&
!!accessToken &&
!!accessTokenSecret &&
!!bearerToken &&
!!goLiveMessage
);
};
useEffect(() => {
setEnableSaveButton(canSave());
}, [formDataValues]);
// update individual values in state
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
setFormDataValues({
...formDataValues,
[fieldName]: value,
});
};
// 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} />
</>
);
};
export default ConfigNotify;

View File

@@ -45,13 +45,11 @@
}
.virtuoso::-webkit-scrollbar {
width: 5px;
height: auto;
background-color: var(--theme-color-components-chat-background);
display: none;
}
.virtuoso::-webkit-scrollbar-thumb {
background: var(--theme-color-components-scrollbar-thumb);
display: none;
}
.chatTextField {

View File

@@ -2,11 +2,8 @@
.chatModerationNotification {
background-color: var(--theme-background-primary);
color: var(--theme-color-components-chat-text);
margin: 5px;
border-radius: 15px;
border-color: rgba(0, 0, 0, 0.3);
border-width: 1px;
border-style: solid;
padding: 10px 10px;
@include flexCenter;

View File

@@ -7,6 +7,7 @@
rgb(83, 67, 130) 80%
);
margin: 5px;
margin-bottom: 10px;
border-radius: 5px;
border-width: 1px;
border-style: solid;
@@ -34,4 +35,13 @@
background-color: var(--theme-color-palette-12);
}
}
a {
color: var(--theme-color-palette-4);
:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: var(--theme-color-palette-15);
}
}
}

View File

@@ -6,9 +6,9 @@
bottom: 0px;
width: 100%;
padding: 4px 0.1vw;
padding: 0.6em;
overflow-x: hidden;
background-color: var(--theme-color-palette-3);
background-color: var(--theme-color-components-chat-background);
.inputWrap {
position: relative;
@@ -23,7 +23,6 @@
transition: box-shadow 90ms ease-in-out;
&:focus-within {
background-color: var(--theme-color-components-form-field-background);
// outline: 1px solid var(--theme-color-components-form-field-border);
box-shadow: inset 0px 0px 2px 2px var(--theme-color-palette-3);
}
}

View File

@@ -22,6 +22,7 @@ $p-size: 8px;
overflow: hidden;
overflow-wrap: anywhere;
font-weight: 500;
position: relative;
mark {
padding-left: 0.35em;
@@ -52,10 +53,16 @@ $p-size: 8px;
display: none;
top: 0;
right: 10px;
color: var(--theme-color-components-text-on-light);
& button:focus,
& button:active {
display: block !important;
}
button {
border-radius: var(--theme-rounded-corners);
opacity: 0.8;
}
}
&:hover .modMenuWrapper {

View File

@@ -5,6 +5,7 @@ import { Tooltip } from 'antd';
import { useRecoilValue } from 'recoil';
import dynamic from 'next/dynamic';
import { decodeHTML } from 'entities';
import linkifyHtml from 'linkify-html';
import styles from './ChatUserMessage.module.scss';
import { formatTimestamp } from './messageFmt';
import { ChatMessage } from '../../../interfaces/chat-message.model';
@@ -107,6 +108,8 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
})}
style={{ borderColor: color }}
>
<div className={styles.background} style={{ color }} />
{!sameUserAsLast && (
<UserTooltip user={user}>
<div className={styles.user} style={{ color }}>
@@ -119,11 +122,10 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
<Highlight search={highlightString}>
<div
className={styles.message}
dangerouslySetInnerHTML={{ __html: formattedMessage }}
dangerouslySetInnerHTML={{ __html: linkifyHtml(formattedMessage) }}
/>
</Highlight>
</Tooltip>
{showModeratorMenu && (
<div className={styles.modMenuWrapper}>
<ChatModerationActionMenu
@@ -134,7 +136,6 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
/>
</div>
)}
<div className={styles.background} style={{ color }} />
</div>
</div>
);

View File

@@ -29,10 +29,10 @@ export const ContentHeader: FC<ContentHeaderProps> = ({
<Logo src={logo} />
</div>
<div className={styles.titleSection}>
<div className={cn(styles.title, styles.row, 'header-title')}>{name}</div>
<div className={cn(styles.subtitle, styles.row, 'header-subtitle')}>
<h2 className={cn(styles.title, styles.row, 'header-title')}>{name}</h2>
<h3 className={cn(styles.subtitle, styles.row, 'header-subtitle')}>
<Linkify>{title || summary}</Linkify>
</div>
</h3>
<div className={cn(styles.tagList, styles.row)}>
{tags.length > 0 && tags.map(tag => <span key={tag}>#{tag}&nbsp;</span>)}
</div>

View File

@@ -96,7 +96,12 @@ export const UserDropdown: FC<UserDropdownProps> = ({ username: defaultUsername
Authenticate
</Menu.Item>
{appState.chatAvailable && (
<Menu.Item key="3" icon={<MessageOutlined />} onClick={() => toggleChatVisibility()}>
<Menu.Item
key="3"
icon={<MessageOutlined />}
onClick={() => toggleChatVisibility()}
aria-expanded={chatToggleVisible}
>
{chatToggleVisible ? 'Hide Chat' : 'Show Chat'}
</Menu.Item>
)}

View File

@@ -5,11 +5,13 @@ import Head from 'next/head';
import { FC, useEffect, useRef } from 'react';
import { Layout } from 'antd';
import dynamic from 'next/dynamic';
import Script from 'next/script';
import {
ClientConfigStore,
isChatAvailableSelector,
clientConfigStateAtom,
fatalErrorStateAtom,
appStateAtom,
} from '../../stores/ClientConfigStore';
import { Content } from '../../ui/Content/Content';
import { Header } from '../../ui/Header/Header';
@@ -21,6 +23,7 @@ import { ServerRenderedHydration } from '../../ServerRendered/ServerRenderedHydr
import { Theme } from '../../theme/Theme';
import styles from './Main.module.scss';
import { PushNotificationServiceWorker } from '../../workers/PushNotificationServiceWorker/PushNotificationServiceWorker';
import { AppStateOptions } from '../../stores/application-state';
const lockBodyStyle = `
body {
@@ -45,9 +48,11 @@ export const Main: FC = () => {
const { name, title, customStyles } = clientConfig;
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
const fatalError = useRecoilValue<DisplayableError>(fatalErrorStateAtom);
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const layoutRef = useRef<HTMLDivElement>(null);
const { chatDisabled } = clientConfig;
const { videoAvailable } = appState;
useEffect(() => {
setupNoLinkReferrer(layoutRef.current);
@@ -133,8 +138,15 @@ export const Main: FC = () => {
<PushNotificationServiceWorker />
<TitleNotifier name={name} />
<Theme />
<Script strategy="afterInteractive" src="/customjavascript" />
<Layout ref={layoutRef} className={styles.layout}>
<Header name={title || name} chatAvailable={isChatAvailable} chatDisabled={chatDisabled} />
<Header
name={title || name}
chatAvailable={isChatAvailable}
chatDisabled={chatDisabled}
online={videoAvailable}
/>
<Content />
{fatalError && (
<FatalErrorStateModal title={fatalError.title} message={fatalError.message} />

View File

@@ -77,6 +77,7 @@ export const NameChangeModal: FC = () => {
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Your chat display name"
aria-label="Your chat display name"
maxLength={30}
showCount
defaultValue={displayName}
@@ -90,7 +91,7 @@ export const NameChangeModal: FC = () => {
>
{colorOptions.map(e => (
<Option key={e.toString()} title={e}>
<UserColor color={e} />
<UserColor color={e} aria-label={e.toString()} />
</Option>
))}
</Select>

View File

@@ -35,6 +35,8 @@ const ACCESS_TOKEN_KEY = 'accessToken';
let serverStatusRefreshPoll: ReturnType<typeof setInterval>;
let hasBeenModeratorNotified = false;
const serverConnectivityError = `Cannot connect to the Owncast service. Please check your internet connection or if needed, double check this Owncast server is running.`;
// Server status is what gets updated such as viewer count, durations,
// stream title, online/offline state, etc.
export const serverStatusState = atom<ServerStatus>({
@@ -200,10 +202,7 @@ export const ClientConfigStore: FC = () => {
setGlobalFatalErrorMessage(null);
setHasLoadedConfig(true);
} catch (error) {
setGlobalFatalError(
'Unable to reach Owncast server',
`Owncast cannot launch. Please make sure the Owncast server is running.`,
);
setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`);
}
};
@@ -221,10 +220,7 @@ export const ClientConfigStore: FC = () => {
setGlobalFatalErrorMessage(null);
} catch (error) {
sendEvent(AppStateEvent.Fail);
setGlobalFatalError(
'Unable to reach Owncast server',
`Owncast cannot launch. Please make sure the Owncast server is running.`,
);
setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
console.error(`serverStatusState -> getStatus() ERROR: \n${error}`);
}
};
@@ -325,7 +321,13 @@ export const ClientConfigStore: FC = () => {
const startChat = async () => {
try {
const { socketHostOverride } = clientConfig;
const host = socketHostOverride || window.location.toString();
// Get a copy of the browser location without #fragments.
const l = window.location;
l.hash = '';
const location = l.toString().replaceAll('#', '');
const host = socketHostOverride || location;
ws = new WebsocketService(accessToken, '/ws', host);
ws.handleMessage = handleMessage;
setWebsocketService(ws);

View File

@@ -20,14 +20,11 @@
}
.mainSection::-webkit-scrollbar {
width: 5px;
height: auto;
background-color: var(--theme-color-components-scrollbar-background);
display: none;
}
.mainSection::-webkit-scrollbar-thumb {
background: var(--theme-color-components-scrollbar-thumb);
border-radius: 1px;
display: none;
}
.topSection {

View File

@@ -115,7 +115,7 @@ const DesktopContent = ({
return (
<>
<div className={styles.lowerHalf}>
<div className={styles.lowerHalf} id="skip-to-content">
<ContentHeader
name={name}
title={streamTitle}
@@ -233,7 +233,7 @@ export const Content: FC = () => {
const isChatVisible = useRecoilValue<boolean>(isChatVisibleSelector);
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
const currentUser = useRecoilValue(currentUserAtom);
const serverStatus = useRecoilValue<ServerStatus>(serverStatusState);
const [isMobile, setIsMobile] = useRecoilState<boolean | undefined>(isMobileAtom);
const messages = useRecoilValue<ChatMessage[]>(chatMessagesAtom);
const online = useRecoilValue<boolean>(isOnlineSelector);
@@ -259,6 +259,7 @@ export const Content: FC = () => {
const { account: fediverseAccount, enabled: fediverseEnabled } = federation;
const { browser: browserNotifications } = notifications;
const { enabled: browserNotificationsEnabled } = browserNotifications;
const { online: isStreamLive } = serverStatus;
const [externalActionToDisplay, setExternalActionToDisplay] = useState<ExternalAction>(null);
const [supportsBrowserNotifications, setSupportsBrowserNotifications] = useState(false);
@@ -334,9 +335,16 @@ export const Content: FC = () => {
<div className={styles.mainSection}>
<div className={styles.topSection}>
{appState.appLoading && <Skeleton loading active paragraph={{ rows: 7 }} />}
{online && <OwncastPlayer source="/hls/stream.m3u8" online={online} />}
{online && (
<OwncastPlayer
source="/hls/stream.m3u8"
online={online}
title={streamTitle || name}
/>
)}
{!online && !appState.appLoading && (
<OfflineBanner
showsHeader={false}
streamName={name}
customText={offlineMessage}
notificationsEnabled={browserNotificationsEnabled}
@@ -346,7 +354,7 @@ export const Content: FC = () => {
onFollowClick={() => setShowFollowModal(true)}
/>
)}
{online && (
{isStreamLive && (
<Statusbar
online={online}
lastConnectTime={lastConnectTime}

View File

@@ -8,12 +8,10 @@
justify-content: space-between;
flex-direction: row;
background-color: var(--theme-color-background-header);
width: 100%;
min-height: 30px;
color: var(--theme-color-components-text-on-dark);
font-family: var(--theme-text-body-font-family);
padding: 0 0.6rem;
padding: 0.6rem;
font-size: 0.8rem;
font-weight: 600;
border-top: 1px solid rgba(214, 211, 211, 0.5);

View File

@@ -6,7 +6,7 @@ export type FooterProps = {
};
export const Footer: FC<FooterProps> = ({ version }) => (
<footer className={styles.footer}>
<footer className={styles.footer} id="footer">
<span>
Powered by <a href="https://owncast.online">{version}</a>
</span>

View File

@@ -6,7 +6,7 @@
align-items: center;
justify-content: space-between;
z-index: 20;
padding: 1rem 0.7rem;
padding: 0.7rem;
box-shadow: 0px 1px 3px 1px rgb(0 0 0 / 10%);
background-color: var(--theme-color-background-header);
@@ -42,3 +42,18 @@
overflow: hidden;
line-height: 1.4;
}
.skipLink {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skipLink:focus {
position: static;
width: auto;
height: auto;
}

View File

@@ -2,6 +2,7 @@ import { Tag, Tooltip } from 'antd';
import { FC } from 'react';
import cn from 'classnames';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { OwncastLogo } from '../../common/OwncastLogo/OwncastLogo';
import styles from './Header.module.scss';
@@ -18,19 +19,32 @@ export type HeaderComponentProps = {
name: string;
chatAvailable: boolean;
chatDisabled: boolean;
online: boolean;
};
export const Header: FC<HeaderComponentProps> = ({
name = 'Your stream title',
chatAvailable,
chatDisabled,
online,
}) => (
<header className={cn([`${styles.header}`], 'global-header')}>
{online && (
<Link href="#player" className={styles.skipLink}>
Skip to player
</Link>
)}
<Link href="#skip-to-content" className={styles.skipLink}>
Skip to page content
</Link>
<Link href="#footer" className={styles.skipLink}>
Skip to footer
</Link>
<div className={styles.logo}>
<div id="header-logo" className={styles.logoImage}>
<OwncastLogo variant="contrast" />
</div>
<h1 className={styles.title} id="global-header-text" title={name}>
<h1 className={styles.title} id="global-header-text">
{name}
</h1>
</div>

View File

@@ -9,15 +9,15 @@
flex-direction: column;
color: var(--theme-color-components-text-on-light);
background-color: var(--theme-color-background-main);
margin: 1rem auto;
margin: 3rem auto;
border-radius: var(--theme-rounded-corners);
padding: 1rem;
padding: 2.5em;
font-size: 1.2rem;
border: 1px solid lightgray;
}
.bodyText {
line-height: 1.5rem;
line-height: 2rem;
}
.separator {

View File

@@ -17,6 +17,7 @@ export type OfflineBannerProps = {
lastLive?: Date;
notificationsEnabled: boolean;
fediverseAccount?: string;
showsHeader?: boolean;
onNotifyClick?: () => void;
onFollowClick?: () => void;
};
@@ -27,6 +28,7 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
lastLive,
notificationsEnabled,
fediverseAccount,
showsHeader = true,
onNotifyClick,
onFollowClick,
}) => {
@@ -74,8 +76,12 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
return (
<div id="offline-banner" className={styles.outerContainer}>
<div className={styles.innerContainer}>
<div className={styles.header}>{streamName}</div>
<Divider className={styles.separator} />
{showsHeader && (
<>
<div className={styles.header}>{streamName}</div>
<Divider className={styles.separator} />
</>
)}
<div className={styles.bodyText}>{text}</div>
{lastLive && (
<div className={styles.lastLiveDate}>

View File

@@ -7,7 +7,6 @@ export type SocialLinksProps = {
links: SocialLink[];
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const SocialLinks: FC<SocialLinksProps> = ({ links }) => (
<div className={styles.links}>
{links.map(link => (
@@ -22,7 +21,6 @@ export const SocialLinks: FC<SocialLinksProps> = ({ links }) => (
<Image
src={link.icon || '/img/platformlogos/default.svg'}
alt={link.platform}
title={link.platform}
className={styles.link}
width="30"
height="30"

View File

@@ -6,8 +6,8 @@
height: 2rem;
width: 100%;
padding: var(--content-padding);
color: var(--theme-color-components-text-on-light);
background-color: var(--component-background);
color: var(--theme-color-components-video-status-bar-foreground);
background-color: var(--theme-color-components-video-status-bar-background);
font-family: var(--theme-text-display-font-family);
font-weight: 600;
font-weight: 400;
}

View File

@@ -66,7 +66,7 @@ export const Statusbar: FC<StatusbarProps> = ({
}
return (
<div className={styles.statusbar}>
<div className={styles.statusbar} role="status">
<div>{onlineMessage}</div>
<div>{rightSideMessage}</div>
</div>

View File

@@ -9,13 +9,33 @@
height: 75px;
width: 250px;
font-size: 0.8rem;
overflow: hidden;
@include screen(mobile){
color: var(--theme-color-components-text-on-light);
.name {
font-weight: 600;
font-size: 1rem;
color: var(--theme-color-components-text-on-light);
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
width: calc(85%);
white-space: nowrap;
}
.account {
color: var(--theme-color-components-text-on-light);
word-break: break-all;
line-height: 0.9rem;
}
@include screen(mobile) {
margin: auto;
}
&:hover {
border-color: var(--theme-text-link);
border-color: var(--theme-color-action);
}
.avatar {
@@ -26,11 +46,6 @@
border-style: solid;
}
.account {
color: var(--theme-text-secondary);
text-overflow: ellipsis;
}
.placeholder {
width: 100%;
height: 100%;

View File

@@ -18,7 +18,7 @@ export const SingleFollower: FC<SingleFollowerProps> = ({ follower }) => (
</Avatar>
</Col>
<Col>
<Row>{follower.name}</Row>
<Row className={styles.name}>{follower.name}</Row>
<Row className={styles.account}>{follower.username}</Row>
</Col>
</Row>

View File

@@ -1,16 +1,16 @@
@import '../../../styles/mixins.scss';
.container {
display: grid;
width: 100%;
justify-items: center;
max-height: 75vh;
aspect-ratio: 16 / 9;
.player,
.poster {
// position: static;
// height: auto !important;
width: 100%;
grid-column: 1;
grid-row: 1;
aspect-ratio: 16 / 9;
max-height: 75vh;
}
}

View File

@@ -34,4 +34,5 @@ export const LiveDemo = Template.bind({});
LiveDemo.args = {
online: true,
source: 'https://watch.owncast.online/hls/stream.m3u8',
title: 'Stream title',
};

View File

@@ -10,7 +10,6 @@ import { isVideoPlayingAtom, clockSkewAtom } from '../../stores/ClientConfigStor
import PlaybackMetrics from '../metrics/playback';
import createVideoSettingsMenuButton from '../settings-menu';
import LatencyCompensator from '../latencyCompensator';
import styles from './OwncastPlayer.module.scss';
const VIDEO_CONFIG_URL = '/api/video/variants';
@@ -26,6 +25,7 @@ export type OwncastPlayerProps = {
source: string;
online: boolean;
initiallyMuted?: boolean;
title: string;
};
async function getVideoSettings() {
@@ -44,6 +44,7 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
source,
online,
initiallyMuted = false,
title,
}) => {
const playerRef = React.useRef(null);
const [videoPlaying, setVideoPlaying] = useRecoilState<boolean>(isVideoPlayingAtom);
@@ -85,13 +86,13 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
}
};
const setLatencyCompensatorItemTitle = title => {
const setLatencyCompensatorItemTitle = t => {
const item = document.querySelector('.latency-toggle-item > .vjs-menu-item-text');
if (!item) {
return;
}
item.innerHTML = title;
item.innerHTML = t;
};
const startLatencyCompensator = () => {
@@ -218,6 +219,7 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
controls: true,
responsive: true,
fluid: false,
fill: true,
playsinline: true,
liveui: true,
preload: 'auto',
@@ -306,10 +308,10 @@ export const OwncastPlayer: FC<OwncastPlayerProps> = ({
);
return (
<div className={styles.container}>
<div className={styles.container} id="player">
{online && (
<div className={styles.player}>
<VideoJS options={videoJsOptions} onReady={handlePlayerReady} />
<VideoJS options={videoJsOptions} onReady={handlePlayerReady} aria-label={title} />
</div>
)}
<div className={styles.poster}>

View File

@@ -1,9 +1,5 @@
@import '../../../styles/mixins.scss';
.player {
height: auto !important;
width: 100%;
video {
position: static !important;
}
}

View File

@@ -33,9 +33,9 @@ export const VideoPoster: FC<VideoPosterProps> = ({ online, initialSrc, src: bas
<CrossfadeImage
src={src}
duration={duration}
objectFit="cover"
objectFit="contain"
height="auto"
width="100%"
height="100%"
/>
)}
</div>

View File

@@ -93,6 +93,7 @@ export function createVideoSettingsMenuButton(player, videojs, qualities, latenc
}
const menuButton = new MenuButton();
menuButton.el().setAttribute('aria-label', 'Settings');
// If none of the settings in this menu are applicable then don't show it.
const tech = player.tech({ IWillNotUseThisInPlugins: true });