Update dependencies, refactor authentication, and enhance UI components

- Upgraded @reduxjs/toolkit to version 2.9.0 and added new dependencies including @techniker-me/pcast-api and moment.
- Refactored authentication logic and added middleware for improved request handling.
- Introduced new UI components such as buttons, loaders, and forms, along with a theme system following SOLID principles.
- Updated routing to include protected routes and improved the login form with better error handling.
- Removed unused CSS and organized the project structure for better maintainability.
This commit is contained in:
2025-09-04 01:10:03 -04:00
parent 04488c43c5
commit 1469c7f52f
85 changed files with 3610 additions and 125 deletions

View File

@@ -1,26 +1,12 @@
import {JSX, useState} from 'react';
import {useAppDispatch} from './store';
import {authenticateCredentialsThunk} from './store/slices/Authentication.slice';
export default function App(): JSX.Element {
const dispatch = useAppDispatch();
const [applicationId, setApplicationId] = useState<string>('phenixrts.com-alex.zinn');
const [secret, setSecret] = useState<string>('AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg');
const handleAuthenticate = async () => {
const response = await dispatch(authenticateCredentialsThunk({applicationId, secret}));
console.log(`${new Date().toISOString()} AuthenticationResponse [%o]`, response.payload);
};
import {JSX} from 'react';
import Router from './routers';
const App = (): JSX.Element => {
return (
<>
<h1>Hello World</h1>
<div>
<input type="text" value={applicationId} onChange={e => setApplicationId(e.target.value)} />
<br />
<input type="text" value={secret} onChange={e => setSecret(e.target.value)} />
</div>
<button onClick={handleAuthenticate}>Authenticate</button>
<Router />
</>
);
}
};
export default App;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,15 @@
import {JSX} from 'react';
import {Navigate, useLocation} from 'react-router-dom';
import {useAppSelector} from 'store';
import {selectIsAuthenticated} from 'store/slices/Authentication.slice';
export function ProtectedRoute({component}: {component: JSX.Element}): JSX.Element {
const isAuthenticated = useAppSelector(selectIsAuthenticated);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{from: location}} replace />;
}
return component;
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {useState} from 'react';
import {faCopy, faCheck} from '@fortawesome/free-solid-svg-icons';
import IconButton from 'components/buttons/icon-button';
import {CopyButtonContainer} from './styles';
const iconChangeTimeout = 2000;
export const CopyIconButton = (props: {text: string; quoted?: boolean; displayText?: boolean; className?: string}): JSX.Element => {
const {text, quoted = false, displayText = true, className} = props;
const [copied, setCopied] = useState(false);
const copyToClipboard = (): void => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), iconChangeTimeout);
};
return (
<CopyButtonContainer className={className}>
{displayText && (quoted ? `"${text}"` : text)}
<IconButton
onClick={copyToClipboard}
tooltipText="Copy"
icon={copied ? faCheck : faCopy}
/>
</CopyButtonContainer>
);
};

View File

@@ -0,0 +1,9 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
export const CopyButtonContainer = styled.default.div`
display: flex;
align-items: center;
`;

View File

@@ -0,0 +1,36 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {theme} from 'components/shared/theme';
import {Button} from 'components/buttons';
const {colors} = theme;
interface IExportFileButton {
label?: string;
file: string;
fileName?: string;
}
export const ExportFileButton = ({label = 'Export File', file, fileName = 'file'}: IExportFileButton): JSX.Element => {
const handleExport = () => {
const downloadUrl = URL.createObjectURL(new Blob([file]));
const linkTag = document.createElement('a');
linkTag.href = downloadUrl;
linkTag.setAttribute('target', '_blank');
linkTag.setAttribute('download', fileName);
linkTag.click();
};
return (
<Button
onClick={handleExport}
className="testId-exportFile"
backgroundColor={colors.red}
borderColor={colors.red}
>
{label}
</Button>
);
};

View File

@@ -0,0 +1,32 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {IconProp} from '@fortawesome/fontawesome-svg-core';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {Position, Tooltip} from 'components/tooltip';
import {IconButtonContainer} from './styles';
interface IIconButton {
onClick: () => void;
tooltipText: string;
icon: IconProp;
className?: string;
}
const IconButton = ({
onClick,
tooltipText,
icon,
className
}: IIconButton) => (
<Tooltip position={Position.Bottom} message={tooltipText}>
<IconButtonContainer className={`icon-button ${className}`} role="link" tabIndex={-11} onKeyDown={null} onClick={onClick}>
<FontAwesomeIcon icon={icon} />
</IconButtonContainer>
</Tooltip>
);
export default IconButton;

View File

@@ -0,0 +1,5 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export {default} from './icon-button';

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
export const IconButtonContainer = styled.default.div`
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&.disabled {
cursor: not-allowed;
pointer-events: none;
opacity: 0.5;
}
&.icon-button {
width: 2.5rem;
height: 2.5rem;
}
svg {
margin: 0;
}
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
`;

View File

@@ -0,0 +1,16 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {IconButton} from './style';
import addIcon from 'assets/images/icon/hash-plus.svg';
interface IAddButton {
onClick: () => void;
className: string;
}
export const AddButton = ({onClick, className}: IAddButton): JSX.Element => (
<IconButton onClick={onClick} className={className}>
<img src={addIcon} alt={'Add'} />
</IconButton>
);

View File

@@ -0,0 +1,5 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export * from './refresh-button';
export * from './add-button';

View File

@@ -0,0 +1,17 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {IconButton} from './style';
import refreshIcon from 'assets/images/icon/refresh.svg';
interface IRefreshButton {
onClick: () => void;
disabled?: boolean;
}
export const RefreshButton = ({onClick, disabled = false}: IRefreshButton): JSX.Element => (
<IconButton onClick={onClick} disabled={disabled}>
<img src={refreshIcon} alt={'Refresh'} />
</IconButton>
);

View File

@@ -0,0 +1,17 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
import {theme} from 'components/shared/theme';
const {fontSizeL, colors} = theme;
export const IconButton = styled.default.button`
border: none;
background-color: transparent;
font-size: ${fontSizeL};
color: ${colors.white};
opacity: ${({disabled}) => disabled ? 0.3 : 1};
cursor: ${({disabled}) => disabled ? 'not-allowed' : 'pointer'};
display: flex;
`;

View File

@@ -0,0 +1,57 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
import Theme from 'theme';
export const Button = styled.default.button<{
backgroundColor?: string;
borderColor?: string;
textColor?: string;
disabled?: boolean;
}>`
${({backgroundColor, textColor, borderColor}) => styled.css`
color: ${textColor || Theme.colors.white};
background-color: ${backgroundColor || Theme.colors.white};
border-color: ${borderColor || backgroundColor || Theme.colors.lightRed};
`}
${({disabled}) => styled.css`
opacity: ${disabled ? 0.8 : 1};
cursor: ${disabled ? 'not-allowed' : 'pointer'};
`}
min-width: 120px;
text-align: center;
outline: none;
vertical-align: middle;
border-radius: ${Theme.primaryBorderRadius};
padding: ${Theme.spacing.small} ${Theme.spacing.medium};
font-size: ${Theme.typography.primaryFontSize};
transition: color .15s ease-in-out, background-color .15s ease-in-out;
`;
export const FilterButton = styled.default(Button)`
font-weight: bolder;
justify-self: center;
margin: 0 ${Theme.spacing.xSmall};
padding: ${Theme.spacing.small} ${Theme.spacing.xlarge};
`;
export const ConfirmButton = styled.default(Button)`
margin-right: 1rem;
display: flex;
align-items: center;
justify-content: center;
`;
export const CancelButton = styled.default(ConfirmButton)``;
export const CustomButton = styled.default(Button)`
background-color: ${Theme.dangerColor};
border-color: ${Theme.dangerColor};
color: ${Theme.colors.white};
font-weight: bolder;
justify-self: center;
margin: 0.75rem;
padding: ${Theme.spacing.small} ${Theme.spacing.xlarge};
overflow: visible;
`;

View File

@@ -0,0 +1,88 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {Fragment} from 'react';
import {Tooltip, Position} from 'components/tooltip';
import {Label} from 'components/label';
import {
RadioGroup,
RadioWrapper,
RadioButtonContainer,
VisibleCheckBox
} from './style';
interface IRadioItems {
label: string;
value: string;
tooltipMessage?: string;
tooltipPosition?: Position;
className?: string;
children?: JSX.Element;
disabled?: boolean;
}
interface IRadioButtonGroup {
items: IRadioItems[];
handleOnChange: (value) => void;
currentValue: string;
}
const RadioButton = (props: {currentValue: string; value: string}) => {
const {currentValue, value} = props;
return (
<RadioButtonContainer>
<input type="radio" readOnly={true} value={value} checked={currentValue === value}/>
<VisibleCheckBox checked={currentValue === value}><div/></VisibleCheckBox>
</RadioButtonContainer>
);
};
const RadioButtonGroup = (props: IRadioButtonGroup): JSX.Element => {
const {items, handleOnChange, currentValue} = props;
return (
<RadioGroup>
{items.map((
{
label,
value,
disabled,
tooltipPosition,
tooltipMessage,
children,
className
}: IRadioItems,
index: number
) => (
<RadioWrapper tabIndex={-1}
onKeyPress={() => null}
disabled={disabled}
className="button-container"
role="button"
key={label + index}
onClick={() => handleOnChange(value)}>
<RadioButton value={value} currentValue={currentValue} />
<Fragment>
{tooltipMessage ? (
<Tooltip position={tooltipPosition} message={tooltipMessage}>
<Label
className={className}
text={label}
/>
</Tooltip>
) :
<Label
className={className}
text={label}
/>
}
{children}
</Fragment>
</RadioWrapper>
))}
</RadioGroup>
);
};
export default RadioButtonGroup;

View File

@@ -0,0 +1,57 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
import {theme, paddings} from 'components/shared/theme';
const {
spacing,
primaryFontSize,
colors
} = theme;
export const RadioGroup = styled.default.div`
display: flex;
`;
export const RadioWrapper = styled.default.div<{disabled?: boolean}>`
padding: ${paddings.small};
display: flex;
${({disabled}) => disabled && styled.css`
pointer-events: none;
opacity: .5;
`}
`;
export const RadioButtonContainer = styled.default.div`
margin-right: ${spacing.xSmall};
align-self: center;
input {
position: absolute;
visibility: hidden;
}
`;
export const VisibleCheckBox = styled.default.div<{checked?: boolean}>`
border: 2px solid ${colors.gray400};
display: flex;
align-items: center;
justify-content: center;
width: ${primaryFontSize};
height: ${primaryFontSize};
border-radius: 50%;
${({checked}) => checked && styled.css`
border: none;
background-color: ${colors.red};
div {
background-color: ${colors.black};
width: 5px;
height: 5px;
border-radius: 50%;
}
`}
`;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
import {theme} from 'components/shared/theme';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {
faBackward,
faFastBackward,
faFastForward,
faForward
} from '@fortawesome/free-solid-svg-icons';
const {colors, spacing} = theme;
const ScrollButton = styled.default.button`
border: none;
width: 25px;
height: 25px;
color: ${colors.white};
margin: ${spacing.xxSmall} 0;
background-color: ${colors.gray600};
cursor: pointer;
transform: rotate(90deg)
`;
const TwinButtons = styled.default.div`
display: flex;
position: absolute;
right: 64px;
bottom: 50px;
flex-direction: column;
`;
export const ScrollButtons = ({current}: {current: HTMLDivElement}): JSX.Element => {
const getScrollStep = current => {
const tableViewHeight = current.offsetHeight;
return tableViewHeight / 2;
};
const scrollToTop = () => {
if (current) {
current.scrollTop = 0;
}
};
const scrollTop = () => {
if (current) {
current.scrollTop = current.scrollTop - getScrollStep(current);
}
};
const scrollBottom = () => {
if (current) {
current.scrollTop = current.scrollTop + getScrollStep(current);
}
};
const scrollToBottom = () => {
if (current) {
current.scrollTop = current.scrollHeight;
}
};
return (
<TwinButtons>
<ScrollButton onClick={scrollToTop}>
<FontAwesomeIcon icon={faFastBackward} />
</ScrollButton>
<ScrollButton onClick={scrollTop}>
<FontAwesomeIcon icon={faBackward} />
</ScrollButton>
<ScrollButton onClick={scrollBottom}>
<FontAwesomeIcon icon={faForward} />
</ScrollButton>
<ScrollButton onClick={scrollToBottom}>
<FontAwesomeIcon icon={faFastForward} />
</ScrollButton>
</TwinButtons>
);
};

