}
>
@@ -107,7 +108,7 @@ function Button(
} else {
return (
)}
ref={ref as ForwardedRef}
>
diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx
index bf98cdae..36c79364 100644
--- a/src/components/Common/ButtonWithDropdown/index.tsx
+++ b/src/components/Common/ButtonWithDropdown/index.tsx
@@ -1,77 +1,29 @@
-import useClickOutside from '@app/hooks/useClickOutside';
+import Dropdown from '@app/components/Common/Dropdown';
import { withProperties } from '@app/utils/typeHelpers';
-import { Transition } from '@headlessui/react';
+import { Menu } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
-import type {
- AnchorHTMLAttributes,
- ButtonHTMLAttributes,
- RefObject,
-} from 'react';
-import { Fragment, useRef, useState } from 'react';
+import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
-interface DropdownItemProps extends AnchorHTMLAttributes {
- buttonType?: 'primary' | 'ghost';
-}
-
-const DropdownItem = ({
- children,
- buttonType = 'primary',
- ...props
-}: DropdownItemProps) => {
- let styleClass = 'button-md text-white';
-
- switch (buttonType) {
- case 'ghost':
- styleClass +=
- ' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white';
- break;
- default:
- styleClass +=
- ' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
- }
- return (
-
- {children}
-
- );
-};
-
-interface ButtonWithDropdownProps {
+type ButtonWithDropdownProps = {
text: React.ReactNode;
dropdownIcon?: React.ReactNode;
buttonType?: 'primary' | 'ghost';
-}
-interface ButtonProps
- extends ButtonHTMLAttributes,
- ButtonWithDropdownProps {
- as?: 'button';
-}
-interface AnchorProps
- extends AnchorHTMLAttributes,
- ButtonWithDropdownProps {
- as: 'a';
-}
+} & (
+ | ({ as?: 'button' } & ButtonHTMLAttributes)
+ | ({ as: 'a' } & AnchorHTMLAttributes)
+);
const ButtonWithDropdown = ({
- as,
text,
children,
dropdownIcon,
className,
buttonType = 'primary',
...props
-}: ButtonProps | AnchorProps) => {
- const [isOpen, setIsOpen] = useState(false);
- const buttonRef = useRef(null);
- useClickOutside(buttonRef, () => setIsOpen(false));
-
+}: ButtonWithDropdownProps) => {
const styleClasses = {
mainButtonClasses: 'button-md text-white border',
dropdownSideButtonClasses: 'button-md border',
- dropdownClasses: 'button-md',
};
switch (buttonType) {
@@ -79,72 +31,40 @@ const ButtonWithDropdown = ({
styleClasses.mainButtonClasses +=
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
- styleClasses.dropdownClasses +=
- ' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur';
break;
default:
styleClasses.mainButtonClasses +=
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
styleClasses.dropdownSideButtonClasses +=
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
- styleClasses.dropdownClasses += ' bg-indigo-600 p-1';
}
+ const TriggerElement = props.as ?? 'button';
+
return (
-
- {as === 'a' ? (
- }
- {...(props as AnchorHTMLAttributes)}
- >
- {text}
-
- ) : (
- }
- {...(props as ButtonHTMLAttributes)}
- >
- {text}
-
- )}
+
+ )}
+ >
+ {text}
+
{children && (
- setIsOpen((state) => !state)}
>
{dropdownIcon ? dropdownIcon : }
-
-
-
-
+
+ {children}
)}
-
+
);
};
-export default withProperties(ButtonWithDropdown, { Item: DropdownItem });
+export default withProperties(ButtonWithDropdown, { Item: Dropdown.Item });
diff --git a/src/components/Common/Dropdown/index.tsx b/src/components/Common/Dropdown/index.tsx
new file mode 100644
index 00000000..74ce79f2
--- /dev/null
+++ b/src/components/Common/Dropdown/index.tsx
@@ -0,0 +1,117 @@
+import { withProperties } from '@app/utils/typeHelpers';
+import { Menu, Transition } from '@headlessui/react';
+import { ChevronDownIcon } from '@heroicons/react/24/solid';
+import {
+ Fragment,
+ useRef,
+ type AnchorHTMLAttributes,
+ type ButtonHTMLAttributes,
+ type HTMLAttributes,
+} from 'react';
+
+interface DropdownItemProps extends AnchorHTMLAttributes {
+ buttonType?: 'primary' | 'ghost';
+}
+
+const DropdownItem = ({
+ children,
+ buttonType = 'primary',
+ ...props
+}: DropdownItemProps) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+type DropdownItemsProps = HTMLAttributes & {
+ dropdownType: 'primary' | 'ghost';
+};
+
+const DropdownItems = ({
+ children,
+ className,
+ dropdownType,
+ ...props
+}: DropdownItemsProps) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+interface DropdownProps extends ButtonHTMLAttributes {
+ text: React.ReactNode;
+ dropdownIcon?: React.ReactNode;
+ buttonType?: 'primary' | 'ghost';
+}
+
+const Dropdown = ({
+ text,
+ children,
+ dropdownIcon,
+ className,
+ buttonType = 'primary',
+ ...props
+}: DropdownProps) => {
+ const buttonRef = useRef(null);
+
+ return (
+
+
+ {text}
+ {children && (dropdownIcon ? dropdownIcon : )}
+
+ {children && (
+ {children}
+ )}
+
+ );
+};
+export default withProperties(Dropdown, {
+ Item: DropdownItem,
+ Items: DropdownItems,
+});
diff --git a/src/components/Common/LabeledCheckbox/index.tsx b/src/components/Common/LabeledCheckbox/index.tsx
new file mode 100644
index 00000000..ff7e1f56
--- /dev/null
+++ b/src/components/Common/LabeledCheckbox/index.tsx
@@ -0,0 +1,44 @@
+import { Field } from 'formik';
+import { twMerge } from 'tailwind-merge';
+
+interface LabeledCheckboxProps {
+ id: string;
+ className?: string;
+ label: string;
+ description: string;
+ onChange: () => void;
+ children?: React.ReactNode;
+}
+
+const LabeledCheckbox: React.FC = ({
+ id,
+ className,
+ label,
+ description,
+ onChange,
+ children,
+}) => {
+ return (
+ <>
+
+
+
+
+
+
+
+ {label}
+ {description}
+
+
+
+
+ {
+ /* can hold child checkboxes */
+ children && {children}
+ }
+ >
+ );
+};
+
+export default LabeledCheckbox;
diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx
index 8cebf06f..ca7be654 100644
--- a/src/components/Common/Modal/index.tsx
+++ b/src/components/Common/Modal/index.tsx
@@ -29,11 +29,16 @@ interface ModalProps {
secondaryDisabled?: boolean;
tertiaryDisabled?: boolean;
tertiaryButtonType?: ButtonType;
+ okButtonProps?: React.ButtonHTMLAttributes;
+ cancelButtonProps?: React.ButtonHTMLAttributes;
+ secondaryButtonProps?: React.ButtonHTMLAttributes;
+ tertiaryButtonProps?: React.ButtonHTMLAttributes;
disableScrollLock?: boolean;
backgroundClickable?: boolean;
loading?: boolean;
backdrop?: string;
children?: React.ReactNode;
+ dialogClass?: string;
}
const Modal = React.forwardRef(
@@ -61,6 +66,11 @@ const Modal = React.forwardRef(
loading = false,
onTertiary,
backdrop,
+ dialogClass,
+ okButtonProps,
+ cancelButtonProps,
+ secondaryButtonProps,
+ tertiaryButtonProps,
},
parentRef
) => {
@@ -106,7 +116,7 @@ const Modal = React.forwardRef(
(
className="ml-3"
disabled={okDisabled}
data-testid="modal-ok-button"
+ {...okButtonProps}
>
{okText ? okText : 'Ok'}
@@ -200,6 +211,7 @@ const Modal = React.forwardRef(
className="ml-3"
disabled={secondaryDisabled}
data-testid="modal-secondary-button"
+ {...secondaryButtonProps}
>
{secondaryText}
@@ -210,6 +222,7 @@ const Modal = React.forwardRef(
onClick={onTertiary}
className="ml-3"
disabled={tertiaryDisabled}
+ {...tertiaryButtonProps}
>
{tertiaryText}
@@ -220,6 +233,7 @@ const Modal = React.forwardRef(
onClick={onCancel}
className="ml-3 sm:ml-0"
data-testid="modal-cancel-button"
+ {...cancelButtonProps}
>
{cancelText
? cancelText
diff --git a/src/components/LanguageSelector/index.tsx b/src/components/LanguageSelector/index.tsx
index d7b9853c..083ecbc7 100644
--- a/src/components/LanguageSelector/index.tsx
+++ b/src/components/LanguageSelector/index.tsx
@@ -33,6 +33,7 @@ interface LanguageSelectorProps {
setFieldValue: (property: string, value: string) => void;
serverValue?: string;
isUserSettings?: boolean;
+ isDisabled?: boolean;
}
const LanguageSelector = ({
@@ -40,6 +41,7 @@ const LanguageSelector = ({
setFieldValue,
serverValue,
isUserSettings = false,
+ isDisabled,
}: LanguageSelectorProps) => {
const intl = useIntl();
const { data: languages } = useSWR('/api/v1/languages');
@@ -96,6 +98,7 @@ const LanguageSelector = ({
options={options}
isMulti
+ isDisabled={isDisabled}
className="react-select-container"
classNamePrefix="react-select"
value={
diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx
index 740e50f8..e0e4b9cf 100644
--- a/src/components/Login/JellyfinLogin.tsx
+++ b/src/components/Login/JellyfinLogin.tsx
@@ -1,63 +1,39 @@
import Button from '@app/components/Common/Button';
-import Tooltip from '@app/components/Common/Tooltip';
+import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings';
import defineMessages from '@app/utils/defineMessages';
-import { InformationCircleIcon } from '@heroicons/react/24/solid';
+import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType, ServerType } from '@server/constants/server';
import { Field, Form, Formik } from 'formik';
-import { FormattedMessage, useIntl } from 'react-intl';
+import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
const messages = defineMessages('components.Login', {
+ loginwithapp: 'Login with {appName}',
username: 'Username',
password: 'Password',
- hostname: '{mediaServerName} URL',
- port: 'Port',
- enablessl: 'Use SSL',
- urlBase: 'URL Base',
- email: 'Email',
- emailtooltip:
- 'Address does not need to be associated with your {mediaServerName} instance.',
- validationhostrequired: '{mediaServerName} URL required',
- validationhostformat: 'Valid URL required',
- validationemailrequired: 'Email required',
- validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required',
- validationservertyperequired: 'Please select a server type',
- validationHostnameRequired: 'You must provide a valid hostname or IP address',
- validationPortRequired: 'You must provide a valid port number',
- validationUrlTrailingSlash: 'URL must not end in a trailing slash',
- validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
- validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
loginerror: 'Something went wrong while trying to sign in.',
adminerror: 'You must use an admin account to sign in.',
noadminerror: 'No admin user found on the server.',
credentialerror: 'The username or password is incorrect.',
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
- signingin: 'Signing in…',
+ signingin: 'Signing In…',
signin: 'Sign In',
- initialsigningin: 'Connecting…',
- initialsignin: 'Connect',
forgotpassword: 'Forgot Password?',
- servertype: 'Server Type',
- back: 'Go back',
});
interface JellyfinLoginProps {
revalidate: () => void;
- initial?: boolean;
serverType?: MediaServerType;
- onCancel?: () => void;
}
const JellyfinLogin: React.FC = ({
revalidate,
- initial,
serverType,
- onCancel,
}) => {
const toasts = useToasts();
const intl = useIntl();
@@ -72,56 +48,29 @@ const JellyfinLogin: React.FC = ({
: 'Media Server',
};
- if (initial) {
- const LoginSchema = Yup.object().shape({
- hostname: Yup.string().required(
- intl.formatMessage(
- messages.validationhostrequired,
- mediaServerFormatValues
- )
- ),
- port: Yup.number().required(
- intl.formatMessage(messages.validationPortRequired)
- ),
- urlBase: Yup.string()
- .test(
- 'leading-slash',
- intl.formatMessage(messages.validationUrlBaseLeadingSlash),
- (value) => !value || value.startsWith('/')
- )
- .test(
- 'trailing-slash',
- intl.formatMessage(messages.validationUrlBaseTrailingSlash),
- (value) => !value || !value.endsWith('/')
- ),
- email: Yup.string()
- .email(intl.formatMessage(messages.validationemailformat))
- .required(intl.formatMessage(messages.validationemailrequired)),
- username: Yup.string().required(
- intl.formatMessage(messages.validationusernamerequired)
- ),
- password: Yup.string(),
- });
+ const LoginSchema = Yup.object().shape({
+ username: Yup.string().required(
+ intl.formatMessage(messages.validationusernamerequired)
+ ),
+ password: Yup.string(),
+ });
+ const baseUrl = settings.currentSettings.jellyfinExternalHost
+ ? settings.currentSettings.jellyfinExternalHost
+ : settings.currentSettings.jellyfinHost;
+ const jellyfinForgotPasswordUrl =
+ settings.currentSettings.jellyfinForgotPasswordUrl;
- return (
+ return (
+
{
try {
- // Check if serverType is either 'Jellyfin' or 'Emby'
- // if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
- // throw new Error('Invalid serverType'); // You can customize the error message
- // }
-
const res = await fetch('/api/v1/auth/jellyfin', {
method: 'POST',
headers: {
@@ -130,12 +79,7 @@ const JellyfinLogin: React.FC = ({
body: JSON.stringify({
username: values.username,
password: values.password,
- hostname: values.hostname,
- port: values.port,
- useSsl: values.useSsl,
- urlBase: values.urlBase,
- email: values.email,
- serverType: serverType,
+ email: values.username,
}),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
@@ -165,7 +109,6 @@ const JellyfinLogin: React.FC = ({
errorMessage = messages.loginerror;
break;
}
-
toasts.addToast(
intl.formatMessage(errorMessage, mediaServerFormatValues),
{
@@ -178,313 +121,51 @@ const JellyfinLogin: React.FC = ({
}
}}
>
- {({
- errors,
- touched,
- values,
- setFieldValue,
- isSubmitting,
- isValid,
- }) => (
-