Files
channels-seerr/src/components/Common/Modal/index.tsx
Michael Thomas 64f05bcad6 feat: add linked accounts page (#883)
* feat(linked-accounts): create page and display linked media server accounts

* feat(dropdown): add new shared Dropdown component

Adds a shared component for plain dropdown menus, based on the headlessui Menu component. Updates
the `ButtonWithDropdown` component to use the same inner components, ensuring that the only
difference between the two components is the trigger button, and both use the same components for
the actual dropdown menu.

* refactor(modal): add support for configuring button props

* feat(linked-accounts): add support for linking/unlinking jellyfin accounts

* feat(linked-accounts): support linking/unlinking plex accounts

* fix(linked-accounts): probibit unlinking accounts in certain cases

Prevents the primary administrator from unlinking their media server account (which would break
sync). Additionally, prevents users without a configured local email and password from unlinking
their accounts, which would render them unable to log in.

* feat(linked-accounts): support linking/unlinking emby accounts

* style(dropdown): improve style class application

* fix(server): improve error handling and API spec

* style(usersettings): improve syntax & performance of user password checks

* style(linkedaccounts): use applicationName in page description

* fix(linkedaccounts): resolve typo

* refactor(app): remove RequestError class
2025-02-23 00:16:25 +08:00

255 lines
8.6 KiB
TypeScript

import type { ButtonType } from '@app/components/Common/Button';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import useClickOutside from '@app/hooks/useClickOutside';
import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import type { MouseEvent } from 'react';
import React, { Fragment, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { useIntl } from 'react-intl';
interface ModalProps {
title?: string;
subTitle?: string;
onCancel?: (e?: MouseEvent<HTMLElement>) => void;
onOk?: (e?: MouseEvent<HTMLButtonElement>) => void;
onSecondary?: (e?: MouseEvent<HTMLButtonElement>) => void;
onTertiary?: (e?: MouseEvent<HTMLButtonElement>) => void;
cancelText?: string;
okText?: string;
secondaryText?: string;
tertiaryText?: string;
okDisabled?: boolean;
cancelButtonType?: ButtonType;
okButtonType?: ButtonType;
secondaryButtonType?: ButtonType;
secondaryDisabled?: boolean;
tertiaryDisabled?: boolean;
tertiaryButtonType?: ButtonType;
okButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
cancelButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
secondaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
tertiaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
disableScrollLock?: boolean;
backgroundClickable?: boolean;
loading?: boolean;
backdrop?: string;
children?: React.ReactNode;
dialogClass?: string;
}
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
(
{
title,
subTitle,
onCancel,
onOk,
cancelText,
okText,
okDisabled = false,
cancelButtonType = 'default',
okButtonType = 'primary',
children,
disableScrollLock,
backgroundClickable = true,
secondaryButtonType = 'default',
secondaryDisabled = false,
onSecondary,
secondaryText,
tertiaryButtonType = 'default',
tertiaryDisabled = false,
tertiaryText,
loading = false,
onTertiary,
backdrop,
dialogClass,
okButtonProps,
cancelButtonProps,
secondaryButtonProps,
tertiaryButtonProps,
},
parentRef
) => {
const intl = useIntl();
const modalRef = useRef<HTMLDivElement>(null);
const backgroundClickableRef = useRef(backgroundClickable); // This ref is used to detect state change inside the useClickOutside hook
useEffect(() => {
backgroundClickableRef.current = backgroundClickable;
}, [backgroundClickable]);
useClickOutside(modalRef, () => {
if (onCancel && backgroundClickableRef.current) {
onCancel();
}
});
useLockBodyScroll(true, disableScrollLock);
return ReactDOM.createPortal(
<Transition.Child
appear
as="div"
className="fixed top-0 bottom-0 left-0 right-0 z-50 flex h-full w-full items-center justify-center bg-gray-800 bg-opacity-70"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
ref={parentRef}
>
<Transition
appear
as={Fragment}
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={loading}
>
<div style={{ position: 'absolute' }}>
<LoadingSpinner />
</div>
</Transition>
<Transition
className={`hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle ${dialogClass}`}
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
style={{
maxHeight: 'calc(100% - env(safe-area-inset-top) * 2)',
}}
appear
as="div"
enter="transition duration-300"
enterFrom="opacity-0 scale-75"
enterTo="opacity-100 scale-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={!loading}
ref={modalRef}
>
{backdrop && (
<div className="absolute top-0 left-0 right-0 z-0 h-64 max-h-full w-full">
<CachedImage
type="tmdb"
alt=""
src={backdrop}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(31, 41, 55, 0.75) 0%, rgba(31, 41, 55, 1) 100%)',
}}
/>
</div>
)}
<div className="relative -mx-4 overflow-x-hidden px-4 pt-0.5 sm:flex sm:items-center">
<div
className={`mt-3 truncate text-center text-white sm:mt-0 sm:text-left`}
>
{(title || subTitle) && (
<div className="flex flex-col space-y-1">
{title && (
<span
className="text-overseerr truncate pb-0.5 text-2xl font-bold leading-6"
id="modal-headline"
data-testid="modal-title"
>
{title}
</span>
)}
{subTitle && (
<span
className="truncate text-lg font-semibold leading-6 text-gray-200"
id="modal-headline"
data-testid="modal-title"
>
{subTitle}
</span>
)}
</div>
)}
</div>
</div>
{children && (
<div
className={`relative mt-4 text-sm leading-5 text-gray-300 ${
!(onCancel || onOk || onSecondary || onTertiary) ? 'mb-3' : ''
}`}
>
{children}
</div>
)}
{(onCancel || onOk || onSecondary || onTertiary) && (
<div className="relative mt-5 flex flex-row-reverse justify-center sm:mt-4 sm:justify-start">
{typeof onOk === 'function' && (
<Button
buttonType={okButtonType}
onClick={onOk}
className="ml-3"
disabled={okDisabled}
data-testid="modal-ok-button"
{...okButtonProps}
>
{okText ? okText : 'Ok'}
</Button>
)}
{typeof onSecondary === 'function' && secondaryText && (
<Button
buttonType={secondaryButtonType}
onClick={onSecondary}
className="ml-3"
disabled={secondaryDisabled}
data-testid="modal-secondary-button"
{...secondaryButtonProps}
>
{secondaryText}
</Button>
)}
{typeof onTertiary === 'function' && tertiaryText && (
<Button
buttonType={tertiaryButtonType}
onClick={onTertiary}
className="ml-3"
disabled={tertiaryDisabled}
{...tertiaryButtonProps}
>
{tertiaryText}
</Button>
)}
{typeof onCancel === 'function' && (
<Button
buttonType={cancelButtonType}
onClick={onCancel}
className="ml-3 sm:ml-0"
data-testid="modal-cancel-button"
{...cancelButtonProps}
>
{cancelText
? cancelText
: intl.formatMessage(globalMessages.cancel)}
</Button>
)}
</div>
)}
</Transition>
</Transition.Child>,
document.body
);
}
);
Modal.displayName = 'Modal';
export default Modal;