diff --git a/.eslintrc.json b/.eslintrc.json index 7d13849a2..3e98369b6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,6 +21,9 @@ "parserOptions": { "project": "./tsconfig.json" }, + "globals": { + "WEBUI_PREFIX": true + }, "settings": { "import/resolver": { "typescript": {} @@ -30,6 +33,9 @@ } }, "rules": { + "@typescript-eslint/comma-dangle": "off", // deprecated rule https://typescript-eslint.io/rules/comma-dangle/ + "function-paren-newline": "off", // deprecated rule https://eslint.org/docs/latest/rules/function-paren-newline + "operator-linebreak": "off", // deprecated rule https://eslint.org/docs/latest/rules/operator-linebreak "@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/indent": "off", "@typescript-eslint/no-floating-promises": [ @@ -93,6 +99,7 @@ "error", { "allow": ["warn", "error"] } ], + "no-continue": "off", "no-param-reassign": [ "error", { "props": true, "ignorePropertyModificationsFor": ["sliceState", "immerState", "draftState", "draftState2", "reduceResult"] } diff --git a/index.html b/index.html index 369c6b384..b39b48353 100644 --- a/index.html +++ b/index.html @@ -1,6 +1,7 @@ + Shoko diff --git a/package.json b/package.json index b4bc405d0..ced2971f9 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@stylistic/stylelint-plugin": "^3.1.1", "@tanstack/eslint-plugin-query": "^5.62.1", "@types/format-thousands": "^2.0.3", + "@types/json-schema": "^7.0.15", "@types/lodash": "^4.17.13", "@types/node": "^22.10.2", "@types/react": "^19.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cb7fd05c..966f66ea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: '@types/format-thousands': specifier: ^2.0.3 version: 2.0.3 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 '@types/lodash': specifier: ^4.17.13 version: 4.17.13 @@ -1276,6 +1279,9 @@ packages: '@types/hoist-non-react-statics@3.3.6': resolution: {integrity: sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} @@ -4788,6 +4794,8 @@ snapshots: '@types/react': 19.0.1 hoist-non-react-statics: 3.3.2 + '@types/json-schema@7.0.15': {} + '@types/json5@0.0.29': {} '@types/lodash@4.17.13': {} diff --git a/src/components/Collection/CollectionTitle.tsx b/src/components/Collection/CollectionTitle.tsx index 2c6489e11..ed3f9f4ea 100644 --- a/src/components/Collection/CollectionTitle.tsx +++ b/src/components/Collection/CollectionTitle.tsx @@ -27,7 +27,7 @@ const CollectionTitle = React.memo(({ count, filterActive, filterName, groupName return (
Collection @@ -36,7 +36,7 @@ const CollectionTitle = React.memo(({ count, filterActive, filterName, groupName <> { }); const handleSave = useEventCallback(() => { - patchSettings({ newSettings }, { + patchSettings(newSettings, { onSuccess: () => onClose(), }); }); diff --git a/src/components/Collection/Episode/EpisodeFiles.tsx b/src/components/Collection/Episode/EpisodeFiles.tsx index 6f8d603ec..3730ea28f 100644 --- a/src/components/Collection/Episode/EpisodeFiles.tsx +++ b/src/components/Collection/Episode/EpisodeFiles.tsx @@ -9,7 +9,7 @@ import { mdiTrashCanOutline, } from '@mdi/js'; import { Icon } from '@mdi/react'; -import { get, map } from 'lodash'; +import { map } from 'lodash'; import DeleteFilesModal from '@/components/Dialogs/DeleteFilesModal'; import FileInfo from '@/components/FileInfo'; @@ -92,8 +92,7 @@ const EpisodeFiles = ({ anidbSeriesId, episodeFiles, episodeId, seriesId }: Prop return (
{map(episodeFiles, (file) => { - const ReleaseGroupID = get(file, 'AniDB.ReleaseGroup.ID', 0); - const ReleaseGroupName = get(file, 'AniDB.ReleaseGroup.Name', null); + const releaseGroup = file.Release?.Group; return (
@@ -141,8 +140,8 @@ const EpisodeFiles = ({ anidbSeriesId, episodeFiles, episodeId, seriesId }: Prop /> Copy ShokoID
- {file.AniDB && ( - + {file.Release?.ReleaseURI?.startsWith('https://anidb.net/file/') && ( +
AniDB @@ -150,15 +149,15 @@ const EpisodeFiles = ({ anidbSeriesId, episodeFiles, episodeId, seriesId }: Prop
)} - {ReleaseGroupID > 0 && ( + {releaseGroup && releaseGroup.Source === 'AniDB' && (
- {ReleaseGroupName ?? 'Unknown'} + {releaseGroup.Name}  (AniDB)
diff --git a/src/components/Collection/Episode/EpisodeSummary.tsx b/src/components/Collection/Episode/EpisodeSummary.tsx index 9fefb47ac..b91e1f72f 100644 --- a/src/components/Collection/Episode/EpisodeSummary.tsx +++ b/src/components/Collection/Episode/EpisodeSummary.tsx @@ -96,7 +96,7 @@ const EpisodeSummary = React.memo( const episodeFilesQuery = useEpisodeFilesQuery( episodeId, - { includeDataFrom: ['AniDB'], include: ['AbsolutePaths', 'MediaInfo'] }, + { include: ['AbsolutePaths', 'ReleaseInfo', 'MediaInfo'] }, open, ); const { isPending: markWatchedPending, mutate: markWatched } = useWatchEpisodeMutation(seriesId, page, nextUp); diff --git a/src/components/Collection/Series/EditSeriesTabs/DeleteActionsTab.tsx b/src/components/Collection/Series/EditSeriesTabs/DeleteActionsTab.tsx index b98f48142..d50bcfa40 100644 --- a/src/components/Collection/Series/EditSeriesTabs/DeleteActionsTab.tsx +++ b/src/components/Collection/Series/EditSeriesTabs/DeleteActionsTab.tsx @@ -14,7 +14,7 @@ const DeleteActionsTab = ({ seriesId }: Props) => { const { mutate: deleteSeries } = useDeleteSeriesMutation(); - const navigateToCollection = () => navigate('/webui/collection'); + const navigateToCollection = () => navigate('/collection'); return (
diff --git a/src/components/Collection/SeriesInfo.tsx b/src/components/Collection/SeriesInfo.tsx index 554e879a0..bceb53158 100644 --- a/src/components/Collection/SeriesInfo.tsx +++ b/src/components/Collection/SeriesInfo.tsx @@ -58,7 +58,7 @@ const SeriesInfo = ({ series }: SeriesInfoProps) => { const [season, year] = overview.FirstAirSeason.split(' '); addFilterCriteriaToStore('InSeason').then(() => { dispatch(setFilterValues({ InSeason: [`${year}: ${season}`] })); - navigate('/webui/collection/filter/live'); + navigate('/collection/filter/live'); }).catch(console.error); }); diff --git a/src/components/Collection/TagButton.tsx b/src/components/Collection/TagButton.tsx index 5855bbad1..117f0d120 100644 --- a/src/components/Collection/TagButton.tsx +++ b/src/components/Collection/TagButton.tsx @@ -23,7 +23,7 @@ const TagButton = React.memo(({ tagType, text, type }: Props) => { dispatch(resetFilter()); addFilterCriteriaToStore('HasTag').then(() => { dispatch(setFilterTag({ HasTag: [{ Name: text, isExcluded: false }] })); - navigate('/webui/collection/filter/live'); + navigate('/collection/filter/live'); }).catch(console.error); }); diff --git a/src/components/Collection/Tags/TagDetailsModal.tsx b/src/components/Collection/Tags/TagDetailsModal.tsx index e743b3793..f6c902f4d 100644 --- a/src/components/Collection/Tags/TagDetailsModal.tsx +++ b/src/components/Collection/Tags/TagDetailsModal.tsx @@ -16,7 +16,7 @@ import type { TagType } from '@/core/types/api/tags'; const SeriesLink = React.memo(({ extraPadding, series }: { series: SeriesType, extraPadding: boolean }) => ( { const mainPoster = useMainPoster(series); - const seriesType = series.AniDB?.Type === SeriesTypeEnum.TVSpecial - ? 'TV Special' - : series.AniDB?.Type; + let seriesType = series.AniDB?.Type as string | undefined; + if (seriesType === SeriesTypeEnum.TVSpecial) seriesType = 'TV Special'; + else if (seriesType === SeriesTypeEnum.MusicVideo) seriesType = 'Music Video'; return (
- + {
{ buttonType="secondary" buttonSize="normal" className="flex flex-row flex-wrap items-center gap-x-2 py-3" - onClick={() => navigate(`/webui/collection/series/${seriesId}`)} + onClick={() => navigate(`/collection/series/${seriesId}`)} > Cancel diff --git a/src/components/Configuration/AnySchema.tsx b/src/components/Configuration/AnySchema.tsx new file mode 100644 index 000000000..0fc171cce --- /dev/null +++ b/src/components/Configuration/AnySchema.tsx @@ -0,0 +1,246 @@ +import React from 'react'; + +import BooleanInput from '@/components/Configuration/Input/BooleanInput'; +import CodeBlockInput from '@/components/Configuration/Input/CodeBlockInput'; +import EnumSelectorInput from '@/components/Configuration/Input/EnumSelectorInput'; +import FloatInput from '@/components/Configuration/Input/FloatInput'; +import IntegerInput from '@/components/Configuration/Input/IntegerInput'; +import StringInput from '@/components/Configuration/Input/StringInput'; +import TextAreaInput from '@/components/Configuration/Input/TextAreaInput'; +import AnyList from '@/components/Configuration/List/AnyList'; +import AnyRecord from '@/components/Configuration/Record/AnyRecord'; +import SectionContainer from '@/components/Configuration/SectionContainer/AnySectionContainer'; +import UnableToRenderSchema from '@/components/Configuration/UnableToRenderSchema'; +import useVisibility from '@/components/Configuration/hooks/useVisibility'; +import { useReference } from '@/core/schema'; + +import type { + ConfigurationUiDefinitionType, + JSONSchema4WithUiDefinition, +} from '@/core/react-query/configuration/types'; + +export type AnySchemaProps = { + config: unknown; + configHasChanged: boolean; + parentConfig: unknown; + renderHeader: boolean; + path: (string | number)[]; + restartPendingFor: string[]; + loadedEnvironmentVariables: string[]; + advancedMode: boolean; + performAction: (path: (string | number)[], action: string) => void; + rootSchema: JSONSchema4WithUiDefinition; + schema: JSONSchema4WithUiDefinition; + updateField: ( + path: (string | number)[], + value: unknown, + schema: JSONSchema4WithUiDefinition, + rootSchema: JSONSchema4WithUiDefinition, + ) => void; +}; + +function AnySchema(props: AnySchemaProps): React.JSX.Element | null { + const resolvedSchema = useReference(props.rootSchema, props.schema); + const type = resolvedSchema.type instanceof Array + ? resolvedSchema.type.find(typ => typ !== 'null') ?? 'null' + : resolvedSchema.type ?? 'null'; + const uiDefinition = Object.assign( + { elementType: 'auto', elementSize: 'default' }, + props.schema['x-uiDefinition'], + resolvedSchema['x-uiDefinition'], + ) as ConfigurationUiDefinitionType; + const visibility = useVisibility( + props.schema, + type === 'object' ? props.config : props.parentConfig, + props.advancedMode, + props.loadedEnvironmentVariables, + ); + const isVisible = visibility !== 'hidden'; + if (type === 'null' || type === 'any' || !isVisible) return null; + + if (uiDefinition.elementType === 'section-container') { + return ( + + ); + } + + if (uiDefinition.elementType === 'list') { + return ( + + ); + } + + if (uiDefinition.elementType === 'record') { + return ( + + ); + } + + switch (type) { + case 'boolean': + return ( + + ); + case 'number': + return ( + + ); + case 'integer': + return ( + + ); + case 'string': + switch (uiDefinition.elementType) { + case 'enum': + return ( + + ); + case 'text-area': + return ( + + ); + case 'code-block': + return ( + + ); + default: + return ( + + ); + } + default: + return ; + } +} + +export default AnySchema; diff --git a/src/components/Configuration/Badges/CustomBadge.tsx b/src/components/Configuration/Badges/CustomBadge.tsx new file mode 100644 index 000000000..b6c8a1117 --- /dev/null +++ b/src/components/Configuration/Badges/CustomBadge.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import type { DisplayColorTheme } from '@/core/react-query/configuration/types'; + +type BadgeProps = { + name: string; + theme: DisplayColorTheme; +}; + +const themes: Record = { + default: 'text-panel-text border-panel-text', + primary: 'text-panel-text-primary border-panel-text-primary', + secondary: 'text-panel-text-secondary border-panel-text-secondary', + important: 'text-panel-text-important border-panel-text-important', + warning: 'text-panel-text-warning border-panel-text-warning', + danger: 'text-panel-text-danger border-panel-text-danger', +}; + +function CustomBadge(props: BadgeProps): React.JSX.Element { + return ( + + {props.name} + + ); +} + +export default CustomBadge; diff --git a/src/components/Configuration/Badges/EnvironmentVariableBadge.tsx b/src/components/Configuration/Badges/EnvironmentVariableBadge.tsx new file mode 100644 index 000000000..4ce60fa67 --- /dev/null +++ b/src/components/Configuration/Badges/EnvironmentVariableBadge.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import { mdiSprout, mdiSproutOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import cx from 'classnames'; + +type EnvironmentVariableIconProps = { + envVar: string; + envVarOverridable: boolean; + loadedEnvironmentVariables: string[]; +}; + +function EnvironmentVariableBadge(props: EnvironmentVariableIconProps): React.JSX.Element { + const isLoaded = useMemo(() => props.loadedEnvironmentVariables.includes(props.envVar), [ + props.envVar, + props.loadedEnvironmentVariables, + ]); + const canSet = useMemo(() => !isLoaded || props.envVarOverridable, [isLoaded, props.envVarOverridable]); + const tooltip = `${props.envVar}${isLoaded ? ' (Loaded)' : ''}${isLoaded && !canSet ? ' (Locked)' : ''}`; + return ( + + + + ); +} + +export default EnvironmentVariableBadge; diff --git a/src/components/Configuration/Badges/NeedsRestartBadge.tsx b/src/components/Configuration/Badges/NeedsRestartBadge.tsx new file mode 100644 index 000000000..f5b08c198 --- /dev/null +++ b/src/components/Configuration/Badges/NeedsRestartBadge.tsx @@ -0,0 +1,35 @@ +import React, { useMemo } from 'react'; +import { mdiRestart, mdiRestartAlert } from '@mdi/js'; +import Icon from '@mdi/react'; +import cx from 'classnames'; + +import { pathToString } from '@/core/schema'; + +type NeedsRestartBadgeProps = { + path: (string | number)[]; + restartPendingFor: string[]; +}; + +function NeedsRestartBadge(props: NeedsRestartBadgeProps): React.JSX.Element { + const isRestartPending = useMemo(() => props.restartPendingFor.includes(pathToString(props.path)), [ + props.path, + props.restartPendingFor, + ]); + const tooltip = isRestartPending ? 'Restart pending for changes to take effect' : 'Requires restart to take effect'; + + return ( + + + + ); +} + +export default NeedsRestartBadge; diff --git a/src/components/Configuration/DynamicConfiguration.tsx b/src/components/Configuration/DynamicConfiguration.tsx new file mode 100644 index 000000000..187c45725 --- /dev/null +++ b/src/components/Configuration/DynamicConfiguration.tsx @@ -0,0 +1,259 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { mdiLoading } from '@mdi/js'; +import Icon from '@mdi/react'; +import { cloneDeep, get, isEqual, set, unset } from 'lodash'; +import { useDebounceValue } from 'usehooks-ts'; + +import AnySchema from '@/components/Configuration/AnySchema'; +import Button from '@/components/Input/Button'; +import toast from '@/components/Toast'; +import { + usePerformConfigurationActionMutation, + useSaveConfigurationActionMutation, +} from '@/core/react-query/configuration/mutations'; +import { useConfigurationJsonSchemaQuery } from '@/core/react-query/configuration/queries'; +import { assertIsNullable, pathToString } from '@/core/schema'; +import useEventCallback from '@/hooks/useEventCallback'; +import useNavigate from '@/hooks/useNavigateVoid'; + +import type { + JSONSchema4WithUiDefinition, + SectionsConfigurationUiDefinitionType, +} from '@/core/react-query/configuration/types'; +import type { RootState } from '@/core/store'; +import type { ConfigurationInfoType } from '@/core/types/api/configuration'; + +type DynamicConfigurationProps = { + configGuid: string | undefined | null; + setTitle?: boolean; + onSave?: () => void; +}; + +function DynamicConfiguration(props: DynamicConfigurationProps): React.JSX.Element { + const { configGuid, onSave, setTitle } = props; + const schemaQuery = useConfigurationJsonSchemaQuery(configGuid!, configGuid != null); + const { config, info, schema } = schemaQuery.data ?? {}; + if (!schema || !info || !config || !configGuid) { + return ( +
+ +
+ ); + } + + return ( + + ); +} + +export default DynamicConfiguration; + +type InternalPageWithSchemaAndConfigProps = { + schema: JSONSchema4WithUiDefinition; + info: ConfigurationInfoType; + config: unknown; + configGuid: string; + setTitle?: boolean; + onSave?: () => void; +}; + +function InternalPageWithSchemaAndConfig(props: InternalPageWithSchemaAndConfigProps): React.JSX.Element { + const [[schema, config], setSchemaAndConfig] = useState<[JSONSchema4WithUiDefinition, unknown]>( + () => [props.schema, cloneDeep(props.config)], + ); + const navigate = useNavigate(); + const { mutate: defaultSaveRemote } = useSaveConfigurationActionMutation(props.configGuid); + const { mutate: performActionRemote } = usePerformConfigurationActionMutation(props.configGuid); + const showAdvancedSettings = useSelector((state: RootState) => state.misc.advancedMode); + const hasChanged = useMemo(() => !isEqual(config, props.config), [config, props.config]); + const toastId = useRef(undefined); + + const updateField = useEventCallback( + ( + path: string[], + value: unknown, + valueSchema: JSONSchema4WithUiDefinition, + rootSchema: JSONSchema4WithUiDefinition, + ) => { + if (rootSchema !== schema) return; + + let configValue = value; + const type = valueSchema.type instanceof Array + ? valueSchema.type.find(typ => typ !== 'null') ?? 'null' + : valueSchema.type ?? 'null'; + if ( + (type === 'string' || type === 'number' || type === 'boolean' || type === 'integer') + && assertIsNullable(valueSchema) && value === '' + ) { + configValue = null; + } + if (type === 'boolean') { + const defaultValue = valueSchema.default ?? false as boolean; + if (value === defaultValue) { + const currentValue = get(props.config, path) as boolean | undefined | null; + if (currentValue === undefined || currentValue === null) { + configValue = currentValue; + } + } + } + if ((type === 'integer' || type === 'number') && typeof value === 'string') { + configValue = type === 'integer' ? parseInt(value, 10) : parseFloat(value); + if (Number.isNaN(value)) { + return; + } + } + + let newConfig = cloneDeep(config); + if (path.length === 0) { + newConfig = configValue; + } else if (configValue === undefined) { + unset(newConfig as Record, path); + } else { + set(newConfig as Record, path, configValue); + } + setSchemaAndConfig([schema, newConfig]); + }, + ); + + const performAction = useEventCallback((path: (string | number)[], action: string) => { + performActionRemote({ config, path: pathToString(path), action }, { + onError(error) { + toast.error(`Failed to perform action "${action}" on ${JSON.stringify(path)}: ${error.message}`); + }, + onSuccess(data) { + if (data.Redirect) { + if (data.Redirect.Location.startsWith('http://') || data.Redirect.Location.startsWith('https://')) { + if (!data.Redirect.OpenInNewTab) { + window.open(data.Redirect.Location, '_self', 'noopener,noreferrer'); + // Return early because we can't show the toasts if we're opening + // the URL in the current window. + return; + } + + window.open(data.Redirect.Location, '_blank', 'noopener,noreferrer'); + } else if (data.Redirect.OpenInNewTab) { + try { + const url = new URL(data.Redirect.Location, window.location.href); + window.open(url.href, '_blank', 'noopener,noreferrer'); + } catch (error) { + toast.error(`Failed to open "${data.Redirect.Location}"!}`); + console.error( + error, + `Failed to open "${data.Redirect.Location}": ${error instanceof Error ? error.message : error}`, + ); + } + } else { + navigate(data.Redirect.Location); + } + } + + if (data.ShowDefaultSaveMessage) { + toast.success(`Successfully saved configuration for "${schema.title}"`); + } + for (const { Message: message } of data.Messages) { + toast.info(message); + } + }, + }); + }); + + const defaultSave = useEventCallback(() => { + if (!hasChanged) return; + + defaultSaveRemote(config, { + onError(error) { + toast.error(`Failed to save: ${error.message}`); + }, + onSuccess() { + toast.success(`Successfully saved configuration for "${schema.title}"`); + if (props.onSave) props.onSave(); + }, + }); + }); + + const [debouncedUnsavedChanges] = useDebounceValue(hasChanged, 100); + + // Use debounced value for unsaved changes to avoid flashing the toast for certain changes + useEffect(() => { + if (!debouncedUnsavedChanges) { + if (toastId.current) toast.dismiss(toastId.current); + return; + } + + toastId.current = toast.info( + `Unsaved Changes for "${props.schema.title}"`, + 'Please save before leaving this page.', + { autoClose: false, position: 'top-right' }, + ); + }, [debouncedUnsavedChanges, props.schema.title]); + + useEffect(() => () => { + if (toastId.current) toast.dismiss(toastId.current); + }, []); + + useEffect(() => { + if (toastId.current) toast.dismiss(toastId.current); + toastId.current = undefined; + setSchemaAndConfig([props.schema, cloneDeep(props.config)]); + }, [props.config, props.schema]); + + const hideButtons = + (schema['x-uiDefinition'] as Partial | undefined)?.actions?.hideSaveAction + ?? false; + + return ( + <> + {props.setTitle && ( + <> + {`Settings > Plugin > ${schema.title} | Shoko`} +
+
{schema.title}
+
+ {schema.description ?? 'No description provided.'} +
+
+ + )} + + + + {!hideButtons && ( + <> +
+ +
+ +
+ + )} + + ); +} diff --git a/src/components/Configuration/Input/BooleanInput.tsx b/src/components/Configuration/Input/BooleanInput.tsx new file mode 100644 index 000000000..9a795f61b --- /dev/null +++ b/src/components/Configuration/Input/BooleanInput.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import useBadges from '@/components/Configuration/hooks/useBadges'; +import useVisibility from '@/components/Configuration/hooks/useVisibility'; +import Checkbox from '@/components/Input/Checkbox'; +import { useReference } from '@/core/schema'; +import useEventCallback from '@/hooks/useEventCallback'; + +import type { AnySchemaProps } from '@/components/Configuration/AnySchema'; + +function BooleanInput(props: AnySchemaProps): React.JSX.Element | null { + const { schema } = props; + const resolvedSchema = useReference(props.rootSchema, schema); + const title = schema.title ?? resolvedSchema.title ?? props.path[props.path.length - 1].toString() ?? ''; + const description = schema.description ?? resolvedSchema.description ?? ''; + const onChange = useEventCallback((event: React.ChangeEvent) => + props.updateField(props.path, event.target.checked, props.schema, props.rootSchema) + ); + const visibility = useVisibility( + resolvedSchema, + props.parentConfig, + props.advancedMode, + props.loadedEnvironmentVariables, + ); + const badges = useBadges(resolvedSchema, props.path, props.loadedEnvironmentVariables, props.restartPendingFor); + const isDisabled = visibility === 'disabled'; + const isReadOnly = visibility === 'read-only'; + return ( +
+ + {title} + {isReadOnly && (Read-Only)} + {badges} + + } + labelClassName="flex gap-x-1.5" + id={props.path.join('.')} + isChecked={props.config as boolean} + onChange={onChange} + /> +
{description}
+
+ ); +} + +export default BooleanInput; diff --git a/src/components/Configuration/Input/CodeBlockInput.tsx b/src/components/Configuration/Input/CodeBlockInput.tsx new file mode 100644 index 000000000..1cb31b009 --- /dev/null +++ b/src/components/Configuration/Input/CodeBlockInput.tsx @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import React, { Suspense, lazy, useMemo } from 'react'; +import { mdiLoading } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import cx from 'classnames'; + +import { resolveReference } from '@/core/schema'; +import useEventCallback from '@/hooks/useEventCallback'; + +import type { AnySchemaProps } from '@/components/Configuration/AnySchema'; +import type { CodeEditorConfigurationUiDefinitionType } from '@/core/react-query/configuration/types'; +import type { OnChange } from '@monaco-editor/react'; + +const RenamerEditor = lazy( + () => import('@/components/Utilities/Renamer/RenamerEditor'), +); + +function CodeBlockInput(props: AnySchemaProps): React.JSX.Element | null { + const { uiDefinition } = useMemo(() => { + const resolvedSchema = resolveReference(props.rootSchema, props.schema); + return { + uiDefinition: resolvedSchema['x-uiDefinition'] as CodeEditorConfigurationUiDefinitionType, + }; + }, [props.rootSchema, props.schema]); + const size = useMemo(() => { + if (uiDefinition.elementSize === 'full') { + return 'h-192'; + } + if (uiDefinition.elementSize === 'large') { + return 'h-128'; + } + if (uiDefinition.elementSize === 'small') { + return 'h-48'; + } + return 'h-96'; + }, [uiDefinition]); + + // TODO: IMPROVE THE AUTO FORMATTING CAPABILITIES OF THIS THING. + + const handleChange: OnChange = useEventCallback((value, _context) => { + if (uiDefinition.codeAutoFormatOnLoad && uiDefinition.codeLanguage === 'Json') { + try { + const formattedValue = JSON.stringify(JSON.parse(value ?? '')); + props.updateField(props.path, formattedValue, props.schema, props.rootSchema); + return; + } catch { /**/ } + } + + props.updateField(props.path, value ?? '', props.schema, props.rootSchema); + }); + + const defaultValue = useMemo(() => { + if (uiDefinition.codeAutoFormatOnLoad && uiDefinition.codeLanguage === 'Json') { + try { + return JSON.stringify(JSON.parse((props.config as string | null) ?? ''), undefined, 2); + } catch { /**/ } + } + return (props.config as string | null) ?? ''; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uiDefinition]); + + return ( +
+
+ {props.schema.title} +
+
+ + +
+ } + > + + +
+
{props.schema.description}
+
+ ); +} + +export default CodeBlockInput; diff --git a/src/components/Configuration/Input/EnumSelectorInput.tsx b/src/components/Configuration/Input/EnumSelectorInput.tsx new file mode 100644 index 000000000..9e6ea429e --- /dev/null +++ b/src/components/Configuration/Input/EnumSelectorInput.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; +import cx from 'classnames'; + +import useBadges from '@/components/Configuration/hooks/useBadges'; +import useVisibility from '@/components/Configuration/hooks/useVisibility'; +import SelectSmall from '@/components/Input/SelectSmall'; +import { useReference } from '@/core/schema'; +import useEventCallback from '@/hooks/useEventCallback'; + +import type { AnySchemaProps } from '@/components/Configuration/AnySchema'; +import type { EnumConfigurationUiDefinitionType } from '@/core/react-query/configuration/types'; + +function EnumSelectorInput(props: AnySchemaProps): React.JSX.Element { + const resolvedSchema = useReference(props.rootSchema, props.schema); + const title = props.schema.title ?? resolvedSchema.title ?? props.path[props.path.length - 1] ?? ''; + const description = props.schema.description ?? resolvedSchema.description ?? ''; + const onChange = useEventCallback((event: React.ChangeEvent) => + props.updateField(props.path, event.target.value, props.schema, props.rootSchema) + ); + const visibility = useVisibility( + props.schema, + props.parentConfig, + props.advancedMode, + props.loadedEnvironmentVariables, + ); + const badges = useBadges(props.schema, props.path, props.loadedEnvironmentVariables, props.restartPendingFor); + const isDisabled = visibility === 'disabled'; + const isReadOnly = visibility === 'read-only'; + const options = useMemo(() => { + const uiDefinition = props.schema['x-uiDefinition'] as EnumConfigurationUiDefinitionType; + const definitions = uiDefinition.enumDefinitions; + const defaultValue = props.schema.default ?? definitions[0].value; + const isFlag = uiDefinition.enumIsFlag; + return { + isFlag, + definitions: definitions.map(definition => ( + + )), + }; + }, [props.schema]); + const size = useMemo(() => { + const uiDefinition = props.schema['x-uiDefinition'] as EnumConfigurationUiDefinitionType; + if (uiDefinition.elementSize === 'full') { + return 'w-full'; + } + return 'w-auto'; + }, [props.schema]); + return ( +
+
+ + {title} + {isReadOnly && (Read-Only)} + {badges} + + + {options.definitions} + +
+
{description}
+
+ ); +} + +export default EnumSelectorInput; diff --git a/src/components/Configuration/Input/FloatInput.tsx b/src/components/Configuration/Input/FloatInput.tsx new file mode 100644 index 000000000..7bc3ac0ff --- /dev/null +++ b/src/components/Configuration/Input/FloatInput.tsx @@ -0,0 +1,76 @@ +import React, { useMemo } from 'react'; +import cx from 'classnames'; + +import useBadges from '@/components/Configuration/hooks/useBadges'; +import useVisibility from '@/components/Configuration/hooks/useVisibility'; +import InputSmall from '@/components/Input/InputSmall'; +import { useReference } from '@/core/schema'; +import useEventCallback from '@/hooks/useEventCallback'; + +import type { AnySchemaProps } from '@/components/Configuration/AnySchema'; +import type { BasicConfigurationUiDefinitionType } from '@/core/react-query/configuration/types'; + +function FloatInput(props: AnySchemaProps): React.JSX.Element | null { + const { schema } = props; + const resolvedSchema = useReference(props.rootSchema, schema); + const title = schema.title ?? resolvedSchema.title ?? props.path[props.path.length - 1] ?? ''; + const description = schema.description ?? resolvedSchema.description ?? ''; + const onChange = useEventCallback((event: React.ChangeEvent) => + props.updateField(props.path, event.target.value, props.schema, props.rootSchema) + ); + const min = resolvedSchema.minimum; + const max = resolvedSchema.maximum; + const visibility = useVisibility( + resolvedSchema, + props.parentConfig, + props.advancedMode, + props.loadedEnvironmentVariables, + ); + const badges = useBadges(resolvedSchema, props.path, props.loadedEnvironmentVariables, props.restartPendingFor); + const isDisabled = visibility === 'disabled'; + const isReadOnly = visibility === 'read-only'; + const size = useMemo(() => { + const uiDefinition = schema['x-uiDefinition'] as BasicConfigurationUiDefinitionType; + if (uiDefinition.elementSize === 'full') { + return 'w-full'; + } + if (uiDefinition.elementSize === 'large') { + return 'w-20'; + } + if (uiDefinition.elementSize === 'small') { + return 'w-11'; + } + return 'w-16'; + }, [schema]); + return ( +
+
+ + {title} + {isReadOnly && (Read-Only)} + {badges} + + +
+
{description}
+
+ ); +} + +export default FloatInput; diff --git a/src/components/Configuration/Input/IntegerInput.tsx b/src/components/Configuration/Input/IntegerInput.tsx new file mode 100644 index 000000000..8c5256c86 --- /dev/null +++ b/src/components/Configuration/Input/IntegerInput.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react'; +import cx from 'classnames'; + +import useBadges from '@/components/Configuration/hooks/useBadges'; +import useVisibility from '@/components/Configuration/hooks/useVisibility'; +import InputSmall from '@/components/Input/InputSmall'; +import { useReference } from '@/core/schema'; +import useEventCallback from '@/hooks/useEventCallback'; + +import type { AnySchemaProps } from '@/components/Configuration/AnySchema'; +import type { BasicConfigurationUiDefinitionType } from '@/core/react-query/configuration/types'; + +function IntegerInput(props: AnySchemaProps): React.JSX.Element | null { + const { schema } = props; + const resolvedSchema = useReference(props.rootSchema, schema); + const title = schema.title ?? resolvedSchema.title ?? props.path[props.path.length - 1] ?? ''; + const description = schema.description ?? resolvedSchema.description ?? ''; + const onChange = useEventCallback((event: React.ChangeEvent) => + props.updateField(props.path, event.target.value, props.schema, props.rootSchema) + ); + const min = resolvedSchema.minimum; + const max = resolvedSchema.maximum; + const visibility = useVisibility( + resolvedSchema, + props.parentConfig, + props.advancedMode, + props.loadedEnvironmentVariables, + ); + const badges = useBadges(resolvedSchema, props.path, props.loadedEnvironmentVariables, props.restartPendingFor); + const isDisabled = visibility === 'disabled'; + const isReadOnly = visibility === 'read-only'; + const size = useMemo(() => { + const uiDefinition = schema['x-uiDefinition'] as BasicConfigurationUiDefinitionType; + if (uiDefinition.elementSize === 'full') { + return 'w-full'; + } + if (uiDefinition.elementSize === 'large') { + return 'w-20'; + } + if (uiDefinition.elementSize === 'small') { + return 'w-11'; + } + return 'w-16'; + }, [schema]); + return ( +
+
+ + {title} + {isReadOnly && (Read-Only)} + {badges} + + +
+
{description}
+
+ ); +} + +export default IntegerInput; diff --git a/src/components/Configuration/Input/StringInput.tsx b/src/components/Configuration/Input/StringInput.tsx new file mode 100644 index 000000000..59b92d70c --- /dev/null +++ b/src/components/Configuration/Input/StringInput.tsx @@ -0,0 +1,72 @@ +import React, { useMemo } from 'react'; +import cx from 'classnames'; + +import useBadges from '@/components/Configuration/hooks/useBadges'; +import useVisibility from '@/components/Configuration/hooks/useVisibility'; +import InputSmall from '@/components/Input/InputSmall'; +import { useReference } from '@/core/schema'; +import useEventCallback from '@/hooks/useEventCallback'; + +import type { AnySchemaProps } from '@/components/Configuration/AnySchema'; +import type { BasicConfigurationUiDefinitionType } from '@/core/react-query/configuration/types'; + +function StringInput(props: AnySchemaProps): React.JSX.Element | null { + const resolvedSchema = useReference(props.rootSchema, props.schema); + const title = props.schema.title ?? resolvedSchema.title ?? props.path[props.path.length - 1] ?? ''; + const description = props.schema.description ?? resolvedSchema.description ?? ''; + const onChange = useEventCallback((event: React.ChangeEvent) => + props.updateField(props.path, event.target.value, props.schema, props.rootSchema) + ); + const isPassword = resolvedSchema.format === 'password' + || resolvedSchema['x-uiDefinition']?.elementType === 'password'; + const size = useMemo(() => { + const uiDefinition = props.schema['x-uiDefinition'] as BasicConfigurationUiDefinitionType; + if (uiDefinition.elementSize === 'full') { + return 'w-full'; + } + if (uiDefinition.elementSize === 'large') { + return 'w-60'; + } + if (uiDefinition.elementSize === 'small') { + return 'w-16'; + } + return 'w-32'; + }, [props.schema]); + const visibility = useVisibility( + resolvedSchema, + props.parentConfig, + props.advancedMode, + props.loadedEnvironmentVariables, + ); + const badges = useBadges(resolvedSchema, props.path, props.loadedEnvironmentVariables, props.restartPendingFor); + const isDisabled = visibility === 'disabled'; + const isReadOnly = visibility === 'read-only'; + return ( +
+
+ + {title} + {isReadOnly && (Read-Only)} + {badges} + + +
+
{description}
+
+ ); +} + +export default StringInput; diff --git a/src/components/Configuration/Input/TextAreaInput.tsx b/src/components/Configuration/Input/TextAreaInput.tsx new file mode 100644 index 000000000..e7a3558ef --- /dev/null +++ b/src/components/Configuration/Input/TextAreaInput.tsx @@ -0,0 +1,65 @@ +import React, { useMemo } from 'react'; +import cx from 'classnames'; + +import useBadges from '@/components/Configuration/hooks/useBadges'; +import useVisibility from '@/components/Configuration/hooks/useVisibility'; +import { pathToString, useReference } from '@/core/schema'; +import useEventCallback from '@/hooks/useEventCallback'; + +import type { AnySchemaProps } from '@/components/Configuration/AnySchema'; +import type { BasicConfigurationUiDefinitionType } from '@/core/react-query/configuration/types'; + +function TextAreaInput(props: AnySchemaProps): React.JSX.Element | null { + const resolvedSchema = useReference(props.rootSchema, props.schema); + const title = props.schema.title ?? resolvedSchema.title ?? props.path[props.path.length - 1] ?? ''; + const description = props.schema.description ?? resolvedSchema.description ?? ''; + const onChange = useEventCallback((event: React.ChangeEvent) => + props.updateField(props.path, event.target.value, props.schema, props.rootSchema) + ); + const size = useMemo(() => { + const uiDefinition = props.schema['x-uiDefinition'] as BasicConfigurationUiDefinitionType; + if (uiDefinition.elementSize === 'full') { + return 'h-64'; + } + if (uiDefinition.elementSize === 'large') { + return 'h-32'; + } + if (uiDefinition.elementSize === 'small') { + return 'h-8'; + } + return 'h-16'; + }, [props.schema]); + const visibility = useVisibility( + resolvedSchema, + props.parentConfig, + props.advancedMode, + props.loadedEnvironmentVariables, + ); + const badges = useBadges(resolvedSchema, props.path, props.loadedEnvironmentVariables, props.restartPendingFor); + const isDisabled = visibility === 'disabled'; + const isReadOnly = visibility === 'read-only'; + return ( +
+
+ + {title} + {isReadOnly && (Read-Only)} + {badges} + +