diff --git a/src/components/settings/MutableListSetting.tsx b/src/components/settings/MutableListSetting.tsx index 8555727d..b87b0374 100644 --- a/src/components/settings/MutableListSetting.tsx +++ b/src/components/settings/MutableListSetting.tsx @@ -15,7 +15,9 @@ import List from '@mui/material/List'; import DeleteIcon from '@mui/icons-material/Delete'; import IconButton from '@mui/material/IconButton'; import DialogContentText from '@mui/material/DialogContentText'; -import { TextSetting, TextSettingProps } from '@/components/settings/TextSetting.tsx'; +import { TextSetting, TextSettingProps } from '@/components/settings/text/TextSetting.tsx'; +import { TextSettingDialog } from '@/components/settings/text/TextSettingDialog.tsx'; +import { makeToast } from '@/components/util/Toast.tsx'; const MutableListItem = ({ handleDelete, @@ -35,24 +37,34 @@ const MutableListItem = ({ ); }; +type MutableListSettingProps = Pick & { + values?: string[]; + description?: string; + addItemButtonTitle?: string; + handleChange: (values: string[]) => void; + allowDuplicates?: boolean; + validateItem?: (value: string) => boolean; + invalidItemError?: string; +}; + export const MutableListSetting = ({ settingName, description, values, handleChange, addItemButtonTitle, -}: { - settingName: string; - description?: string; - values?: string[]; - handleChange: (values: string[]) => void; - addItemButtonTitle?: string; -}) => { + placeholder, + allowDuplicates = false, + validateItem = () => true, + invalidItemError, +}: MutableListSettingProps) => { const { t } = useTranslation(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [dialogValues, setDialogValues] = useState(values ?? []); + const [isAddItemDialogOpen, setIsAddItemDialogOpen] = useState(false); + useEffect(() => { if (!values) { return; @@ -76,11 +88,25 @@ export const MutableListSetting = ({ return; } + const isDuplicate = !allowDuplicates && dialogValues.includes(newValue); + if (isDuplicate) { + return; + } + + if (newValue === '') { + return; + } + + if (!validateItem(newValue)) { + makeToast(invalidItemError ?? t('global.error.label.invalid_input'), 'error'); + return; + } + setDialogValues(dialogValues.toSpliced(index, 1, newValue.trim())); }; const saveChanges = () => { - closeDialog(); + closeDialog(true); handleChange(dialogValues.filter((dialogValue) => dialogValue !== '')); }; @@ -105,8 +131,8 @@ export const MutableListSetting = ({ {dialogValues.map((dialogValue, index) => ( updateSetting(index, newValue)} handleDelete={() => updateSetting(index, undefined)} value={dialogValue} @@ -116,7 +142,7 @@ export const MutableListSetting = ({ - @@ -126,6 +152,16 @@ export const MutableListSetting = ({ + + {isAddItemDialogOpen && ( + updateSetting(dialogValues.length, newValue)} + isDialogOpen={isAddItemDialogOpen} + setIsDialogOpen={setIsAddItemDialogOpen} + /> + )} ); }; diff --git a/src/components/settings/TextSetting.tsx b/src/components/settings/TextSetting.tsx deleted file mode 100644 index bfd461a2..00000000 --- a/src/components/settings/TextSetting.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (C) Contributors to the Suwayomi project - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import { Button, Dialog, DialogTitle, InputAdornment, ListItemText } from '@mui/material'; -import DialogContent from '@mui/material/DialogContent'; -import TextField from '@mui/material/TextField'; -import DialogActions from '@mui/material/DialogActions'; -import ListItemButton from '@mui/material/ListItemButton'; -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import DialogContentText from '@mui/material/DialogContentText'; -import IconButton from '@mui/material/IconButton'; -import { Visibility, VisibilityOff } from '@mui/icons-material'; - -export type TextSettingProps = { - settingName: string; - dialogTitle?: string; - dialogDescription?: string; - value?: string; - handleChange: (value: string) => void; - isPassword?: boolean; - placeholder?: string; - disabled?: boolean; -}; - -export const TextSetting = ({ - settingName, - dialogTitle = settingName, - dialogDescription, - value, - handleChange, - isPassword = false, - placeholder = '', - disabled = false, -}: TextSettingProps) => { - const { t } = useTranslation(); - - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [dialogValue, setDialogValue] = useState(value ?? ''); - const [showPassword, setShowPassword] = useState(false); - - const handleClickShowPassword = () => setShowPassword((show) => !show); - - useEffect(() => { - if (!value) { - return; - } - - setDialogValue(value); - }, [value]); - - const closeDialog = (resetValue: boolean = true) => { - if (resetValue) { - setDialogValue(value ?? ''); - } - - setShowPassword(false); - setIsDialogOpen(false); - }; - - const updateSetting = () => { - closeDialog(false); - handleChange(dialogValue); - }; - - return ( - <> - setIsDialogOpen(true)}> - - - - closeDialog()} fullWidth> - - {dialogTitle} - {!!dialogDescription && ( - {dialogDescription} - )} - setDialogValue(e.target.value)} - InputProps={{ - endAdornment: isPassword ? ( - - - {showPassword ? : } - - - ) : null, - }} - /> - - - - - - - - ); -}; diff --git a/src/components/settings/text/TextSetting.tsx b/src/components/settings/text/TextSetting.tsx new file mode 100644 index 00000000..9ed90484 --- /dev/null +++ b/src/components/settings/text/TextSetting.tsx @@ -0,0 +1,41 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { ListItemText } from '@mui/material'; +import ListItemButton from '@mui/material/ListItemButton'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TextSettingDialog, TextSettingDialogProps } from '@/components/settings/text/TextSettingDialog.tsx'; + +export type TextSettingProps = Omit & { + disabled?: boolean; +}; + +export const TextSetting = (props: TextSettingProps) => { + const { t } = useTranslation(); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const { settingName, value, isPassword = false, disabled = false } = props; + + return ( + <> + setIsDialogOpen(true)}> + + + + + + ); +}; diff --git a/src/components/settings/text/TextSettingDialog.tsx b/src/components/settings/text/TextSettingDialog.tsx new file mode 100644 index 00000000..ce1f59ba --- /dev/null +++ b/src/components/settings/text/TextSettingDialog.tsx @@ -0,0 +1,113 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { Button, Dialog, DialogTitle, InputAdornment } from '@mui/material'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import TextField from '@mui/material/TextField'; +import IconButton from '@mui/material/IconButton'; +import { Visibility, VisibilityOff } from '@mui/icons-material'; +import DialogActions from '@mui/material/DialogActions'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +export type TextSettingDialogProps = { + settingName: string; + dialogTitle?: string; + dialogDescription?: string; + value?: string; + handleChange: (value: string) => void; + isPassword?: boolean; + placeholder?: string; + isDialogOpen: boolean; + setIsDialogOpen: (open: boolean) => void; +}; + +export const TextSettingDialog = ({ + settingName, + dialogTitle = settingName, + dialogDescription, + value, + handleChange, + isPassword = false, + placeholder = '', + isDialogOpen, + setIsDialogOpen, +}: TextSettingDialogProps) => { + const { t } = useTranslation(); + + const [dialogValue, setDialogValue] = useState(value ?? ''); + const [showPassword, setShowPassword] = useState(false); + + const handleClickShowPassword = () => setShowPassword((show) => !show); + + useEffect(() => { + if (!value) { + return; + } + + setDialogValue(value); + }, [value]); + + const closeDialog = (resetValue: boolean = true) => { + if (resetValue) { + setDialogValue(value ?? ''); + } + + setShowPassword(false); + setIsDialogOpen(false); + }; + + const updateSetting = () => { + closeDialog(false); + handleChange(dialogValue); + }; + + return ( + closeDialog()} fullWidth> + + {dialogTitle} + {!!dialogDescription && ( + {dialogDescription} + )} + setDialogValue(e.target.value)} + InputProps={{ + endAdornment: isPassword ? ( + + + {showPassword ? : } + + + ) : null, + }} + /> + + + + + + + ); +}; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 734c703a..b20e61f8 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -243,6 +243,11 @@ } } }, + "error": { + "label": { + "invalid_url": "Invalid repository url" + } + }, "label": { "description": "Add custom repositories from which extensions can be installed", "title": "Custom repositories" @@ -309,6 +314,7 @@ "failed_to_save_changes": "Failed to save changes", "invalid_action": "This is not a valid action", "invalid_file_type": "Invalid filetype", + "invalid_input": "Invalid input", "update_failed": "Could not check for updates" } }, diff --git a/src/screens/settings/Backup.tsx b/src/screens/settings/Backup.tsx index d94c4a61..2ff8929d 100644 --- a/src/screens/settings/Backup.tsx +++ b/src/screens/settings/Backup.tsx @@ -28,7 +28,7 @@ import { ListItemLink } from '@/components/util/ListItemLink'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext'; import { BackupRestoreState, ValidateBackupQuery } from '@/lib/graphql/generated/graphql.ts'; import { Progress } from '@/components/util/Progress.tsx'; -import { TextSetting } from '@/components/settings/TextSetting.tsx'; +import { TextSetting } from '@/components/settings/text/TextSetting.tsx'; import { NumberSetting } from '@/components/settings/NumberSetting.tsx'; import { TimeSetting } from '@/components/settings/TimeSetting.tsx'; import { ServerSettings } from '@/typings.ts'; diff --git a/src/screens/settings/DownloadSettings.tsx b/src/screens/settings/DownloadSettings.tsx index 24b6dbc2..f6974ace 100644 --- a/src/screens/settings/DownloadSettings.tsx +++ b/src/screens/settings/DownloadSettings.tsx @@ -11,7 +11,7 @@ import { useContext, useEffect } from 'react'; import List from '@mui/material/List'; import { ListItem, ListItemText, Switch } from '@mui/material'; import ListSubheader from '@mui/material/ListSubheader'; -import { TextSetting } from '@/components/settings/TextSetting.tsx'; +import { TextSetting } from '@/components/settings/text/TextSetting.tsx'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx'; import { MetadataServerSettingKeys, MetadataServerSettings, ServerSettings } from '@/typings.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts'; diff --git a/src/screens/settings/ServerSettings.tsx b/src/screens/settings/ServerSettings.tsx index ff8e4aa0..0fed83a9 100644 --- a/src/screens/settings/ServerSettings.tsx +++ b/src/screens/settings/ServerSettings.tsx @@ -13,7 +13,7 @@ import ListSubheader from '@mui/material/ListSubheader'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { useLocalStorage } from '@/util/useLocalStorage.tsx'; -import { TextSetting } from '@/components/settings/TextSetting.tsx'; +import { TextSetting } from '@/components/settings/text/TextSetting.tsx'; import { ServerSettings as GqlServerSettings } from '@/typings.ts'; import { NumberSetting } from '@/components/settings/NumberSetting.tsx'; import { MutableListSetting } from '@/components/settings/MutableListSetting.tsx'; @@ -136,6 +136,13 @@ export const ServerSettings = () => { handleChange={(repos) => updateSetting('extensionRepos', repos)} values={serverSettings?.extensionRepos} addItemButtonTitle={t('extension.settings.repositories.custom.dialog.action.button.add')} + placeholder="https://github.com/MY_ACCOUNT/MY_REPO/tree/repo" + validateItem={(repo) => + !!repo.match( + /https:\/\/(?:www|raw)?(?:github|githubusercontent)\.com\/([^/]+)\/([^/]+)(?:\/(?:tree|blob)\/(.*))?\/?/g, + ) + } + invalidItemError={t('extension.settings.repositories.custom.error.label.invalid_url')} />