View File

@@ -0,0 +1,134 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {ChangeEvent, forwardRef, ForwardedRef, InputHTMLAttributes} from 'react';
import * as styled from 'styled-components';
import Theme from 'theme';
import {Label} from '../label';
export interface IInput extends InputHTMLAttributes<HTMLInputElement> {
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
error?: boolean;
icon?: JSX.Element;
imagePath?: string;
imageAltText?: string;
label?: string;
labelColor?: string;
labelIcon?: JSX.Element;
labelClassName?: string;
helperText?: string;
helperTextClassName?: string;
width?: number | string;
}
const {
colors,
typography,
formFieldWidth,
formFieldMaxWidth,
primaryBorderColor,
primaryBorderRadius,
primaryInputHeight,
inputIconWidth,
spacing
} = Theme;
export const InputElement = styled.default.input<IInput>`
background-color: ${colors.white};
border: 1px solid ${({error}) => error ? colors.lightRed : primaryBorderColor};
border-radius: ${primaryBorderRadius};
display: block;
font-size: ${typography.primaryFontSize};
height: ${primaryInputHeight};
line-height: ${typography.primaryLineHeight};
outline: none;
padding: ${spacing.small} ${({icon, imagePath}) => (icon || imagePath) ? spacing.xlarge : spacing.small};
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
background-position: 1rem center;
background-repeat: no-repeat;
width: inherit;
opacity: ${({disabled}) => disabled ? 0.8 : 1};
cursor: ${({disabled}) => disabled && 'not-allowed'};
-webkit-text-fill-color: ${({disabled}) => disabled && colors.gray800};
`;
const HelperText = styled.default.p<IInput>`
color: ${({error}) => error ? colors.lightRed : colors.gray600};
font-weight: 400;
font-size: ${typography.fontSizeS};
margin-top: ${spacing.xxSmall};
`;
const ImageWrapper = styled.default.div`
width: ${inputIconWidth}px;
height: ${inputIconWidth}px;
z-index: 1;
top: calc(50% - ${inputIconWidth / 2}px);
left: 8px;
position: absolute;
display: flex;
align-items: center;
& img, svg {
width: ${inputIconWidth}px;
height: ${inputIconWidth}px;
}
`;
const InputWrapper = styled.default.div`
width: 100%;
position: relative;
`;
export const InputComponentWrapper = styled.default.div<IInput>`
position: relative;
width: ${({width}) => width && isNaN(+width) ? width : ((width || formFieldWidth) + 'px')};
max-width: ${formFieldMaxWidth}px;
display: flex;
flex-direction: column;
`;
export const InputComponent = forwardRef((
{
label,
labelColor,
labelIcon,
labelClassName,
icon,
imagePath,
imageAltText = '',
helperText,
helperTextClassName,
error,
width,
disabled,
name,
...props
}: IInput, ref: ForwardedRef<HTMLInputElement>): JSX.Element => {
const InputIcon = icon || (imagePath && <img src={imagePath} alt={imageAltText} />);
return (
<InputComponentWrapper width={width}>
{!!label && (
<Label
text={label}
color={labelColor}
icon={labelIcon}
className={labelClassName}
/>
)}
<InputWrapper>
<ImageWrapper>
{InputIcon}
</ImageWrapper>
<InputElement
icon={icon}
name={name}
imagePath={imagePath}
disabled={disabled}
ref={ref}
{...props}
/>
</InputWrapper>
{!!helperText && <HelperText className={helperTextClassName} error={error}>{helperText}</HelperText>}
</InputComponentWrapper>
);
});
export default InputComponent;

View File

@@ -0,0 +1,23 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {JSX} from 'react';
import {Label as StyledLabel} from './style';
interface ILabel {
text: string;
htmlFor?: string;
color?: string;
icon?: JSX.Element;
className?: string;
}
export const Label = ({text, htmlFor, color, icon, className}: ILabel): JSX.Element => (
<StyledLabel
className={className}
htmlFor={htmlFor}
color={color}
>
{text} {icon}
</StyledLabel>
);

View File

@@ -0,0 +1,15 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
import Theme from 'theme';
const {spacing, colors, typography} = Theme;
export const Label = styled.default.label<{color?: string}>`
font-size: ${typography.fontSizeS};
color: ${({color}) => (color || colors.gray900)};
font-weight: bold;
margin: ${spacing.xxSmall} 0;
display: block;
`;

4
src/components/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './buttons';
export * from './loaders';
export * from './ProtectedRoute';
export * from './typography';

View File

