Merge branch 'develop' into fix/ImplementPasswordRules

This commit is contained in:
Jambaldorj Ochirpurev
2023-03-01 14:11:50 +01:00
committed by GitHub
371 changed files with 23954 additions and 2697 deletions

View File

@@ -4,6 +4,68 @@ import '../styles/theme.less';
import './preview.scss';
import { themes } from '@storybook/theming';
import { DocsContainer } from './storybook-theme';
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';
import _ from 'lodash';
/**
* Takes an entry of a viewport (from Object.entries()) and converts it
* into two entries, one for landscape and one for portrait.
*
* @template {string} Key
*
* @param {[Key, import('@storybook/addon-viewport/dist/ts3.9/models').Viewport]} entry
* @returns {Array<[`${Key}${'Portrait' | 'Landscape'}`, import('@storybook/addon-viewport/dist/ts3.9/models').Viewport]>}
*/
const convertToLandscapeAndPortraitEntries = ([objectKey, viewport]) => {
const pixelStringToNumber = str => parseInt(str.split('px')[0]);
const dimensions = [viewport.styles.width, viewport.styles.height].map(pixelStringToNumber);
const minDimension = Math.min(...dimensions);
const maxDimension = Math.max(...dimensions);
return [
[
`${objectKey}Portrait`,
{
...viewport,
name: viewport.name + ' (Portrait)',
styles: {
...viewport.styles,
height: maxDimension + 'px',
width: minDimension + 'px',
},
},
],
[
`${objectKey}Landscape`,
{
...viewport,
name: viewport.name + ' (Landscape)',
styles: {
...viewport.styles,
height: minDimension + 'px',
width: maxDimension + 'px',
},
},
],
];
};
/**
* Takes an object and a function f and returns a new object.
* f takes the original object's entries (key-value-pairs
* from Object.entries) and returns a list of new entries
* (also key-value-pairs). These new entries then form the
* result.
* @template {string | number} OriginalKey
* @template {string | number} NewKey
* @template OriginalValue
* @template OriginalValue
*
* @param {Record<OriginalKey, OriginalValue>} obj
* @param {(entry: [OriginalKey, OriginalValue], index: number, all: Array<[OriginalKey, OriginalValue]>) => Array<[NewKey, NewValue]>} f
* @returns {Record<NewKey, NevValue>}
*/
const flatMapObject = (obj, f) => Object.fromEntries(Object.entries(obj).flatMap(f));
export const parameters = {
fetchMock: {
@@ -13,6 +75,8 @@ export const parameters = {
docs: {
container: DocsContainer,
},
actions: { argTypesRegex: '^on[A-Z].*' },
viewMode: 'docs',
controls: {
matchers: {
color: /(background|color)$/i,
@@ -34,4 +98,9 @@ export const parameters = {
// Override the default light theme
light: { ...themes.normal },
},
viewport: {
// Take a bunch of viewports from the storybook addon and convert them
// to portrait + landscape. Keys are appended with 'Landscape' or 'Portrait'.
viewports: flatMapObject(INITIAL_VIEWPORTS, convertToLandscapeAndPortraitEntries),
},
};

View File

@@ -1,132 +0,0 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { ColorRow } from './Color';
<Meta title="owncast/Styles/Colors" parameters={{ chromatic: { disableSnapshot: true } }} />
# Default theme colors
These colors are assigned in our [color token](https://github.com/owncast/owncast/tree/develop/web/style-definitions/tokens/color) files
and get reflected here as they change. run `npm run build-styles` to regenerate.
<Story name="Default Theme">
## Default Theme
These color names are assigned to specific component variables. They can be overwritten via CSS.
<ColorRow
colors={[
'theme-color-palette-0',
'theme-color-palette-1',
'theme-color-palette-2',
'theme-color-palette-3',
'theme-color-palette-4',
'theme-color-palette-5',
'theme-color-palette-6',
'theme-color-palette-7',
'theme-color-palette-8',
'theme-color-palette-9',
'theme-color-palette-10',
'theme-color-palette-11',
'theme-color-palette-12',
'theme-color-palette-13',
'theme-color-palette-15',
'theme-color-palette-error',
'theme-color-palette-warning',
'theme-color-background-main',
'theme-color-background-light',
'theme-color-background-header',
'theme-color-action',
'theme-color-action-hover',
'theme-color-action-disabled',
]}
/>
</Story>
<Story
name="Frontend Components"
>
## Component Colors
<ColorRow
colors={[
'theme-color-components-text-on-light',
'theme-color-components-text-on-dark',
'theme-color-components-primary-button-background',
'theme-color-components-primary-button-background-disabled',
'theme-color-components-primary-button-text',
'theme-color-components-primary-button-text-disabled',
'theme-color-components-primary-button-border',
'theme-color-components-secondary-button-background',
'theme-color-components-secondary-button-background-disabled',
'theme-color-components-secondary-button-text',
'theme-color-components-secondary-button-text-disabled',
'theme-color-components-secondary-button-border',
'theme-color-components-chat-background',
'theme-color-components-chat-text',
'theme-color-components-modal-header-background',
'theme-color-components-modal-header-text',
'theme-color-components-modal-content-background',
'theme-color-components-content-background',
'theme-color-components-modal-content-text',
'theme-color-components-menu-background',
'theme-color-components-menu-item-text',
'theme-color-components-menu-item-bg',
'theme-color-components-menu-item-hover-bg',
'theme-color-components-menu-item-focus-bg',
'theme-color-components-form-field-background',
'theme-color-components-form-field-placeholder',
'theme-color-components-form-field-text',
'theme-color-components-form-field-border',
'theme-color-components-video-status-bar-background',
'theme-color-components-video-status-bar-foreground',
]}
/>
</Story>
<Story
name="Owncast Color Palette"
>
## Default Palette
These are the core colors for the default, out of the box, Owncast web application theme.
They should not be overwritten, instead the theme variables should be overwritten.
<ColorRow
colors={[
'color-owncast-palette-0',
'color-owncast-palette-1',
'color-owncast-palette-2',
'color-owncast-palette-3',
'color-owncast-palette-4',
'color-owncast-palette-5',
'color-owncast-palette-6',
'color-owncast-palette-7',
'color-owncast-palette-9',
'color-owncast-palette-10',
'color-owncast-palette-11',
'color-owncast-palette-12',
'color-owncast-palette-13',
'color-owncast-palette-15',
]}
/>
</Story>
<Story
name="User Chat Colors"
>
## User Colors
<ColorRow
colors={[
'theme-color-users-0',
'theme-color-users-1',
'theme-color-users-2',
'theme-color-users-3',
'theme-color-users-4',
'theme-color-users-5',
'theme-color-users-6',
'theme-color-users-7',
]}
/>
</Story>

View File

@@ -1,6 +1,6 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta title="owncast/Documentation/Design" parameters={{chromatic: { disableSnapshot: true }}}/>
<Meta title="owncast/Documentation/Design" parameters={{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}}/>
# Owncast Design Guidelines & Resources
@@ -31,9 +31,20 @@ Read the detailed [product definition](https://github.com/owncast/owncast/blob/d
Owncast is a is a live streaming and chat server targeted to anybody who has live streaming needs. This means anything from corporate events, government meetings, game streams, concerts, TV stations, and more.
## 🧑‍🎨 Product design opportunities
Owncast is a constantly moving project with features both old and new. This allows for design contributions to be both big or small.
You may not know how much time you can dedicate to the project, or if you'll be able to see something through to the end, so be honest about that. Take on projects that you'll be able to see completed.
- So maybe start small by finding rough edges and improvements to existing features without requiring complete rewrites. As a small project the bandwidth for rebuilding existing designs is limited, but tweaks are appreciated. This is especially great if you don't know how much time or energy you'll be able to provide the project. If you think you have a week to help, but might not be around in a month small projects are better.
- If you think you'll be around longer term, learn about future new features and start thinking about the design challenges of those so we can build them your feedback and design contributions in mind. See your designs put in the world through brand new functionality!
- Not everything has to be a a feature. Think big picture. What can we start doing now to put the project in a better place six months from now, or a year?
## 💅 Design relevant materials
Here is a list of design relevant information and materials:
A collection of design relevant information and materials can be found under the "style" section of "Storybook" here:
http://owncast.online/components
### Fonts

View File

@@ -1,6 +1,6 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta title="owncast/Documentation/Get Started with Owncast Development" parameters={{chromatic: { disableSnapshot: true }}}/>
<Meta title="owncast/Documentation/Get Started with Owncast Development" parameters={{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}}/>
---
title: "How to work on Owncast"

View File

@@ -1,148 +1,324 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { Canvas, Meta, Story, Description, IconGallery, IconItem } from '@storybook/addon-docs';
import { Image, ImageRow } from './ImageAsset';
<Meta title="owncast/Frontend Assets/Emoji" parameters={{chromatic: { disableSnapshot: true }}} />
<Meta title="owncast/Frontend Assets/Emoji" parameters={{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}} />
# Built-in Custom Emoji
<Description
markdown={`
## Blob
<Story
name="Blob"
>
<a href="img/emoji/blob/LICENSE.md" target="_blank">
LICENSE
</a>
<ImageRow images={[
{src: "img/emoji/blob/ablobattention.gif", name: "ablobattention.gif"},
{src: "img/emoji/blob/ablobaww.gif", name: "ablobaww.gif"},
{src: "img/emoji/blob/ablobblewobble.gif", name: "ablobblewobble.gif"},
{src: "img/emoji/blob/ablobcheer.gif", name: "ablobcheer.gif"},
{src: "img/emoji/blob/ablobcry.gif", name: "ablobcry.gif"},
{src: "img/emoji/blob/ablobdancer.gif", name: "ablobdancer.gif"},
{src: "img/emoji/blob/ablobgift.gif", name: "ablobgift.gif"},
{src: "img/emoji/blob/ablobgiggle.gif", name: "ablobgiggle.gif"},
{src: "img/emoji/blob/ablobparty.gif", name: "ablobparty.gif"},
{src: "img/emoji/blob/ablobsleep.gif", name: "ablobsleep.gif"},
{src: "img/emoji/blob/ablobthinking.gif", name: "ablobthinking.gif"},
{src: "img/emoji/blob/ablobwave.gif", name: "ablobwave.gif"},
{src: "img/emoji/blob/blobangry.png", name: "blobangry.png"},
{src: "img/emoji/blob/blobaww.png", name: "blobaww.png"},
{src: "img/emoji/blob/blobdancer.png", name: "blobdancer.png"},
{src: "img/emoji/blob/blobjam.png", name: "blobjam.png"},
{src: "img/emoji/blob/blobscream.png", name: "blobscream.png"},
{src: "img/emoji/blob/blobthanks.png", name: "blobthanks.png"},
{src: "img/emoji/blob/blobthinking.png", name: "blobthinking.png"},
{src: "img/emoji/blob/blobwave.png", name: "blobwave.png"},
{src: "img/emoji/blob/blobyes.png", name: "blobyes.png"},
{src: "img/emoji/blob/blobyum.png", name: "blobyum.png"},
]}/>
</Story>
<a href="img/emoji/blob/LICENSE.md" target="_blank">
LICENSE
</a>
`} />
<IconGallery>
<IconItem name="ablobattention.gif">
<img src="img/emoji/blob/ablobattention.gif" />
</IconItem>
<IconItem name="ablobaww.gif">
<img src="img/emoji/blob/ablobaww.gif" />
</IconItem>
<IconItem name="ablobblewobble.gif">
<img src="img/emoji/blob/ablobblewobble.gif" />
</IconItem>
<IconItem name="ablobcheer.gif">
<img src="img/emoji/blob/ablobcheer.gif" />
</IconItem>
<IconItem name="ablobcry.gif">
<img src="img/emoji/blob/ablobcry.gif" />
</IconItem>
<IconItem name="ablobdancer.gif">
<img src="img/emoji/blob/ablobdancer.gif" />
</IconItem>
<IconItem name="ablobgift.gif">
<img src="img/emoji/blob/ablobgift.gif" />
</IconItem>
<IconItem name="ablobgiggle.gif">
<img src="img/emoji/blob/ablobgiggle.gif" />
</IconItem>
<IconItem name="ablobparty.gif">
<img src="img/emoji/blob/ablobparty.gif" />
</IconItem>
<IconItem name="ablobsleep.gif">
<img src="img/emoji/blob/ablobsleep.gif" />
</IconItem>
<IconItem name="ablobthinking.gif">
<img src="img/emoji/blob/ablobthinking.gif" />
</IconItem>
<IconItem name="ablobwave.gif">
<img src="img/emoji/blob/ablobwave.gif" />
</IconItem>
<IconItem name="blobangry.png">
<img src="img/emoji/blob/blobangry.png" />
</IconItem>
<IconItem name="blobaww.png">
<img src="img/emoji/blob/blobaww.png" />
</IconItem>
<IconItem name="blobdancer.png">
<img src="img/emoji/blob/blobdancer.png" />
</IconItem>
<IconItem name="blobjam.png">
<img src="img/emoji/blob/blobjam.png" />
</IconItem>
<IconItem name="blobscream.png">
<img src="img/emoji/blob/blobscream.png" />
</IconItem>
<IconItem name="blobthanks.png">
<img src="img/emoji/blob/blobthanks.png" />
</IconItem>
<IconItem name="blobthinking.png">
<img src="img/emoji/blob/blobthinking.png" />
</IconItem>
<IconItem name="blobwave.png">
<img src="img/emoji/blob/blobwave.png" />
</IconItem>
<IconItem name="blobyes.png">
<img src="img/emoji/blob/blobyes.png" />
</IconItem>
<IconItem name="blobyum.png">
<img src="img/emoji/blob/blobyum.png" />
</IconItem>
</IconGallery>
<Description
markdown={`
## Conigliolo96
<Story
name="Conigliolo96"
>
<a href="img/emoji/conigliolo96/LICENSE.md" target="_blank">
LICENSE
</a>
<ImageRow images={[
{src: "img/emoji/conigliolo96/conigliolo1.gif", name: "conigliolo1.gif"},
{src: "img/emoji/conigliolo96/conigliolo15.gif", name: "conigliolo15.gif"},
{src: "img/emoji/conigliolo96/conigliolo17.gif", name: "conigliolo17.gif"},
{src: "img/emoji/conigliolo96/conigliolo21.gif", name: "conigliolo21.gif"},
{src: "img/emoji/conigliolo96/conigliolo25.gif", name: "conigliolo25.gif"},
{src: "img/emoji/conigliolo96/conigliolo28.gif", name: "conigliolo28.gif"},
]}/>
</Story>
<a href="img/emoji/conigliolo96/LICENSE.md" target="_blank">
LICENSE
</a>
`} />
<IconGallery>
<IconItem name="conigliolo1.gif">
<img src="img/emoji/conigliolo96/conigliolo1.gif" />
</IconItem>
<IconItem name="conigliolo15.gif">
<img src="img/emoji/conigliolo96/conigliolo15.gif" />
</IconItem>
<IconItem name="conigliolo17.gif">
<img src="img/emoji/conigliolo96/conigliolo17.gif" />
</IconItem>
<IconItem name="conigliolo21.gif">
<img src="img/emoji/conigliolo96/conigliolo21.gif" />
</IconItem>
<IconItem name="conigliolo25.gif">
<img src="img/emoji/conigliolo96/conigliolo25.gif" />
</IconItem>
<IconItem name="conigliolo28.gif">
<img src="img/emoji/conigliolo96/conigliolo28.gif" />
</IconItem>
</IconGallery>
<Description
markdown={`
## Dog
<Story
name="Dog"
>
<a href="img/emoji/dog/LICENSE.md" target="_blank">
LICENSE
</a>
<ImageRow images={[
{src: "img/emoji/dog/img001.svg", name: "img001.svg"},
{src: "img/emoji/dog/img091.svg", name: "img091.svg"},
{src: "img/emoji/dog/img093.svg", name: "img093.svg"},
{src: "img/emoji/dog/img203.svg", name: "img203.svg"},
{src: "img/emoji/dog/img288.svg", name: "img288.svg"},
{src: "img/emoji/dog/img327.svg", name: "img327.svg"},
{src: "img/emoji/dog/img346.svg", name: "img346.svg"},
{src: "img/emoji/dog/img347.svg", name: "img347.svg"},
{src: "img/emoji/dog/img352.svg", name: "img352.svg"},
]}/>
</Story>
<a href="img/emoji/dog/LICENSE.md" target="_blank">
LICENSE
</a>
`} />
<IconGallery>
<IconItem name="img001.svg">
<img src="img/emoji/dog/img001.svg" />
</IconItem>
<IconItem name="img091.svg">
<img src="img/emoji/dog/img091.svg" />
</IconItem>
<IconItem name="img093.svg">
<img src="img/emoji/dog/img093.svg" />
</IconItem>
<IconItem name="img203.svg">
<img src="img/emoji/dog/img203.svg" />
</IconItem>
<IconItem name="img288.svg">
<img src="img/emoji/dog/img288.svg" />
</IconItem>
<IconItem name="img327.svg">
<img src="img/emoji/dog/img327.svg" />
</IconItem>
<IconItem name="img346.svg">
<img src="img/emoji/dog/img346.svg" />
</IconItem>
<IconItem name="img347.svg">
<img src="img/emoji/dog/img347.svg" />
</IconItem>
<IconItem name="img352.svg">
<img src="img/emoji/dog/img352.svg" />
</IconItem>
</IconGallery>
<Description
markdown={`
## Mutant
<Story
name="Mutant"
>
<a href="img/emoji/mutant/LICENSE.md" target="_blank">
LICENSE
</a>
<ImageRow images={[
{src: "img/emoji/mutant/8_ball.svg", name: "8_ball.svg"},
{src: "img/emoji/mutant/alien.svg", name: "alien.svg"},
{src: "img/emoji/mutant/american_football.svg", name: "american_football.svg"},
{src: "img/emoji/mutant/arms_in_the_air.svg", name: "arms_in_the_air.svg"},
{src: "img/emoji/mutant/artist.svg", name: "artist.svg"},
{src: "img/emoji/mutant/astronaut.svg", name: "astronaut.svg"},
{src: "img/emoji/mutant/back_of_hand_clw.svg", name: "back_of_hand_clw.svg"},
{src: "img/emoji/mutant/back_of_hand_hoof.svg", name: "back_of_hand_hoof.svg"},
{src: "img/emoji/mutant/back_of_hand_paw.svg", name: "back_of_hand_paw.svg"},
{src: "img/emoji/mutant/baseball.svg", name: "baseball.svg"},
{src: "img/emoji/mutant/basketball.svg", name: "basketball.svg"},
{src: "img/emoji/mutant/blep.svg", name: "blep.svg"},
{src: "img/emoji/mutant/bow_b3.svg", name: "bow_b3.svg"},
{src: "img/emoji/mutant/cat_crying.svg", name: "cat_crying.svg"},
{src: "img/emoji/mutant/cat_devious.svg", name: "cat_devious.svg"},
{src: "img/emoji/mutant/cat_grin.svg", name: "cat_grin.svg"},
{src: "img/emoji/mutant/cat_heart_eyes.svg", name: "cat_heart_eyes.svg"},
{src: "img/emoji/mutant/cat_joy.svg", name: "cat_joy.svg"},
{src: "img/emoji/mutant/cat_kiss.svg", name: "cat_kiss.svg"},
{src: "img/emoji/mutant/cat_pouting.svg", name: "cat_pouting.svg"},
{src: "img/emoji/mutant/cat_scream.svg", name: "cat_scream.svg"},
{src: "img/emoji/mutant/cat_smile.svg", name: "cat_smile.svg"},
{src: "img/emoji/mutant/chef.svg", name: "chef.svg"},
{src: "img/emoji/mutant/detective.svg", name: "detective.svg"},
{src: "img/emoji/mutant/ear.svg", name: "ear.svg"},
{src: "img/emoji/mutant/eye.svg", name: "eye.svg"},
{src: "img/emoji/mutant/eyes.svg", name: "eyes.svg"},
{src: "img/emoji/mutant/facepalm.svg", name: "facepalm.svg"},
{src: "img/emoji/mutant/football.svg", name: "football.svg"},
{src: "img/emoji/mutant/ghost.svg", name: "ghost.svg"},
{src: "img/emoji/mutant/grumpy_block.svg", name: "grumpy_block.svg"},
{src: "img/emoji/mutant/hot_shit.svg", name: "hot_shit.svg"},
{src: "img/emoji/mutant/jack_o_lantern.svg", name: "jack_o_lantern.svg"},
{src: "img/emoji/mutant/long_pointed_ear.svg", name: "long_pointed_ear.svg"},
{src: "img/emoji/mutant/mechanical_arm.svg", name: "mechanical_arm.svg"},
{src: "img/emoji/mutant/no_good.svg", name: "no_good.svg"},
{src: "img/emoji/mutant/office_worker.svg", name: "office_worker.svg"},
{src: "img/emoji/mutant/ok_gesture.svg", name: "ok_gesture.svg"},
{src: "img/emoji/mutant/person_frowning.svg", name: "person_frowning.svg"},
{src: "img/emoji/mutant/raising_hand.svg", name: "raising_hand.svg"},
{src: "img/emoji/mutant/robot.svg", name: "robot.svg"},
{src: "img/emoji/mutant/shrug.svg", name: "shrug.svg"},
{src: "img/emoji/mutant/singer.svg", name: "singer.svg"},
{src: "img/emoji/mutant/skull.svg", name: "skull.svg"},
{src: "img/emoji/mutant/skull_and_crossbones.svg", name: "skull_and_crossbones.svg"},
{src: "img/emoji/mutant/softball.svg", name: "softball.svg"},
{src: "img/emoji/mutant/student.svg", name: "student.svg"},
{src: "img/emoji/mutant/studio_microphone.svg", name: "studio_microphone.svg"},
{src: "img/emoji/mutant/technologist.svg", name: "technologist.svg"},
{src: "img/emoji/mutant/tennis.svg", name: "tennis.svg"},
{src: "img/emoji/mutant/volleyball.svg", name: "volleyball.svg"},
]}/>
</Story>
<a href="img/emoji/mutant/LICENSE.md" target="_blank">
LICENSE
</a>
`} />
<IconGallery>
<IconItem name="8_ball.svg">
<img src="img/emoji/mutant/8_ball.svg" />
</IconItem>
<IconItem name="alien.svg">
<img src="img/emoji/mutant/alien.svg" />
</IconItem>
<IconItem name="american_football.svg">
<img src="img/emoji/mutant/american_football.svg" />
</IconItem>
<IconItem name="arms_in_the_air.svg">
<img src="img/emoji/mutant/arms_in_the_air.svg" />
</IconItem>
<IconItem name="artist.svg">
<img src="img/emoji/mutant/artist.svg" />
</IconItem>
<IconItem name="astronaut.svg">
<img src="img/emoji/mutant/astronaut.svg" />
</IconItem>
<IconItem name="back_of_hand_clw.svg">
<img src="img/emoji/mutant/back_of_hand_clw.svg" />
</IconItem>
<IconItem name="back_of_hand_hoof.svg">
<img src="img/emoji/mutant/back_of_hand_hoof.svg" />
</IconItem>
<IconItem name="back_of_hand_paw.svg">
<img src="img/emoji/mutant/back_of_hand_paw.svg" />
</IconItem>
<IconItem name="baseball.svg">
<img src="img/emoji/mutant/baseball.svg" />
</IconItem>
<IconItem name="basketball.svg">
<img src="img/emoji/mutant/basketball.svg" />
</IconItem>
<IconItem name="blep.svg">
<img src="img/emoji/mutant/blep.svg" />
</IconItem>
<IconItem name="bow_b3.svg">
<img src="img/emoji/mutant/bow_b3.svg" />
</IconItem>
<IconItem name="cat_crying.svg">
<img src="img/emoji/mutant/cat_crying.svg" />
</IconItem>
<IconItem name="cat_devious.svg">
<img src="img/emoji/mutant/cat_devious.svg" />
</IconItem>
<IconItem name="cat_grin.svg">
<img src="img/emoji/mutant/cat_grin.svg" />
</IconItem>
<IconItem name="cat_heart_eyes.svg">
<img src="img/emoji/mutant/cat_heart_eyes.svg" />
</IconItem>
<IconItem name="cat_joy.svg">
<img src="img/emoji/mutant/cat_joy.svg" />
</IconItem>
<IconItem name="cat_kiss.svg">
<img src="img/emoji/mutant/cat_kiss.svg" />
</IconItem>
<IconItem name="cat_pouting.svg">
<img src="img/emoji/mutant/cat_pouting.svg" />
</IconItem>
<IconItem name="cat_scream.svg">
<img src="img/emoji/mutant/cat_scream.svg" />
</IconItem>
<IconItem name="cat_smile.svg">
<img src="img/emoji/mutant/cat_smile.svg" />
</IconItem>
<IconItem name="chef.svg">
<img src="img/emoji/mutant/chef.svg" />
</IconItem>
<IconItem name="detective.svg">
<img src="img/emoji/mutant/detective.svg" />
</IconItem>
<IconItem name="ear.svg">
<img src="img/emoji/mutant/ear.svg" />
</IconItem>
<IconItem name="eye.svg">
<img src="img/emoji/mutant/eye.svg" />
</IconItem>
<IconItem name="eyes.svg">
<img src="img/emoji/mutant/eyes.svg" />
</IconItem>
<IconItem name="facepalm.svg">
<img src="img/emoji/mutant/facepalm.svg" />
</IconItem>
<IconItem name="football.svg">
<img src="img/emoji/mutant/football.svg" />
</IconItem>
<IconItem name="ghost.svg">
<img src="img/emoji/mutant/ghost.svg" />
</IconItem>
<IconItem name="grumpy_block.svg">
<img src="img/emoji/mutant/grumpy_block.svg" />
</IconItem>
<IconItem name="hot_shit.svg">
<img src="img/emoji/mutant/hot_shit.svg" />
</IconItem>
<IconItem name="jack_o_lantern.svg">
<img src="img/emoji/mutant/jack_o_lantern.svg" />
</IconItem>
<IconItem name="long_pointed_ear.svg">
<img src="img/emoji/mutant/long_pointed_ear.svg" />
</IconItem>
<IconItem name="mechanical_arm.svg">
<img src="img/emoji/mutant/mechanical_arm.svg" />
</IconItem>
<IconItem name="no_good.svg">
<img src="img/emoji/mutant/no_good.svg" />
</IconItem>
<IconItem name="office_worker.svg">
<img src="img/emoji/mutant/office_worker.svg" />
</IconItem>
<IconItem name="ok_gesture.svg">
<img src="img/emoji/mutant/ok_gesture.svg" />
</IconItem>
<IconItem name="person_frowning.svg">
<img src="img/emoji/mutant/person_frowning.svg" />
</IconItem>
<IconItem name="raising_hand.svg">
<img src="img/emoji/mutant/raising_hand.svg" />
</IconItem>
<IconItem name="robot.svg">
<img src="img/emoji/mutant/robot.svg" />
</IconItem>
<IconItem name="shrug.svg">
<img src="img/emoji/mutant/shrug.svg" />
</IconItem>
<IconItem name="singer.svg">
<img src="img/emoji/mutant/singer.svg" />
</IconItem>
<IconItem name="skull.svg">
<img src="img/emoji/mutant/skull.svg" />
</IconItem>
<IconItem name="skull_and_crossbones.svg">
<img src="img/emoji/mutant/skull_and_crossbones.svg" />
</IconItem>
<IconItem name="softball.svg">
<img src="img/emoji/mutant/softball.svg" />
</IconItem>
<IconItem name="student.svg">
<img src="img/emoji/mutant/student.svg" />
</IconItem>
<IconItem name="studio_microphone.svg">
<img src="img/emoji/mutant/studio_microphone.svg" />
</IconItem>
<IconItem name="technologist.svg">
<img src="img/emoji/mutant/technologist.svg" />
</IconItem>
<IconItem name="tennis.svg">
<img src="img/emoji/mutant/tennis.svg" />
</IconItem>
<IconItem name="volleyball.svg">
<img src="img/emoji/mutant/volleyball.svg" />
</IconItem>
</IconGallery>

View File

@@ -1,16 +1,27 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { Image, ImageRow } from './ImageAsset';
import { Canvas, Meta, Story, IconItem, IconGallery } from '@storybook/addon-docs';
<Meta title="owncast/Frontend Assets/Images" parameters={{chromatic: { disableSnapshot: true }}} />
<Meta title="owncast/Frontend Assets/Images" parameters={{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}} />
# Images
<ImageRow images={[
{src: "img/fediverse-black.png", name: "fediverse-black.png"},
{src: "img/fediverse-color.png", name: "fediverse-color.png"},
{src: "img/follow.svg", name: "follow.svg"},
{src: "img/indieauth.png", name: "indieauth.png"},
{src: "img/like.svg", name: "like.svg"},
{src: "img/repost.svg", name: "repost.svg"},
]}/>
<IconGallery>
<IconItem name="fediverse-black.png">
<img src="img/fediverse-black.png" />
</IconItem>
<IconItem name="fediverse-color.png">
<img src="img/fediverse-color.png" />
</IconItem>
<IconItem name="follow.svg">
<img src="img/follow.svg" />
</IconItem>
<IconItem name="indieauth.png">
<img src="img/indieauth.png" />
</IconItem>
<IconItem name="like.svg">
<img src="img/like.svg" />
</IconItem>
<IconItem name="repost.svg">
<img src="img/repost.svg" />
</IconItem>
</IconGallery>

View File

@@ -1,6 +1,6 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta title="owncast/Documentation/Product Definition" parameters={{chromatic: { disableSnapshot: true }}}/>
<Meta title="owncast/Documentation/Product Definition" parameters={{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}}/>
# Owncast Product Definition

View File

@@ -1,41 +1,102 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { Image, ImageRow } from './ImageAsset';
import { Canvas, Meta, Story, IconItem, IconGallery } from '@storybook/addon-docs';
<Meta title="owncast/Frontend Assets/Social Platform Images" parameters={{chromatic: { disableSnapshot: true }}} />
<Meta title="owncast/Frontend Assets/Social Platform Images" parameters={{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}} />
# Social Platform Images
<ImageRow images={[
{src: "img/platformlogos/bandcamp.svg", name: "bandcamp.svg"},
{src: "img/platformlogos/default.svg", name: "default.svg"},
{src: "img/platformlogos/discord.svg", name: "discord.svg"},
{src: "img/platformlogos/donate.svg", name: "donate.svg"},
{src: "img/platformlogos/facebook.svg", name: "facebook.svg"},
{src: "img/platformlogos/fediverse.svg", name: "fediverse.svg"},
{src: "img/platformlogos/follow.svg", name: "follow.svg"},
{src: "img/platformlogos/github.svg", name: "github.svg"},
{src: "img/platformlogos/gitlab.svg", name: "gitlab.svg"},
{src: "img/platformlogos/google.svg", name: "google.svg"},
{src: "img/platformlogos/instagram.svg", name: "instagram.svg"},
{src: "img/platformlogos/keyoxide.png", name: "keyoxide.png"},
{src: "img/platformlogos/ko-fi.svg", name: "ko-fi.svg"},
{src: "img/platformlogos/lbry.svg", name: "lbry.svg"},
{src: "img/platformlogos/liberapay.svg", name: "liberapay.svg"},
{src: "img/platformlogos/link.svg", name: "link.svg"},
{src: "img/platformlogos/linkedin.svg", name: "linkedin.svg"},
{src: "img/platformlogos/mastodon.svg", name: "mastodon.svg"},
{src: "img/platformlogos/matrix.svg", name: "matrix.svg"},
{src: "img/platformlogos/odysee.svg", name: "odysee.svg"},
{src: "img/platformlogos/patreon.svg", name: "patreon.svg"},
{src: "img/platformlogos/paypal.svg", name: "paypal.svg"},
{src: "img/platformlogos/snapchat.svg", name: "snapchat.svg"},
{src: "img/platformlogos/soundcloud.svg", name: "soundcloud.svg"},
{src: "img/platformlogos/spotify.svg", name: "spotify.svg"},
{src: "img/platformlogos/steam.svg", name: "steam.svg"},
{src: "img/platformlogos/tiktok.svg", name: "tiktok.svg"},
{src: "img/platformlogos/twitch.svg", name: "twitch.svg"},
{src: "img/platformlogos/twitter.svg", name: "twitter.svg"},
{src: "img/platformlogos/xmpp.svg", name: "xmpp.svg"},
{src: "img/platformlogos/youtube.svg", name: "youtube.svg"},
]}/>
<IconGallery>
<IconItem name="bandcamp.svg">
<img src="img/platformlogos/bandcamp.svg" />
</IconItem>
<IconItem name="default.svg">
<img src="img/platformlogos/default.svg" />
</IconItem>
<IconItem name="discord.svg">
<img src="img/platformlogos/discord.svg" />
</IconItem>
<IconItem name="donate.svg">
<img src="img/platformlogos/donate.svg" />
</IconItem>
<IconItem name="facebook.svg">
<img src="img/platformlogos/facebook.svg" />
</IconItem>
<IconItem name="fediverse.svg">
<img src="img/platformlogos/fediverse.svg" />
</IconItem>
<IconItem name="follow.svg">
<img src="img/platformlogos/follow.svg" />
</IconItem>
<IconItem name="github.svg">
<img src="img/platformlogos/github.svg" />
</IconItem>
<IconItem name="gitlab.svg">
<img src="img/platformlogos/gitlab.svg" />
</IconItem>
<IconItem name="google.svg">
<img src="img/platformlogos/google.svg" />
</IconItem>
<IconItem name="instagram.svg">
<img src="img/platformlogos/instagram.svg" />
</IconItem>
<IconItem name="keyoxide.png">
<img src="img/platformlogos/keyoxide.png" />
</IconItem>
<IconItem name="ko-fi.svg">
<img src="img/platformlogos/ko-fi.svg" />
</IconItem>
<IconItem name="lbry.svg">
<img src="img/platformlogos/lbry.svg" />
</IconItem>
<IconItem name="liberapay.svg">
<img src="img/platformlogos/liberapay.svg" />
</IconItem>
<IconItem name="link.svg">
<img src="img/platformlogos/link.svg" />
</IconItem>
<IconItem name="linkedin.svg">
<img src="img/platformlogos/linkedin.svg" />
</IconItem>
<IconItem name="mastodon.svg">
<img src="img/platformlogos/mastodon.svg" />
</IconItem>
<IconItem name="matrix.svg">
<img src="img/platformlogos/matrix.svg" />
</IconItem>
<IconItem name="odysee.svg">
<img src="img/platformlogos/odysee.svg" />
</IconItem>
<IconItem name="patreon.svg">
<img src="img/platformlogos/patreon.svg" />
</IconItem>
<IconItem name="paypal.svg">
<img src="img/platformlogos/paypal.svg" />
</IconItem>
<IconItem name="snapchat.svg">
<img src="img/platformlogos/snapchat.svg" />
</IconItem>
<IconItem name="soundcloud.svg">
<img src="img/platformlogos/soundcloud.svg" />
</IconItem>
<IconItem name="spotify.svg">
<img src="img/platformlogos/spotify.svg" />
</IconItem>
<IconItem name="steam.svg">
<img src="img/platformlogos/steam.svg" />
</IconItem>
<IconItem name="tiktok.svg">
<img src="img/platformlogos/tiktok.svg" />
</IconItem>
<IconItem name="twitch.svg">
<img src="img/platformlogos/twitch.svg" />
</IconItem>
<IconItem name="twitter.svg">
<img src="img/platformlogos/twitter.svg" />
</IconItem>
<IconItem name="xmpp.svg">
<img src="img/platformlogos/xmpp.svg" />
</IconItem>
<IconItem name="youtube.svg">
<img src="img/platformlogos/youtube.svg" />
</IconItem>
</IconGallery>

View File

@@ -1,29 +1,146 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { Canvas, Meta, Story, Typeset, Source } from '@storybook/addon-docs';
<Meta title="owncast/Styles/Typography" />
<Meta
title="owncast/Styles/Typography"
parameters={{
previewTabs: { canvas: { hidden: true } },
chromatic: { disableSnapshot: true },
}}
/>
## Body
export const SampleText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
<div style={{ fontSize: '1.2rem', fontFamily: 'var(--theme-text-body-font-family)' }}>
The quick brown fox jumps over the lazy dog.
</div>
# Typography
These are the font families in use by Owncast.
---
export const bodyFont = {
type: {
primary: getComputedStyle(document.documentElement).getPropertyValue(
'--theme-text-body-font-family',
),
},
weight: {
regular: '400',
bold: '600',
extrabold: '800',
},
size: {
s1: 12,
s2: 14,
s3: 16,
m1: 20,
m2: 24,
m3: 28,
l1: 32,
l2: 40,
l3: 48,
},
};
## {bodyFont.type.primary.split(',')[0].replaceAll('"', '')}
### Everywhere but headings and titles.
<Typeset
fontSizes={[
Number(bodyFont.size.s1),
Number(bodyFont.size.s2),
Number(bodyFont.size.s3),
Number(bodyFont.size.m1),
Number(bodyFont.size.m2),
Number(bodyFont.size.m3),
Number(bodyFont.size.l1),
Number(bodyFont.size.l2),
Number(bodyFont.size.l3),
]}
fontWeight={bodyFont.weight.black}
sampleText={SampleText}
fontFamily={bodyFont.type.primary}
/>
## Usage
<Canvas
style={{ color: 'var(--theme-text-secondary)', fontFamily: 'var(--theme-text-body-font-family)' }}
columns={2}
withSource="open"
withToolbar
style={{ fontFamily: 'var(--theme-text-body-font-family)' }}
>
{getComputedStyle(document.documentElement).getPropertyValue('--theme-text-body-font-family')}
</Canvas>
## Display
<Source
language="css"
dark
format={true}
code={`
font-family: var(--theme-text-body-font-family);
`}
/>
export const displayFont = {
type: {
primary: getComputedStyle(document.documentElement).getPropertyValue(
'--theme-text-display-font-family',
),
},
weight: {
regular: '400',
bold: '600',
extrabold: '800',
},
size: {
s1: 12,
s2: 14,
s3: 16,
m1: 20,
m2: 24,
m3: 28,
l1: 32,
l2: 40,
l3: 48,
},
};
## {displayFont.type.primary.split(',')[0].replaceAll('"','')}
### Headings and titles.
<Typeset
fontSizes={[
Number(displayFont.size.s1),
Number(displayFont.size.s2),
Number(displayFont.size.s3),
Number(displayFont.size.m1),
Number(displayFont.size.m2),
Number(displayFont.size.m3),
Number(displayFont.size.l1),
Number(displayFont.size.l2),
Number(displayFont.size.l3),
]}
fontWeight={displayFont.weight.black}
sampleText={SampleText}
fontFamily={displayFont.type.primary}
/>
## Usage
<div style={{ fontSize: '1.2rem', fontFamily: 'var(--theme-text-display-font-family)' }}>
The quick brown fox jumps over the lazy dog.
</div>
<Canvas
style={{
color: 'var(--theme-text-secondary)',
fontFamily: 'var(--theme-text-display-font-family)',
}}
columns={2}
withSource="open"
withToolbarstyle={{ fontFamily: 'var(--theme-text-display-font-family)' }}
>
{getComputedStyle(document.documentElement).getPropertyValue('--theme-text-display-font-family')}
</Canvas>
<Source
language="css"
dark
format={true}
code={`
font-family: var(--theme-text-display-font-family);
`}
/>

View File

@@ -1,6 +1,6 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta title="owncast/Documentation/Building Frontend Components" parameters={{chromatic: { disableSnapshot: true }}}/>
<Meta title="owncast/Documentation/Building Frontend Components" parameters={{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}}/>
# How we develop components

View File

@@ -1,9 +1,8 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FC } from 'react';
export type ColorProps = {
color: any; // TODO specify better type
color: string;
};
export const Color: FC<ColorProps> = ({ color }) => {
@@ -55,10 +54,6 @@ export const Color: FC<ColorProps> = ({ color }) => {
);
};
Color.propTypes = {
color: PropTypes.string.isRequired,
};
const rowStyle = {
display: 'flex',
flexDirection: 'row' as 'row',
@@ -66,7 +61,11 @@ const rowStyle = {
alignItems: 'center',
};
export const ColorRow = props => {
export type ColorRowProps = {
colors: string[];
};
export const ColorRow: FC<ColorRowProps> = (props: ColorRowProps) => {
const { colors } = props;
return (
@@ -78,6 +77,8 @@ export const ColorRow = props => {
);
};
ColorRow.propTypes = {
colors: PropTypes.arrayOf(PropTypes.string).isRequired,
export const getColor = color => {
const colorValue = getComputedStyle(document.documentElement).getPropertyValue(`--${color}`);
return { [color]: colorValue };
// return { [color]: colorValue };
};

View File

@@ -0,0 +1,110 @@
import { Canvas, Meta, Story, ColorPalette, ColorItem, Description } from '@storybook/addon-docs';
import { ColorRow, getColor } from './Color';
<Meta
title="owncast/Styles/Colors/Components"
parameters={{
previewTabs: { canvas: { hidden: true } },
chromatic: { disableSnapshot: true },
}}
/>
<Description markdown={`
## Component Colors
These are the specific colors used for components in the web application. They point to colors in the Owncast color palette but CSS variable names can be overwritten for customizing the theme.
`}/>
<ColorPalette>
<ColorItem
title="Text"
subtitle=""
colors={{
...getColor('theme-color-components-text-on-light'),
...getColor('theme-color-components-text-on-dark'),
}}
/>
<ColorItem
title="Primary Button"
subtitle=""
colors={{
...getColor('theme-color-components-primary-button-background'),
...getColor('theme-color-components-primary-button-background-disabled'),
...getColor('theme-color-components-primary-button-text'),
...getColor('theme-color-components-primary-button-text-disabled'),
...getColor('theme-color-components-primary-button-border')
}}
/>
<ColorItem
title="Secondary Button"
subtitle=""
colors={{
...getColor('theme-color-components-secondary-button-background'),
...getColor('theme-color-components-secondary-button-background-disabled'),
...getColor('theme-color-components-secondary-button-text'),
...getColor('theme-color-components-secondary-button-text-disabled'),
...getColor('theme-color-components-secondary-button-border'),
}}
/>
<ColorItem
title="Chat"
subtitle=""
colors={{
...getColor('theme-color-components-chat-background'),
...getColor('theme-color-components-chat-text'),
}}
/>
<ColorItem
title="Modals"
subtitle=""
colors={{
...getColor('theme-color-components-modal-header-background'),
...getColor('theme-color-components-modal-header-text'),
...getColor('theme-color-components-modal-content-background'),
...getColor('theme-color-components-modal-content-text'),
}}
/>
<ColorItem
title="Page Content"
subtitle="Tabbed content"
colors={{
...getColor('theme-color-components-content-background'),
}}
/>
<ColorItem
title="Menus"
subtitle=""
colors={{
...getColor('theme-color-components-menu-background'),
...getColor('theme-color-components-menu-item-text'),
...getColor('theme-color-components-menu-item-bg'),
...getColor('theme-color-components-menu-item-text'),
...getColor('theme-color-components-menu-item-hover-bg'),
...getColor('theme-color-components-menu-item-focus-bg'),
}}
/>
<ColorItem
title="Form Fields"
subtitle=""
colors={{
...getColor('theme-color-components-form-field-background'),
...getColor('theme-color-components-form-field-placeholder'),
...getColor('theme-color-components-form-field-text'),
...getColor('theme-color-components-menu-item-text'),
...getColor('theme-color-components-form-field-border'),
}}
/>
<ColorItem
title="Video Player Status Bar"
subtitle="Displays duration and viewer count."
colors={{
...getColor('theme-color-components-video-status-bar-background'),
...getColor('theme-color-components-video-status-bar-foreground'),
}}
/>
</ColorPalette>

View File

@@ -0,0 +1,64 @@
import { Canvas, Meta, Story, ColorPalette, ColorItem, Description } from '@storybook/addon-docs';
import { ColorRow, getColor } from './Color';
<Meta
title="owncast/Styles/Colors/Palette"
parameters={{
previewTabs: { canvas: { hidden: true } },
chromatic: { disableSnapshot: true },
}}
/>
<Description markdown={`
## Theme Color Palette
These are the colors used across the web application. All the specific component colors point to colors in this palette. If you override one of these colors all usage of that color will be updated.
`}/>
<ColorPalette>
<ColorItem
title="Theme color palette"
subtitle="Colors used across the theme."
colors={{
...getColor('theme-color-palette-0'),
...getColor('theme-color-palette-1'),
...getColor('theme-color-palette-2'),
...getColor('theme-color-palette-3'),
...getColor('theme-color-palette-4'),
...getColor('theme-color-palette-5'),
}}
/>
<ColorItem
colors={{
...getColor('theme-color-palette-6'),
...getColor('theme-color-palette-7'),
...getColor('theme-color-palette-8'),
...getColor('theme-color-palette-9'),
...getColor('theme-color-palette-10'),
...getColor('theme-color-palette-11'),
}}
/>
<ColorItem
colors={{
...getColor('theme-color-palette-12'),
...getColor('theme-color-palette-13'),
...getColor('theme-color-palette-14'),
...getColor('theme-color-palette-15'),
...getColor('theme-color-palette-error'),
...getColor('theme-color-palette-warning'),
}}
/>
<ColorItem
colors={{
...getColor('theme-color-background-light'),
...getColor('theme-color-background-header'),
...getColor('theme-color-action'),
...getColor('theme-color-action-hover'),
...getColor('theme-color-action-disabled'),
}}
/>
</ColorPalette>

View File

@@ -0,0 +1,64 @@
import { Canvas, Meta, Story, ColorPalette, ColorItem, Description } from '@storybook/addon-docs';
import { ColorRow, getColor } from './Color';
<Meta
title="owncast/Styles/Colors/Users"
parameters={{
previewTabs: { canvas: { hidden: true } },
chromatic: { disableSnapshot: true },
}}
/>
<Description markdown={`
## User Colors
These are the colors available for assigning to chat users for display purposes. The CSS variables can be overwritten for customizing the theme.
`}/>
<ColorPalette>
<ColorItem title="User Color 0" colors={{ ...getColor('theme-color-users-0') }} />
<ColorItem
title="User Color 1"
colors={{
...getColor('theme-color-users-1'),
}}
/>
<ColorItem
title="User Color 2"
colors={{
...getColor('theme-color-users-2'),
}}
/>
<ColorItem
title="User Color 3"
colors={{
...getColor('theme-color-users-3'),
}}
/>
<ColorItem
title="User Color 4"
colors={{
...getColor('theme-color-users-4'),
}}
/>
<ColorItem
title="User Color 5"
colors={{
...getColor('theme-color-users-5'),
}}
/>
<ColorItem
title="User Color 6"
colors={{
...getColor('theme-color-users-6'),
}}
/>
<ColorItem
title="User Color 7"
colors={{
...getColor('theme-color-users-7'),
}}
/>
</ColorPalette>

View File

@@ -1,14 +1,20 @@
import { Mermaid } from 'mdx-mermaid/Mermaid';
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { Canvas, Meta, Story, Description } from '@storybook/addon-docs';
<Meta
title="owncast/Documentation/Usage Examples"
parameters={{ chromatic: { disableSnapshot: true } }}
title="owncast/Documentation/Infrastructure Examples/Basic Example"
parameters={{
previewTabs: { canvas: { hidden: true } },
chromatic: { disableSnapshot: true },
}}
/>
<Story name="Basic">
<Description
markdown={`
## Basic
This is the most basic Owncast setup and is what you get when you run it without any additional services.
`} />
<Mermaid
chart={`graph TD
@@ -28,12 +34,13 @@ This is the most basic Owncast setup and is what you get when you run it without
`}
/>
</Story>
<Story name="S3 Object Storage">
<Description
markdown={`
## S3 Object Storage
If you would like no video traffic to take place from your Owncast server to your clients, you can use S3 Object Storage to be responsible for this instead.
Instead of video players pulling the stream from your Owncast server it would instead make these requests to a S3 provider, and Owncast would upload video to this provider to make it available.
`} />
<Mermaid
chart={`graph TD
@@ -67,13 +74,15 @@ Instead of video players pulling the stream from your Owncast server it would in
`}
/>
</Story>
<Story name="CDN in front of S3 Object Storage">
<Description
markdown={`
## CDN in front of S3 Object Storage
If you're using a S3 provider but would like to increase the speed of your video delivery to your clients around the world, you can use a CDN in front of your S3 provider.
This leads to not only your server not serving any video traffic, but the video traffic would be distributed globally so each client player would be pulling video from somewhere geographically closer, reducing the likelihood of buffering and slower network requests.
`} />
<Mermaid
chart={`flowchart TD
subgraph Video Assets
@@ -110,11 +119,12 @@ This leads to not only your server not serving any video traffic, but the video
`}
/>
</Story>
<Story name="CDN in front of Owncast">
<Description
markdown={`
## CDN in front of Owncast
If you're ok with some video requests coming directly to your Owncast server, and want to generally increase the overall speed that your viewers globally access all your Owncast assets, including video and the web interface, you can put a CDN in front of your entire Owncast server.
`} />
<Mermaid
chart={`flowchart TD
@@ -143,5 +153,3 @@ If you're ok with some video requests coming directly to your Owncast server, an
`}
/>
</Story>

View File

@@ -0,0 +1,55 @@
import { Mermaid } from 'mdx-mermaid/Mermaid';
import { Canvas, Meta, Story, Description } from '@storybook/addon-docs';
<Meta
title="owncast/Documentation/Infrastructure Examples/S3 With CDN Example"
parameters={{
previewTabs: { canvas: { hidden: true } },
chromatic: { disableSnapshot: true },
}}
/>
<Description
markdown={`
## CDN in front of S3 Object Storage
If you're using a S3 provider but would like to increase the speed of your video delivery to your clients around the world, you can use a CDN in front of your S3 provider.
This leads to not only your server not serving any video traffic, but the video traffic would be distributed globally so each client player would be pulling video from somewhere geographically closer, reducing the likelihood of buffering and slower network requests.
`} />
<Mermaid
chart={`flowchart TD
subgraph Video Assets
S3[fa:fa-hard-drive S3 Object Storage]
CDN[fa:fa-cloud Global CDN]
end
subgraph Clients
WebPlayer[fa:fa-window-maximize Web App]
Embeds[fa:fa-window-restore Embeds]
MobileApps[fa:fa-mobile-screen Mobile Apps]
SmartTV[fa:fa-tv Smart TV]
VLC[fa:fa-shapes VLC]
end
subgraph Web Assets & Chat Service
direction TB
Owncast{fa:fa-server Owncast}
end
Owncast--Upload\\nVideo-->S3
Owncast--Web-->WebPlayer
Owncast<--Chat-->WebPlayer
Owncast--Web-->Embeds
CDN--Download\\nVideo-->WebPlayer
CDN--Download\\nVideo-->Embeds
CDN--Download\\nVideo-->MobileApps
CDN--Download\\nVideo-->SmartTV
CDN--Download\\nVideo-->VLC
S3 --- CDN
`}
/>

View File

@@ -0,0 +1,45 @@
import { Mermaid } from 'mdx-mermaid/Mermaid';
import { Canvas, Meta, Story, Description } from '@storybook/addon-docs';
<Meta
title="owncast/Documentation/Infrastructure Examples/CDN Example"
parameters={{
previewTabs: { canvas: { hidden: true } },
chromatic: { disableSnapshot: true },
}}
/>
<Description
markdown={`
## CDN in front of Owncast
If you're ok with some video requests coming directly to your Owncast server, and want to generally increase the overall speed that your viewers globally access all your Owncast assets, including video and the web interface, you can put a CDN in front of your entire Owncast server.
`} />
<Mermaid
chart={`flowchart TD
CDN{{fa:fa-cloud Global CDN}}
Owncast{fa:fa-server Owncast}
subgraph Clients
WebPlayer[fa:fa-window-maximize Web App]
Embeds[fa:fa-window-restore Embeds]
MobileApps[fa:fa-mobile-screen Mobile Apps]
SmartTV[fa:fa-tv Smart TV]
VLC[fa:fa-shapes VLC]
end
CDN--Web-->WebPlayer
Owncast<--Chat-->WebPlayer
CDN--Web-->Embeds
CDN--Download\\nVideo-->WebPlayer
CDN--Download\\nVideo-->Embeds
CDN--Download\\nVideo-->MobileApps
CDN--Download\\nVideo-->SmartTV
CDN--Download\\nVideo-->VLC
CDN --- Owncast
`}
/>

View File

@@ -0,0 +1,50 @@
import { Mermaid } from 'mdx-mermaid/Mermaid';
import { Canvas, Meta, Story, Description } from '@storybook/addon-docs';
<Meta
title="owncast/Documentation/Infrastructure Examples/S3 Example"
parameters={{
previewTabs: { canvas: { hidden: true } },
chromatic: { disableSnapshot: true },
}}
/>
<Description
markdown={`
## S3 Object Storage
If you would like no video traffic to take place from your Owncast server to your clients, you can use S3 Object Storage to be responsible for this instead.
Instead of video players pulling the stream from your Owncast server it would instead make these requests to a S3 provider, and Owncast would upload video to this provider to make it available.
`} />
<Mermaid
chart={`graph TD
subgraph Chat & Web Assets
Owncast{fa:fa-server Owncast}
end
Owncast--Upload\\nVideo-->S3
subgraph Video Assets
S3[fa:fa-hard-drive S3 Object Storage]
end
subgraph Clients
WebPlayer[fa:fa-window-maximize Web App]
Embeds[fa:fa-window-restore Embeds]
MobileApps[fa:fa-mobile-screen Mobile Apps]
SmartTV[fa:fa-tv Smart TV]
VLC[fa:fa-shapes VLC]
end
Owncast--Web-->WebPlayer
Owncast--Web-->Embeds
Owncast<--Chat-->WebPlayer
S3--Download\\nVideo-->WebPlayer
S3--Download\\nVideo-->Embeds
S3--Download\\nVideo-->MobileApps
S3--Download\\nVideo-->SmartTV
S3--Download\\nVideo-->VLC
`}
/>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/Android Landscape/Stock Browser"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/android-10.0-android-browser-samsung-galaxy-s20-ultra-landscape-offline.png"
alt="Android Browser offline"
height="1000px"
/>
</Story>
<Story name="Online">
<img
src="screenshots/android-10.0-android-browser-samsung-galaxy-s20-ultra-landscape-online.png"
alt="Android Browser offline"
height="1000px"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/Android Portrait/Stock Browser"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/android-13.0-android-browser-google-pixel-7-pro-portrait-offline.png"
alt="Android Browser offline"
height="1000px"
/>
</Story>
<Story name="Online">
<img
src="screenshots/android-13.0-android-browser-google-pixel-7-pro-portrait-online.png"
alt="Android Browser offline"
height="1000px"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/iPad Landscape/Safari"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/ios-16-mobile-safari-ipad-pro-11-2022-landscape-offline.png"
alt="macOS Safari offline"
width="800px"
/>
</Story>
<Story name="Online">
<img
src="screenshots/ios-16-mobile-safari-ipad-pro-11-2022-landscape-online.png"
alt="macOS Safari online"
width="800px"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/iPad Portrait/Safari"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/ios-16-mobile-safari-ipad-pro-11-2022-portrait-offline.png"
alt="macOS Safari offline"
width="800px"
/>
</Story>
<Story name="Online">
<img
src="screenshots/ios-16-mobile-safari-ipad-pro-11-2022-portrait-online.png"
alt="macOS Safari online"
width="800px"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/iPhone/Safari/Landscape"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/ios-16-mobile-safari-ipad-pro-11-2022-landscape-offline.png"
alt="iPhone Safari offline"
height="1000px"
/>
</Story>
<Story name="Online">
<img
src="screenshots/ios-16-mobile-safari-ipad-pro-11-2022-landscape-online.png"
alt="iPhone Safari online"
height="1000px"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/iPhone/Safari/Portrait"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/ios-16-mobile-safari-ipad-pro-11-2022-landscape-offline.png"
alt="iPhone Safari offline"
height="1000px"
/>
</Story>
<Story name="Online">
<img
src="screenshots/ios-16-mobile-safari-ipad-pro-11-2022-landscape-online.png"
alt="iPhone Safari online"
height="1000px"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/macOS/Chrome"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/os-x-ventura-chrome-desktop-default-offline.png"
alt="macOS Chrome offline"
width="100%"
/>
</Story>
<Story name="Online">
<img
src="screenshots/os-x-ventura-chrome-desktop-default-online.png"
alt="macOS Chrome online"
width="100%"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/macOS/Firefox"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/os-x-ventura-firefox-desktop-default-offline.png"
alt="macOS Chrome offline"
width="100%"
/>
</Story>
<Story name="Online">
<img
src="screenshots/os-x-ventura-firefox-desktop-default-online.png"
alt="macOS Chrome online"
width="100%"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/macOS/Safari"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/os-x-ventura-safari-desktop-default-offline.png"
alt="macOS Safari offline"
width="100%"
/>
</Story>
<Story name="Online">
<img
src="screenshots/os-x-ventura-safari-desktop-default-online.png"
alt="macOS Safari online"
width="100%"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/Windows/Chrome"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/windows-11-chrome-desktop-default-offline.png"
alt="Windows Chrome offline"
width="100%"
/>
</Story>
<Story name="Online">
<img
src="screenshots/windows-11-chrome-desktop-default-online.png"
alt="Windows Chrome online"
width="100%"
/>
</Story>

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta
title="owncast/Screenshots/Windows/Firefox"
parameters={{ chromatic: { disableSnapshot: true } }}
/>
<Story name="Offline">
<img
src="screenshots/windows-10-firefox-desktop-default-offline.png"
alt="Windows Firefox offline"
width="100%"
/>
</Story>
<Story name="Online">
<img
src="screenshots/windows-10-firefox-desktop-default-online.png"
alt="Windows Firefox online"
width="100%"
/>
</Story>

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,5 +1,5 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
<Meta title="owncast/Documentation/{{title}}" parameters=\{{chromatic: { disableSnapshot: true }}}/>
<Meta title="owncast/Documentation/{{title}}" parameters=\{{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}}/>
{{content}}

View File

@@ -1,25 +1,27 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { Canvas, Meta, Story, Description, IconGallery, IconItem } from '@storybook/addon-docs';
import { Image, ImageRow } from './ImageAsset';
<Meta title="owncast/Frontend Assets/Emoji" parameters=\{{chromatic: { disableSnapshot: true }}} />
<Meta title="owncast/Frontend Assets/Emoji" parameters=\{{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}} />
# Built-in Custom Emoji
{{#each emojiCollections}}
<Description
markdown={`
## {{capitalize this.name}}
<Story
name="{{capitalize this.name}}"
>
<a href="img/emoji/{{this.name}}/LICENSE.md" target="_blank">
LICENSE
</a>
<ImageRow images={[
{{#each this.images}}
{src: "{{this.src}}", name: "{{this.name}}"},
{{/each}}
]}/>
</Story>
<a href="img/emoji/{{this.name}}/LICENSE.md" target="_blank">
LICENSE
</a>
`} />
<IconGallery>
{{#each images}}
<IconItem name="{{this.name}}">
<img src="{{this.src}}" />
</IconItem>
{{/each}}
</IconGallery>
{{/each}}

View File

@@ -1,12 +1,13 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { Image, ImageRow } from './ImageAsset';
import { Canvas, Meta, Story, IconItem, IconGallery } from '@storybook/addon-docs';
<Meta title="{{category}}" parameters=\{{chromatic: { disableSnapshot: true }}} />
<Meta title="{{category}}" parameters=\{{previewTabs: { canvas: { hidden: true } }, chromatic: { disableSnapshot: true }}} />
# {{capitalize title}}
<ImageRow images={[
{{#each images}}
{src: "{{this.src}}", name: "{{this.name}}"},
{{/each}}
]}/>
<IconGallery>
{{#each images}}
<IconItem name="{{this.name}}">
<img src="{{this.src}}" />
</IconItem>
{{/each}}
</IconGallery>

View File

@@ -0,0 +1,12 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { Image, ImageRow } from './ImageAsset';
<Meta title="{{category}}" parameters=\{{chromatic: { disableSnapshot: true }}} />
# {{capitalize title}}
<ImageRow images={[
{{#each images}}
{src: "{{this.src}}", name: "{{this.name}}"},
{{/each}}
]}/>

View File

@@ -13,6 +13,7 @@ const dir = args[2];
const title = args[3];
const category = args[4];
const publicPath = args[5];
const useLarge = args[6];
if (args.length < 6) {
console.error('Usage: generate-image-story.mjs <dir> <title> <category> <webpublicpath>');
@@ -30,7 +31,8 @@ const images = readdirSync(dir)
})
.filter(Boolean);
const template = fs.readFileSync('./Images.stories.mdx', 'utf8');
const templateFile = useLarge ? './ImagesLarge.stories.mdx' : './Images.stories.mdx';
const template = fs.readFileSync(templateFile, 'utf8');
let t = handlebars.compile(template);
let output = t({ images, title, category });
console.log(output);

View File

@@ -13,5 +13,5 @@ node generate-document-stories.mjs
node generate-image-story.mjs ../../public/img/ Images "owncast/Frontend Assets/Images" "img" >../stories-category-doc-pages/Images.stories.mdx
node generate-image-story.mjs ../../public/img/platformlogos/ "Social Platform Images" "owncast/Frontend Assets/Social Platform Images" "img/platformlogos" >../stories-category-doc-pages/SocialPlatformImages.stories.mdx
node generate-image-story.mjs ../story-assets/project/ "Logos & Graphics" "owncast/Project Assets/Logos & Graphics" "project" >../stories-category-doc-pages/LogosAndGraphics.stories.mdx
node generate-image-story.mjs ../story-assets/tshirt/ "T-shirt" "owncast/Project Assets/T-Shirt" "tshirt" >../stories-category-doc-pages/Tshirt.stories.mdx
node generate-image-story.mjs ../story-assets/project/ "Logos & Graphics" "owncast/Project Assets/Logos & Graphics" "project" --large >../stories-category-doc-pages/LogosAndGraphics.stories.mdx
node generate-image-story.mjs ../story-assets/tshirt/ "T-shirt" "owncast/Project Assets/T-Shirt" "tshirt" --large >../stories-category-doc-pages/Tshirt.stories.mdx

View File

@@ -151,7 +151,9 @@ export const CurrentVariantsTable: FC = () => {
dataIndex: 'cpuUsageLevel',
key: 'cpuUsageLevel',
render: (level: string, variant: VideoVariant) =>
!level || variant.videoPassthrough ? 'n/a' : ENCODER_PRESET_TOOLTIPS[level].split(' ')[0],
!level || variant.videoPassthrough
? 'n/a'
: ENCODER_PRESET_TOOLTIPS[level]?.split(' ')[0] || 'Warning: please edit & reset',
},
{
title: '',

View File

@@ -68,7 +68,7 @@ export const EditCustomJavascript: FC = () => {
setContent(initialContent);
}, [instanceDetails]);
const onCSSValueChange = React.useCallback(value => {
const onValueChange = React.useCallback(value => {
setContent(value);
if (value !== initialContent && !hasChanged) {
setHasChanged(true);
@@ -80,20 +80,18 @@ export const EditCustomJavascript: FC = () => {
return (
<div className="edit-custom-css">
<Title level={3} className="section-title">
Customize your page styling with CSS
Customize your page with Javascript
</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{' '}
Insert custom Javascript into your Owncast page to add your own functionality or to add 3rd
party scripts. Read more about how to use this feature in the{' '}
<a href="https://owncast.online/docs/website/" rel="noopener noreferrer" target="_blank">
CSS &amp; Components guide
Web page documentation.
</a>
.
</p>
<p className="description">
Please input plain CSS text, as this will be directly injected onto your page during load.
</p>
<p className="description">Please use raw Javascript, no HTML or any script tags.</p>
<CodeMirror
value={content}
@@ -101,7 +99,7 @@ export const EditCustomJavascript: FC = () => {
theme={bbedit}
height="200px"
extensions={[javascript()]}
onChange={onCSSValueChange}
onChange={onValueChange}
/>
<br />

View File

@@ -1,6 +1,5 @@
import React, { useState, useContext, useEffect } from 'react';
import { Button, Collapse, Typography, Tooltip } from 'antd';
import dynamic from 'next/dynamic';
import { Collapse, Typography } from 'antd';
import { TEXTFIELD_TYPE_NUMBER, TEXTFIELD_TYPE_PASSWORD, TEXTFIELD_TYPE_URL } from './TextField';
import { TextFieldWithSubmit } from './TextFieldWithSubmit';
import { ServerStatusContext } from '../../utils/server-status-context';
@@ -15,16 +14,6 @@ import {
import { UpdateArgs } from '../../types/config-section';
import { ResetYP } from './ResetYP';
// Lazy loaded components
const CopyOutlined = dynamic(() => import('@ant-design/icons/CopyOutlined'), {
ssr: false,
});
const RedoOutlined = dynamic(() => import('@ant-design/icons/RedoOutlined'), {
ssr: false,
});
const { Panel } = Collapse;
// eslint-disable-next-line react/function-component-definition
@@ -38,10 +27,6 @@ export default function EditInstanceDetails() {
const { adminPassword, ffmpegPath, rtmpServerPort, webServerPort, yp, socketHostOverride } =
serverConfig;
const [copyIsVisible, setCopyVisible] = useState(false);
const COPY_TOOLTIP_TIMEOUT = 3000;
useEffect(() => {
setFormDataValues({
adminPassword,
@@ -79,22 +64,6 @@ export default function EditInstanceDetails() {
}
};
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">
@@ -108,18 +77,6 @@ export default function EditInstanceDetails() {
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

View File

@@ -1,5 +1,4 @@
import React, { FC, ReactNode, 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';
@@ -224,7 +223,7 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
key: 'viewer-info',
},
!chatDisabled && {
label: <Link href="/admin/viewer-info">Chat &amp; Users</Link>,
label: <span>Chat &amp; Users</span>,
icon: <MessageOutlined />,
children: chatMenu,
key: 'chat-and-users',
@@ -338,7 +337,3 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
</Layout>
);
};
MainLayout.propTypes = {
children: PropTypes.element.isRequired,
};

View File

@@ -44,7 +44,7 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
const serverStatusData = useContext(ServerStatusContext);
const { serverConfig } = serverStatusData || {};
const { rtmpServerPort } = serverConfig;
const { rtmpServerPort, streamKeyOverridden } = serverConfig;
const instanceUrl = global.window?.location.hostname || '';
let rtmpURL;
@@ -79,7 +79,13 @@ export const Offline: FC<OfflineProps> = ({ logs = [], config }) => {
Streaming Keys:
</Text>
<Text strong className="stream-info-box">
<Link href="/admin/config/server"> View </Link>
{!streamKeyOverridden ? (
<Link href="/admin/config/server"> View </Link>
) : (
<span style={{ paddingLeft: '10px', fontWeight: 'normal' }}>
Overridden via command line.
</span>
)}
</Text>
</div>
</div>

View File

@@ -159,8 +159,8 @@ export const VideoVariantForm: FC<VideoVariantFormProps> = ({
<Slider
tipFormatter={value => ENCODER_PRESET_TOOLTIPS[value]}
onChange={handleVideoCpuUsageLevelChange}
min={1}
max={Object.keys(ENCODER_PRESET_SLIDER_MARKS).length}
min={0}
max={Object.keys(ENCODER_PRESET_SLIDER_MARKS).length - 1}
marks={ENCODER_PRESET_SLIDER_MARKS}
defaultValue={dataState.cpuUsageLevel}
value={dataState.cpuUsageLevel}

View File

@@ -87,7 +87,7 @@ const AddKeyForm = ({ setShowAddKeyForm, setFieldInConfigState, streamKeys, setE
setHasChanged(false);
}
};
// Default auto-generated key
const defaultKey = generateRndKey();

View File

@@ -14,23 +14,6 @@
}
}
.nameChangeView {
display: flex;
font-size: 0.9rem;
border-radius: var(--theme-rounded-corners);
padding: 5px 15px;
color: var(--theme-color-components-text-on-light);
background-color: var(--color-owncast-background);
& .nameChangeText {
font-weight: bold;
font-family: var(--theme-text-display-font-family);
& .plain {
font-weight: normal;
font-family: var(--theme-text-body-font-family) !important;
}
}
}
.chatContainer {
display: flex;
flex-direction: column;

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
import { Virtuoso } from 'react-virtuoso';
import { useState, useMemo, useRef, CSSProperties, FC, useEffect } from 'react';
import dynamic from 'next/dynamic';
import {
ConnectedClientInfoEvent,
FediverseEvent,
MessageType,
NameChangeEvent,
} from '../../../interfaces/socket-events';
@@ -11,17 +11,13 @@ import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ChatUserMessage } from '../ChatUserMessage/ChatUserMessage';
import { ChatTextField } from '../ChatTextField/ChatTextField';
import { ChatModeratorNotification } from '../ChatModeratorNotification/ChatModeratorNotification';
// import ChatActionMessage from '../ChatAction/ChatActionMessage';
import { ChatSystemMessage } from '../ChatSystemMessage/ChatSystemMessage';
import { ChatJoinMessage } from '../ChatJoinMessage/ChatJoinMessage';
import { ScrollToBotBtn } from './ScrollToBotBtn';
import { ChatActionMessage } from '../ChatActionMessage/ChatActionMessage';
import { ChatSocialMessage } from '../ChatSocialMessage/ChatSocialMessage';
import { ChatNameChangeMessage } from '../ChatNameChangeMessage/ChatNameChangeMessage';
// Lazy loaded components
const EditFilled = dynamic(() => import('@ant-design/icons/EditFilled'), {
ssr: false,
});
export type ChatContainerProps = {
messages: ChatMessage[];
usernameToHighlight: string;
@@ -31,7 +27,11 @@ export type ChatContainerProps = {
height?: string;
};
function shouldCollapseMessages(messages: ChatMessage[], index: number): boolean {
function shouldCollapseMessages(
messages: ChatMessage[],
index: number,
collapsedMessageIds: Set<string>,
): boolean {
if (messages.length < 2) {
return false;
}
@@ -53,14 +53,24 @@ function shouldCollapseMessages(messages: ChatMessage[], index: number): boolean
return false;
}
const maxTimestampDelta = 1000 * 60 * 2; // 2 minutes
const maxTimestampDelta = 1000 * 60; // 1 minute
const lastTimestamp = new Date(lastMessage?.timestamp).getTime();
const thisTimestamp = new Date(message.timestamp).getTime();
if (thisTimestamp - lastTimestamp > maxTimestampDelta) {
return false;
}
return id === lastMessage?.user.id;
if (id !== lastMessage?.user.id) {
return false;
}
// Limit the number of messages that can be collapsed in a row.
const maxCollapsedMessageCount = 5;
if (collapsedMessageIds.size >= maxCollapsedMessageCount) {
return false;
}
return true;
}
function checkIsModerator(message: ChatMessage | ConnectedClientInfoEvent) {
@@ -83,28 +93,33 @@ export const ChatContainer: FC<ChatContainerProps> = ({
showInput,
height,
}) => {
const [atBottom, setAtBottom] = useState(false);
const [showScrollToBottomButton, setShowScrollToBottomButton] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(false);
const chatContainerRef = useRef(null);
const showScrollToBottomButtonDelay = useRef(null);
const scrollToBottomDelay = useRef(null);
const getNameChangeViewForMessage = (message: NameChangeEvent) => {
const { oldName, user } = message;
const { displayName, displayColor } = user;
const color = `var(--theme-color-users-${displayColor})`;
const collapsedMessageIds = new Set<string>();
return (
<div className={styles.nameChangeView}>
<div style={{ marginRight: 5, height: 'max-content', margin: 'auto 5px auto 0' }}>
<EditFilled />
</div>
<div className={styles.nameChangeText}>
<span style={{ color }}>{oldName}</span>
<span className={styles.plain}> is now known as </span>
<span style={{ color }}>{displayName}</span>
</div>
</div>
);
const setShowScrolltoBottomButtonWithDelay = (show: boolean) => {
showScrollToBottomButtonDelay.current = setTimeout(() => {
setShowScrollToBottomButton(show);
}, 1500);
};
useEffect(
() =>
// Clear the timer when the component unmounts
() => {
clearTimeout(showScrollToBottomButtonDelay.current);
clearTimeout(scrollToBottomDelay.current);
},
[],
);
const getFediverseMessage = (message: FediverseEvent) => <ChatSocialMessage message={message} />;
const getUserJoinedMessage = (message: ChatMessage) => {
const {
user: { displayName, displayColor },
@@ -123,6 +138,7 @@ export const ChatContainer: FC<ChatContainerProps> = ({
const { body } = message;
return <ChatActionMessage body={body} />;
};
const getConnectedInfoMessage = (message: ConnectedClientInfoEvent) => {
const modStatusUpdate = checkIsModerator(message);
if (!modStatusUpdate) {
@@ -136,28 +152,39 @@ export const ChatContainer: FC<ChatContainerProps> = ({
return <ChatModeratorNotification />;
};
const getUserChatMessageView = (index: number, message: ChatMessage) => {
const collapsed = shouldCollapseMessages(messages, index, collapsedMessageIds);
if (!collapsed) {
collapsedMessageIds.clear();
} else {
collapsedMessageIds.add(message.id);
}
return (
<ChatUserMessage
message={message}
showModeratorMenu={isModerator} // Moderators have access to an additional menu
highlightString={usernameToHighlight} // What to highlight in the message
sentBySelf={message.user?.id === chatUserId} // The local user sent this message
sameUserAsLast={collapsed}
isAuthorModerator={message.user?.scopes?.includes('MODERATOR')}
isAuthorBot={message.user?.scopes?.includes('BOT')}
isAuthorAuthenticated={message.user?.authenticated}
key={message.id}
/>
);
};
const getViewForMessage = (
index: number,
message: ChatMessage | NameChangeEvent | ConnectedClientInfoEvent,
message: ChatMessage | NameChangeEvent | ConnectedClientInfoEvent | FediverseEvent,
) => {
switch (message.type) {
case MessageType.CHAT:
return (
<ChatUserMessage
message={message as ChatMessage}
showModeratorMenu={isModerator} // Moderators have access to an additional menu
highlightString={usernameToHighlight} // What to highlight in the message
sentBySelf={message.user?.id === chatUserId} // The local user sent this message
sameUserAsLast={shouldCollapseMessages(messages, index)}
isAuthorModerator={(message as ChatMessage).user.scopes?.includes('MODERATOR')}
isAuthorAuthenticated={message.user?.authenticated}
key={message.id}
/>
);
return getUserChatMessageView(index, message as ChatMessage);
case MessageType.NAME_CHANGE:
return getNameChangeViewForMessage(message as NameChangeEvent);
return <ChatNameChangeMessage message={message as NameChangeEvent} />;
case MessageType.CONNECTED_USER_INFO:
return getConnectedInfoMessage(message);
return getConnectedInfoMessage(message as ConnectedClientInfoEvent);
case MessageType.USER_JOINED:
return getUserJoinedMessage(message as ChatMessage);
case MessageType.CHAT_ACTION:
@@ -170,22 +197,29 @@ export const ChatContainer: FC<ChatContainerProps> = ({
key={message.id}
/>
);
case MessageType.FEDIVERSE_ENGAGEMENT_FOLLOW:
return getFediverseMessage(message as FediverseEvent);
case MessageType.FEDIVERSE_ENGAGEMENT_LIKE:
return getFediverseMessage(message as FediverseEvent);
case MessageType.FEDIVERSE_ENGAGEMENT_REPOST:
return getFediverseMessage(message as FediverseEvent);
default:
return null;
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const scrollChatToBottom = (ref, behavior = 'smooth') => {
setTimeout(() => {
clearTimeout(scrollToBottomDelay.current);
clearTimeout(showScrollToBottomButtonDelay.current);
scrollToBottomDelay.current = setTimeout(() => {
ref.current?.scrollToIndex({
index: messages.length - 1,
behavior,
});
setIsAtBottom(true);
setShowScrollToBottomButton(false);
}, 100);
setAtBottom(true);
};
// This is a hack to force a scroll to the very bottom of the chat messages
@@ -194,6 +228,7 @@ export const ChatContainer: FC<ChatContainerProps> = ({
useEffect(() => {
setTimeout(() => {
scrollChatToBottom(chatContainerRef, 'auto');
setShowScrolltoBottomButtonWithDelay(false);
}, 500);
}, []);
@@ -207,22 +242,41 @@ export const ChatContainer: FC<ChatContainerProps> = ({
ref={chatContainerRef}
data={messages}
itemContent={(index, message) => getViewForMessage(index, message)}
followOutput={(isAtBottom: boolean) => {
initialTopMostItemIndex={messages.length - 1}
followOutput={() => {
clearTimeout(showScrollToBottomButtonDelay.current);
if (isAtBottom) {
scrollChatToBottom(chatContainerRef, 'smooth');
setShowScrollToBottomButton(false);
scrollChatToBottom(chatContainerRef, 'auto');
return 'smooth';
}
setShowScrolltoBottomButtonWithDelay(true);
return false;
}}
alignToBottom
atBottomThreshold={70}
atBottomStateChange={bottom => {
setAtBottom(bottom);
setIsAtBottom(bottom);
if (bottom) {
setShowScrollToBottomButton(false);
} else {
setShowScrolltoBottomButtonWithDelay(true);
}
}}
/>
{!atBottom && <ScrollToBotBtn chatContainerRef={chatContainerRef} messages={messages} />}
{showScrollToBottomButton && (
<ScrollToBotBtn
onClick={() => {
scrollChatToBottom(chatContainerRef, 'auto');
}}
/>
)}
</>
),
[messages, usernameToHighlight, chatUserId, isModerator, atBottom],
[messages, usernameToHighlight, chatUserId, isModerator, showScrollToBottomButton, isAtBottom],
);
return (

View File

@@ -1,7 +1,6 @@
import { Button } from 'antd';
import dynamic from 'next/dynamic';
import { FC, MutableRefObject } from 'react';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { FC } from 'react';
import styles from './ChatContainer.module.scss';
// Lazy loaded components
@@ -12,23 +11,18 @@ const VerticalAlignBottomOutlined = dynamic(
ssr: false,
},
);
type Props = {
chatContainerRef: MutableRefObject<any>;
messages: ChatMessage[];
onClick: () => void;
};
export const ScrollToBotBtn: FC<Props> = ({ chatContainerRef, messages }) => (
export const ScrollToBotBtn: FC<Props> = ({ onClick }) => (
<div className={styles.toBottomWrap}>
<Button
type="default"
style={{ color: 'currentColor' }}
icon={<VerticalAlignBottomOutlined />}
onClick={() =>
chatContainerRef.current.scrollToIndex({
index: messages.length - 1,
behavior: 'auto',
})
}
onClick={onClick}
>
Go to last message
</Button>

View File

@@ -0,0 +1,15 @@
.nameChangeView {
display: flex;
font-size: 0.9rem;
border-radius: var(--theme-rounded-corners);
padding: 5px 15px;
color: var(--theme-color-components-chat-text);
& .nameChangeText {
font-weight: bold;
font-family: var(--theme-text-display-font-family);
& .plain {
font-weight: normal;
font-family: var(--theme-text-body-font-family) !important;
}
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ChatNameChangeMessage } from './ChatNameChangeMessage';
export default {
title: 'owncast/Chat/Messages/Chat name change',
component: ChatNameChangeMessage,
} as ComponentMeta<typeof ChatNameChangeMessage>;
const Template: ComponentStory<typeof ChatNameChangeMessage> = args => (
<ChatNameChangeMessage {...args} />
);
export const Basic = Template.bind({});
Basic.args = {
message: {
oldName: 'JohnnyOldName',
user: {
displayName: 'JohnnyNewName',
displayColor: '3',
},
},
};

View File

@@ -0,0 +1,35 @@
// export const ChatSocialMessage: FC<ChatSocialMessageProps> = ({ message }) => {
import dynamic from 'next/dynamic';
import { FC } from 'react';
import { NameChangeEvent } from '../../../interfaces/socket-events';
import styles from './ChatNameChangeMessage.module.scss';
export interface ChatNameChangeMessageProps {
message: NameChangeEvent;
}
// Lazy loaded components
const EditFilled = dynamic(() => import('@ant-design/icons/EditFilled'), {
ssr: false,
});
export const ChatNameChangeMessage: FC<ChatNameChangeMessageProps> = ({ message }) => {
const { oldName, user } = message;
const { displayName, displayColor } = user;
const color = `var(--theme-color-users-${displayColor})`;
return (
<div className={styles.nameChangeView}>
<div style={{ marginRight: 5, height: 'max-content', margin: 'auto 5px auto 0' }}>
<EditFilled />
</div>
<div className={styles.nameChangeText}>
<span style={{ color }}>{oldName}</span>
<span className={styles.plain}> is now known as </span>
<span style={{ color }}>{displayName}</span>
</div>
</div>
);
};

View File

@@ -4,9 +4,7 @@
border-style: solid;
padding: 10px 10px;
border-radius: 15px;
height: 85px;
width: 300px;
overflow: hidden;
background-color: var(--theme-color-background-main);
&:hover {
border-color: var(--theme-text-link);
@@ -18,11 +16,22 @@
border-color: rgba(0, 0, 0, 0.3);
border-width: 1px;
border-style: solid;
font-size: 1.8rem;
}
.avatarColumn {
max-width: 75px;
min-width: 75px;
}
.body {
color: var(--theme-color-components-text-on-light);
text-overflow: ellipsis;
line-height: 1.2rem;
p {
margin: 0;
}
}
.account {
@@ -32,21 +41,16 @@
}
.icon {
position: relative;
position: absolute;
width: 25px;
height: 25px;
top: -20px;
top: 40px;
left: 40px;
border-color: white;
border-width: 1px;
border-color: var(--theme-color-background-main);
border-width: 2px;
border-style: solid;
border-radius: 50%;
background-size: cover;
background-position: center;
}
.placeholder {
width: 100%;
height: 100%;
}
}

View File

@@ -13,8 +13,8 @@ const Template: ComponentStory<typeof ChatSocialMessage> = args => <ChatSocialMe
export const Follow = Template.bind({});
Follow.args = {
message: {
type: 'follow',
body: 'james followed this live stream.',
type: 'FEDIVERSE_ENGAGEMENT_FOLLOW',
body: '<p>james followed this live stream.</p>',
title: 'james@mastodon.social',
image: 'https://mastodon.social/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
@@ -24,8 +24,8 @@ Follow.args = {
export const Like = Template.bind({});
Like.args = {
message: {
type: 'like',
body: 'james liked that this stream went live.',
type: 'FEDIVERSE_ENGAGEMENT_LIKE',
body: '<p>james liked that this stream went live.</p>',
title: 'james@mastodon.social',
image: 'https://mastodon.social/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
@@ -35,10 +35,32 @@ Like.args = {
export const Repost = Template.bind({});
Repost.args = {
message: {
type: 'repost',
body: 'james shared this stream with their followers.',
type: 'FEDIVERSE_ENGAGEMENT_REPOST',
body: '<p>james shared this stream with their followers.</p>',
title: 'james@mastodon.social',
image: 'https://mastodon.social/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
},
};
export const LongAccountName = Template.bind({});
LongAccountName.args = {
message: {
type: 'FEDIVERSE_ENGAGEMENT_REPOST',
body: '<p>james shared this stream with their followers.</p>',
title: 'littlejimmywilliams@technology.biz.net.org.technology.gov',
image: 'https://mastodon.social/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
},
};
export const InvalidAvatarImage = Template.bind({});
InvalidAvatarImage.args = {
message: {
type: 'FEDIVERSE_ENGAGEMENT_REPOST',
body: '<p>james shared this stream with their followers.</p>',
title: 'james@mastodon.social',
image: 'https://xx.xx/avatars/original/missing.png',
link: 'https://mastodon.social/@james',
},
};

View File

@@ -25,13 +25,13 @@ export const ChatSocialMessage: FC<ChatSocialMessageProps> = ({ message }) => {
let Icon;
switch (type.toString()) {
case 'follow':
case 'FEDIVERSE_ENGAGEMENT_FOLLOW':
Icon = FollowIcon;
break;
case 'like':
case 'FEDIVERSE_ENGAGEMENT_LIKE':
Icon = LikeIcon;
break;
case 'repost':
case 'FEDIVERSE_ENGAGEMENT_REPOST':
Icon = RepostIcon;
break;
default:
@@ -42,15 +42,16 @@ export const ChatSocialMessage: FC<ChatSocialMessageProps> = ({ message }) => {
<div className={cn([styles.follower, 'chat-message_social'])}>
<a href={link} target="_blank" rel="noreferrer">
<Row wrap={false}>
<Col span={6}>
<Avatar src={image} alt="Avatar" className={styles.avatar}>
<img src="/logo" alt="Logo" className={styles.placeholder} />
<Col span={6} className={styles.avatarColumn}>
<Avatar src={image} alt="Avatar" className={styles.avatar} size="large">
{title.charAt(0).toUpperCase()}
</Avatar>
<Icon className={styles.icon} />
</Col>
<Col>
<Row className={styles.account}>{title}</Row>
<Row className={styles.body}>{body}</Row>
<Row className={styles.body} dangerouslySetInnerHTML={{ __html: body }} />
</Col>
</Row>
</a>

View File

@@ -30,9 +30,11 @@
}
mark {
padding-left: 0.35em;
padding-right: 0.35em;
background-color: var(--theme-color-palette-12);
padding-left: 0.3em;
padding-right: 0.3em;
color: var(--theme-color-palette-4);
border-radius: var(--theme-rounded-corners);
background-color: var(--color-owncast-palette-7);
}
}

View File

@@ -1,9 +1,10 @@
/* eslint-disable react/no-danger */
import { Highlight } from 'react-highlighter-ts';
import { FC } from 'react';
import cn from 'classnames';
import { Interweave } from 'interweave';
import { UrlMatcher } from 'interweave-autolink';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import styles from './ChatSystemMessage.module.scss';
import { ChatMessageHighlightMatcher } from '../ChatUserMessage/customMatcher';
export type ChatSystemMessageProps = {
message: ChatMessage;
@@ -21,8 +22,13 @@ export const ChatSystemMessage: FC<ChatSystemMessageProps> = ({
<div className={styles.user}>
<span className={styles.userName}>{displayName}</span>
</div>
<Highlight search={highlightString}>
<div className={styles.message} dangerouslySetInnerHTML={{ __html: body }} />
</Highlight>
<Interweave
className={styles.message}
content={body}
matchers={[
new UrlMatcher('url', { validateTLD: false }),
new ChatMessageHighlightMatcher('highlight', { highlightString }),
]}
/>
</div>
);

View File

@@ -33,7 +33,7 @@ export default {
component: ChatTextField,
parameters: {
fetchMock: mocks,
chromatic: { diffThreshold: 0.8 },
chromatic: { diffThreshold: 0.88 },
design: {
type: 'image',

View File

@@ -1,7 +1,7 @@
import { Popover } from 'antd';
import React, { FC, useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Transforms, createEditor, BaseEditor, Text, Descendant, Editor, Node, Path } from 'slate';
import { Transforms, createEditor, BaseEditor, Text, Descendant, Editor } from 'slate';
import { Slate, Editable, withReact, ReactEditor, useSelected, useFocused } from 'slate-react';
import dynamic from 'next/dynamic';
import classNames from 'classnames';
@@ -169,31 +169,10 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText }) => {
const insertImage = (url, name) => {
if (!url) return;
const { selection } = editor;
const image = createImageNode(name, url, name);
Transforms.insertNodes(editor, image, { select: true });
if (selection) {
const [parentNode, parentPath] = Editor.parent(editor, selection.focus?.path);
if (editor.isVoid(parentNode) || Node.string(parentNode).length) {
// Insert the new image node after the void node or a node with content
Transforms.insertNodes(editor, image, {
at: Path.next(parentPath),
select: true,
});
} else {
// If the node is empty, replace it instead
// Transforms.removeNodes(editor, { at: parentPath });
Transforms.insertNodes(editor, image, { at: parentPath, select: true });
Editor.normalize(editor, { force: true });
}
} else {
// Insert the new image node at the bottom of the Editor when selection
// is falsey
Transforms.insertNodes(editor, image, { select: true });
}
Transforms.insertNodes(editor, image);
Editor.normalize(editor, { force: true });
};
// Native emoji

View File

@@ -0,0 +1,17 @@
import dynamic from 'next/dynamic';
import React, { FC } from 'react';
import { ChatUserBadge } from './ChatUserBadge';
// Lazy loaded components
const BulbFilled = dynamic(() => import('@ant-design/icons/BulbFilled'), {
ssr: false,
});
export type BotBadgeProps = {
userColor: number;
};
export const BotUserBadge: FC<BotBadgeProps> = ({ userColor }) => (
<ChatUserBadge badge={<BulbFilled />} userColor={userColor} title="Bot" />
);

View File

@@ -3,6 +3,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ChatUserBadge } from './ChatUserBadge';
import { ModerationBadge } from './ModerationBadge';
import { AuthedUserBadge } from './AuthedUserBadge';
import { BotUserBadge } from './BotUserBadge';
export default {
title: 'owncast/Chat/Messages/User Flag',
@@ -24,6 +25,8 @@ const AuthedTemplate: ComponentStory<typeof ModerationBadge> = args => (
<AuthedUserBadge {...args} />
);
const BotTemplate: ComponentStory<typeof BotUserBadge> = args => <BotUserBadge {...args} />;
export const Authenticated = AuthedTemplate.bind({});
Authenticated.args = {
userColor: '3',
@@ -34,6 +37,11 @@ Moderator.args = {
userColor: '5',
};
export const Bot = BotTemplate.bind({});
Bot.args = {
userColor: '7',
};
export const Generic = Template.bind({});
Generic.args = {
badge: '?',

View File

@@ -56,7 +56,7 @@ const messageWithLinkAndCustomEmoji: ChatMessage = JSON.parse(`{
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"scopes": []
},
"body": "Test message with a link https://owncast.online and a custom emoji <img src='/img/emoji/blob/ablobattention.gif' width='30px'/> ."}`);
"body": "Test message with a link https://owncast.online and a custom emoji <img src='/img/emoji/mutant/skull.svg' width='30px'/> ."}`);
const moderatorMessage: ChatMessage = JSON.parse(`{
"type": "CHAT",
@@ -89,6 +89,22 @@ const authenticatedUserMessage: ChatMessage = JSON.parse(`{
},
"body": "I am an authenticated user."}`);
const botUserMessage: ChatMessage = JSON.parse(`{
"type": "CHAT",
"id": "wY-MEXwnR",
"timestamp": "2022-04-28T20:30:27.001762726Z",
"user": {
"id": "h_5GQ6E7R",
"displayName": "EliteMooseTaskForce",
"displayColor": 7,
"createdAt": "2022-03-24T03:52:37.966584694Z",
"previousNames": ["gifted-nobel", "EliteMooseTaskForce"],
"nameChangedAt": "2022-04-26T23:56:05.531287897Z",
"authenticated": true,
"scopes": ["bot"]
},
"body": "I am a bot."}`);
export const WithoutModeratorMenu = Template.bind({});
WithoutModeratorMenu.args = {
message: standardMessage,
@@ -121,6 +137,13 @@ FromAuthenticatedUser.args = {
isAuthorAuthenticated: true,
};
export const FromBotUser = Template.bind({});
FromBotUser.args = {
message: botUserMessage,
showModeratorMenu: false,
isAuthorBot: true,
};
export const WithStringHighlighted = Template.bind({});
WithStringHighlighted.args = {
message: standardMessage,

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/no-danger */
import { FC, ReactNode } from 'react';
import cn from 'classnames';
import { Tooltip } from 'antd';
@@ -14,6 +13,7 @@ import { accessTokenAtom } from '../../stores/ClientConfigStore';
import { User } from '../../../interfaces/user.model';
import { AuthedUserBadge } from '../ChatUserBadge/AuthedUserBadge';
import { ModerationBadge } from '../ChatUserBadge/ModerationBadge';
import { BotUserBadge } from '../ChatUserBadge/BotUserBadge';
// Lazy loaded components
@@ -35,6 +35,7 @@ export type ChatUserMessageProps = {
sameUserAsLast: boolean;
isAuthorModerator: boolean;
isAuthorAuthenticated: boolean;
isAuthorBot: boolean;
};
export type UserTooltipProps = {
@@ -61,6 +62,7 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
sameUserAsLast,
isAuthorModerator,
isAuthorAuthenticated,
isAuthorBot,
}) => {
const { id: messageId, body, user, timestamp } = message;
const { id: userId, displayName, displayColor } = user;
@@ -76,7 +78,9 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
if (isAuthorAuthenticated) {
badgeNodes.push(<AuthedUserBadge key="auth" userColor={displayColor} />);
}
if (isAuthorBot) {
badgeNodes.push(<BotUserBadge key="bot" userColor={displayColor} />);
}
return (
<div
className={cn(

View File

@@ -40,6 +40,7 @@
font-size: 1.7rem;
font-weight: bold;
line-height: 30px;
margin: unset;
}
.subtitle {
@@ -48,6 +49,7 @@
line-height: 1.3;
color: var(--theme-color-background-header);
max-width: 900px;
margin-top: 7px;
}
}

View File

@@ -8,21 +8,13 @@ import styles from './ContentHeader.module.scss';
export type ContentHeaderProps = {
name: string;
title: string;
summary: string;
tags: string[];
links: SocialLink[];
logo: string;
};
export const ContentHeader: FC<ContentHeaderProps> = ({
name,
title,
summary,
logo,
tags,
links,
}) => (
export const ContentHeader: FC<ContentHeaderProps> = ({ name, summary, logo, tags, links }) => (
<div className={styles.root}>
<div className={styles.logoTitleSection}>
<div className={styles.logo}>
@@ -31,7 +23,7 @@ export const ContentHeader: FC<ContentHeaderProps> = ({
<div className={styles.titleSection}>
<h2 className={cn(styles.title, styles.row, 'header-title')}>{name}</h2>
<h3 className={cn(styles.subtitle, styles.row, 'header-subtitle')}>
<Linkify>{title || summary}</Linkify>
<Linkify>{summary}</Linkify>
</h3>
<div className={cn(styles.tagList, styles.row)}>
{tags.length > 0 && tags.map(tag => <span key={tag}>#{tag}&nbsp;</span>)}

View File

@@ -1,4 +0,0 @@
/* eslint-disable react/no-danger */
export const HtmlComment = ({ text }) => (
<span style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `\n\n<!-- ${text} -->` }} />
);

View File

@@ -0,0 +1,158 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { MutableSnapshot, RecoilRoot } from 'recoil';
import { makeEmptyClientConfig } from '../../../interfaces/client-config.model';
import { ServerStatus, makeEmptyServerStatus } from '../../../interfaces/server-status.model';
import {
accessTokenAtom,
appStateAtom,
chatMessagesAtom,
chatVisibleToggleAtom,
clientConfigStateAtom,
currentUserAtom,
fatalErrorStateAtom,
isMobileAtom,
isVideoPlayingAtom,
serverStatusState,
} from '../../stores/ClientConfigStore';
import { Main } from './Main';
import { ClientConfigServiceContext } from '../../../services/client-config-service';
import { ChatServiceContext } from '../../../services/chat-service';
import {
ServerStatusServiceContext,
ServerStatusStaticService,
} from '../../../services/status-service';
import { clientConfigServiceMockOf } from '../../../services/client-config-service.mock';
import chatServiceMockOf from '../../../services/chat-service.mock';
import serverStatusServiceMockOf from '../../../services/status-service.mock';
import { VideoSettingsServiceContext } from '../../../services/video-settings-service';
import videoSettingsServiceMockOf from '../../../services/video-settings-service.mock';
import { grootUser, spidermanUser } from '../../../interfaces/user.fixture';
import { exampleChatHistory } from '../../../interfaces/chat-message.fixture';
export default {
title: 'owncast/Layout/Main',
parameters: {
layout: 'fullscreen',
},
} satisfies ComponentMeta<typeof Main>;
type StateInitializer = (mutableState: MutableSnapshot) => void;
const composeStateInitializers =
(...fns: Array<StateInitializer>): StateInitializer =>
state =>
fns.forEach(fn => fn?.(state));
const defaultClientConfig = {
...makeEmptyClientConfig(),
logo: 'http://localhost:8080/logo',
name: "Spiderman's super serious stream",
summary: 'Strong Spidey stops supervillains! Streamed Saturdays & Sundays.',
extraPageContent: 'Spiderman is **cool**',
};
const defaultServerStatus = makeEmptyServerStatus();
const onlineServerStatus: ServerStatus = {
...defaultServerStatus,
online: true,
viewerCount: 5,
};
const initializeDefaultState = (mutableState: MutableSnapshot) => {
mutableState.set(appStateAtom, {
videoAvailable: false,
chatAvailable: false,
});
mutableState.set(clientConfigStateAtom, defaultClientConfig);
mutableState.set(chatVisibleToggleAtom, true);
mutableState.set(accessTokenAtom, 'token');
mutableState.set(currentUserAtom, {
...spidermanUser,
isModerator: false,
});
mutableState.set(serverStatusState, defaultServerStatus);
mutableState.set(isMobileAtom, false);
mutableState.set(chatMessagesAtom, exampleChatHistory);
mutableState.set(isVideoPlayingAtom, false);
mutableState.set(fatalErrorStateAtom, null);
};
const ClientConfigServiceMock = clientConfigServiceMockOf(defaultClientConfig);
const ChatServiceMock = chatServiceMockOf(exampleChatHistory, {
...grootUser,
accessToken: 'some fake token',
});
const DefaultServerStatusServiceMock = serverStatusServiceMockOf(defaultServerStatus);
const OnlineServerStatusServiceMock = serverStatusServiceMockOf(onlineServerStatus);
const VideoSettingsServiceMock = videoSettingsServiceMockOf([]);
const Template: ComponentStory<typeof Main> = ({
initializeState,
ServerStatusServiceMock = DefaultServerStatusServiceMock,
...args
}: {
initializeState: (mutableState: MutableSnapshot) => void;
ServerStatusServiceMock: ServerStatusStaticService;
}) => (
<RecoilRoot initializeState={composeStateInitializers(initializeDefaultState, initializeState)}>
<ClientConfigServiceContext.Provider value={ClientConfigServiceMock}>
<ChatServiceContext.Provider value={ChatServiceMock}>
<ServerStatusServiceContext.Provider value={ServerStatusServiceMock}>
<VideoSettingsServiceContext.Provider value={VideoSettingsServiceMock}>
<Main {...args} />
</VideoSettingsServiceContext.Provider>
</ServerStatusServiceContext.Provider>
</ChatServiceContext.Provider>
</ClientConfigServiceContext.Provider>
</RecoilRoot>
);
export const OfflineDesktop: typeof Template = Template.bind({});
export const OfflineMobile: typeof Template = Template.bind({});
OfflineMobile.args = {
initializeState: (mutableState: MutableSnapshot) => {
mutableState.set(isMobileAtom, true);
},
};
OfflineMobile.parameters = {
viewport: {
defaultViewport: 'mobile1',
},
};
export const OfflineTablet: typeof Template = Template.bind({});
OfflineTablet.parameters = {
viewport: {
defaultViewport: 'tablet',
},
};
export const Online: typeof Template = Template.bind({});
Online.args = {
ServerStatusServiceMock: OnlineServerStatusServiceMock,
};
export const OnlineMobile: typeof Template = Online.bind({});
OnlineMobile.args = {
ServerStatusServiceMock: OnlineServerStatusServiceMock,
initializeState: (mutableState: MutableSnapshot) => {
mutableState.set(isMobileAtom, true);
},
};
OnlineMobile.parameters = {
viewport: {
defaultViewport: 'mobile1',
},
};
export const OnlineTablet: typeof Template = Online.bind({});
OnlineTablet.args = {
ServerStatusServiceMock: OnlineServerStatusServiceMock,
};
OnlineTablet.parameters = {
viewport: {
defaultViewport: 'tablet',
},
};

View File

@@ -12,6 +12,7 @@ import {
clientConfigStateAtom,
fatalErrorStateAtom,
appStateAtom,
serverStatusState,
} from '../../stores/ClientConfigStore';
import { Content } from '../../ui/Content/Content';
import { Header } from '../../ui/Header/Header';
@@ -25,6 +26,7 @@ import styles from './Main.module.scss';
import { PushNotificationServiceWorker } from '../../workers/PushNotificationServiceWorker/PushNotificationServiceWorker';
import { AppStateOptions } from '../../stores/application-state';
import { Noscript } from '../../ui/Noscript/Noscript';
import { ServerStatus } from '../../../interfaces/server-status.model';
const lockBodyStyle = `
body {
@@ -46,7 +48,8 @@ const FatalErrorStateModal = dynamic(
export const Main: FC = () => {
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const { name, title, customStyles } = clientConfig;
const clientStatus = useRecoilValue<ServerStatus>(serverStatusState);
const { name, customStyles } = clientConfig;
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
const fatalError = useRecoilValue<DisplayableError>(fatalErrorStateAtom);
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
@@ -54,12 +57,14 @@ export const Main: FC = () => {
const layoutRef = useRef<HTMLDivElement>(null);
const { chatDisabled } = clientConfig;
const { videoAvailable } = appState;
const { online, streamTitle } = clientStatus;
useEffect(() => {
setupNoLinkReferrer(layoutRef.current);
}, []);
const isProduction = process.env.NODE_ENV === 'production';
const headerText = online ? streamTitle || name : name;
return (
<>
@@ -143,7 +148,7 @@ export const Main: FC = () => {
<Layout ref={layoutRef} className={styles.layout}>
<Header
name={title || name}
name={headerText}
chatAvailable={isChatAvailable}
chatDisabled={chatDisabled}
online={videoAvailable}

View File

@@ -1,9 +1,9 @@
import { FC, useEffect, useState } from 'react';
import { FC, useContext, useEffect, useState } from 'react';
import { atom, selector, useRecoilState, useSetRecoilState, RecoilEnv } from 'recoil';
import { useMachine } from '@xstate/react';
import { makeEmptyClientConfig, ClientConfig } from '../../interfaces/client-config.model';
import ClientConfigService from '../../services/client-config-service';
import ChatService from '../../services/chat-service';
import { ClientConfigServiceContext } from '../../services/client-config-service';
import { ChatServiceContext } from '../../services/chat-service';
import WebsocketService from '../../services/websocket-service';
import { ChatMessage } from '../../interfaces/chat-message.model';
import { CurrentUser } from '../../interfaces/current-user';
@@ -20,10 +20,11 @@ import {
ChatEvent,
MessageVisibilityEvent,
SocketEvent,
FediverseEvent,
} from '../../interfaces/socket-events';
import { mergeMeta } from '../../utils/helpers';
import handleConnectedClientInfoMessage from './eventhandlers/connected-client-info-handler';
import ServerStatusService from '../../services/status-service';
import { ServerStatusServiceContext } from '../../services/status-service';
import handleNameChangeEvent from './eventhandlers/handleNameChangeEvent';
import { DisplayableError } from '../../types/displayable-error';
@@ -154,13 +155,17 @@ export const visibleChatMessagesSelector = selector<ChatMessage[]>({
});
export const ClientConfigStore: FC = () => {
const ClientConfigService = useContext(ClientConfigServiceContext);
const ChatService = useContext(ChatServiceContext);
const ServerStatusService = useContext(ServerStatusServiceContext);
const [appState, appStateSend, appStateService] = useMachine(appStateModel);
const [currentUser, setCurrentUser] = useRecoilState(currentUserAtom);
const setChatAuthenticated = useSetRecoilState<boolean>(chatAuthenticatedAtom);
const [clientConfig, setClientConfig] = useRecoilState<ClientConfig>(clientConfigStateAtom);
const [, setServerStatus] = useRecoilState<ServerStatus>(serverStatusState);
const setClockSkew = useSetRecoilState<Number>(clockSkewAtom);
const [chatMessages, setChatMessages] = useRecoilState<ChatMessage[]>(chatMessagesAtom);
const [chatMessages, setChatMessages] = useRecoilState<SocketEvent[]>(chatMessagesAtom);
const [accessToken, setAccessToken] = useRecoilState<string>(accessTokenAtom);
const setAppState = useSetRecoilState<AppStateOptions>(appStateAtom);
const setGlobalFatalErrorMessage = useSetRecoilState<DisplayableError>(fatalErrorStateAtom);
@@ -208,7 +213,7 @@ export const ClientConfigStore: FC = () => {
setHasLoadedConfig(true);
} catch (error) {
setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
console.error(`ClientConfigService -> getConfig() ERROR: \n${error}`);
console.error(`ClientConfigService -> getConfig() ERROR: \n`, error);
}
};
@@ -227,7 +232,7 @@ export const ClientConfigStore: FC = () => {
} catch (error) {
sendEvent([AppStateEvent.Fail]);
setGlobalFatalError('Unable to reach Owncast server', serverConnectivityError);
console.error(`serverStatusState -> getStatus() ERROR: \n${error}`);
console.error(`serverStatusState -> getStatus() ERROR: \n`, error);
}
};
@@ -307,6 +312,15 @@ export const ClientConfigStore: FC = () => {
case MessageType.CHAT_ACTION:
setChatMessages(currentState => [...currentState, message as ChatEvent]);
break;
case MessageType.FEDIVERSE_ENGAGEMENT_FOLLOW:
setChatMessages(currentState => [...currentState, message as FediverseEvent]);
break;
case MessageType.FEDIVERSE_ENGAGEMENT_LIKE:
setChatMessages(currentState => [...currentState, message as FediverseEvent]);
break;
case MessageType.FEDIVERSE_ENGAGEMENT_REPOST:
setChatMessages(currentState => [...currentState, message as FediverseEvent]);
break;
case MessageType.VISIBILITY_UPDATE:
handleMessageVisibilityChange(message as MessageVisibilityEvent);
break;

View File

@@ -3,19 +3,46 @@
.root {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 100%;
width: 100%;
background-color: var(--theme-color-background-main);
height: 100%;
min-height: 0;
@include screen(desktop) {
height: var(--content-height);
}
.mainSection {
display: flex;
flex-direction: column;
display: grid;
grid-template-rows: min-content // Skeleton when app is loading
minmax(30px, min-content) // player
min-content // status bar when live
min-content // mid section
minmax(250px, 1fr) // mobile content
;
grid-template-columns: 100%;
&.offline {
grid-template-rows: min-content // Skeleton when app is loading
min-content // offline banner
min-content // status bar when live
min-content // mid section
minmax(250px, 1fr) // mobile content
;
}
@include screen(tablet) {
grid-template-columns: 100vw;
}
@include screen(desktop) {
overflow-y: scroll;
grid-template-rows: unset;
&.offline {
grid-template-rows: unset;
}
}
}
@@ -27,10 +54,6 @@
display: none;
}
.topSection {
padding: 0;
background-color: var(--theme-color-components-video-background);
}
.lowerSection {
padding: 0em 2%;
margin-bottom: 2em;
@@ -38,12 +61,21 @@
.lowerSectionMobile {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 0;
padding: 0.3em;
}
}
.topSectionElement {
background-color: var(--theme-color-components-video-background);
}
.statusBar {
flex-shrink: 0;
}
.leftCol {
display: flex;
flex-direction: column;
@@ -53,13 +85,6 @@
display: grid;
}
.main {
display: grid;
flex: 1;
height: 100%;
grid-template-rows: 1fr auto;
}
.replacementBar {
display: flex;
justify-content: space-between;
@@ -101,3 +126,10 @@
}
}
}
.bottomPageContentContainer {
background-color: var(--theme-color-components-content-background);
padding: calc(2 * var(--content-padding));
border-radius: var(--theme-rounded-corners);
width: 100%;
}

View File

@@ -2,6 +2,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { Skeleton } from 'antd';
import { FC, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import classnames from 'classnames';
import { LOCAL_STORAGE_KEYS, getLocalStorage, setLocalStorage } from '../../../utils/localStorage';
import isPushNotificationSupported from '../../../utils/browserPushNotifications';
@@ -17,7 +18,6 @@ import {
serverStatusState,
} from '../../stores/ClientConfigStore';
import { ClientConfig } from '../../../interfaces/client-config.model';
import { CustomPageContent } from '../CustomPageContent/CustomPageContent';
import styles from './Content.module.scss';
import { Sidebar } from '../Sidebar/Sidebar';
@@ -29,30 +29,21 @@ import { OfflineBanner } from '../OfflineBanner/OfflineBanner';
import { AppStateOptions } from '../../stores/application-state';
import { FollowButton } from '../../action-buttons/FollowButton';
import { NotifyButton } from '../../action-buttons/NotifyButton';
import { ContentHeader } from '../../common/ContentHeader/ContentHeader';
import { ServerStatus } from '../../../interfaces/server-status.model';
import { Statusbar } from '../Statusbar/Statusbar';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { ExternalAction } from '../../../interfaces/external-action';
import { Modal } from '../Modal/Modal';
import { ActionButtonMenu } from '../../action-buttons/ActionButtonMenu/ActionButtonMenu';
import { DesktopContent } from './DesktopContent';
import { MobileContent } from './MobileContent';
// Lazy loaded components
const FollowerCollection = dynamic(
() =>
import('../followers/FollowerCollection/FollowerCollection').then(
mod => mod.FollowerCollection,
),
{
ssr: false,
},
);
const FollowModal = dynamic(
() => import('../../modals/FollowModal/FollowModal').then(mod => mod.FollowModal),
{
ssr: false,
loading: () => <Skeleton loading active paragraph={{ rows: 8 }} />,
},
);
@@ -63,6 +54,7 @@ const BrowserNotifyModal = dynamic(
),
{
ssr: false,
loading: () => <Skeleton loading active paragraph={{ rows: 6 }} />,
},
);
@@ -70,6 +62,7 @@ const NotifyReminderPopup = dynamic(
() => import('../NotifyReminderPopup/NotifyReminderPopup').then(mod => mod.NotifyReminderPopup),
{
ssr: false,
loading: () => <Skeleton loading active paragraph={{ rows: 8 }} />,
},
);
@@ -81,142 +74,8 @@ const OwncastPlayer = dynamic(
},
);
const ChatContainer = dynamic(
() => import('../../chat/ChatContainer/ChatContainer').then(mod => mod.ChatContainer),
{
ssr: false,
},
);
const Tabs = dynamic(() => import('antd').then(mod => mod.Tabs), {
ssr: false,
});
const DesktopContent = ({
name,
streamTitle,
summary,
tags,
socialHandles,
extraPageContent,
setShowFollowModal,
supportFediverseFeatures,
}) => {
const aboutTabContent = <CustomPageContent content={extraPageContent} />;
const followersTabContent = (
<div>
<FollowerCollection name={name} onFollowButtonClick={() => setShowFollowModal(true)} />
</div>
);
const items = [{ label: 'About', key: '2', children: aboutTabContent }];
if (supportFediverseFeatures) {
items.push({ label: 'Followers', key: '3', children: followersTabContent });
}
return (
<>
<div className={styles.lowerHalf} id="skip-to-content">
<ContentHeader
name={name}
title={streamTitle}
summary={summary}
tags={tags}
links={socialHandles}
logo="/logo"
/>
</div>
<div className={styles.lowerSection}>
{items.length > 1 ? <Tabs defaultActiveKey="0" items={items} /> : aboutTabContent}
</div>
</>
);
};
const MobileContent = ({
name,
streamTitle,
summary,
tags,
socialHandles,
extraPageContent,
messages,
currentUser,
showChat,
actions,
setExternalActionToDisplay,
setShowNotifyPopup,
setShowFollowModal,
supportFediverseFeatures,
supportsBrowserNotifications,
}) => {
if (!currentUser) {
return <Skeleton loading active paragraph={{ rows: 7 }} />;
}
const { id, displayName } = currentUser;
const chatContent = showChat && (
<ChatContainer
messages={messages}
usernameToHighlight={displayName}
chatUserId={id}
isModerator={false}
/>
);
const aboutTabContent = (
<>
<ContentHeader
name={name}
title={streamTitle}
summary={summary}
tags={tags}
links={socialHandles}
logo="/logo"
/>
<CustomPageContent content={extraPageContent} />
</>
);
const followersTabContent = (
<FollowerCollection name={name} onFollowButtonClick={() => setShowFollowModal(true)} />
);
const items = [
showChat && { label: 'Chat', key: '0', children: chatContent },
{ label: 'About', key: '2', children: aboutTabContent },
{ label: 'Followers', key: '3', children: followersTabContent },
];
const replacementTabBar = (props, DefaultTabBar) => (
<div className={styles.replacementBar}>
<DefaultTabBar {...props} className={styles.defaultTabBar} />
<ActionButtonMenu
className={styles.actionButtonMenu}
showFollowItem={supportFediverseFeatures}
showNotifyItem={supportsBrowserNotifications}
actions={actions}
externalActionSelected={setExternalActionToDisplay}
notifyItemSelected={() => setShowNotifyPopup(true)}
followItemSelected={() => setShowFollowModal(true)}
/>
</div>
);
return (
<div className={styles.lowerSectionMobile}>
<Tabs
className={styles.tabs}
defaultActiveKey="0"
items={items}
renderTabBar={replacementTabBar}
/>
</div>
);
};
const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay }) => {
const { title, description, url } = externalActionToDisplay;
const { title, description, url, html } = externalActionToDisplay;
return (
<Modal
title={description || title}
@@ -224,7 +83,19 @@ const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay })
open={!!externalActionToDisplay}
height="80vh"
handleCancel={() => setExternalActionToDisplay(null)}
/>
>
{html ? (
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
style={{
height: '100%',
width: '100%',
overflow: 'auto',
}}
/>
) : null}
</Modal>
);
};
@@ -268,7 +139,8 @@ export const Content: FC = () => {
const externalActionSelected = (action: ExternalAction) => {
const { openExternally, url } = action;
if (openExternally) {
// apply openExternally only if we don't have an HTML embed
if (openExternally && url) {
window.open(url, '_blank');
} else {
setExternalActionToDisplay(action);
@@ -277,7 +149,7 @@ export const Content: FC = () => {
const externalActionButtons = externalActions.map(action => (
<ActionButton
key={action.url}
key={action.url || action.html}
action={action}
externalActionSelected={externalActionSelected}
/>
@@ -331,105 +203,111 @@ export const Content: FC = () => {
return (
<>
<div className={styles.main}>
<div className={styles.root}>
<div className={styles.mainSection}>
<div className={styles.topSection}>
{appState.appLoading && <Skeleton loading active paragraph={{ rows: 7 }} />}
{online && (
<OwncastPlayer
source="/hls/stream.m3u8"
online={online}
title={streamTitle || name}
/>
)}
{!online && !appState.appLoading && (
<div id="offline-message">
<OfflineBanner
showsHeader={false}
streamName={name}
customText={offlineMessage}
notificationsEnabled={browserNotificationsEnabled}
fediverseAccount={fediverseAccount}
lastLive={lastDisconnectTime}
onNotifyClick={() => setShowNotifyModal(true)}
onFollowClick={() => setShowFollowModal(true)}
/>
</div>
)}
{isStreamLive && (
<Statusbar
online={online}
lastConnectTime={lastConnectTime}
lastDisconnectTime={lastDisconnectTime}
viewerCount={viewerCount}
/>
)}
<div className={styles.root}>
<div className={classnames(styles.mainSection, { [styles.offline]: !online })}>
{appState.appLoading ? (
<Skeleton loading active paragraph={{ rows: 7 }} className={styles.topSectionElement} />
) : (
<div className="skeleton-placeholder" />
)}
{online && (
<OwncastPlayer
source="/hls/stream.m3u8"
online={online}
title={streamTitle || name}
className={styles.topSectionElement}
/>
)}
{!online && !appState.appLoading && (
<div id="offline-message">
<OfflineBanner
showsHeader={false}
streamName={name}
customText={offlineMessage}
notificationsEnabled={supportsBrowserNotifications}
fediverseAccount={fediverseAccount}
lastLive={lastDisconnectTime}
onNotifyClick={() => setShowNotifyModal(true)}
onFollowClick={() => setShowFollowModal(true)}
className={styles.topSectionElement}
/>
</div>
<div className={styles.midSection}>
<div className={styles.buttonsLogoTitleSection}>
{!isMobile && (
<ActionButtonRow>
{externalActionButtons}
{supportFediverseFeatures && (
<FollowButton size="small" onClick={() => setShowFollowModal(true)} />
)}
{supportsBrowserNotifications && (
<NotifyReminderPopup
open={showNotifyReminder}
notificationClicked={() => setShowNotifyModal(true)}
notificationClosed={() => disableNotifyReminderPopup()}
>
<NotifyButton onClick={() => setShowNotifyModal(true)} />
</NotifyReminderPopup>
)}
</ActionButtonRow>
)}
)}
{isStreamLive ? (
<Statusbar
online={online}
lastConnectTime={lastConnectTime}
lastDisconnectTime={lastDisconnectTime}
viewerCount={viewerCount}
className={classnames(styles.topSectionElement, styles.statusBar)}
/>
) : (
<div className="statusbar-placeholder" />
)}
<div className={styles.midSection}>
<div className={styles.buttonsLogoTitleSection}>
{!isMobile && (
<ActionButtonRow>
{externalActionButtons}
{supportFediverseFeatures && (
<FollowButton size="small" onClick={() => setShowFollowModal(true)} />
)}
{supportsBrowserNotifications && (
<NotifyReminderPopup
open={showNotifyReminder}
notificationClicked={() => setShowNotifyModal(true)}
notificationClosed={() => disableNotifyReminderPopup()}
>
<NotifyButton onClick={() => setShowNotifyModal(true)} />
</NotifyReminderPopup>
)}
</ActionButtonRow>
)}
<Modal
title="Browser Notifications"
open={showNotifyModal}
afterClose={() => disableNotifyReminderPopup()}
handleCancel={() => disableNotifyReminderPopup()}
>
<BrowserNotifyModal />
</Modal>
</div>
<Modal
title="Browser Notifications"
open={showNotifyModal}
afterClose={() => disableNotifyReminderPopup()}
handleCancel={() => disableNotifyReminderPopup()}
>
<BrowserNotifyModal />
</Modal>
</div>
{isMobile ? (
<MobileContent
name={name}
streamTitle={streamTitle}
summary={summary}
tags={tags}
socialHandles={socialHandles}
extraPageContent={extraPageContent}
messages={messages}
currentUser={currentUser}
showChat={showChat}
actions={externalActions}
setExternalActionToDisplay={externalActionSelected}
setShowNotifyPopup={setShowNotifyModal}
setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures}
supportsBrowserNotifications={supportsBrowserNotifications}
/>
) : (
<DesktopContent
name={name}
streamTitle={streamTitle}
summary={summary}
tags={tags}
socialHandles={socialHandles}
extraPageContent={extraPageContent}
setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures}
/>
)}
{!isMobile && <Footer version={version} />}
</div>
{showChat && !isMobile && <Sidebar />}
{isMobile ? (
<MobileContent
name={name}
summary={summary}
tags={tags}
socialHandles={socialHandles}
extraPageContent={extraPageContent}
messages={messages}
currentUser={currentUser}
showChat={showChat}
actions={externalActions}
setExternalActionToDisplay={externalActionSelected}
setShowNotifyPopup={setShowNotifyModal}
setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures}
supportsBrowserNotifications={supportsBrowserNotifications}
notifyItemSelected={() => setShowNotifyModal(true)}
followItemSelected={() => setShowFollowModal(true)}
externalActionSelected={externalActionSelected}
/>
) : (
<DesktopContent
name={name}
summary={summary}
tags={tags}
socialHandles={socialHandles}
extraPageContent={extraPageContent}
setShowFollowModal={setShowFollowModal}
supportFediverseFeatures={supportFediverseFeatures}
/>
)}
{!isMobile && <Footer version={version} />}
</div>
{showChat && !isMobile && <Sidebar />}
</div>
{externalActionToDisplay && (
<ExternalModal

View File

@@ -0,0 +1,81 @@
import React, { ComponentType, FC } from 'react';
import dynamic from 'next/dynamic';
import { TabsProps } from 'antd';
import { SocialLink } from '../../../interfaces/social-link.model';
import styles from './Content.module.scss';
import { CustomPageContent } from '../CustomPageContent/CustomPageContent';
import { ContentHeader } from '../../common/ContentHeader/ContentHeader';
export type DesktopContentProps = {
name: string;
summary: string;
tags: string[];
socialHandles: SocialLink[];
extraPageContent: string;
setShowFollowModal: (show: boolean) => void;
supportFediverseFeatures: boolean;
};
// lazy loaded components
const Tabs: ComponentType<TabsProps> = dynamic(() => import('antd').then(mod => mod.Tabs), {
ssr: false,
});
const FollowerCollection = dynamic(
() =>
import('../followers/FollowerCollection/FollowerCollection').then(
mod => mod.FollowerCollection,
),
{
ssr: false,
},
);
export const DesktopContent: FC<DesktopContentProps> = ({
name,
summary,
tags,
socialHandles,
extraPageContent,
setShowFollowModal,
supportFediverseFeatures,
}) => {
const aboutTabContent = (
<div className={styles.bottomPageContentContainer}>
<CustomPageContent content={extraPageContent} />
</div>
);
const followersTabContent = (
<div className={styles.bottomPageContentContainer}>
<FollowerCollection name={name} onFollowButtonClick={() => setShowFollowModal(true)} />
</div>
);
const items = [!!extraPageContent && { label: 'About', key: '2', children: aboutTabContent }];
if (supportFediverseFeatures) {
items.push({ label: 'Followers', key: '3', children: followersTabContent });
}
return (
<>
<div className={styles.lowerHalf} id="skip-to-content">
<ContentHeader
name={name}
summary={summary}
tags={tags}
links={socialHandles}
logo="/logo"
/>
</div>
<div className={styles.lowerSection}>
{items.length > 1 ? (
<Tabs defaultActiveKey="0" items={items} />
) : (
!!extraPageContent && aboutTabContent
)}
</div>
</>
);
};

View File

@@ -0,0 +1,140 @@
import React, { ComponentType, FC } from 'react';
import dynamic from 'next/dynamic';
import { Skeleton, TabsProps } from 'antd';
import { SocialLink } from '../../../interfaces/social-link.model';
import styles from './Content.module.scss';
import { CustomPageContent } from '../CustomPageContent/CustomPageContent';
import { ContentHeader } from '../../common/ContentHeader/ContentHeader';
import { ChatMessage } from '../../../interfaces/chat-message.model';
import { CurrentUser } from '../../../interfaces/current-user';
import { ActionButtonMenu } from '../../action-buttons/ActionButtonMenu/ActionButtonMenu';
import { ExternalAction } from '../../../interfaces/external-action';
export type MobileContentProps = {
name: string;
summary: string;
tags: string[];
socialHandles: SocialLink[];
extraPageContent: string;
notifyItemSelected: () => void;
followItemSelected: () => void;
setExternalActionToDisplay: (action: ExternalAction) => void;
setShowNotifyPopup: (show: boolean) => void;
setShowFollowModal: (show: boolean) => void;
supportFediverseFeatures: boolean;
messages: ChatMessage[];
currentUser: CurrentUser;
showChat: boolean;
actions: ExternalAction[];
externalActionSelected: (action: ExternalAction) => void;
supportsBrowserNotifications: boolean;
};
// lazy loaded components
const Tabs: ComponentType<TabsProps> = dynamic(() => import('antd').then(mod => mod.Tabs), {
ssr: false,
});
const FollowerCollection = dynamic(
() =>
import('../followers/FollowerCollection/FollowerCollection').then(
mod => mod.FollowerCollection,
),
{
ssr: false,
},
);
const ChatContainer = dynamic(
() => import('../../chat/ChatContainer/ChatContainer').then(mod => mod.ChatContainer),
{
ssr: false,
},
);
export const MobileContent: FC<MobileContentProps> = ({
name,
summary,
tags,
socialHandles,
extraPageContent,
messages,
currentUser,
showChat,
actions,
setExternalActionToDisplay,
setShowNotifyPopup,
setShowFollowModal,
supportFediverseFeatures,
supportsBrowserNotifications,
}) => {
if (!currentUser) {
return <Skeleton loading active paragraph={{ rows: 7 }} />;
}
const { id, displayName } = currentUser;
const chatContent = showChat && (
<ChatContainer
messages={messages}
usernameToHighlight={displayName}
chatUserId={id}
isModerator={false}
/>
);
const aboutTabContent = (
<>
<ContentHeader name={name} summary={summary} tags={tags} links={socialHandles} logo="/logo" />
{!!extraPageContent && (
<div className={styles.bottomPageContentContainer}>
<CustomPageContent content={extraPageContent} />
</div>
)}
</>
);
const followersTabContent = (
<div className={styles.bottomPageContentContainer}>
<FollowerCollection name={name} onFollowButtonClick={() => setShowFollowModal(true)} />
</div>
);
const items = [];
if (showChat) {
items.push({ label: 'Chat', key: '0', children: chatContent });
}
items.push({ label: 'About', key: '2', children: aboutTabContent });
if (supportFediverseFeatures) {
items.push({ label: 'Followers', key: '3', children: followersTabContent });
}
const replacementTabBar = (props, DefaultTabBar) => (
<div className={styles.replacementBar}>
<DefaultTabBar {...props} className={styles.defaultTabBar} />
<ActionButtonMenu
className={styles.actionButtonMenu}
showFollowItem={supportFediverseFeatures}
showNotifyItem={supportsBrowserNotifications}
actions={actions}
externalActionSelected={setExternalActionToDisplay}
notifyItemSelected={() => setShowNotifyPopup(true)}
followItemSelected={() => setShowFollowModal(true)}
/>
</div>
);
return (
<div className={styles.lowerSectionMobile}>
{items.length > 1 ? (
<Tabs
className={styles.tabs}
defaultActiveKey="0"
items={items}
renderTabBar={replacementTabBar}
/>
) : (
aboutTabContent
)}
</div>
);
};

View File

@@ -8,6 +8,7 @@ export type CrossfadeImageProps = {
height: string;
objectFit?: ObjectFit;
duration?: string;
className?: string;
};
const imgStyle: React.CSSProperties = {
@@ -22,6 +23,7 @@ export const CrossfadeImage: FC<CrossfadeImageProps> = ({
height,
objectFit = 'fill',
duration = '1s',
className,
}) => {
const spanStyle: React.CSSProperties = useMemo(
() => ({
@@ -52,7 +54,7 @@ export const CrossfadeImage: FC<CrossfadeImageProps> = ({
};
return (
<span style={spanStyle}>
<span style={spanStyle} className={className}>
{[...srcs, nextSrc].map(
(singleSrc, index) =>
singleSrc !== '' && (

View File

@@ -1,22 +1,14 @@
@import 'styles/mixins.scss';
.pageContentContainer {
@include flexCenter;
}
.customPageContent {
font-size: 1rem;
line-height: 1.6em;
color: var(--theme-color-components-text-on-light);
padding: calc(2 * var(--content-padding));
border-radius: var(--theme-rounded-corners);
width: 100%;
background-color: var(--theme-color-components-content-background);
hr {
margin: 1.35em 0;
border: 0;
border-top: solid 1px var(--theme-color-components-content-background);
border-top: solid 1px var(--theme-color-palette-6);
}
div.summary {

View File

@@ -18,12 +18,12 @@ const Template: ComponentStory<typeof CustomPageContent> = args => (
export const Example1 = Template.bind({});
Example1.args = {
content: `"\u003cp\u003eOwncast TV is a 24/7 live stream run by the Owncast project as an example of the software in use. Learn more about how you can have your own live stream that you completely control at \u003ca href=\"https://owncast.online\"\u003eowncast.online\u003c/a\u003e.\u003c/p\u003e\n\u003cp\u003eThis example instance shows how you can customize the page by changing things like fonts and colors as well as how you can add custom action buttons such as a donation button.\u003c/p\u003e\n\u003cp\u003eStay tuned in to learn about Owncast, hear from some streamers about their experiences using it, some bits and pieces of Owncast promo material, and highlights from other projects that are pretty cool.\u003c/p\u003e\n\u003cp\u003eBut when you've seen what we have to share with you, do yourself a favor and visit the \u003ca href=\"https://directory.owncast.online\"\u003eOwncast Directory\u003c/a\u003e and find an awesome stream to check out!\u003c/p\u003e\n\u003chr\u003e\n\u003ch2\u003eLinks to content seen in this stream\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"https://owncast.online/quickstart/\"\u003eOwncast Install Quickstart\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://directory.owncast.online\"\u003eOwncast Directory\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://creativecommons.org\"\u003eCreative Commons\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://tilvids.com\"\u003eTILVids\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://studio.blender.org/\"\u003eBlender Studio\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://archive.org/details/computerchronicles\"\u003eComputer Chronicles\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://joinmastodon.org\"\u003eMastodon\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e",`,
content: `\u003cp\u003eOwncast TV is a 24/7 live stream run by the Owncast project as an example of the software in use. Learn more about how you can have your own live stream that you completely control at \u003ca href=\"https://owncast.online\"\u003eowncast.online\u003c/a\u003e.\u003c/p\u003e\n\u003cp\u003eThis example instance shows how you can customize the page by changing things like fonts and colors as well as how you can add custom action buttons such as a donation button.\u003c/p\u003e\n\u003cp\u003eStay tuned in to learn about Owncast, hear from some streamers about their experiences using it, some bits and pieces of Owncast promo material, and highlights from other projects that are pretty cool.\u003c/p\u003e\n\u003cp\u003eBut when you've seen what we have to share with you, do yourself a favor and visit the \u003ca href=\"https://directory.owncast.online\"\u003eOwncast Directory\u003c/a\u003e and find an awesome stream to check out!\u003c/p\u003e\n\u003chr\u003e\n\u003ch2\u003eLinks to content seen in this stream\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"https://owncast.online/quickstart/\"\u003eOwncast Install Quickstart\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://directory.owncast.online\"\u003eOwncast Directory\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://creativecommons.org\"\u003eCreative Commons\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://tilvids.com\"\u003eTILVids\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://studio.blender.org/\"\u003eBlender Studio\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://archive.org/details/computerchronicles\"\u003eComputer Chronicles\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://joinmastodon.org\"\u003eMastodon\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e",`,
};
export const Example2 = Template.bind({});
Example2.args = {
content: `"<h1>WHAT IS HAPPENING HERE</h1>\n<p>Game That Tune Radio is live with fantastic video game music streaming around the clock! We've got music from NES, SNES, Sega Genesis, Nintendo 64, Playstation, PC, and more coming all the time! If it's been featured on our podcast, it's gonna be on this stream! We only play three songs from each game on our podcast, and we decided that everyone needs more tunes!</p>\n<p>We'll be updating this livestream with new games as they're played on the show, including your requests! To get priority in requesting games for the show, check out <a href=\"https://www.patreon.com/GameThatTune\">https://www.patreon.com/GameThatTune</a></p>\n<p>Be sure to check out our live recordings of the Game That Tune podcast! We broadcast every Wednesday night at 9 PM EST on our YouTube channel as well as <a href=\"https://www.twitch.tv/GameThatTune\">https://www.twitch.tv/GameThatTune</a> and <a href=\"https://www.facebook.com/GameThatTune\">https://www.facebook.com/GameThatTune</a>\nTune in and join in on the fun! Find the podcast in iTunes every Wednesday morning or head to <a href=\"https://www.gamethattune.com\">https://www.gamethattune.com</a>!</p>\n<p>Visit <a href=\"https://www.patreon.com/GameThatTune\">https://www.patreon.com/GameThatTune</a> to help us keep up this live stream and upgrade our equipment for the live show! We've got exclusive mixtapes for our patrons, and lots more stuff planned for the future, so consider helping us out!</p>\n<h1>HOW IT WORKS</h1>\n<p>Featuring music from over 1000 games! Check out <a href=\"https://music.gamethattune.com/songs\">https://music.gamethattune.com/songs</a> for the full list and make a request from your favorite game!</p>\n<p>Now that you've seen the list of games, make a request in the chat!</p>\n<p><code>!sr</code> + anything = general search<br>\n<code>!gr</code> + game title = random song from matching game<br>\n<code>!cr</code> + composer name = random song from matching composer<br>\n<code>!tr</code> + anything = random result only searching song titles<br>\n<code>!rr</code> + anything = random result from all searchable fields<br>\n<code>!game gtt</code> = starts a round of our guessing game for bonus points!</p>\n<p>We have gifs!</p>\n<p>Wanna see your favorite gif on screen? type <code>!summon</code> followed by the gif name! Want your favorite gif to take over the video? Type <code>!spawn</code> followed by the gif name!</p>\n<p>Still have questions? Ask the chatbot! type <code>!info</code> to...wait for it...get more info!</p>\n<p>Thanks for listening!</p>"`,
content: `<h1>WHAT IS HAPPENING HERE</h1>\n<p>Game That Tune Radio is live with fantastic video game music streaming around the clock! We've got music from NES, SNES, Sega Genesis, Nintendo 64, Playstation, PC, and more coming all the time! If it's been featured on our podcast, it's gonna be on this stream! We only play three songs from each game on our podcast, and we decided that everyone needs more tunes!</p>\n<p>We'll be updating this livestream with new games as they're played on the show, including your requests! To get priority in requesting games for the show, check out <a href=\"https://www.patreon.com/GameThatTune\">https://www.patreon.com/GameThatTune</a></p>\n<p>Be sure to check out our live recordings of the Game That Tune podcast! We broadcast every Wednesday night at 9 PM EST on our YouTube channel as well as <a href=\"https://www.twitch.tv/GameThatTune\">https://www.twitch.tv/GameThatTune</a> and <a href=\"https://www.facebook.com/GameThatTune\">https://www.facebook.com/GameThatTune</a>\nTune in and join in on the fun! Find the podcast in iTunes every Wednesday morning or head to <a href=\"https://www.gamethattune.com\">https://www.gamethattune.com</a>!</p>\n<p>Visit <a href=\"https://www.patreon.com/GameThatTune\">https://www.patreon.com/GameThatTune</a> to help us keep up this live stream and upgrade our equipment for the live show! We've got exclusive mixtapes for our patrons, and lots more stuff planned for the future, so consider helping us out!</p>\n<h1>HOW IT WORKS</h1>\n<p>Featuring music from over 1000 games! Check out <a href=\"https://music.gamethattune.com/songs\">https://music.gamethattune.com/songs</a> for the full list and make a request from your favorite game!</p>\n<p>Now that you've seen the list of games, make a request in the chat!</p>\n<p><code>!sr</code> + anything = general search<br>\n<code>!gr</code> + game title = random song from matching game<br>\n<code>!cr</code> + composer name = random song from matching composer<br>\n<code>!tr</code> + anything = random result only searching song titles<br>\n<code>!rr</code> + anything = random result from all searchable fields<br>\n<code>!game gtt</code> = starts a round of our guessing game for bonus points!</p>\n<p>We have gifs!</p>\n<p>Wanna see your favorite gif on screen? type <code>!summon</code> followed by the gif name! Want your favorite gif to take over the video? Type <code>!spawn</code> followed by the gif name!</p>\n<p>Still have questions? Ask the chatbot! type <code>!info</code> to...wait for it...get more info!</p>\n<p>Thanks for listening!</p>"`,
};
export const Example3 = Template.bind({});

View File

@@ -7,7 +7,7 @@ export type CustomPageContentProps = {
};
export const CustomPageContent: FC<CustomPageContentProps> = ({ content }) => (
<div className={styles.pageContentContainer} id="custom-page-content">
<div id="custom-page-content">
<div className={styles.customPageContent} dangerouslySetInnerHTML={{ __html: content }} />
</div>
);

View File

@@ -10,6 +10,11 @@
box-shadow: 0px 1px 3px 1px rgb(0 0 0 / 10%);
background-color: var(--theme-color-background-header);
h1 {
margin-top: unset;
margin-bottom: unset;
}
@include screen(mobile) {
--header-height: 3.85rem;
}
@@ -38,7 +43,9 @@
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
width: 70vw;
// 6rem is an overapproximation of the width of
// the user menu
max-width: min(70vw, calc(100vw - 6rem));
overflow: hidden;
line-height: 1.4;
}
@@ -57,3 +64,8 @@
width: auto;
height: auto;
}
.offlineTag {
cursor: default;
color: var(--theme-color-components-text-on-light);
}

View File

@@ -22,12 +22,7 @@ export type HeaderComponentProps = {
online: boolean;
};
export const Header: FC<HeaderComponentProps> = ({
name = 'Your stream title',
chatAvailable,
chatDisabled,
online,
}) => (
export const Header: FC<HeaderComponentProps> = ({ name, chatAvailable, chatDisabled, online }) => (
<header className={cn([`${styles.header}`], 'global-header')}>
{online ? (
<Link href="#player" className={styles.skipLink}>
@@ -55,7 +50,7 @@ export const Header: FC<HeaderComponentProps> = ({
{chatAvailable && !chatDisabled && <UserDropdown />}
{!chatAvailable && !chatDisabled && (
<Tooltip title="Chat is available when the stream is live." placement="left">
<Tag style={{ cursor: 'pointer' }}>Chat offline</Tag>
<Tag className={styles.offlineTag}>Chat offline</Tag>
</Tooltip>
)}
</header>

View File

@@ -11,3 +11,4 @@
background-color: var(--theme-color-components-modal-content-background);
color: var(--theme-color-components-modal-content-text);
}

View File

@@ -21,7 +21,7 @@
@include screen(tablet) {
font-size: 1.2rem;
padding: 1em;
margin: 1rem auto;
margin: 1rem 0.2rem;
}
}

View File

@@ -3,6 +3,7 @@ import { Divider } from 'antd';
import { FC } from 'react';
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import dynamic from 'next/dynamic';
import classNames from 'classnames';
import styles from './OfflineBanner.module.scss';
// Lazy loaded components
@@ -20,6 +21,7 @@ export type OfflineBannerProps = {
showsHeader?: boolean;
onNotifyClick?: () => void;
onFollowClick?: () => void;
className?: string;
};
export const OfflineBanner: FC<OfflineBannerProps> = ({
@@ -31,6 +33,7 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
showsHeader = true,
onNotifyClick,
onFollowClick,
className,
}) => {
let text;
if (customText) {
@@ -74,7 +77,7 @@ export const OfflineBanner: FC<OfflineBannerProps> = ({
}
return (
<div id="offline-banner" className={styles.outerContainer}>
<div id="offline-banner" className={classNames(styles.outerContainer, className)}>
<div className={styles.innerContainer}>
{showsHeader && (
<>

View File

@@ -2,6 +2,7 @@ import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import intervalToDuration from 'date-fns/intervalToDuration';
import { FC, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import classNames from 'classnames';
import styles from './Statusbar.module.scss';
import { pluralize } from '../../../utils/helpers';
@@ -16,6 +17,7 @@ export type StatusbarProps = {
lastConnectTime?: Date;
lastDisconnectTime?: Date;
viewerCount: number;
className?: string;
};
function makeDurationString(lastConnectTime: Date): string {
@@ -43,6 +45,7 @@ export const Statusbar: FC<StatusbarProps> = ({
lastConnectTime,
lastDisconnectTime,
viewerCount,
className,
}) => {
const [, setNow] = useState(new Date());
@@ -75,7 +78,7 @@ export const Statusbar: FC<StatusbarProps> = ({
}
return (
<div className={styles.statusbar} role="status">
<div className={classNames(styles.statusbar, className)} role="status">
<div>{onlineMessage}</div>
<div>{rightSideMessage}</div>
</div>

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