From 891ed296fb4794ba4ab164850bcf3c70c8f93825 Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Wed, 18 Dec 2024 14:08:53 -0500 Subject: [PATCH] feat: support for toggling between 12/24-hour time in UI (#1026) This is implemented as baseline for region and timezone selection. --- server/src/services/TvGuideService.ts | 4 + web/src/Tunarr.tsx | 34 ++ web/src/components/ProgramDetailsDialog.tsx | 4 +- .../channel_config/ChannelProgrammingList.tsx | 34 +- .../channel_config/PlexFilterBuilder.tsx | 2 +- web/src/components/guide/TvGuide.tsx | 17 +- .../settings/general/GeneralSettingsForm.tsx | 428 +++++++++++++++++ .../settings/general/WebSettings.tsx | 50 ++ .../slot_scheduler/TimeSlotTable.tsx | 4 +- web/src/hooks/useDayjs.ts | 6 + web/src/main.tsx | 38 +- web/src/pages/guide/GuidePage.tsx | 7 +- .../pages/settings/GeneralSettingsPage.tsx | 445 +----------------- web/src/providers/DayjsProvider.tsx | 35 ++ web/src/store/index.ts | 1 + web/src/store/settings/actions.ts | 8 + web/src/store/settings/store.ts | 12 + web/src/store/themeEditor/store.ts | 1 + web/tsconfig.json | 1 + 19 files changed, 629 insertions(+), 502 deletions(-) create mode 100644 web/src/Tunarr.tsx create mode 100644 web/src/components/settings/general/GeneralSettingsForm.tsx create mode 100644 web/src/components/settings/general/WebSettings.tsx create mode 100644 web/src/hooks/useDayjs.ts create mode 100644 web/src/providers/DayjsProvider.tsx diff --git a/server/src/services/TvGuideService.ts b/server/src/services/TvGuideService.ts index e9c1d2019..85a655f4b 100644 --- a/server/src/services/TvGuideService.ts +++ b/server/src/services/TvGuideService.ts @@ -199,6 +199,10 @@ export class TVGuideService { lastUpdate: mapValues(this.lastUpdateTime, (time) => dayjs(time).format(), ), + guideTimes: mapValues(this.cachedGuide, ({ channel, programs }) => ({ + start: dayjs(first(programs)?.startTimeMs).format(), + end: dayjs(this.lastEndTime[channel.uuid]).format(), + })), channelIds: keys(this.cachedGuide), }; } diff --git a/web/src/Tunarr.tsx b/web/src/Tunarr.tsx new file mode 100644 index 000000000..73cd44249 --- /dev/null +++ b/web/src/Tunarr.tsx @@ -0,0 +1,34 @@ +import { DayjsProvider } from '@/providers/DayjsProvider.tsx'; +import useStore from '@/store/index.ts'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider } from '@tanstack/react-router'; +import { SnackbarProvider } from 'notistack'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { TunarrApiProvider } from './components/TunarrApiContext.tsx'; +import { ServerEventsProvider } from './components/server_events/ServerEventsProvider.tsx'; +import { router } from './main.tsx'; +import { queryClient } from './queryClient.ts'; + +export const Tunarr = () => { + const locale = useStore((store) => store.settings.ui.i18n.locale); + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/web/src/components/ProgramDetailsDialog.tsx b/web/src/components/ProgramDetailsDialog.tsx index 2e67f8595..03ced538e 100644 --- a/web/src/components/ProgramDetailsDialog.tsx +++ b/web/src/components/ProgramDetailsDialog.tsx @@ -169,9 +169,7 @@ export default function ProgramDetailsDialog({ return ( diff --git a/web/src/components/channel_config/ChannelProgrammingList.tsx b/web/src/components/channel_config/ChannelProgrammingList.tsx index d9a24a7d6..d62b48f78 100644 --- a/web/src/components/channel_config/ChannelProgrammingList.tsx +++ b/web/src/components/channel_config/ChannelProgrammingList.tsx @@ -20,7 +20,7 @@ import IconButton from '@mui/material/IconButton'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; -import { ChannelProgram } from '@tunarr/types'; +import { Channel, ChannelProgram } from '@tunarr/types'; import dayjs, { Dayjs } from 'dayjs'; import { findIndex, isString, isUndefined, map, sumBy } from 'lodash-es'; import React, { CSSProperties, useCallback, useState } from 'react'; @@ -44,16 +44,6 @@ import ProgramDetailsDialog from '../ProgramDetailsDialog.tsx'; import AddFlexModal from '../programming_controls/AddFlexModal.tsx'; import AddRedirectModal from '../programming_controls/AddRedirectModal.tsx'; -const ListItemTimeFormatter = new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'short', -}); - -const MobileListItemTimeFormatter = new Intl.DateTimeFormat(undefined, { - dateStyle: 'short', - timeStyle: 'short', -}); - type CommonProps = { moveProgram?: (originalIndex: number, toIndex: number) => void; deleteProgram?: (index: number) => void; @@ -110,6 +100,7 @@ type ListItemProps = { program: (UIFlexProgram | UIRedirectProgram) & { index: number }, ) => void; titleFormatter: (program: ChannelProgram) => string; + channel: Channel; }; type ListDragItem = { @@ -122,7 +113,6 @@ const ProgramListItem = ({ style, program, index, - startTimeDate, moveProgram, deleteProgram, findProgram, @@ -132,6 +122,7 @@ const ProgramListItem = ({ enableEdit, enableDelete, titleFormatter, + channel, }: ListItemProps) => { const [{ isDragging }, drag] = useDrag( () => ({ @@ -164,19 +155,17 @@ const ProgramListItem = ({ const theme = useTheme(); const smallViewport = useMediaQuery(theme.breakpoints.down('sm')); - const startTime = startTimeDate - ? smallViewport - ? MobileListItemTimeFormatter.format(startTimeDate) - : ListItemTimeFormatter.format(startTimeDate) - : null; + const startTimeDate = !isUndefined(program.startTimeOffset) + ? dayjs(channel.startTime + program.startTimeOffset) + : undefined; + + const startTime = startTimeDate?.format(smallViewport ? 'L LT' : 'lll'); const handleInfoButtonClick = (e: React.MouseEvent) => { e.stopPropagation(); onInfoClicked(program); }; - // const dayBoundary = startTimes[idx + 1].isAfter(startTimes[idx], 'day'); - let title = `${titleFormatter(program)}`; if (!smallViewport && startTime) { title += ` - ${startTime}`; @@ -384,22 +373,19 @@ export default function ChannelProgrammingList(props: Props) { const renderProgram = (idx: number, style?: CSSProperties) => { const program = programList[idx]; - const startTimeDate = !isUndefined(program.startTimeOffset) - ? dayjs(channel!.startTime + program.startTimeOffset).toDate() - : undefined; return ( openDetailsDialog(program, startTimeDate)} + onInfoClicked={() => openDetailsDialog(program)} onEditClicked={openEditDialog} titleFormatter={titleFormatter} /> diff --git a/web/src/components/channel_config/PlexFilterBuilder.tsx b/web/src/components/channel_config/PlexFilterBuilder.tsx index 7030d0116..6b9e121ba 100644 --- a/web/src/components/channel_config/PlexFilterBuilder.tsx +++ b/web/src/components/channel_config/PlexFilterBuilder.tsx @@ -212,7 +212,7 @@ export function PlexValueNode({ }, }} value={dayjs(field.value)} - onChange={(e) => field.onChange(e?.format('YYYY-MM-DD'))} + onChange={(e) => field.onChange(e?.format('L'))} /> )} /> diff --git a/web/src/components/guide/TvGuide.tsx b/web/src/components/guide/TvGuide.tsx index 8f9142fee..497541ce4 100644 --- a/web/src/components/guide/TvGuide.tsx +++ b/web/src/components/guide/TvGuide.tsx @@ -147,7 +147,7 @@ export function TvGuide({ channelId, start, end }: Props) { const [channelMenu, setChannelMenu] = useState(); const [progress, setProgress] = useState(calcProgress(start, end)); - const [currentTime, setCurrentTime] = useState(dayjs().format('h:mm')); + const [currentTime, setCurrentTime] = useState(dayjs().format('LT')); const [modalProgram, setModalProgram] = useState< TvGuideProgram | undefined @@ -191,7 +191,7 @@ export function TvGuide({ channelId, start, end }: Props) { useEffect(() => { setProgress(calcProgress(start, end)); - setCurrentTime(dayjs().format('h:mm')); + setCurrentTime(dayjs().format('LT')); if (ref.current) { setMinHeight(ref.current.offsetHeight); } @@ -199,7 +199,7 @@ export function TvGuide({ channelId, start, end }: Props) { useInterval(() => { setProgress(calcProgress(start, end)); - setCurrentTime(dayjs().format('h:mm')); + setCurrentTime(dayjs().format('LT')); }, 60000); useTvGuidesPrefetch( @@ -384,9 +384,7 @@ export function TvGuide({ channelId, start, end }: Props) { {((smallViewport && pct > 20) || (!smallViewport && pct > 8)) && ( <> - {`${programStart.format('h:mm')} - ${programEnd.format( - 'h:mma', - )}`} + {`${programStart.format('LT')} - ${programEnd.format('LT')}`} {isPlaying ? ` (${remainingTime}m left)` : null} @@ -560,12 +558,17 @@ export function TvGuide({ channelId, start, end }: Props) { width={100 / intervalArray.length} sx={{ height: '2rem', + borderLeft: '1px solid white', + textAlign: 'center', + '&:last-child': { + borderRight: '1px solid white', + }, }} key={slot} > {start .add(slot * increments, 'minutes') - .format(`${smallViewport ? 'h:mm' : 'h:mm A'}`)} + .format(`${smallViewport ? 'h:mm' : 'LT'}`)} ))} diff --git a/web/src/components/settings/general/GeneralSettingsForm.tsx b/web/src/components/settings/general/GeneralSettingsForm.tsx new file mode 100644 index 000000000..c9aabf0ff --- /dev/null +++ b/web/src/components/settings/general/GeneralSettingsForm.tsx @@ -0,0 +1,428 @@ +import { RotatingLoopIcon } from '@/components/base/LoadingIcon.tsx'; +import { NumericFormControllerText } from '@/components/util/TypedController.tsx'; +import { isValidUrl } from '@/helpers/util.ts'; +import { useUpdateSystemSettings } from '@/hooks/useSystemSettings.ts'; +import { useVersion } from '@/hooks/useVersion.ts'; +import { + GeneralSetingsFormProps, + GeneralSettingsFormData, + LogLevelChoices, +} from '@/pages/settings/GeneralSettingsPage'; +import { setBackendUri } from '@/store/settings/actions.ts'; +import { useSettings } from '@/store/settings/selectors.ts'; +import { CloudDoneOutlined, CloudOff } from '@mui/icons-material'; +import { + Box, + Checkbox, + FormControl, + FormControlLabel, + FormHelperText, + InputAdornment, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + TextField, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import Button from '@mui/material/Button'; +import Grid from '@mui/material/Grid2'; +import Stack from '@mui/material/Stack'; +import { TimePicker } from '@mui/x-date-pickers'; +import { SystemSettings } from '@tunarr/types'; +import { UpdateSystemSettingsRequest } from '@tunarr/types/api'; +import { EverySchedule } from '@tunarr/types/schemas'; +import dayjs from 'dayjs'; +import { first, isNull, map, trim, trimEnd } from 'lodash-es'; +import { useSnackbar } from 'notistack'; +import pluralize from 'pluralize'; +import { useCallback } from 'react'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; + +export function GeneralSettingsForm({ + systemSettings, +}: GeneralSetingsFormProps) { + const settings = useSettings(); + const snackbar = useSnackbar(); + const versionInfo = useVersion({ + retry: 0, + }); + const theme = useTheme(); + + const { isLoading, isError } = versionInfo; + + const updateSystemSettings = useUpdateSystemSettings(); + + const getBaseFormValues = ( + systemSettings: SystemSettings, + ): GeneralSettingsFormData => ({ + backendUri: settings.backendUri, + logLevel: systemSettings.logging.useEnvVarLevel + ? 'env' + : systemSettings.logging.logLevel, + backup: systemSettings.backup, + cache: systemSettings.cache ?? { + enablePlexRequestCache: false, + }, + }); + + const { + control, + handleSubmit, + reset, + formState: { isDirty, isValid, isSubmitting }, + watch, + setValue, + } = useForm({ + defaultValues: getBaseFormValues(systemSettings), + }); + + const { remove, append } = useFieldArray({ + control, + name: 'backup.configurations', + }); + + const backupsValue = watch('backup'); + const backupsEnabled = backupsValue.configurations.length > 0; + const currentBackupSchedule = first(backupsValue.configurations)?.schedule as + | EverySchedule + | undefined; + + const onSave = (data: GeneralSettingsFormData) => { + const newBackendUri = trimEnd(trim(data.backendUri), '/'); + setBackendUri(newBackendUri); + snackbar.enqueueSnackbar('Settings Saved!', { + variant: 'success', + }); + const updateReq: UpdateSystemSettingsRequest = { + logging: { + logLevel: data.logLevel === 'env' ? undefined : data.logLevel, + useEnvVarLevel: data.logLevel === 'env', + }, + backup: data.backup, + cache: data.cache, + }; + updateSystemSettings.mutate(updateReq, { + onSuccess(data) { + reset(getBaseFormValues(data), { keepDirty: false }); + }, + }); + }; + + const toggleBackupEnabled = useCallback(() => { + if (backupsEnabled) { + remove(); + } else { + append({ + enabled: true, + outputs: [ + { + type: 'file', + archiveFormat: 'tar', + maxBackups: 3, + outputPath: '', + }, + ], + schedule: { + type: 'every', + increment: 1, + unit: 'day', + offsetMs: dayjs.duration({ hours: 3 }).asMilliseconds(), + }, + }); + } + }, [append, backupsEnabled, remove]); + + function handleArchiveFormatUpdate(ev: SelectChangeEvent) { + if (ev.target.value === 'zip' || ev.target.value === 'tar') { + setValue( + 'backup.configurations.0.outputs.0.archiveFormat', + ev.target.value, + { shouldDirty: true }, + ); + setValue('backup.configurations.0.outputs.0.gzip', false, { + shouldDirty: true, + }); + } else if (ev.target.value === 'targz') { + setValue('backup.configurations.0.outputs.0.archiveFormat', 'tar', { + shouldDirty: true, + }); + setValue('backup.configurations.0.outputs.0.gzip', true, { + shouldDirty: true, + }); + } + } + + function renderBackupsForm() { + return ( + + + + + } + label="Enable Backups" + /> + + When enabling, Tunarr will generate an initial backup immediately + + + + {backupsEnabled && ( + <> + + ( + + )} + /> + + + + + + + Archive Format + ( + + )} + /> + + + + + Every + + ( + + )} + /> + + {currentBackupSchedule!.unit === 'day' && ( + + setValue( + 'backup.configurations.0.schedule.offsetMs', + isNull(value) + ? 0 + : value + .mod(dayjs.duration(1, 'day')) + .asMilliseconds(), + { shouldDirty: true }, + ) + } + /> + )} + + + + )} + + ); + } + + return ( + + + + Server Settings + + + isValidUrl(s, true) } }} + render={({ field, fieldState: { error } }) => ( + + {isLoading ? ( + + ) : !isError ? ( + + ) : ( + + )} + + ), + }, + }} + {...field} + helperText={ + error?.type === 'isValidUrl' + ? 'Must use a valid URL, or empty.' + : 'Set the host of your Tunarr backend. When empty, the web UI will use the current host/port to communicate with the backend.' + } + /> + )} + /> + + + + Log Level + ( + + )} + /> + + Set the log level for the Tunarr server. +
+ Selecting "Use environment settings" will + instruct the server to use the LOG_LEVEL environment + variable, if set, or system default "info". +
+
+
+ + + Backups + + {renderBackupsForm()} + + + + Caching + + + + ( + + )} + /> + } + label={ + + Experimental: Enable Plex Request Cache{' '} + + + [?] + + + + } + /> + + This feature is currently experimental. Proceed with caution and + if you experience an issue, try disabling caching. + + + + +
+ + {isDirty && ( + + )} + + +
+ ); +} diff --git a/web/src/components/settings/general/WebSettings.tsx b/web/src/components/settings/general/WebSettings.tsx new file mode 100644 index 000000000..3ca5fd303 --- /dev/null +++ b/web/src/components/settings/general/WebSettings.tsx @@ -0,0 +1,50 @@ +import DarkModeButton from '@/components/settings/DarkModeButton.tsx'; +import useStore from '@/store/index.ts'; +import { setUiLocale } from '@/store/settings/actions.ts'; +import { SupportedLocales } from '@/store/settings/store.ts'; +import { + Box, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; + +export const WebSettings = () => { + const locale = useStore((state) => state.settings.ui.i18n.locale); + + return ( + + + Web Settings + + These settings are stored in your browser and are saved automatically + when changed. + + + + + setUiLocale(value as SupportedLocales)} + aria-label="text alignment" + > + + 12-hour + + + 24-hour + + + + + + + Theme Settings + + + + + ); +}; diff --git a/web/src/components/slot_scheduler/TimeSlotTable.tsx b/web/src/components/slot_scheduler/TimeSlotTable.tsx index 5f0dcb1f2..e707d4ce7 100644 --- a/web/src/components/slot_scheduler/TimeSlotTable.tsx +++ b/web/src/components/slot_scheduler/TimeSlotTable.tsx @@ -292,8 +292,8 @@ export const TimeSlotTable = () => { const value = cell.getValue(); const dateTime = startOfPeriod.add(value); return currentPeriod === 'day' - ? dateTime.format('hh:mm A') - : dateTime.format('dddd hh:mm A'); + ? dateTime.format('LT') + : dateTime.format('dddd LT'); }, size: 100, grow: false, diff --git a/web/src/hooks/useDayjs.ts b/web/src/hooks/useDayjs.ts new file mode 100644 index 000000000..bddcb076e --- /dev/null +++ b/web/src/hooks/useDayjs.ts @@ -0,0 +1,6 @@ +import { DayjsContext } from '@/providers/DayjsProvider'; +import { useContext } from 'react'; + +export const useDayjs = () => { + return useContext(DayjsContext).dayjs; +}; diff --git a/web/src/main.tsx b/web/src/main.tsx index c6ae82c4f..b3a843df9 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,24 +1,18 @@ import { routeTree } from '@/routeTree.gen'; -import { LocalizationProvider } from '@mui/x-date-pickers'; -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { RouterProvider, createRouter } from '@tanstack/react-router'; -import { SnackbarProvider } from 'notistack'; +import { createRouter } from '@tanstack/react-router'; +import dayjs from 'dayjs'; +import 'dayjs/locale/en-gb'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; import React from 'react'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; import ReactDOM from 'react-dom/client'; -import { - TunarrApiProvider, - getApiClient, -} from './components/TunarrApiContext.tsx'; -import { ServerEventsProvider } from './components/server_events/ServerEventsProvider.tsx'; +import { Tunarr } from './Tunarr.tsx'; +import { getApiClient } from './components/TunarrApiContext.tsx'; import './helpers/dayjs.ts'; import './index.css'; import { queryClient } from './queryClient.ts'; // Create a new router instance -const router = createRouter({ +export const router = createRouter({ routeTree, context: { queryClient, tunarrApiClientProvider: getApiClient }, }); @@ -30,21 +24,11 @@ declare module '@tanstack/react-router' { } } +dayjs.extend(localizedFormat); +dayjs.locale('en-gb'); + ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - - - - - - - - , + , ); diff --git a/web/src/pages/guide/GuidePage.tsx b/web/src/pages/guide/GuidePage.tsx index 8543aba6e..e2aba5974 100644 --- a/web/src/pages/guide/GuidePage.tsx +++ b/web/src/pages/guide/GuidePage.tsx @@ -13,7 +13,7 @@ import { Tooltip, Typography, } from '@mui/material'; -import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { DateTimePicker } from '@mui/x-date-pickers'; import dayjs, { Dayjs, duration } from 'dayjs'; import isBetween from 'dayjs/plugin/isBetween'; import { useCallback, useState } from 'react'; @@ -117,8 +117,9 @@ export default function GuidePage({ channelId }: Props = { channelId: 'all' }) { sx={{ my: 1 }} > - handleDayChange(v)} label="Guide Start Time" diff --git a/web/src/pages/settings/GeneralSettingsPage.tsx b/web/src/pages/settings/GeneralSettingsPage.tsx index 064da7b52..1d53d2f3b 100644 --- a/web/src/pages/settings/GeneralSettingsPage.tsx +++ b/web/src/pages/settings/GeneralSettingsPage.tsx @@ -1,63 +1,29 @@ -import { isValidUrl } from '@/helpers/util.ts'; -import { CloudDoneOutlined, CloudOff } from '@mui/icons-material'; -import { - Box, - Checkbox, - Divider, - FormControl, - FormControlLabel, - FormHelperText, - InputAdornment, - InputLabel, - MenuItem, - Select, - SelectChangeEvent, - TextField, - Tooltip, - Typography, - useTheme, -} from '@mui/material'; -import Button from '@mui/material/Button'; -import Grid from '@mui/material/Grid2'; +import { GeneralSettingsForm } from '@/components/settings/general/GeneralSettingsForm.tsx'; +import { WebSettings } from '@/components/settings/general/WebSettings.tsx'; +import { Box, Divider } from '@mui/material'; import Stack from '@mui/material/Stack'; -import { TimePicker } from '@mui/x-date-pickers'; import { CacheSettings, LogLevel, LogLevels, SystemSettings, } from '@tunarr/types'; -import { UpdateSystemSettingsRequest } from '@tunarr/types/api'; -import { BackupSettings, EverySchedule } from '@tunarr/types/schemas'; -import dayjs from 'dayjs'; -import { first, isNull, map, trim, trimEnd } from 'lodash-es'; -import { useSnackbar } from 'notistack'; -import pluralize from 'pluralize'; -import { useCallback } from 'react'; -import { Controller, useFieldArray, useForm } from 'react-hook-form'; -import { RotatingLoopIcon } from '../../components/base/LoadingIcon.tsx'; -import DarkModeButton from '../../components/settings/DarkModeButton.tsx'; -import { NumericFormControllerText } from '../../components/util/TypedController.tsx'; -import { - useSystemSettings, - useUpdateSystemSettings, -} from '../../hooks/useSystemSettings.ts'; -import { useVersion } from '../../hooks/useVersion.ts'; -import { setBackendUri } from '../../store/settings/actions.ts'; -import { useSettings } from '../../store/settings/selectors.ts'; +import { BackupSettings } from '@tunarr/types/schemas'; +import { map } from 'lodash-es'; +import { useSystemSettings } from '../../hooks/useSystemSettings.ts'; -type GeneralSettingsFormData = { +export type GeneralSettingsFormData = { backendUri: string; logLevel: LogLevel | 'env'; backup: BackupSettings; cache: CacheSettings; }; -type GeneralSetingsFormProps = { +export type GeneralSetingsFormProps = { systemSettings: SystemSettings; }; -const LogLevelChoices = [ +export const LogLevelChoices = [ { description: 'Use environment settings', value: 'env', @@ -68,388 +34,6 @@ const LogLevelChoices = [ })), ]; -function GeneralSettingsForm({ systemSettings }: GeneralSetingsFormProps) { - const settings = useSettings(); - const snackbar = useSnackbar(); - const versionInfo = useVersion({ - retry: 0, - }); - const theme = useTheme(); - - const { isLoading, isError } = versionInfo; - - const updateSystemSettings = useUpdateSystemSettings(); - - const getBaseFormValues = ( - systemSettings: SystemSettings, - ): GeneralSettingsFormData => ({ - backendUri: settings.backendUri, - logLevel: systemSettings.logging.useEnvVarLevel - ? 'env' - : systemSettings.logging.logLevel, - backup: systemSettings.backup, - cache: systemSettings.cache ?? { - enablePlexRequestCache: false, - }, - }); - - const { - control, - handleSubmit, - reset, - formState: { isDirty, isValid, isSubmitting }, - watch, - setValue, - } = useForm({ - defaultValues: getBaseFormValues(systemSettings), - }); - - const { remove, append } = useFieldArray({ - control, - name: 'backup.configurations', - }); - - const backupsValue = watch('backup'); - const backupsEnabled = backupsValue.configurations.length > 0; - const currentBackupSchedule = first(backupsValue.configurations)?.schedule as - | EverySchedule - | undefined; - - const onSave = (data: GeneralSettingsFormData) => { - const newBackendUri = trimEnd(trim(data.backendUri), '/'); - setBackendUri(newBackendUri); - snackbar.enqueueSnackbar('Settings Saved!', { - variant: 'success', - }); - const updateReq: UpdateSystemSettingsRequest = { - logging: { - logLevel: data.logLevel === 'env' ? undefined : data.logLevel, - useEnvVarLevel: data.logLevel === 'env', - }, - backup: data.backup, - cache: data.cache, - }; - updateSystemSettings.mutate(updateReq, { - onSuccess(data) { - reset(getBaseFormValues(data), { keepDirty: false }); - }, - }); - }; - - const toggleBackupEnabled = useCallback(() => { - if (backupsEnabled) { - remove(); - } else { - append({ - enabled: true, - outputs: [ - { - type: 'file', - archiveFormat: 'tar', - maxBackups: 3, - outputPath: '', - }, - ], - schedule: { - type: 'every', - increment: 1, - unit: 'day', - offsetMs: dayjs.duration({ hours: 3 }).asMilliseconds(), - }, - }); - } - }, [append, backupsEnabled, remove]); - - function handleArchiveFormatUpdate(ev: SelectChangeEvent) { - if (ev.target.value === 'zip' || ev.target.value === 'tar') { - setValue( - 'backup.configurations.0.outputs.0.archiveFormat', - ev.target.value, - { shouldDirty: true }, - ); - setValue('backup.configurations.0.outputs.0.gzip', false, { - shouldDirty: true, - }); - } else if (ev.target.value === 'targz') { - setValue('backup.configurations.0.outputs.0.archiveFormat', 'tar', { - shouldDirty: true, - }); - setValue('backup.configurations.0.outputs.0.gzip', true, { - shouldDirty: true, - }); - } - } - - function renderBackupsForm() { - return ( - - - - - } - label="Enable Backups" - /> - - When enabling, Tunarr will generate an initial backup immediately - - - - {backupsEnabled && ( - <> - - ( - - )} - /> - - - - - - - Archive Format - ( - - )} - /> - - - - - Every - - ( - - )} - /> - - {currentBackupSchedule!.unit === 'day' && ( - - setValue( - 'backup.configurations.0.schedule.offsetMs', - isNull(value) - ? 0 - : value - .mod(dayjs.duration(1, 'day')) - .asMilliseconds(), - { shouldDirty: true }, - ) - } - /> - )} - - - - )} - - ); - } - - return ( - - - - Server Settings - - - isValidUrl(s, true) } }} - render={({ field, fieldState: { error } }) => ( - - {isLoading ? ( - - ) : !isError ? ( - - ) : ( - - )} - - ), - }} - {...field} - helperText={ - error?.type === 'isValidUrl' - ? 'Must use a valid URL, or empty.' - : 'Set the host of your Tunarr backend. When empty, the web UI will use the current host/port to communicate with the backend.' - } - /> - )} - /> - - - - Log Level - ( - - )} - /> - - Set the log level for the Tunarr server. -
- Selecting "Use environment settings" will - instruct the server to use the LOG_LEVEL environment - variable, if set, or system default "info". -
-
-
- - - Backups - - {renderBackupsForm()} - - - - Caching - - - - ( - - )} - /> - } - label={ - - Experimental: Enable Plex Request Cache{' '} - - - [?] - - - - } - /> - - This feature is currently experimental. Proceed with caution and - if you experience an issue, try disabling caching. - - - - -
- - {isDirty && ( - - )} - - -
- ); -} - export default function GeneralSettingsPage() { const systemSettings = useSystemSettings(); @@ -459,16 +43,7 @@ export default function GeneralSettingsPage() { systemSettings.data && ( - - - Theme Settings - - - - This setting is stored in your browser and is saved automatically - when changed. - - + diff --git a/web/src/providers/DayjsProvider.tsx b/web/src/providers/DayjsProvider.tsx new file mode 100644 index 000000000..539add25f --- /dev/null +++ b/web/src/providers/DayjsProvider.tsx @@ -0,0 +1,35 @@ +import useStore from '@/store'; +import originalDayjs from 'dayjs'; +import React, { useMemo } from 'react'; + +type ContextType = { + dayjs: (date?: originalDayjs.ConfigType) => originalDayjs.Dayjs; +}; + +const defaultContextType: ContextType = { + dayjs(date?: originalDayjs.ConfigType) { + return originalDayjs(date); + }, +}; + +export const DayjsContext = + React.createContext(defaultContextType); + +type Props = { + children: React.ReactNode | React.ReactNode[]; +}; + +export const DayjsProvider = ({ children }: Props) => { + const locale = useStore((store) => store.settings.ui.i18n.locale); + const value = useMemo(() => { + originalDayjs.locale(locale); + return { + dayjs: (date?: originalDayjs.ConfigType) => { + return originalDayjs(date); + }, + } satisfies ContextType; + }, [locale]); + return ( + {children} + ); +}; diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 8e211ec4d..1dcffda98 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -56,6 +56,7 @@ const useStore = create()( }, channelTableColumnModel: state.settings.ui.channelTableColumnModel, + i18n: state.settings.ui.i18n, }, }, }) satisfies PersistedState, diff --git a/web/src/store/settings/actions.ts b/web/src/store/settings/actions.ts index ea0e3dde3..4dcf5eec1 100644 --- a/web/src/store/settings/actions.ts +++ b/web/src/store/settings/actions.ts @@ -1,4 +1,6 @@ +import { SupportedLocales } from '@/store/settings/store.ts'; import { PaginationState } from '@tanstack/react-table'; +import dayjs from 'dayjs'; import useStore from '..'; export const setBackendUri = (uri: string) => @@ -16,3 +18,9 @@ export const setChannelPaginationState = (p: PaginationState) => useStore.setState(({ settings }) => { settings.ui.channelTablePagination = p; }); + +export const setUiLocale = (locale: SupportedLocales) => + useStore.setState(({ settings }) => { + dayjs.locale(locale); // Changes the default dayjs locale globally + settings.ui.i18n.locale = locale; + }); diff --git a/web/src/store/settings/store.ts b/web/src/store/settings/store.ts index ddf8362be..d2d541fda 100644 --- a/web/src/store/settings/store.ts +++ b/web/src/store/settings/store.ts @@ -1,11 +1,17 @@ import { PaginationState } from '@tanstack/react-table'; import { StateCreator } from 'zustand'; +// Only these 2 are supported currently +export type SupportedLocales = 'en' | 'en-gb'; + interface SettingsStateInternal { backendUri: string; ui: { channelTablePagination: PaginationState; channelTableColumnModel: Record; + i18n: { + locale: SupportedLocales; + }; }; } @@ -21,6 +27,9 @@ export type PersistedSettingsState = { pageSize: number; }; channelTableColumnModel: Record; + i18n: { + locale: SupportedLocales; + }; }; }; }; @@ -44,6 +53,9 @@ export const createSettingsSlice: StateCreator = () => ({ channelTableColumnModel: { onDemand: false, }, + i18n: { + locale: 'en', + }, }, }, }); diff --git a/web/src/store/themeEditor/store.ts b/web/src/store/themeEditor/store.ts index 265d78abc..ac1b6a035 100644 --- a/web/src/store/themeEditor/store.ts +++ b/web/src/store/themeEditor/store.ts @@ -4,6 +4,7 @@ import { StateCreator } from 'zustand'; import { ProgramSelectorViewType } from '../../types'; dayjs.extend(duration); + export interface ThemeEditorStateInner { darkMode?: boolean | undefined; showWelcome: boolean; diff --git a/web/tsconfig.json b/web/tsconfig.json index 0e9327a56..3dbd74dfa 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -34,6 +34,7 @@ "vitest.config.ts", "./src/components/slot_scheduler/EditSlotDialogContent.tsx", "src/components/slot_scheduler/EditSlotProgrammingForm.tsx", + "./src/Tunarr.tsx", ], "include": [ "./src/**/*.ts",