@@ -0,0 +1,54 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
import Theme from 'theme';
interface LoadingWheelProps {
size?: 'small' | 'medium' | 'large';
color?: string;
className?: string;
}
const LoadingWheelContainer = styled.default.div<{
size: number;
color: string;
}>`
display: inline-block;
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
border: 3px solid ${({ color }) => color}20;
border-radius: 50%;
border-top-color: ${({ color }) => color};
animation: spin 1s ease-in-out infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
export const LoadingWheel: React.FC<LoadingWheelProps> = ({
size = 'medium',
color = Theme.colors.white,
className
}) => {
const sizeMap = {
small: Theme.loaderSize.small,
medium: Theme.loaderSize.medium,
large: Theme.loaderSize.large
};
return (
<LoadingWheelContainer
size={sizeMap[size]}
color={color}
className={className}
role="status"
aria-label="Loading"
/>
);
};
export default LoadingWheel;

View File

@@ -0,0 +1,4 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export * from './LoadingWheel';

View File

@@ -0,0 +1,85 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import moment, {DurationInputArg1, unitOfTime} from 'moment';
import PlatformDetectionService from 'services/platformDetection.service';
export const truncateWord = (word = '', length = 30): string => {
if (word.length >= length) {
return `${word.substring(0, length)}...`;
}
return word;
};
export const isEqual = (a: any, b: any): boolean => { // eslint-disable-line
if (typeof(a) === typeof(b)) {
if (typeof(a) === 'object') {
return JSON.stringify(a) === JSON.stringify(b);
}
return a === b;
}
return false;
};
export const splitWord = (string = ' ', seperator = ' '): string => {
return string.split(/(?=[A-Z])/).join(seperator);
};
export const mergeSimilarCSVFiles = (formerCSV: string, latterCSV: string): string => {
const formerCsvArray = formerCSV.split('\n').filter(line => line.trim() !== '');
const latterCsvArray = latterCSV.split('\n').filter(line => line.trim() !== '');
if (latterCsvArray.length > 1) {
return `${formerCsvArray.join('\n')}\n${latterCsvArray.splice(1).join('\n')}`;
}
return formerCSV;
};
export const chunk = (array: any[], size: number = 1) => { // eslint-disable-line
const arrayChunks = [];
for (let i = 0; i < array.length; i += size) {
const arrayChunk = array.slice(i, i + size);
arrayChunks.push(arrayChunk);
}
return arrayChunks;
};
export const getRangeByInterval = (start: Date, end: Date, intervalAmount: DurationInputArg1 = 24, intervalUnits: unitOfTime.DurationConstructor = 'hours') => {
const datesArray = [];
let currentDate = moment.utc(start);
const stopDate = moment.utc(end);
while (currentDate.isSameOrBefore(stopDate)) {
datesArray.push(currentDate.toISOString());
currentDate = currentDate.add(intervalAmount, intervalUnits);
}
return datesArray.reverse();
};
export const getBrowserLimits = (): IBrowserLimits => browserLimits[PlatformDetectionService.browserName.toLowerCase()];
export const getMaxByPropertyName = (
array: Record<string, number | string | boolean>[],
propertyName: string
): number => {
let length = array.length;
let max = -Infinity;
while (length--) {
const element = parseFloat(array[length][propertyName] as string);
if (isFinite(element) && element > max) {
max = element;
}
}
return max;
};

View File

@@ -0,0 +1,31 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
import Theme from 'theme';
export const H1 = styled.default.h1`
font-family: ${Theme.typography.primaryFont};
font-size: ${Theme.typography.fontSizeXxl};
font-weight: lighter;
${Theme.screenSizes.mediaPhone}{
font-size: ${Theme.typography.fontSizeXl};
}
`;
export const P = styled.default.p`
font-family: ${Theme.typography.primaryFont};
font-size: ${Theme.typography.primaryFontSize};
font-weight: normal;
`;
export const Heading = styled.default.h2`
color: ${Theme.colors.white};
font-size: ${Theme.typography.fontSizeXl};
font-weight: bold;
`;
export const WhiteText = styled.default(P)`
color: ${Theme.colors.white};
`;

View File

@@ -0,0 +1,27 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export interface IBrowserLimits {
bytes: number;
value: string;
}
export const browserLimits = {
chrome: {
bytes: 563085312,
value: '537 MB'
},
firefox: {
bytes: 1073741824,
value: '1 G'
},
edge: {
bytes: 524288000,
value: '500 MB'
},
safari: {
bytes: 1342177280,
value: '1.25G'
}
};

View File

@@ -0,0 +1,268 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import { IAdvancedSelectItem } from 'components/ui/advanced-select';
export enum VideoQuality {
Xhd = 'xhd',
Fhd = 'fhd',
Hd = 'hd',
Sd = 'sd',
Ld = 'ld',
Vld = 'vld',
Uld = 'uld',
AudioOnly = 'audio-only'
}
export enum CapabilitiesSet {
TrackIsolation = 'track-isolation',
AspectRatio = 'aspect-ratio',
ResolutionLimit = 'resolution-limit',
EncodingJitterBuffer = 'encoding-jitter-buffer',
EncodingProfile = 'encoding-profile',
PlayoutBuffer = 'playout-buffer',
Ingest = 'ingest',
MultiBitrateMode = 'multi-bitrate-mode',
Quality = 'quality'
}
export enum CapabilitiesType {
RemoteUriPublishing = 'remote-uri-publishing',
Publishing = 'publishing',
Viewing = 'viewing',
Forking = 'forking',
Quality = 'quality'
}
export const capabilities: IAdvancedSelectItem[] = [
{
value: VideoQuality.Xhd,
type: [CapabilitiesType.Quality],
set: [CapabilitiesSet.Quality]
},
{
value: VideoQuality.Fhd,
type: [CapabilitiesType.Quality],
set: [CapabilitiesSet.Quality]
},
{
value: VideoQuality.Hd,
type: [CapabilitiesType.Quality],
set: [CapabilitiesSet.Quality]
},
{
value: VideoQuality.Sd,
type: [CapabilitiesType.Quality],
set: [CapabilitiesSet.Quality]
},
{
value: VideoQuality.Ld,
type: [CapabilitiesType.Quality],
set: [CapabilitiesSet.Quality]
},
{
value: VideoQuality.Vld,
type: [CapabilitiesType.Quality],
set: [CapabilitiesSet.Quality]
},
{
value: VideoQuality.Uld,
type: [CapabilitiesType.Quality],
set: [CapabilitiesSet.Quality]
},
{
value: 'prefer-vp8',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking]
},
{
value: 'prefer-vp9',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking]
},
{
value: 'prefer-h264',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking]
},
{
value: 'streaming',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Viewing, CapabilitiesType.Forking]
},
{
value: 'on-demand',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Viewing, CapabilitiesType.Forking]
},
{
value: 'streaming-lite',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['streaming']
},
{
value: 'on-demand-lite',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['on-demand']
},
{
value: 'multi-bitrate',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.MultiBitrateMode]
},
{
value: 'multi-bitrate-contribution',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.MultiBitrateMode]
},
{
value: 'multi-bitrate-codec=vp8',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking]
},
{
value: 'multi-bitrate-codec=vp9',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking]
},
{
value: 'multi-bitrate-codec=h264',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking]
},
{
value: 'aspect-ratio=16x9',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['multi-bitrate'],
set: [CapabilitiesSet.AspectRatio]
},
{
value: 'aspect-ratio=4x3',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['multi-bitrate'],
set: [CapabilitiesSet.AspectRatio]
},
{
value: 'aspect-ratio=9x16',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['multi-bitrate'],
set: [CapabilitiesSet.AspectRatio]
},
{
value: 'aspect-ratio=3x4',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['multi-bitrate'],
set: [CapabilitiesSet.AspectRatio]
},
{
value: 'resolution-limit=480',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing],
set: [CapabilitiesSet.ResolutionLimit]
},
{
value: 'resolution-limit=720',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing],
set: [CapabilitiesSet.ResolutionLimit]
},
{
value: 'bitrate-limit=1000000',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking]
},
{
value: 'encoding-jitter-buffer=PT0.5S',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.EncodingJitterBuffer]
},
{
value: 'encoding-jitter-buffer=PT1.0S',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.EncodingJitterBuffer]
},
{
value: 'encoding-jitter-buffer=PT2.0S',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.EncodingJitterBuffer]
},
{
value: 'encoding-profile=phenix-2020-1080p',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['multi-bitrate', 'multi-bitrate-contribution'],
set: [CapabilitiesSet.EncodingProfile]
},
{
value: 'encoding-profile=phenix-2020-720p',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['multi-bitrate', 'multi-bitrate-contribution'],
set: [CapabilitiesSet.EncodingProfile]
},
{
value: 'encoding-profile=phenix-2020-480p',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['multi-bitrate', 'multi-bitrate-contribution'],
set: [CapabilitiesSet.EncodingProfile]
},
{
value: 'playout-buffer=PT0.3S',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.PlayoutBuffer]
},
{
value: 'playout-buffer=PT0.5S',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.PlayoutBuffer]
},
{
value: 'playout-buffer=PT0.8S',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.PlayoutBuffer]
},
{
value: 'playout-buffer=PT1.0S',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.PlayoutBuffer]
},
{
value: 'playout-buffer=PT2.0S',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.PlayoutBuffer]
},
{
value: 'audio-only',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Viewing, CapabilitiesType.Forking, CapabilitiesType.Quality],
set: [CapabilitiesSet.TrackIsolation, CapabilitiesSet.Quality]
},
{
value: 'video-only',
type: [CapabilitiesType.Viewing, CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
set: [CapabilitiesSet.TrackIsolation]
},
{
value: 'high-fidelity',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking]
},
{
value: 'on-demand-archive=PT1D',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing, CapabilitiesType.Forking],
dependency: ['on-demand']
},
{
value: 'token-auth',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing],
dependency: ['streaming']
},
{
value: 'source-uri-ingest',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing],
set: [CapabilitiesSet.Ingest]
},
{
value: 'mpegts-unicast-ingest',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing],
set: [CapabilitiesSet.Ingest]
},
{
value: 'mpegts-multicast-ingest',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing],
set: [CapabilitiesSet.Ingest]
},
{
value: 'time-shift-manifest',
type: [CapabilitiesType.RemoteUriPublishing, CapabilitiesType.Publishing]
},
{
value: 'replay',
type: [CapabilitiesType.Viewing]
}
];

15
src/constants/data.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export const legacyStreamTokens = [
'streamTokenForBroadcastStream',
'streamTokenForLiveStream',
'streamTokenForLiveStreamWithDrmOpenAccess',
'streamTokenForLiveStreamWithDrmHollywood',
'streamToken'
];
export enum ViewContextTypes {
Channel = 'channel',
Room = 'room'
}

View File

@@ -0,0 +1,178 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
const commonErrorMessages = {
unauthorized: 'The streaming platform was not able to authorize the provided credentials',
capacity: 'The system is temporarily overloaded. Please try again later',
'rate-limited': 'The system is temporarily overloaded. Please try again later',
'time-exceeded': 'The request took to long to execute. Please try again later'
};
export const reportsErrorMessages = {
'excessive-report-interval': 'The interval for the report exceed 1 month',
'period-start-outside-supported-window': 'The year of the start of period is more than 1 year before the current year',
'period-end-must-be-in-past': 'The end of period is in future',
'period-end-must-be-after-start': 'The end of period is less than or equal to the start of the period',
'malformed-version-in-request-query-parameter': 'The supplied version in HTTP query parameter does not conform to the format "YYYY-MM-dd"',
'version-does-not-exist': 'The supplied version in HTTP query parameter does not exist',
unsupported: 'The parameter combination is not supported',
unauthorized: 'The streaming platform was not able to authorize the provided credentials',
'request-timeout': 'Request timed out likely due to temporary resource or network conditions. Please try again',
capacity: 'The system is temporarily overloaded. Please try again later',
default: 'Unable to generate report'
};
export const currentActivityErrorMessages = {
'socket-not-authenticated': 'Socket not authenticated',
'unable-to-fetch-data': 'Unable to fetch active users data. Please try again',
'by-country-missing': 'Missing data sorted by country'
};
export const summaryErrorMessages = {
'socket-not-authenticated': 'Socket not authenticated',
'unable-to-fetch-data': 'Unable to fetch usage statistics data. Please try again'
};
export const timeToFirstFrameErrorMessages = {
'socket-not-authenticated': 'Socket not authenticated',
'unable-to-fetch-data': 'Unable to fetch time to first frame data. Please try again'
};
export const usageErrorMessages = {
'socket-not-authenticated': 'Socket not authenticated',
'unable-to-fetch-data': 'Unable to fetch usage statistics data. Please try again'
};
export const concurrentAndIngestErrorMessages = {
...reportsErrorMessages,
'excessive-report-interval': 'The interval for the report exceed 1 day',
default: 'Unable to fetch data for the report'
};
export const forkingHistoryErrorMessages = {
...reportsErrorMessages,
'excessive-report-interval': 'The interval for the report exceed 1 day',
'maximal-result-size-exceeded': 'The size of the result exceeds the maximum allowed (10GB)',
'unable-to-clear-forking-history': 'Unable to clear forking history',
'unable-to-change-forking-history-data': 'Unable to change forking history data'
};
export const publishingHistoryErrorMessages = {
...reportsErrorMessages,
'excessive-report-interval': 'The interval for the report exceed 1 year',
'maximal-result-size-exceeded': 'The size of the result exceeds the maximum allowed (10GB)',
'unable-to-clear-publishing-history': 'Unable to clear publishing history',
'unable-to-change-publishing-history-data': 'Unable to change publishing history data'
};
export const messagesPageErrorMessages = {
...commonErrorMessages,
'not-found': 'The channel does not exist',
default: 'Unable to fetch messages'
};
export const channelListErrorMessages = {
...commonErrorMessages,
default: 'Unable to fetch channels list'
};
export const channelListPublishingStateErrorMessages = {
...commonErrorMessages,
'not-found': 'The channel was not found',
default: 'Unable to fetch publishing state'
};
export const roomsListErrorMessages = {
...commonErrorMessages,
default: 'Unable to fetch rooms list'
};
export const roomsListPublishingStateErrorMessages = {
...commonErrorMessages,
'not-found': 'The room was not found',
default: 'Unable to fetch publishing state'
};
export const forkChannelErrorMessages = {
...commonErrorMessages,
'not-found': 'One or both of the source and destination channels are not found',
default: 'Unable to fork a channel'
};
export const deleteChannelErrorMessages = {
...commonErrorMessages,
default: 'Unable to delete a channel'
};
export const deleteRoomErrorMessages = {
...commonErrorMessages,
default: 'Unable to delete a room'
};
export const createChannelErrorMessages = {
...commonErrorMessages,
'already-exists': 'The channel already exists',
'type-conflict': 'A room with the provided alias already exists',
default: 'Unable to create a channel'
};
export const createRoomErrorMessages = {
...commonErrorMessages,
'already-exists': 'The room already exists',
'type-conflict': 'A channel with the provided alias already exists',
default: 'Unable to create a room'
};
export const killChannelErrorMessages = {
...commonErrorMessages,
'not-found': 'The channel was not found',
default: 'Unable to kill a channel'
};
export const tokenErrorMessages = {
...commonErrorMessages,
default: 'Unable to generate a token'
};
export const playlistErrorMessages = {
...commonErrorMessages,
default: 'Unable to fetch playlist data'
};
export const channelDetailsErrorMessages = {
...commonErrorMessages,
'not-found': 'The channel does not exist',
default: 'Unable to fetch channel details'
};
export const channelStreamsErrorMessages = {
...commonErrorMessages,
'not found': 'The channel was not found',
default: 'Unable to fetch streams for this channel'
};
export const roomDetailsErrorMessages = {
...commonErrorMessages,
'not-found': 'The room does not exist',
default: 'Unable to fetch room details'
};
export const roomMembersErrorMessages = {
...commonErrorMessages,
'not-found': 'The room was not found',
default: 'Unable to fetch members for this room'
};
export const publishPullErrorMessages = {
...commonErrorMessages,
default: 'Unable to publish with uri'
};
export const qosErrorMessages = {
...reportsErrorMessages,
...commonErrorMessages,
'excessive-report-interval': 'The interval for the report exceed 1 day',
unsupported: 'The parameter combination is not supported',
'request-timeout': 'Request timed out likely due to temporary resource or network conditions. Please try again',
capacity: 'The system is temporarily overloaded. Please try again later'
};

7
src/constants/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export * from './capabilities';
export * from './data';
export * from './links';
export * from './error-messages';

26
src/constants/links.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export const documentationLinks = {
portal: 'https://phenixrts.com/docs/portal/',
createChannel: 'https://phenixrts.com/docs/sdk_ref/rest-api/channel/#creating-a-channel',
deleteChannel: 'https://phenixrts.com/docs/sdk_ref/rest-api/channel/#deleting-a-channel',
forkChannel: 'https://phenixrts.com/docs/sdk_ref/rest-api/channel/#fork-a-channel',
killChannel: 'https://phenixrts.com/docs/sdk_ref/rest-api/channel/#kill-a-channel',
createRoom: 'https://phenixrts.com/docs/sdk_ref/rest-api/room/#creating-a-room',
deleteRoom: 'https://phenixrts.com/docs/sdk_ref/rest-api/room/#deleting-a-room',
terminateStream: 'https://phenixrts.com/docs/sdk_ref/rest-api/overview/#terminating-a-stream',
RTMP: 'https://phenixrts.com/docs/integration-guides/rtmp/',
FFmpeg: 'https://phenixrts.com/docs/integration-guides/3rd-party-encoders/ffmpeg/',
WHIP: 'https://phenixrts.com/docs/integration-guides/whip/',
supportedStreamCapabilities: 'https://phenixrts.com/docs/knowledge-base/reference/capabilities/#supported-stream-capabilities',
releaseNotes: 'https://phenixrts.com/docs/portal/portal-release-notes/'
};
export const phenixWebSiteLinks = {
privacyPolicyProduction: 'https://www.phenixrts.com/privacy-policy',
privacyPolicyStaging: 'https://www-stg.phenixrts.com/privacy-policy',
termsOfServiceProduction: 'https://www.phenixrts.com/terms-of-service',
termsOfServiceStaging: 'https://www-stg.phenixrts.com/terms-of-service',
mainWebSite: 'https://www.phenixrts.com'
};

23
src/declaredTypes.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
type WindowProps = Window & typeof globalThis & {URL: URL};
type JsonSimpleType = number | string | boolean | null | undefined;
type JsonObjType = {
[key: string]: JsonSimpleType | JsonSimpleType[] | JsonObjType | JsonObjType[];
};
type JsonType = JsonSimpleType | JsonSimpleType[] | JsonObjType | JsonObjType[];
declare module 'config/version.json' {
const value: {version: string};
export default value;
}
declare module '*.gif';
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.svg';

View File

@@ -1,68 +1,4 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
padding: 0;
}

View File

@@ -1,8 +1,7 @@
import {createRoot} from 'react-dom/client';
import {Provider} from 'react-redux';
import store from './store';
import App from './App.tsx';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(

21
src/routers/index.tsx Normal file
View File

@@ -0,0 +1,21 @@
import {BrowserRouter, Route, Routes, Navigate} from 'react-router-dom';
import {ProtectedRoute} from 'components';
import {LoginForm, ChannelList} from 'views';
export default function Router() {
return (
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginForm />} />
{/* Protected routes */}
<Route path="/" element={<Navigate to="/channels" replace />} />
<Route path="/channels" element={<ProtectedRoute component={<ChannelList />} />} />
{/* Fallback route */}
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,41 @@
import {ApplicationCredentials, Channels, PCastApi, Reporting, Streams} from '@techniker-me/pcast-api';
export default class PCastApiService {
private static _instance: PCastApi;
public static initialize(pcastUri: string, applciationCredentials: ApplicationCredentials) {
PCastApiService._instance = PCastApi.create(pcastUri, applciationCredentials);
}
public static getInstance(): PCastApiService {
if (!PCastApiService._instance) {
throw new Error('PCastApiService has not been initialized');
}
return PCastApiService._instance;
}
static get channels(): Channels {
if (!PCastApiService._instance) {
throw new Error('PCastApiService has not been initialized');
}
return PCastApiService._instance.channels;
}
static get streams(): Streams {
if (!PCastApiService._instance) {
throw new Error('PCastApiService has not been initialized');
}
return PCastApiService._instance.streams;
}
static get reporting(): Reporting {
if (!PCastApiService._instance) {
throw new Error('PCastApiService has not been initialized');
}
return PCastApiService._instance.reporting;
}
}

View File

@@ -0,0 +1,6 @@
export default interface IUserDataStore {
setItem(key: string, value: string): void;
getItem(key: string): string | null;
removeItem(key: string): void;
clear(): void;
}

View File

@@ -0,0 +1,23 @@
import IUserDataStore from './IUserDataStore';
export class IndexedDB implements IUserDataStore {
static isSupported(): boolean {
return 'indexedDB' in window;
}
public getItem(key: string): string | null {
throw new Error('Not Implemented');
}
public setItem(key: string, value: string): void {
throw new Error('Not Implemented');
}
public removeItem(key: string): void {
throw new Error('Not Implemented');
}
public clear(): void {
throw new Error('Not Implemented');
}
}

View File

@@ -0,0 +1,23 @@
import IUserDataStore from './IUserDataStore';
export class LocalStorage implements IUserDataStore {
static isSupported(): boolean {
return 'localStorage' in window;
}
public getItem(key: string): string | null {
throw new Error('Not Implemented');
}
public setItem(key: string, value: string): void {
throw new Error('Not Implemented');
}
public removeItem(key: string): void {
throw new Error('Not Implemented');
}
public clear(): void {
throw new Error('Not Implemented');
}
}

View File

@@ -0,0 +1,23 @@
import IUserDataStore from './IUserDataStore';
export class ObjectStrore implements IUserDataStore {
static isSupported(): boolean {
return true;
}
public getItem(key: string): string | null {
throw new Error('Not Implemented');
}
public setItem(key: string, value: string): void {
throw new Error('Not Implemented');
}
public removeItem(key: string): void {
throw new Error('Not Implemented');
}
public clear(): void {
throw new Error('Not Implemented');
}
}

View File

@@ -0,0 +1,24 @@
import IUserDataStore from './IUserDataStore';
import {IndexedDB} from './IndexedDB';
import {LocalStorage} from './LocalStorage';
import {ObjectStrore} from './ObjectStore';
class UserDataStoreService {
private static _instance: IUserDataStore;
static {
if (IndexedDB.isSupported()) {
this._instance = new IndexedDB();
} else if (LocalStorage.isSupported()) {
this._instance = new LocalStorage();
} else {
this._instance = new ObjectStrore();
}
}
public static getInstance(): IUserDataStore {
return this._instance;
}
}
export default UserDataStoreService.getInstance();

View File

@@ -20,6 +20,7 @@ export interface IPhenixWebSocketResponse {
sessionId: string;
redirect: string;
roles: string[];
[key: string]: unknown;
}
export class PhenixWebSocket extends MQWebSocket {
@@ -60,7 +61,9 @@ export class PhenixWebSocket extends MQWebSocket {
public async sendMessage<T>(kind: PhenixWebSocketMessage, message: T): Promise<IPhenixWebSocketResponse> {
if (this._status.value !== PhenixWebSocketStatus.Online) {
throw new Error(`Unable to send message, web socket is not [Online] WebSocket status [${PhenixWebSocketStatusMapping.convertPhenixWebSocketStatusToPhenixWebSocketStatusType(this._status.value)}]`);
throw new Error(
`Unable to send message, web socket is not [Online] WebSocket status [${PhenixWebSocketStatusMapping.convertPhenixWebSocketStatusToPhenixWebSocketStatusType(this._status.value)}]`
);
}
this._pendingRequests++;
@@ -89,27 +92,27 @@ export class PhenixWebSocket extends MQWebSocket {
private initialize(): void {
super.onEvent('connected', () => {
this.setStatus(PhenixWebSocketStatus.Online);
})
});
super.onEvent('disconnected', () => {
this.setStatus(PhenixWebSocketStatus.Offline);
})
});
super.onEvent('error', (error: unknown) => {
this._logger.error('Error [%s]', error);
this.setStatus(PhenixWebSocketStatus.Error);
})
});
super.onEvent('reconnecting', () => {
this.setStatus(PhenixWebSocketStatus.Reconnecting);
})
});
super.onEvent('reconnected', () => {
this.setStatus(PhenixWebSocketStatus.Online);
})
});
super.onEvent('timeout', () => {
this.setStatus(PhenixWebSocketStatus.Error);
})
});
}
}
}

View File

@@ -0,0 +1,191 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
type NavigatorUAData = {
brands?: { brand: string; version: string }[];
mobile?: boolean;
platform?: string;
getHighEntropyValues?: (hints: string[]) => Promise<Record<string, string>>;
toJSON?: () => object;
};
export default class PlatformDetectionService {
private static readonly _userAgent: string = globalThis.navigator?.userAgent ?? '';
// @ts-expect-error NavigatorUAData is experimental and not defined in the lib dom yet https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
private static readonly _userAgentData: NavigatorUAData | undefined = globalThis.navigator?.userAgentData;
private static readonly _areClientHintsSupported: boolean = !!PlatformDetectionService._userAgentData;
private static _platform: string = 'Unknown';
private static _platformVersion: string = '';
private static _browserName: string = 'Unknown';
private static _browserVersion: string = '?';
private static _isWebview: boolean = false;
static {
if (PlatformDetectionService._areClientHintsSupported) {
PlatformDetectionService.initFromClientHints();
} else {
PlatformDetectionService.initFromUserAgent();
}
}
private constructor() {
throw new Error('PlatformDetectionService is a static class that may not be instantiated');
}
// ---- Public API ----
static get platform(): string {
return PlatformDetectionService._platform;
}
static get platformVersion(): string {
return PlatformDetectionService._platformVersion;
}
static get userAgent(): string {
return PlatformDetectionService._userAgent;
}
static get browserName(): string {
return PlatformDetectionService._browserName;
}
static get browserVersion(): string {
return PlatformDetectionService._browserVersion;
}
static get isWebview(): boolean {
return PlatformDetectionService._isWebview;
}
static get areClientHintsSupported(): boolean {
return PlatformDetectionService._areClientHintsSupported;
}
/**
* Optional async initialization for high-entropy values like platformVersion
*/
static async initAsync(): Promise<void> {
if (PlatformDetectionService._areClientHintsSupported && PlatformDetectionService._userAgentData?.getHighEntropyValues) {
const values = await PlatformDetectionService._userAgentData.getHighEntropyValues(['platformVersion']);
if (values.platformVersion) {
PlatformDetectionService._platformVersion = values.platformVersion;
}
}
}
// ---- Init strategies ----
private static initFromClientHints() {
const data = PlatformDetectionService._userAgentData as NavigatorUAData;
const nonChromiumBrand = data.brands?.find(b => b.brand !== 'Chromium');
PlatformDetectionService._browserName = nonChromiumBrand?.brand ?? 'Unknown';
PlatformDetectionService._browserVersion = nonChromiumBrand?.version ?? '?';
PlatformDetectionService._platform = data.platform ?? 'Unknown';
PlatformDetectionService._isWebview = PlatformDetectionService.extractIsWebviewFromUserAgent(); // Fallback check
}
private static initFromUserAgent() {
PlatformDetectionService._platform = PlatformDetectionService.extractPlatformFromUserAgent();
PlatformDetectionService._platformVersion = PlatformDetectionService.extractPlatformVersionFromUserAgent();
PlatformDetectionService._browserName = PlatformDetectionService.extractBrowserNameFromUserAgent();
PlatformDetectionService._browserVersion = PlatformDetectionService.extractBrowserVersionFromUserAgent();
PlatformDetectionService._isWebview = PlatformDetectionService.extractIsWebviewFromUserAgent();
}
// ---- Helpers ----
private static extractBrowserNameFromUserAgent(): string {
if (/Edg\//.test(PlatformDetectionService._userAgent)) {
return 'Edge';
}
if (/OPR\//.test(PlatformDetectionService._userAgent)) {
return 'Opera';
}
if (/Firefox\//.test(PlatformDetectionService._userAgent)) {
return 'Firefox';
}
if (/Trident\/.*rv:/.test(PlatformDetectionService._userAgent)) {
return 'IE';
}
if (/Chrome\//.test(PlatformDetectionService._userAgent)) {
return 'Chrome';
}
if (/Safari\//.test(PlatformDetectionService._userAgent)) {
return 'Safari';
}
if (/ReactNative\//.test(PlatformDetectionService._userAgent)) {
return 'ReactNative';
}
return 'Unknown';
}
private static extractBrowserVersionFromUserAgent(): string {
return (
PlatformDetectionService.matchVersion(/Edg\/([\d.]+)/) ??
PlatformDetectionService.matchVersion(/OPR\/([\d.]+)/) ??
PlatformDetectionService.matchVersion(/Firefox\/([\d.]+)/) ??
PlatformDetectionService.matchVersion(/rv:([\d.]+)/) ?? // IE
PlatformDetectionService.matchVersion(/Chrome\/([\d.]+)/) ??
PlatformDetectionService.matchVersion(/Version\/([\d.]+)/) ?? // Safari often uses "Version/"
PlatformDetectionService.matchVersion(/Safari\/([\d.]+)/) ??
PlatformDetectionService.matchVersion(/ReactNative\/([\d.]+)/) ??
'?'
);
}
private static extractPlatformFromUserAgent(): string {
if (/Windows/.test(PlatformDetectionService._userAgent)) {
return 'Windows';
}
if (/iPhone|iPad|iPod/.test(PlatformDetectionService._userAgent)) {
return 'iOS';
}
if (/Mac OS X/.test(PlatformDetectionService._userAgent)) {
return 'macOS';
}
if (/Android/.test(PlatformDetectionService._userAgent)) {
return 'Android';
}
if (/Linux/.test(PlatformDetectionService._userAgent)) {
return 'Linux';
}
return 'Unknown';
}
private static extractPlatformVersionFromUserAgent(): string {
switch (PlatformDetectionService._platform) {
case 'Windows':
return PlatformDetectionService.matchVersion(/Windows NT ([\d.]+)/) ?? '';
case 'iOS':
return PlatformDetectionService.matchVersion(/OS ([\d_]+)/)?.replace(/_/g, '.') ?? '';
case 'macOS':
return PlatformDetectionService.matchVersion(/Mac OS X ([\d_]+)/)?.replace(/_/g, '.') ?? '';
case 'Android':
return PlatformDetectionService.matchVersion(/Android ([\d.]+)/) ?? '';
default:
return '';
}
}
private static extractIsWebviewFromUserAgent(): boolean {
return (
/; wv/.test(PlatformDetectionService._userAgent) || // Android webview
(/Android/.test(PlatformDetectionService._userAgent) && /Version\/[\d.]+/.test(PlatformDetectionService._userAgent)) || // Some Android webviews
/(iPhone|iPod|iPad).*AppleWebKit(?!.*Safari)/.test(PlatformDetectionService._userAgent) // IOS webview
);
}
private static matchVersion(pattern: RegExp): string | null {
const match = PlatformDetectionService._userAgent.match(pattern);
return match ? match[1] : null;
}
}

View File

@@ -0,0 +1,73 @@
import {
authenticateCredentialsThunk,
setError,
selectIsAuthenticated,
selectIsLoading,
selectApplicationId,
selectSecret,
setUnauthorized
} from 'store/slices/Authentication.slice';
import {Middleware} from '@reduxjs/toolkit';
export const authenticateRequestMiddleware: Middleware = store => next => async action => {
const state = store.getState();
const isAuthenticated = selectIsAuthenticated(state);
const isLoading = selectIsLoading(state);
const applicationId = selectApplicationId(state);
const secret = selectSecret(state);
console.log(
'[authenticateRequest] action [%o] isAuthenticated [%o] isLoading [%o] applicationId [%o] secret [%o]',
action,
isAuthenticated,
isLoading,
applicationId,
secret
);
// Skip authentication middleware for authentication-related actions
if (
typeof action === 'object' &&
action !== null &&
'type' in action &&
typeof (action as any).type === 'string' &&
(action as any).type.startsWith('authentication/')
) {
return next(action);
}
// If already authenticated, proceed normally
if (isAuthenticated) {
return next(action);
}
// If currently loading, wait for it to complete
if (isLoading) {
return next(action);
}
// If no credentials, set unauthorized
if (!applicationId || !secret) {
console.log('[authenticateRequest] No credentials available, proceeding with action');
return next(setUnauthorized());
}
// We have credentials but are not authenticated, try to authenticate
try {
console.log('[authenticateRequest] Attempting auto-authentication');
// Use the Redux thunk to properly update the state
const authResult = await store.dispatch(authenticateCredentialsThunk({applicationId, secret}) as any);
if (authResult.type.endsWith('/rejected') || authResult.payload === 'Authentication failed') {
console.log('[authenticateRequest] Authentication failed');
return next(setUnauthorized());
}
console.log('[authenticateRequest] Auto-authentication successful, proceeding with action');
return next(action);
} catch (error) {
console.error('[authenticateRequest] Auto-authentication failed:', error);
return next(setUnauthorized());
}
};

View File

@@ -0,0 +1,3 @@
export * from './authenticationMiddleware';
export * from './promiseMiddleware';
export * from './loggerMiddleware';

View File

@@ -0,0 +1,13 @@
import {Middleware} from '@reduxjs/toolkit';
/**
* Logs all actions and states after they are dispatched.
*/
export const loggerMiddleware: Middleware = store => next => action => {
console.group((action as any).type);
console.info('dispatching', action);
const result = next(action);
console.log('next state', store.getState());
console.groupEnd();
return result;
};

View File

@@ -0,0 +1,9 @@
import {Middleware} from '@reduxjs/toolkit';
export const vanillaPromiseMiddleware: Middleware = store => next => (action: any) => {
if (typeof action.then !== 'function') {
return next(action);
}
return Promise.resolve(action).then((resolvedAction: any) => store.dispatch(resolvedAction));
};

View File

@@ -46,6 +46,18 @@ export const selectSessionInfo = createSelector([selectAuthentication], authenti
roles: authentication.roles
}));
export const selectApplicationId = createSelector([selectAuthentication], authentication => authentication.applicationId);
export const selectSecret = createSelector([selectAuthentication], authentication => authentication.secret);
export const selectSessionId = createSelector([selectAuthentication], authentication => authentication.sessionId);
export const selectRoles = createSelector([selectAuthentication], authentication => authentication.roles);
export const selectHasRole = createSelector([selectAuthentication, (_, role: string) => role], (authentication, role) => authentication.roles.includes(role));
export const selectIsOnline = createSelector([selectAuthentication], authentication => authentication.status === 'Online');
const authenticateCredentialsThunk = createAsyncThunk<IPhenixWebSocketResponse, {applicationId: string; secret: string}>(
'authentication/authenticate',
async (credentials, {rejectWithValue}) => {
@@ -56,6 +68,7 @@ const authenticateCredentialsThunk = createAsyncThunk<IPhenixWebSocketResponse,
} catch (error) {
// Convert error to serializable format
const errorMessage = error instanceof Error ? error.message : 'Authentication failed';
return rejectWithValue(errorMessage);
}
}
@@ -67,6 +80,7 @@ const signoutThunk = createAsyncThunk('authentication/signout', async (_, {rejec
} catch (error) {
// Convert error to serializable format
const errorMessage = error instanceof Error ? error.message : 'Signout failed';
return rejectWithValue(errorMessage);
}
});
@@ -95,14 +109,16 @@ const authenticationSlice = createSlice({
setSessionId: (state, action: PayloadAction<string>) => {
state.sessionId = action.payload;
},
setIsAuthenticated: (state, action: PayloadAction<boolean>) => {
state.isAuthenticated = action.payload;
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
setRoles: (state, action: PayloadAction<string[]>) => {
state.roles = action.payload;
},
setApplicationId: (state, action: PayloadAction<string>) => {
state.applicationId = action.payload;
setUnauthorized: state => {
state.isAuthenticated = false;
state.isLoading = false;
state.error = 'Unauthorized';
state.secret = null;
state.status = 'Offline';
state.roles = [];
}
},
extraReducers: builder => {
@@ -119,17 +135,18 @@ const authenticationSlice = createSlice({
state.sessionId = authenticationResponse.sessionId ?? null;
state.isAuthenticated = true;
state.roles = authenticationResponse.roles ?? [];
state.status = 'Online';
state.isLoading = false;
} else {
state.applicationId = null;
state.sessionId = null;
state.isAuthenticated = false;
state.secret = null;
state.roles = [];
state.status = 'Offline';
state.error = 'Invalid credentials. Please check your Application ID and Secret.';
state.isLoading = false;
}
state.status = 'Online';
state.isLoading = false;
state.error = null;
})
.addCase(authenticateCredentialsThunk.rejected, (state, action) => {
state.applicationId = null;
@@ -164,6 +181,6 @@ const authenticationSlice = createSlice({
}
});
export const {setIsLoading, setCredentials, clearState, setSessionId, setIsAuthenticated, setRoles, setApplicationId} = authenticationSlice.actions;
export const {setUnauthorized, setIsLoading, setCredentials, clearState, setSessionId, setError} = authenticationSlice.actions;
export {authenticateCredentialsThunk};
export default authenticationSlice.reducer;
export default authenticationSlice.reducer;

View File

@@ -0,0 +1,103 @@
import {createAsyncThunk, createSelector, createSlice, PayloadAction, WritableDraft} from '@reduxjs/toolkit';
import {ApplicationCredentials, Channel} from '@techniker-me/pcast-api';
import PCastApiService from 'services/PCastApi.service';
export interface IChannelsState {
isLoading: boolean;
channels: Channel[];
selectedChannel: Channel | null;
error: string | null;
}
export const initialChannelsState: IChannelsState = {
isLoading: false,
channels: [],
selectedChannel: null,
error: null
};
export const selectChannels = (state: {channels: IChannelsState}) => state.channels;
export const selectChannelList = createSelector([selectChannels], channels => channels.channels);
export const fetchChannelList = createAsyncThunk('channels/fetchChannelList', async (_, {rejectWithValue}) => {
try {
return PCastApiService.channels.list();
} catch (error) {
return rejectWithValue(error);
}
});
export const fetchChannelsListPublisherStatus = createAsyncThunk(
'channels/fetchChannelsListPublisherStatus',
async (channels: Channel[], {rejectWithValue}) => {
try {
const channelResponses = await Promise.all(
channels.map(async channel => {
const publisherCount = await PCastApiService.channels.getPublisherCount(channel.channelId);
return {
...channel,
isActivePublisher: publisherCount > 0
};
})
);
return channelResponses as Channel[];
} catch (error) {
return rejectWithValue(error);
}
}
);
const channelsSlice = createSlice({
name: 'channels',
initialState: {...initialChannelsState},
reducers: {
initializeChannels: (state, action: PayloadAction<{pcastUri: string; applicationCredentials: ApplicationCredentials}>) => {
PCastApiService.initialize(action.payload.pcastUri, action.payload.applicationCredentials);
state.isLoading = false;
state.error = null;
},
setChannels: (state, action: PayloadAction<Channel[]>) => {
state.channels = action.payload as WritableDraft<Channel>[];
},
setIsLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setSelectedChannel: (state, action: PayloadAction<Channel | null>) => {
state.selectedChannel = action.payload as WritableDraft<Channel> | null;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
}
},
extraReducers: builder => {
builder.addCase(fetchChannelList.pending, state => {
state.isLoading = true;
});
builder.addCase(fetchChannelList.fulfilled, (state, action) => {
state.channels = action.payload as WritableDraft<Channel[]>;
state.isLoading = false;
state.error = null;
});
builder.addCase(fetchChannelList.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
builder.addCase(fetchChannelsListPublisherStatus.pending, state => {
state.isLoading = true;
});
builder.addCase(fetchChannelsListPublisherStatus.fulfilled, (state, action) => {
state.channels = action.payload as WritableDraft<Channel[]>;
state.isLoading = false;
state.error = null;
});
builder.addCase(fetchChannelsListPublisherStatus.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
}
});
export const {initializeChannels, setChannels, setIsLoading, setSelectedChannel, setError} = channelsSlice.actions;
export default channelsSlice.reducer;

View File

@@ -1,10 +1,18 @@
import {configureStore} from '@reduxjs/toolkit';
import AuthenticationState from './slices/Authentication.slice';
import AuthenticationReducer from './slices/Authentication.slice';
import ChannelsReducer from './slices/Channels.slice';
import {authenticateRequestMiddleware, loggerMiddleware, vanillaPromiseMiddleware} from './middlewares';
const store = configureStore({
reducer: {
authentication: AuthenticationState
}
authentication: AuthenticationReducer,
channels: ChannelsReducer
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
thunk: true,
serializableCheck: false
}).concat(authenticateRequestMiddleware, vanillaPromiseMiddleware, loggerMiddleware)
});
export default store;

214
src/theme/README.md Normal file
View File

@@ -0,0 +1,214 @@
# Theme System - SOLID Principles Refactor
This document describes the refactored theme system that follows SOLID principles for better maintainability, extensibility, and testability.
## Architecture Overview
The theme system has been refactored from a monolithic class into a modular, extensible architecture:
```
theme/
├── interfaces/ # Type definitions (abstractions)
│ ├── ITheme.ts
│ ├── IColorSystem.ts
│ ├── ISpacingSystem.ts
│ ├── ITypographySystem.ts
│ └── IScreenSystem.ts
├── systems/ # Concrete implementations
│ ├── ColorSystem.ts
│ ├── SpacingSystem.ts
│ ├── TypographySystem.ts
│ └── ScreenSystem.ts
├── utils/ # Utility functions
│ ├── ColorUtils.ts
│ └── ViewportUtils.ts
├── examples/ # Usage examples
│ └── ThemeUsageExamples.ts
├── ThemeFactory.ts # Factory for creating themes
└── index.ts # Main entry point
```
## SOLID Principles Implementation
### 1. Single Responsibility Principle (SRP)
- **ColorSystem**: Only handles color definitions
- **SpacingSystem**: Only handles spacing values
- **TypographySystem**: Only handles typography settings
- **ScreenSystem**: Only handles responsive breakpoints
- **ColorUtils**: Only handles color-related utilities
- **ViewportUtils**: Only handles viewport-related utilities
### 2. Open/Closed Principle (OCP)
- The system is open for extension but closed for modification
- New color systems, spacing systems, etc. can be created by implementing the respective interfaces
- No need to modify existing code when adding new themes
### 3. Liskov Substitution Principle (LSP)
- All implementations can be substituted for their interfaces
- Custom color systems can replace the default ColorSystem without breaking functionality
### 4. Interface Segregation Principle (ISP)
- Interfaces are focused and specific
- Clients only depend on the interfaces they actually use
- No fat interfaces that force unnecessary dependencies
### 5. Dependency Inversion Principle (DIP)
- High-level modules depend on abstractions (interfaces)
- Low-level modules implement these abstractions
- Factory pattern enables dependency injection
## Usage Examples
### Basic Usage (Backward Compatible)
```typescript
import Theme from './theme';
// Old way (still works)
const primaryColor = Theme.colors.blue;
const spacing = Theme.paddings.medium;
// New way (recommended)
const theme = Theme.instance;
const newPrimaryColor = theme.colors.blue;
const newSpacing = theme.spacing.medium;
```
### Creating Custom Themes
```typescript
import { ThemeFactory, ColorSystem, SpacingSystem, TypographySystem, ScreenSystem } from './theme';
// Create a custom color system
class DarkColorSystem extends ColorSystem {
readonly white = '#1a1a1a';
readonly black = '#ffffff';
// ... override other colors
}
// Create custom theme
const darkTheme = ThemeFactory.createCustomTheme(
new DarkColorSystem(),
new SpacingSystem(),
new TypographySystem(),
new ScreenSystem()
);
// Apply the theme
Theme.setTheme(darkTheme);
```
### Using in React Components
```typescript
import Theme from './theme';
function MyComponent() {
const theme = Theme.instance;
return (
<div style={{
backgroundColor: theme.colors.primaryBackground,
color: theme.colors.white,
padding: theme.spacing.medium,
fontFamily: theme.typography.primaryFont
}}>
Content
</div>
);
}
```
## API Reference
### Theme Class
```typescript
class Theme {
// Get the current theme instance
static get instance(): ITheme;
// Set a custom theme
static setTheme(theme: ITheme): void;
// Reset to default theme
static resetToDefault(): void;
// Backward compatibility
static get colors(): IColorSystem;
static get paddings(): ISpacingSystem;
static get theme(): ITheme;
}
```
### ThemeFactory
```typescript
class ThemeFactory {
// Create default theme
static createDefaultTheme(): ITheme;
// Create custom theme
static createCustomTheme(
colorSystem: IColorSystem,
spacingSystem: IExtendedSpacingSystem,
typographySystem: ITypographySystem,
screenSystem: IScreenSystem
): ITheme;
}
```
### Utility Functions
```typescript
// Color utilities
ColorUtils.hexToRgba(hex: string, opacityPercentage: number): string | null;
// Viewport utilities
ViewportUtils.pxToVw(screenWidthInPixels: number, elementSizeInPixels: number): string;
```
## Migration Guide
### From Old Theme System
The refactored system maintains backward compatibility. Existing code will continue to work:
```typescript
// This still works
const color = Theme.colors.blue;
const padding = Theme.paddings.medium;
const themeConfig = Theme.theme;
// But this is recommended
const theme = Theme.instance;
const color = theme.colors.blue;
const padding = theme.spacing.medium;
```
### Benefits of Migration
1. **Better Performance**: Lazy loading and singleton pattern
2. **Extensibility**: Easy to create custom themes
3. **Testability**: Each component can be tested in isolation
4. **Maintainability**: Clear separation of concerns
5. **Type Safety**: Strong typing with interfaces
## Best Practices
1. **Use the new API**: Prefer `Theme.instance` over static properties
2. **Create custom themes**: Use the factory pattern for theme variations
3. **Implement interfaces**: When extending, implement the appropriate interfaces
4. **Use utility classes**: Leverage ColorUtils and ViewportUtils for common operations
5. **Test in isolation**: Each system can be unit tested independently
## Performance Considerations
The refactored system prioritizes performance through:
- **Singleton Pattern**: Single theme instance across the application
- **Lazy Loading**: Theme is created only when first accessed
- **Immutable Properties**: Readonly properties prevent accidental mutations
- **Efficient Factory**: Minimal overhead in theme creation
This architecture ensures the theme system is both performant and maintainable while following industry best practices for object-oriented design.

139
src/theme/ThemeFactory.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import { ITheme } from './interfaces/ITheme';
import { IColorSystem } from './interfaces/IColorSystem';
import { IExtendedSpacingSystem } from './interfaces/ISpacingSystem';
import { ITypographySystem } from './interfaces/ITypographySystem';
import { IScreenSystem } from './interfaces/IScreenSystem';
import { IBackgroundSystem } from './interfaces/IBackgroundSystem';
import { ColorSystem } from './defaultTheme/ColorSystem';
import { SpacingSystem } from './defaultTheme/SpacingSystem';
import { TypographySystem } from './defaultTheme/TypographySystem';
import { ScreenSystem } from './defaultTheme/ScreenSystem';
import { BackgroundSystem } from './defaultTheme/BackgroundSystem';
export class ThemeFactory {
/**
* Creates a default theme instance
* @returns A complete theme object
*/
static createDefaultTheme(): ITheme {
const colors = new ColorSystem();
const spacing = new SpacingSystem();
const typography = new TypographySystem();
const screenSizes = new ScreenSystem();
const backgrounds = new BackgroundSystem();
return new DefaultTheme(colors, spacing, typography, screenSizes, backgrounds);
}
/**
* Creates a custom theme with provided systems
* @param colorSystem - Custom color system
* @param spacingSystem - Custom spacing system
* @param typographySystem - Custom typography system
* @param screenSystem - Custom screen system
* @returns A complete theme object
*/
static createCustomTheme(
colorSystem: IColorSystem,
spacingSystem: IExtendedSpacingSystem,
typographySystem: ITypographySystem,
screenSystem: IScreenSystem,
backgroundSystem?: IBackgroundSystem
): ITheme {
const backgrounds = backgroundSystem || new BackgroundSystem();
return new DefaultTheme(colorSystem, spacingSystem, typographySystem, screenSystem, backgrounds);
}
}
class DefaultTheme implements ITheme {
constructor(
public readonly colors: IColorSystem,
public readonly spacing: IExtendedSpacingSystem,
public readonly typography: ITypographySystem,
public readonly screenSizes: IScreenSystem,
public readonly backgrounds: IBackgroundSystem
) {}
readonly footerHeight = '51px';
readonly headerAllowance = 200;
readonly formFieldWidth = 350;
readonly formFieldMaxWidth = 500;
readonly formFieldMaxHeight = 250;
readonly inputIconWidth = 16;
get primaryColor(): string {
return this.colors.blue;
}
get secondaryColor(): string {
return this.colors.gray700;
}
get successColor(): string {
return this.colors.green;
}
get infoColor(): string {
return this.colors.cyan;
}
get warningColor(): string {
return this.colors.yellow;
}
get dangerColor(): string {
return this.colors.red;
}
get secondaryLight(): string {
return this.colors.gray800;
}
get secondaryDark(): string {
return this.colors.gray500;
}
get primaryThemeColor(): string {
return this.colors.lightBlue;
}
get primaryBackground(): string {
return this.colors.gray900;
}
get linkColor(): string {
return this.colors.green;
}
get blackWithOpacity(): string {
return this.colors.blackWithOpacity;
}
get primaryInputHeight(): string {
return '2.2rem';
}
get primaryBorderColor(): string {
return this.colors.gray400;
}
get primaryBorderWidth(): string {
return '1px';
}
get primaryBorderRadius(): string {
return '1.2rem';
}
get loaderSize() {
return {
small: 12,
medium: 20,
large: 50
};
}
}

View File

@@ -0,0 +1,11 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import { IBackgroundSystem } from '../interfaces/IBackgroundSystem';
import bgImage from '../../assets/images/background-1415x959.png';
export class BackgroundSystem implements IBackgroundSystem {
readonly loginBackground = bgImage;
readonly defaultBackground = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
}

View File

@@ -0,0 +1,39 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import { IColorSystem } from '../interfaces/IColorSystem';
export class ColorSystem implements IColorSystem {
readonly white = '#F9F9F9';
readonly whiteWithOpacity = 'rgba(249, 249, 249, 0.5)';
readonly gray100 = '#f8f9fa';
readonly gray200 = '#ebebeb';
readonly gray300 = '#dee2e6';
readonly gray400 = '#ced4da';
readonly gray500 = '#adb5bd';
readonly gray600 = '#999';
readonly gray700 = '#444';
readonly gray800 = '#303030';
readonly gray900 = '#222222';
readonly gray1000 = '#1f1f1f';
readonly black = '#000000';
readonly blue = '#375a7f';
readonly indigo = '#6610f2';
readonly purple = '#6f42c1';
readonly pink = '#e83e8c';
readonly lightBlue = '#66b3ff';
readonly linkBlue = 'blue';
readonly red = '#EE2D52';
readonly lightRed = '#f70d1a';
readonly orange = '#fd7e14';
readonly yellow = '#F39C12';
readonly green = '#00bc8c';
readonly teal = '#20c997';
readonly cyan = '#3498DB';
readonly headerColor = '#2C2D37';
readonly transparent = 'transparent';
readonly blackWithOpacity = 'rgba(0, 0, 0, 0.95)';
readonly halfTransparentBlack = 'rgba(0, 0, 0, 0.5)';
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import { IScreenSystem } from '../interfaces/IScreenSystem';
export class ScreenSystem implements IScreenSystem {
readonly large = 1170;
readonly desktop = 992;
readonly tablet = 768;
readonly phone = 660;
readonly smallPhone = 360;
readonly mediaPhone = `@media only screen
and (max-device-width: 737px)
and (-webkit-min-device-pixel-ratio: 2)
`;
readonly mediaLargeScreen = `@media only screen
and (min-width: 1920px)
`;
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import { IExtendedSpacingSystem } from '../interfaces/ISpacingSystem';
export class SpacingSystem implements IExtendedSpacingSystem {
readonly xsmall = '0.25rem';
readonly small = '0.5rem';
readonly medium = '1rem';
readonly large = '1.5rem';
readonly xlarge = '2rem';
readonly xxSmall = '4px';
readonly xSmall = '8px';
readonly xLarge = '48px';
readonly xxLarge = '64px';
}

View File

@@ -0,0 +1,19 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import { ITypographySystem } from '../interfaces/ITypographySystem';
export class TypographySystem implements ITypographySystem {
readonly primaryFontSize = '0.9375rem';
readonly primaryLineHeight = '1.6rem';
readonly primaryFont = '\'Montserrat\', Helvetica, Arial, sans-serif';
readonly fontSizeXS = '0.65rem';
readonly fontSizeS = '0.8rem';
readonly fontSizeL = '1.2rem';
readonly fontSizeXl = '1.5rem';
readonly fontSizeXxl = '2rem';
readonly fontSizeXxxl = '2.5rem';
readonly fontSizeXxxxl = '3rem';
}

63
src/theme/index.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export type { ITheme } from './interfaces/ITheme';
export type { IColorSystem } from './interfaces/IColorSystem';
export type { ISpacingSystem, IExtendedSpacingSystem } from './interfaces/ISpacingSystem';
export type { ITypographySystem } from './interfaces/ITypographySystem';
export type { IScreenSystem } from './interfaces/IScreenSystem';
export type { IBackgroundSystem } from './interfaces/IBackgroundSystem';
export { ColorSystem } from './defaultTheme/ColorSystem';
export { SpacingSystem } from './defaultTheme/SpacingSystem';
export { TypographySystem } from './defaultTheme/TypographySystem';
export { ScreenSystem } from './defaultTheme/ScreenSystem';
export { BackgroundSystem } from './defaultTheme/BackgroundSystem';
export { ThemeFactory } from './ThemeFactory';
export { ColorUtils } from './utils/ColorUtils';
export { ViewportUtils } from './utils/ViewportUtils';
import { ITheme } from './interfaces/ITheme';
import { ThemeFactory } from './ThemeFactory';
import { ColorUtils } from './utils/ColorUtils';
import { ViewportUtils } from './utils/ViewportUtils';
export class Theme {
private static _instance: ITheme = ThemeFactory.createDefaultTheme();
static get instance(): ITheme {
return Theme._instance;
}
static setTheme(theme: ITheme): void {
Theme._instance = theme;
}
static resetToDefault(): void {
Theme._instance = ThemeFactory.createDefaultTheme();
}
// Backward compatibility properties
static get colors() {
return Theme.instance.colors;
}
static get paddings() {
return {
xsmall: Theme.instance.spacing.xsmall,
small: Theme.instance.spacing.small,
medium: Theme.instance.spacing.medium,
large: Theme.instance.spacing.large,
xlarge: Theme.instance.spacing.xlarge
};
}
static get theme() {
return Theme.instance;
}
}
export default Theme.instance
export const theme = Theme.instance;
export const pxToVw = ViewportUtils.pxToVw;
export const hexToRgba = ColorUtils.hexToRgba;

View File

@@ -0,0 +1,8 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export interface IBackgroundSystem {
readonly loginBackground: string;
readonly defaultBackground: string;
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export interface IColorSystem {
readonly white: string;
readonly whiteWithOpacity: string;
readonly gray100: string;
readonly gray200: string;
readonly gray300: string;
readonly gray400: string;
readonly gray500: string;
readonly gray600: string;
readonly gray700: string;
readonly gray800: string;
readonly gray900: string;
readonly gray1000: string;
readonly black: string;
readonly blue: string;
readonly indigo: string;
readonly purple: string;
readonly pink: string;
readonly lightBlue: string;
readonly linkBlue: string;
readonly red: string;
readonly lightRed: string;
readonly orange: string;
readonly yellow: string;
readonly green: string;
readonly teal: string;
readonly cyan: string;
readonly headerColor: string;
readonly transparent: string;
readonly blackWithOpacity: string;
readonly halfTransparentBlack: string;
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export interface IScreenSystem {
readonly large: number;
readonly desktop: number;
readonly tablet: number;
readonly phone: number;
readonly smallPhone: number;
readonly mediaPhone: string;
readonly mediaLargeScreen: string;
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export interface ISpacingSystem {
readonly xsmall: string;
readonly small: string;
readonly medium: string;
readonly large: string;
readonly xlarge: string;
}
export interface IExtendedSpacingSystem extends ISpacingSystem {
readonly xxSmall: string;
readonly xSmall: string;
readonly xLarge: string;
readonly xxLarge: string;
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import { IColorSystem } from './IColorSystem';
import { IExtendedSpacingSystem } from './ISpacingSystem';
import { ITypographySystem } from './ITypographySystem';
import { IScreenSystem } from './IScreenSystem';
import { IBackgroundSystem } from './IBackgroundSystem';
export interface ITheme {
readonly colors: IColorSystem;
readonly spacing: IExtendedSpacingSystem;
readonly typography: ITypographySystem;
readonly screenSizes: IScreenSystem;
readonly backgrounds: IBackgroundSystem;
readonly footerHeight: string;
readonly primaryColor: string;
readonly secondaryColor: string;
readonly successColor: string;
readonly infoColor: string;
readonly warningColor: string;
readonly dangerColor: string;
readonly secondaryLight: string;
readonly secondaryDark: string;
readonly headerAllowance: number;
readonly primaryThemeColor: string;
readonly primaryBackground: string;
readonly linkColor: string;
readonly blackWithOpacity: string;
readonly primaryInputHeight: string;
readonly formFieldWidth: number;
readonly formFieldMaxWidth: number;
readonly formFieldMaxHeight: number;
readonly inputIconWidth: number;
readonly primaryBorderColor: string;
readonly primaryBorderWidth: string;
readonly primaryBorderRadius: string;
readonly loaderSize: {
readonly small: number;
readonly medium: number;
readonly large: number;
};
}

View File

@@ -0,0 +1,16 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export interface ITypographySystem {
readonly primaryFontSize: string;
readonly primaryLineHeight: string;
readonly primaryFont: string;
readonly fontSizeXS: string;
readonly fontSizeS: string;
readonly fontSizeL: string;
readonly fontSizeXl: string;
readonly fontSizeXxl: string;
readonly fontSizeXxxl: string;
readonly fontSizeXxxxl: string;
}

View File

@@ -0,0 +1,31 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export class ColorUtils {
/**
* Converts a hex color to RGBA format
* @param hex - The hex color string (with or without #)
* @param opacityPercentage - Opacity as percentage (1-100)
* @returns RGBA string or null if invalid hex
*/
static hexToRgba(hex: string, opacityPercentage: number): string | null {
const rgbParsed = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!rgbParsed) {
return null;
}
const rgb = {
r: parseInt(rgbParsed[1], 16),
g: parseInt(rgbParsed[2], 16),
b: parseInt(rgbParsed[3], 16)
};
const rgba = opacityPercentage > 100 || opacityPercentage < 1 ?
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)` :
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacityPercentage / 100})`;
return rgba;
}
}

View File

@@ -0,0 +1,15 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export class ViewportUtils {
/**
* Converts pixels to viewport width units
* @param screenWidthInPixels - The screen width in pixels
* @param elementSizeInPixels - The element size in pixels
* @returns Viewport width string
*/
static pxToVw(screenWidthInPixels: number, elementSizeInPixels: number): string {
return `${100 * elementSizeInPixels / screenWidthInPixels}vw`;
}
}

View File

@@ -0,0 +1,44 @@
import {JSX, useEffect} from 'react';
import {useAppDispatch, useAppSelector} from 'store';
import {fetchChannelsListPublisherStatus, selectChannels, fetchChannelList} from 'store/slices/Channels.slice';
import {Channel} from '@techniker-me/pcast-api';
type ChannelWithPublisherStatus = Channel & {
isActivePublisher: boolean;
};
export function ChannelList(): JSX.Element {
const channelsList = useAppSelector(selectChannels);
const dispatch = useAppDispatch();
useEffect(() => {
const initializeChannels = async () => {
const channelListResult = await dispatch(fetchChannelList());
if (channelListResult.payload && Array.isArray(channelListResult.payload) && channelListResult.payload.length > 0) {
dispatch(fetchChannelsListPublisherStatus(channelListResult.payload as ChannelWithPublisherStatus[]));
}
};
initializeChannels();
}, [dispatch]);
return (
<>
<h1>Channels List</h1>
<ul>
{channelsList.channels?.map(channel => {
const channelWithStatus = channel as ChannelWithPublisherStatus;
return (
<li key={channel.channelId}>
<div>
<div>{channel.name}</div>
{channelWithStatus.isActivePublisher && <div>Status: {channelWithStatus.isActivePublisher ? 'Active' : 'Inactive'}</div>}
</div>
</li>
);
})}
</ul>
</>
);
}

View File

@@ -0,0 +1 @@
export * from './ChannelList';

View File

@@ -0,0 +1,118 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {FC, useState, useEffect} from 'react';
import text from './text';
import {useAppDispatch, useAppSelector} from 'store/index';
import {authenticateCredentialsThunk, selectIsLoading, selectError, setError} from 'store/slices/Authentication.slice';
import {
LoginFormBackground,
LogoContainer,
LoginContainer,
LoginForm as StyledForm,
LoginButton,
LoginHeader,
LoginText,
ErrorText,
InputContainer,
InputField,
InputIcon,
Footer
} from './style';
import personImage from 'assets/images/symbol-person-24x24.png';
import lockImage from 'assets/images/symbol-lock-24x24.png';
import phenixLogo from 'assets/images/phenix-logo-101x41.png';
import { LoadingWheel } from 'components';
import links from './links';
export const LoginForm: FC = () => {
const {headerText, headerTextSmall, signInText} = text;
const [applicationId, setApplicationId] = useState('');
const [secret, setSecret] = useState('');
const dispatch = useAppDispatch();
const error = useAppSelector(selectError);
const isLoading = useAppSelector(selectIsLoading);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
dispatch(authenticateCredentialsThunk({applicationId, secret}));
};
const handleInputChange = (setter: (value: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
// Clear error when user starts typing
if (error) {
dispatch(setError(null));
}
setter(e.target.value);
};
useEffect(() => {
if (error) {
setTimeout(() => {
dispatch(setError(null));
}, 3000);
}
}, [error]);
return (
<LoginFormBackground>
<LogoContainer>
<img src={phenixLogo} alt="Phenix RTS" />
</LogoContainer>
<LoginContainer>
<LoginHeader>
{headerText}
</LoginHeader>
<LoginText>
{headerTextSmall}
</LoginText>
<StyledForm onSubmit={handleSubmit}>
<InputContainer>
<InputIcon src={personImage} alt="User" />
<InputField
type="text"
value={applicationId}
placeholder="Application ID"
onChange={handleInputChange(setApplicationId)}
disabled={isLoading}
required
/>
</InputContainer>
<InputContainer>
<InputIcon src={lockImage} alt="Lock" />
<InputField
type="password"
value={secret}
placeholder="Secret"
onChange={handleInputChange(setSecret)}
disabled={isLoading}
required
/>
</InputContainer>
{error && (
<ErrorText className="error-message testId-displayMessage">
{error}
</ErrorText>
)}
<LoginButton
type="submit"
disabled={isLoading}
className="testId-loginButton"
>
{isLoading ? <LoadingWheel size={'small'} /> : signInText}
</LoginButton>
</StyledForm>
<Footer>
© 2025 Phenix RTS
<br />
<a href={links.privacyPolicy} target="_blank">Privacy Policy</a> <a href={links.termsOfService} target="_blank">Terms of Service</a> <a href={links.documentation} target="_blank">Documentation</a>
</Footer>
</LoginContainer>
</LoginFormBackground>
);
};

View File

@@ -0,0 +1 @@
export * from './LoginForm';

View File

@@ -0,0 +1,6 @@
export default {
privacyPolicy: 'https://www.phenixrts.com/privacy-policy',
termsOfService: 'https://www.phenixrts.com/terms-of-service',
documentation: 'https://www.phenixrts.com/docs'
}

View File

@@ -0,0 +1,189 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import * as styled from 'styled-components';
import {H1, P} from 'components/typography';
import Theme from 'theme';
import phenixLogo from 'assets/images/phenix-logo-101x41.png';
export const LoginFormBackground = styled.default.div`
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
background-image: url(${Theme.backgrounds.loginBackground});
background-size: cover;
background-position: center;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1;
}
/* Ensure content is above the overlay */
> * {
position: relative;
z-index: 2;
}
`;
export const LogoContainer = styled.default.div`
position: absolute;
top: 2rem;
left: 2rem;
z-index: 3;
img {
height: 40px;
width: auto;
filter: brightness(0) invert(1); /* Makes the logo white */
}
`;
export const LoginContainer = styled.default.div`
background: rgba(25, 25, 25, 0.9);
border-radius: 12px;
padding: 2.5rem;
min-width: 400px;
max-width: 450px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
`;
export const LoginHeader = styled.default(H1)`
color: ${Theme.colors.white};
text-align: center;
font-size: 2.5rem;
font-weight: 300;
margin: 0 0 0.5rem 0;
`;
export const LoginText = styled.default(P)`
color: ${Theme.colors.white};
text-align: center;
margin-bottom: 2rem;
font-size: 1.1rem;
opacity: 0.9;
`;
export const LoginForm = styled.default.form`
display: flex;
flex-direction: column;
gap: 1rem;
`;
export const InputContainer = styled.default.div`
position: relative;
margin-bottom: 1rem;
`;
export const InputField = styled.default.input`
width: 100%;
padding: 1rem 1rem 1rem 3rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(35, 35, 35, 0.7);
color: ${Theme.colors.white};
font-size: 1rem;
outline: none;
transition: all 0.3s ease;
box-sizing: border-box;
&::placeholder {
color: ${Theme.colors.gray500};
}
&:focus {
border-color: ${Theme.colors.white};
background: rgba(45, 45, 45, 0.9);
}
`;
export const InputIcon = styled.default.img`
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
opacity: 0.7;
`;
export const LoginButton = styled.default.button`
background: linear-gradient(135deg, ${Theme.colors.red}, ${Theme.colors.lightRed});
color: ${Theme.colors.white};
border: none;
border-radius: 8px;
padding: 1rem;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1rem;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(238, 45, 82, 0.4);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
`;
export const LoginLink = styled.default.a`
color: ${Theme.colors.cyan};
cursor: pointer;
text-align: center;
margin-top: 1rem;
text-decoration: none;
font-size: 0.9rem;
opacity: 0.8;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
`;
export const ErrorText = styled.default.div`
text-align: center;
color: ${Theme.colors.red};
background: rgba(238, 45, 82, 0.1);
border: 1px solid rgba(238, 45, 82, 0.3);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
font-size: 0.9rem;
`;
export const Footer = styled.default.div`
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
a {
color: rgba(255, 255, 255, 0.5);
text-decoration: none;
margin: 0 0.3rem;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
`;

View File

@@ -0,0 +1,13 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export default {
phenixText: 'Phenix',
headerText: 'Customer Portal',
headerTextSmall: 'Please sign in',
loginForgottenText: 'Forgot password?',
signInText: 'Sign In',
failAuthenticationText: 'Failed to authenticate. Please check your credentials',
emptyCredentialsText: 'Please enter Application Id and Secret',
unknownErrorText: 'An error has occurred. Please try again'
};

View File

@@ -0,0 +1,93 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {
ChangeEvent,
useRef,
FC
} from 'react';
import text from './text';
import {InputWithRef} from 'components/inputs';
import Theme from 'theme';
import {
LoginLayout as Layout,
LoginButton,
LoginHeader,
LoginText,
LoginLink,
ErrorText
} from './style';
import personImage from 'assets/images/symbol-person-24x24.png';
import lockImage from 'assets/images/symbol-lock-24x24.png';
import { LoadingWheel } from 'components/loaders';
interface ILoginContainer {
onSubmit: () => void;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
errorMessage: string;
isLoading?: boolean;
isWebsocketConnected?: boolean;
}
export const Login: FC<ILoginContainer> = (props: ILoginContainer) => {
const {headerText, headerTextSmall, signInText, loginForgottenText} = text;
const {onChange, onSubmit, errorMessage, isLoading, isWebsocketConnected} = props;
const applicationIdRef = useRef(null);
const secretRef = useRef(null);
const handleKeySubmit = ({key}) => {
if (key !== 'Enter') {
return;
}
onSubmit();
};
return (
<Layout>
<LoginHeader>
{headerText}
</LoginHeader>
<LoginText>
{headerTextSmall}
</LoginText>
{/* <InputWithRef
imagePath={personImage}
imageAltText="Application Id"
onChange={onChange}
onKeyPress={handleKeySubmit}
name="applicationId"
type="text"
ref={applicationIdRef}
disabled={isLoading}
autocomplete="off"
/>
<InputWithRef
imagePath={lockImage}
imageAltText="Secret"
onChange={onChange}
onKeyPress={handleKeySubmit}
ref={secretRef}
name="secret"
type="password"
disabled={isLoading}
autocomplete="new-password"
/> */}
<ErrorText className="error-message testId-displayMessage">{errorMessage || null}</ErrorText>
<LoginButton
backgroundColor={Theme.colors.red}
onClick={onSubmit}
disabled={isLoading || !isWebsocketConnected}
className="testId-loginButton"
>
{(!isWebsocketConnected || isLoading) && <span><LoadingWheel size={'small'} /></span>}
{isWebsocketConnected && signInText}
</LoginButton>
<LoginLink>
{loginForgottenText}
</LoginLink>
</Layout>
);
};

View File

@@ -0,0 +1,149 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {useEffect, useRef, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import moment from 'moment';
import LoggerFactory from 'services/logger/LoggerFactory';
import {channelListErrorMessages, channelListPublishingStateErrorMessages} from 'constants/index';
import {transformToPortalError} from 'utility/error-handler';
import {ITableSortSearch} from 'interfaces/tableProps';
import {IAppStore} from 'store';
import {channelsSelector, listChannels} from 'store/action/channels';
import {fetchChannelsPublishingState} from 'store/action/channels-publishing';
import {StoreScreensType} from 'store/reducers/screens';
import {screensSelector, setScreenProps} from 'store/action/screens';
import Loader from 'components/loaders';
import RequireAuthentication from 'components/requiresAuth';
import {Body, Main} from 'components/layout';
import {TableHeaderKey, ITableWithPaginationHeader} from 'components/table';
import {TableWithPagination} from 'components/table-with-pagination';
import {Error} from 'components/error-renderer/style';
import {columns} from './columns-config';
import {CreateChannelModal} from './create-channel';
const intervalFetchChannelsPublishingState = moment.duration(5, 'seconds').asMilliseconds();
export const ChannelsContainer = (): JSX.Element => {
const logger = LoggerFactory.getLogger('views/channels/ChannelsContainer');
const dispatch = useDispatch();
const interval = useRef(null);
const {channelList = [], isFetching, error} = useSelector((state: IAppStore) => channelsSelector(state));
const {searchValue, sortDirection, sortColumn} = useSelector((state: IAppStore) => screensSelector(state)[StoreScreensType.Channels]);
const [isCreateChannelModalOpened, setCreateChannelModalOpened] = useState(false);
const [channels, setChannels] = useState([]);
const [channelsIdOnDisplay, setChannelsIdOnDisplay] = useState([]);
const channelsColumns = {...columns};
const getChannelList = async (): Promise<void> => {
try {
logger.info('Fetching the list of channels');
await dispatch(listChannels());
logger.info('List of channels was fetched successfully');
} catch (e) {
const {status, message, requestPayload} = transformToPortalError(e);
logger.error(`${channelListErrorMessages[status] || message} [%s]`, status, requestPayload);
}
};
const getChannelsPublishingState = async (): Promise<void> => {
if (isFetching || !channelsIdOnDisplay?.length) {
return;
}
logger.info('Checking channels publishing state');
try {
await dispatch(fetchChannelsPublishingState(channelsIdOnDisplay));
} catch (e) {
const {status, message, requestPayload} = transformToPortalError(e);
return logger.error(
`${channelListPublishingStateErrorMessages[status] || message || channelListPublishingStateErrorMessages['default']} [%s]`,
status,
requestPayload
);
}
};
useEffect(() => {
getChannelList();
}, []);
useEffect(() => {
const modifiedChannels = channelList.map(channel => ({
...channel,
extraPath: `${encodeURIComponent(channel.channelId)}/preview`
}));
setChannels(modifiedChannels || []);
}, [channelList]);
useEffect(() => {
clearInterval(interval.current);
interval.current = setInterval(getChannelsPublishingState, intervalFetchChannelsPublishingState);
return () => clearInterval(interval.current);
}, [channelsIdOnDisplay, isFetching]);
const screenHeader: ITableWithPaginationHeader = {
[TableHeaderKey.Search]: {},
[TableHeaderKey.AddRow]: {
openAddRowModal: () => {
setCreateChannelModalOpened(true);
}
}
};
const getCurrentDisplayList = (data: Record<string, string | number | null>[]) => {
const newChannelsIdOnDisplay: string[] = data.map(val => val?.channelId) as string[];
setChannelsIdOnDisplay(newChannelsIdOnDisplay);
};
const changeScreenProps = (data: Partial<ITableSortSearch>) =>
dispatch(
setScreenProps({
screen: StoreScreensType.Channels,
data
})
);
if (error) {
return <Error>{channelListErrorMessages[error.status] || error.message}</Error>;
}
return (
<>
<Body className="table-container">
{isFetching ? (
<Main>
<Loader />
</Main>
) : (
<TableWithPagination
title={'Channels'}
screenHeader={screenHeader}
columns={channelsColumns}
data={channels}
sortColumn={sortColumn}
sortDirection={sortDirection}
paginationItemText={'channels'}
getCurrentDisplayList={getCurrentDisplayList}
changeSortProps={changeScreenProps}
searchValue={searchValue}
changeSearch={changeScreenProps}
/>
)}
</Body>
{isCreateChannelModalOpened && <CreateChannelModal getChannelList={getChannelList} setCreateChannelModalOpened={setCreateChannelModalOpened} />}
</>
);
};
export default RequireAuthentication(ChannelsContainer);

View File

@@ -0,0 +1,65 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {faEllipsisV} from '@fortawesome/free-solid-svg-icons';
import {CellType, ColumnsType, DataRowType} from 'components/table';
import {ChannelIconMenu} from 'components/channel-icon-menu';
import {IconMenuPosition} from 'components/icon-menu/icon-menu';
import PublishingStateIndicator from 'components/indicator-component/publishing-state-indicator';
import {theme} from 'theme';
const ChannelPublishingStateIndicator = (row?: DataRowType) => <PublishingStateIndicator row={row} idKey="channelId" publishingStateKey="channelsPublishing" />;
export const columns: ColumnsType = {
indicator: {
title: '',
hasBorder: false,
width: 40,
type: CellType.Component,
renderCell: ChannelPublishingStateIndicator
},
name: {
title: 'Channel Name',
type: CellType.Link,
textCell: {propName: 'name'},
thStyle: {
textAlign: 'left',
paddingLeft: 16
}
},
alias: {
title: 'Alias',
textCell: {propName: 'alias'}
},
channelId: {
title: 'Channel Id',
hideColumnAt: theme.screenSizes.desktop,
textCell: {propName: 'channelId'}
},
streamKey: {
title: 'Stream Key',
hideColumnAt: theme.screenSizes.tablet,
textCell: {propName: 'streamKey'}
},
created: {
title: 'Created',
hideColumnAt: theme.screenSizes.large,
type: CellType.Date,
textCell: {propName: 'created'}
},
dropdown: {
title: '',
hasBorder: false,
width: 50,
type: CellType.DropDown,
dropdownCell: {
Component: ChannelIconMenu,
keys: ['channelId', 'name', 'alias'],
componentProps: {
icon: faEllipsisV,
showTail: false,
position: IconMenuPosition.Left
}
}
}
};

View File

@@ -0,0 +1,124 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
import {ChangeEvent, useState} from 'react';
import {faQuestionCircle} from '@fortawesome/free-solid-svg-icons';
import LoggerFactory from 'services/logger/LoggerFactory';
import {createChannel} from 'services/channel.service';
import {transformToPortalError} from 'utility/error-handler';
import {createChannelErrorMessages} from 'constants/error-messages';
import {documentationLinks} from 'constants/links';
import Loader from 'components/loaders';
import {Input} from 'components/forms/Input';
import {NewTabLink} from 'components/new-tab-link';
import {Label} from 'components/label';
import {DialogForm, Error, FormLoaderContainer} from 'components/modal/modal-form-response/style';
import {Modal} from 'components/modal';
interface ICreateChannelInput {
setCreateChannelModalOpened: React.Dispatch<React.SetStateAction<boolean>>;
getChannelList: () => Promise<void>;
}
const CreateChannelModal = ({setCreateChannelModalOpened, getChannelList}: ICreateChannelInput): JSX.Element => {
const logger = LoggerFactory.getLogger('view/channels/create-channel/CreateChannelModal');
const initialState = {
alias: '',
name: '',
description: ''
};
const [inputValues, setInputValue] = useState(initialState);
const {alias, name, description} = inputValues;
const [isFormValid, setIsFormValid] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
const {name, value} = event.target;
setInputValue({
...inputValues,
[name]: value
});
};
const handleClose = (): void => {
setCreateChannelModalOpened(false);
};
const handleSubmit = async (): Promise<void> => {
const valid = alias.trim() && name.trim() && description.trim();
if (!valid) {
setIsFormValid(false);
return;
}
setIsLoading(true);
try {
logger.info('Creating a channel with the following parameters : [%j]', {
alias,
name,
description
});
await createChannel(alias, name, description);
logger.info('Channel [%s] was created successfully', alias);
setInputValue(initialState);
setIsLoading(false);
await getChannelList();
handleClose();
} catch (e) {
const {status, message, requestPayload} = transformToPortalError(e);
setIsLoading(false);
setError(createChannelErrorMessages[status] || message || createChannelErrorMessages['default']);
logger.error(`${createChannelErrorMessages[status] || message || createChannelErrorMessages['default']} [%s]`, status, requestPayload);
}
};
return (
<Modal
close={handleClose}
submitButton={{
className: 'testId-createChannel',
disabled: isLoading,
onClick: handleSubmit,
label: 'Create Channel'
}}
cancelButton={{onClick: handleClose}}>
{isLoading ? (
<FormLoaderContainer>
<Loader color="dark" />
</FormLoaderContainer>
) : (
<DialogForm>
<h3 className="testId-createChannelForm">
Create Channel <NewTabLink link={documentationLinks.createChannel} icon={faQuestionCircle} iconColor="black" />
</h3>
<Label htmlFor="alias" text="Alias" />
<Input onChange={handleChange} value={alias} name="alias" placeholder={'Alias'} error={!isFormValid && !alias} />
{!isFormValid && !alias && <Error className="error-message testId-displayMessage">Alias can not be empty</Error>}
<Label htmlFor="name" text="Name" />
<Input onChange={handleChange} value={name} name="name" placeholder={'Name'} error={!isFormValid && !name} />
{!isFormValid && !name && <Error className="error-message testId-displayMessage">Name can not be empty</Error>}
<Label htmlFor="description" text="Description" />
<Input onChange={handleChange} value={description} name="description" placeholder={'Description'} error={!isFormValid && !description} />
{!isFormValid && !description && <Error className="error-message testId-displayMessage">Description can not be empty</Error>}
{error && <Error className="error-message testId-displayMessage">{error}</Error>}
</DialogForm>
)}
</Modal>
);
};
export default CreateChannelModal;

View File

@@ -0,0 +1,4 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export {default as CreateChannelModal} from './create-channel-modal';

View File

@@ -0,0 +1,4 @@
/**
* Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved.
*/
export {default} from './channels';

2
src/views/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './LoginForm';
export * from './ChannelList';