wrolpi/app/src/components/Theme.tsx
Roland 0ffbc05e91
Unifying video download settings so that global/channel/rss/video
download settings have inheritance.  Allowing more granular control of
potentially failed downloads.
2026-03-05 17:40:17 -07:00

659 lines
20 KiB
TypeScript

import React, {useContext, useEffect, useState, forwardRef, HTMLAttributes, CSSProperties} from 'react';
import {ThemeContext} from "../contexts/contexts";
import {
Accordion as SAccordion,
AccordionContent as SAccordionContent,
AccordionTitle as SAccordionTitle,
Breadcrumb as SBreadcrumb,
Button as SButton,
ButtonGroup as SButtonGroup,
Card as SCard,
Divider as SDivider,
Form as SForm,
FormField as SFormField,
FormGroup as SFormGroup,
FormInput as SFormInput,
Header as SHeader,
Icon as SIcon,
Loader as SLoader,
Menu as SMenu,
Modal as SModal,
ModalActions as SModalActions,
ModalContent as SModalContent,
ModalDescription as SModalDescription,
ModalHeader as SModalHeader,
Placeholder as SPlaceholder,
Popup as SPopup,
Progress as SProgress,
Segment as SSegment,
Statistic as SStatistic,
StatisticGroup as SStatisticGroup,
Tab as STab,
Table as STable,
TableBody as STableBody,
TableCell as STableCell,
TableFooter as STableFooter,
TableHeader as STableHeader,
TableHeaderCell as STableHeaderCell,
TableRow as STableRow,
TabPane as STabPane,
TextArea as STextArea,
AccordionContentProps,
AccordionProps,
AccordionTitleProps,
BreadcrumbProps,
ButtonProps,
ButtonGroupProps,
CardProps as SCardProps,
DividerProps,
FormProps,
FormFieldProps,
FormGroupProps,
FormInputProps,
HeaderProps,
IconProps,
LoaderProps,
MenuProps,
ModalProps,
ModalActionsProps,
ModalContentProps,
ModalDescriptionProps,
ModalHeaderProps,
PlaceholderProps,
PopupProps,
ProgressProps,
SegmentProps,
StatisticProps,
StatisticGroupProps,
TabProps,
TableProps,
TableBodyProps,
TableCellProps,
TableFooterProps,
TableHeaderProps,
TableHeaderCellProps,
TableRowProps,
TabPaneProps,
TextAreaProps,
} from "semantic-ui-react";
import {ColorToSemanticHexColor} from "./Vars";
import _ from "lodash";
import {ThemeContextValue, ThemeName, SavedThemeName} from "../types/theme";
export const darkTheme = 'dark';
export const lightTheme = 'light';
export const defaultTheme = lightTheme;
export const systemTheme = 'system';
export const themeSessionKey = 'color-scheme';
interface ThemeProviderProps {
children: React.ReactNode;
}
export function ThemeProvider({children, ...props}: ThemeProviderProps) {
if (!_.isEmpty(props)) {
console.log(props);
console.error('ThemeWrapper does not support props!');
}
// Properties to manipulate elements with theme.
// Invert when Semantic supports it. Example: <Menu {...i}>
const [i, setI] = useState<{inverted?: boolean}>({});
// Invert style when Semantic does not support it. Example: <p {...s}>This paragraph changes style</p>
const [s, setS] = useState<{style?: CSSProperties}>({});
// Invert text when Semantic does not support it.
const [t, setT] = useState<{style?: CSSProperties}>({});
const [inverted, setInverted] = useState<string>('');
// theme can be one of [darkTheme, lightTheme]
const [theme, setTheme] = useState<ThemeName>(lightTheme);
// savedTheme can be one of [null, darkTheme, lightTheme, systemTheme]
const [savedTheme, setSavedTheme] = useState<SavedThemeName>(
localStorage.getItem('color-scheme') as SavedThemeName
);
const setDarkTheme = (save = false) => {
console.debug('setDarkTheme');
setI({inverted: true});
setS({style: {backgroundColor: '#1B1C1D', color: '#dddddd'}});
setT({style: {color: '#eeeeee'}});
setInverted('inverted');
setTheme(darkTheme);
document.body.style.background = '#171616';
if (save) {
saveTheme(darkTheme);
}
}
const setLightTheme = (save = false) => {
console.debug('setLightTheme');
setI({inverted: undefined});
setS({});
setT({});
setInverted('');
setTheme(lightTheme);
document.body.style.background = '#FFFFFF';
if (save) {
saveTheme(lightTheme);
}
}
const saveTheme = (value: ThemeName) => {
setSavedTheme(value);
localStorage.setItem(themeSessionKey, value);
}
const cycleSavedTheme = (e?: React.MouseEvent) => {
// Cycle: System -> Dark -> Light
if (e) {
e.preventDefault();
}
if (savedTheme === systemTheme || savedTheme == null) {
saveTheme(darkTheme);
} else if (savedTheme === darkTheme) {
saveTheme(lightTheme);
} else if (savedTheme === lightTheme) {
saveTheme(systemTheme as ThemeName);
} else {
console.error(`Unknown theme! savedTheme=${savedTheme}`);
}
applyTheme();
}
const matchTheme = (): ThemeName => {
// Returns darkTheme if saved theme is dark, lightTheme if saved theme is dark,
// darkTheme if system prefers dark, otherwise lightTheme.
const colorScheme = localStorage.getItem(themeSessionKey);
setSavedTheme(colorScheme as SavedThemeName);
if (colorScheme === darkTheme) {
return darkTheme;
} else if (colorScheme === lightTheme) {
return lightTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? darkTheme : lightTheme;
}
const applyTheme = () => {
const match = matchTheme();
if (match === darkTheme) {
setDarkTheme();
} else {
setLightTheme();
}
};
useEffect(() => {
applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const themeValue: ThemeContextValue = {
i, // Used for Semantic elements which support "inverted".
s, // Used to invert the style some elements.
t, // Used to invert text.
inverted, // Used to add "invert" to className.
theme,
savedTheme,
setDarkTheme,
setLightTheme,
cycleSavedTheme,
};
return <ThemeContext.Provider value={themeValue}>
{children}
</ThemeContext.Provider>
}
// Simple wrappers for Semantic UI elements to use the current theme.
const invertedNull = <T extends {inverted?: boolean | null}>(props: T): T => {
if (props['inverted'] === true) {
return {...props, inverted: null};
}
return props;
}
const defaultGrey = <T extends object>(props: T, inverted: boolean): T => {
// Some elements look softer when grey, use grey only if another color is not provided.
if (inverted) {
return {color: 'grey', ...props} as T;
}
return props;
}
// ----------------------------------------------------------------------------
// Accordion Compound Component
// ----------------------------------------------------------------------------
type AccordionTitleComponent = React.FC<AccordionTitleProps>;
type AccordionContentComponent = React.FC<AccordionContentProps>;
interface AccordionComponent extends React.FC<AccordionProps> {
Title: AccordionTitleComponent;
Content: AccordionContentComponent;
}
const AccordionTitle: AccordionTitleComponent = (props) => {
return <SAccordionTitle {...props}/>
};
const AccordionContent: AccordionContentComponent = (props) => {
return <SAccordionContent {...props}/>
};
const AccordionBase: React.FC<AccordionProps> = (props) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
return <SAccordion {...mergedProps}/>
};
export const Accordion: AccordionComponent = Object.assign(AccordionBase, {
Title: AccordionTitle,
Content: AccordionContent,
});
// ----------------------------------------------------------------------------
// Button Compound Component
// ----------------------------------------------------------------------------
type ButtonGroupComponent = React.FC<ButtonGroupProps>;
interface ButtonComponent extends React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<any>> {
Group: ButtonGroupComponent;
}
const ButtonGroup: ButtonGroupComponent = (props) => {
return <SButtonGroup {...props}/>
};
const ButtonBase = forwardRef<any, ButtonProps>((props, ref) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
return <SButton ref={ref} {...mergedProps}/>
});
ButtonBase.displayName = 'ButtonBase';
export const Button: ButtonComponent = Object.assign(ButtonBase, {
Group: ButtonGroup,
});
export function Divider(props: DividerProps) {
const {i} = useContext(ThemeContext);
return <SDivider {...i} {...props}/>
}
export function Header(props: HeaderProps) {
const {t} = useContext(ThemeContext);
return <SHeader {...t} {...props}/>
}
export function Icon(props: IconProps) {
const {i} = useContext(ThemeContext);
return <SIcon {...i} {...props}/>
}
export function Loader(props: LoaderProps) {
const {i} = useContext(ThemeContext);
return <SLoader {...i} {...props}/>
}
export function Menu(props: MenuProps) {
const {i} = useContext(ThemeContext);
return <SMenu {...i} {...props}/>
}
export function Placeholder(props: PlaceholderProps) {
const {i} = useContext(ThemeContext);
return <SPlaceholder {...i} {...props}/>
}
export function Popup(props: PopupProps) {
const {i} = useContext(ThemeContext);
return <SPopup {...i} style={{border: '1px solid grey'}} {...props}/>
}
export function Progress(props: ProgressProps) {
const {i} = useContext(ThemeContext);
return <SProgress {...i} {...props}/>
}
export function Segment(props: SegmentProps) {
const {i} = useContext(ThemeContext);
return <SSegment {...i} {...props}/>
}
// Table is now a compound component - see COMPOUND COMPONENTS section below
export function TextArea(props: TextAreaProps) {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <STextArea {...mergedProps}/>
}
// ============================================================================
// COMPOUND COMPONENTS
// ============================================================================
// ----------------------------------------------------------------------------
// Modal Compound Component
// ----------------------------------------------------------------------------
type ModalActionsComponent = React.FC<ModalActionsProps>;
type ModalContentComponent = React.FC<ModalContentProps>;
type ModalDescriptionComponent = React.FC<ModalDescriptionProps>;
type ModalHeaderComponent = React.FC<ModalHeaderProps>;
interface ModalComponent extends React.FC<ModalProps> {
Actions: ModalActionsComponent;
Content: ModalContentComponent;
Description: ModalDescriptionComponent;
Header: ModalHeaderComponent;
}
const ModalActions: ModalActionsComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalActions {...props} className={`${inverted}`}/>
};
const ModalContent: ModalContentComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalContent {...props} className={`${inverted}`}/>
};
const ModalDescription: ModalDescriptionComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalDescription {...props} className={`${inverted}`}/>
};
const ModalHeader: ModalHeaderComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalHeader {...props} className={`${inverted}`}>
{props.children}
</SModalHeader>
};
const ModalBase: React.FC<ModalProps> = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModal {...props} className={`${inverted}`}/>
};
export const Modal: ModalComponent = Object.assign(ModalBase, {
Actions: ModalActions,
Content: ModalContent,
Description: ModalDescription,
Header: ModalHeader,
});
// ----------------------------------------------------------------------------
// Confirm Component
// ----------------------------------------------------------------------------
export interface ConfirmProps {
open: boolean;
header?: string;
content?: string | React.ReactNode;
confirmButton?: string;
cancelButton?: string;
onConfirm?: () => void;
onCancel?: () => void;
onClose?: () => void;
size?: 'mini' | 'tiny' | 'small' | 'large' | 'fullscreen';
}
export const Confirm: React.FC<ConfirmProps> = ({
open,
header,
content,
confirmButton = 'OK',
cancelButton = 'Cancel',
onConfirm,
onCancel,
onClose,
size = 'tiny',
}) => {
const {i} = useContext(ThemeContext);
return (
<Modal open={open} onClose={onClose} size={size} className="themed-confirm">
{header && <Modal.Header>{header}</Modal.Header>}
<Modal.Content>
{typeof content === 'string' ? <p>{content}</p> : content}
</Modal.Content>
<Modal.Actions>
<Button onClick={onCancel} {...i}>{cancelButton}</Button>
<Button onClick={onConfirm} color="green" {...i}>{confirmButton}</Button>
</Modal.Actions>
</Modal>
);
};
// ----------------------------------------------------------------------------
// Form Compound Component
// ----------------------------------------------------------------------------
type FormFieldComponent = React.FC<FormFieldProps>;
type FormGroupComponent = React.FC<FormGroupProps>;
type FormInputComponent = React.FC<FormInputProps>;
interface FormComponent extends React.FC<FormProps> {
Field: FormFieldComponent;
Group: FormGroupComponent;
Input: FormInputComponent;
}
const FormField: FormFieldComponent = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <SFormField {...mergedProps}/>
};
const FormGroup: FormGroupComponent = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <SFormGroup {...mergedProps}/>
};
const FormInput: FormInputComponent = (props) => {
const {i} = useContext(ThemeContext);
return <SFormInput {...i} {...props}/>
};
const FormBase: React.FC<FormProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SForm {...i} {...props}/>
};
export const Form: FormComponent = Object.assign(FormBase, {
Field: FormField,
Group: FormGroup,
Input: FormInput,
});
// ----------------------------------------------------------------------------
// Card Compound Component
// ----------------------------------------------------------------------------
interface CardIconProps extends HTMLAttributes<HTMLDivElement> {
onClick?: () => void;
children?: React.ReactNode;
}
type CardIconComponent = React.FC<CardIconProps>;
interface CardProps extends Omit<SCardProps, 'color'> {
color?: string;
}
interface CardComponent extends React.FC<CardProps> {
Icon: CardIconComponent;
}
const CardIcon: CardIconComponent = ({onClick, children, ...props}) => {
const {inverted} = useContext(ThemeContext);
const cardIcon = <center className={`card-icon ${inverted}`} {...props}>{children}</center>;
if (onClick) {
return <div onClick={onClick} className='clickable'>
{cardIcon}
</div>
} else {
return cardIcon;
}
};
const CardBase: React.FC<CardProps> = ({color, ...props}) => {
const {inverted} = useContext(ThemeContext);
const style: CSSProperties = props.style || {};
const emphasisColor = ColorToSemanticHexColor(color);
if (emphasisColor) {
// Increase drop shadow to emphasize color.
const borderColor = inverted ? '#888' : '#ddd';
style.boxShadow = `0 0 0 2px ${borderColor}, 0 5px 0 0 ${emphasisColor}, 0 0px 3px 0 #d4d4d5`;
}
return <SCard {...props} style={style}/>
};
export const Card: CardComponent = Object.assign(CardBase, {
Icon: CardIcon,
});
// ----------------------------------------------------------------------------
// Tab Compound Component
// ----------------------------------------------------------------------------
type TabPaneComponent = React.FC<TabPaneProps>;
interface TabComponent extends React.FC<TabProps> {
Pane: TabPaneComponent;
}
const TabPane: TabPaneComponent = (props) => {
const {i} = useContext(ThemeContext);
return <STabPane {...i} {...props}/>
};
const TabBase: React.FC<TabProps> = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <STab {...mergedProps}/>
};
export const Tab: TabComponent = Object.assign(TabBase, {
Pane: TabPane,
});
// ----------------------------------------------------------------------------
// Statistic Compound Component
// ----------------------------------------------------------------------------
type StatisticGroupComponent = React.FC<StatisticGroupProps>;
interface StatisticComponent extends React.FC<StatisticProps> {
Group: StatisticGroupComponent;
}
const StatisticGroup: StatisticGroupComponent = (props) => {
const {i} = useContext(ThemeContext);
const style: CSSProperties = {...props.style, marginLeft: 0, marginRight: 0};
return <SStatisticGroup {...i} {...props} style={style}/>
};
const StatisticBase: React.FC<StatisticProps> = (props) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
return <SStatistic {...mergedProps}/>
};
export const Statistic: StatisticComponent = Object.assign(StatisticBase, {
Group: StatisticGroup,
});
// ----------------------------------------------------------------------------
// Breadcrumb Compound Component
// ----------------------------------------------------------------------------
interface BreadcrumbDividerProps {
icon?: string;
}
type BreadcrumbDividerComponent = React.FC<BreadcrumbDividerProps>;
interface BreadcrumbComponent extends React.FC<BreadcrumbProps> {
Divider: BreadcrumbDividerComponent;
}
const BreadcrumbDivider: BreadcrumbDividerComponent = ({icon, ...props}) => {
const {inverted} = useContext(ThemeContext);
// TODO this only handles icons for now.
const className = `divider icon ${inverted} ${icon || ''}`;
return <i aria-hidden="true" className={className}></i>
};
const BreadcrumbBase: React.FC<BreadcrumbProps> = (props) => {
const {t} = useContext(ThemeContext);
return <SBreadcrumb {...props} {...t}/>
};
export const Breadcrumb: BreadcrumbComponent = Object.assign(BreadcrumbBase, {
Divider: BreadcrumbDivider,
});
// ----------------------------------------------------------------------------
// Table Compound Component
// ----------------------------------------------------------------------------
type TableBodyComponent = React.FC<TableBodyProps>;
type TableCellComponent = React.FC<TableCellProps>;
type TableFooterComponent = React.FC<TableFooterProps>;
type TableHeaderComponent = React.FC<TableHeaderProps>;
type TableHeaderCellComponent = React.FC<TableHeaderCellProps>;
type TableRowComponent = React.FC<TableRowProps>;
interface TableComponent extends React.FC<TableProps> {
Body: TableBodyComponent;
Cell: TableCellComponent;
Footer: TableFooterComponent;
Header: TableHeaderComponent;
HeaderCell: TableHeaderCellComponent;
Row: TableRowComponent;
}
const TableBody: TableBodyComponent = (props) => {
return <STableBody {...props}/>
};
const TableCell: TableCellComponent = (props) => {
return <STableCell {...props}/>
};
const TableFooter: TableFooterComponent = (props) => {
return <STableFooter {...props}/>
};
const TableHeader: TableHeaderComponent = (props) => {
return <STableHeader {...props}/>
};
const TableHeaderCell: TableHeaderCellComponent = (props) => {
return <STableHeaderCell {...props}/>
};
const TableRow: TableRowComponent = (props) => {
return <STableRow {...props}/>
};
const TableBase: React.FC<TableProps> = (props) => {
const {i} = useContext(ThemeContext);
return <STable {...i} {...props}/>
};
export const Table: TableComponent = Object.assign(TableBase, {
Body: TableBody,
Cell: TableCell,
Footer: TableFooter,
Header: TableHeader,
HeaderCell: TableHeaderCell,
Row: TableRow,
});