From e766c4dd54a9414536a9da4314a243b8ea5e9810 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:06:52 -0700 Subject: [PATCH] Refactored components --- frontend/src/components/forms/Recording.jsx | 252 ++++-------------- .../forms/RecordingDetailsModal.jsx | 70 ++--- .../components/forms/RecurringRuleModal.jsx | 91 ++----- .../src/components/forms/ScheduleInput.jsx | 12 +- .../components/forms/SeriesRecordingModal.jsx | 26 +- .../forms/SeriesRuleEditorModal.jsx | 124 +++------ frontend/src/components/forms/Stream.jsx | 21 +- .../src/components/forms/StreamProfile.jsx | 81 ++---- .../src/components/forms/SuperuserForm.jsx | 16 +- frontend/src/components/forms/User.jsx | 228 ++++------------ frontend/src/components/forms/UserAgent.jsx | 28 +- 11 files changed, 270 insertions(+), 679 deletions(-) diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index 23f78867..163a43a7 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -1,86 +1,40 @@ import React, { useEffect, useMemo, useState } from 'react'; -import dayjs from 'dayjs'; -import API from '../../api'; import { Alert, Button, + Group, + Loader, Modal, + MultiSelect, + SegmentedControl, Select, Stack, - SegmentedControl, - MultiSelect, - Group, TextInput, - Loader, } from '@mantine/core'; -import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates'; +import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates'; import { CircleAlert } from 'lucide-react'; -import { isNotEmpty, useForm } from '@mantine/form'; +import { useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; -import { notifications } from '@mantine/notifications'; - -const DAY_OPTIONS = [ - { value: '6', label: 'Sun' }, - { value: '0', label: 'Mon' }, - { value: '1', label: 'Tue' }, - { value: '2', label: 'Wed' }, - { value: '3', label: 'Thu' }, - { value: '4', label: 'Fri' }, - { value: '5', label: 'Sat' }, -]; - -const asDate = (value) => { - if (!value) return null; - if (value instanceof Date) return value; - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed; -}; - -const toIsoIfDate = (value) => { - const dt = asDate(value); - return dt ? dt.toISOString() : value; -}; - -// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm" -const toTimeString = (value) => { - if (!value) return '00:00'; - if (typeof value === 'string') { - const parsed = dayjs( - value, - ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], - true - ); - if (parsed.isValid()) return parsed.format('HH:mm'); - return value; - } - const dt = asDate(value); - if (!dt) return '00:00'; - return dayjs(dt).format('HH:mm'); -}; - -const toDateString = (value) => { - const dt = asDate(value); - if (!dt) return null; - const year = dt.getFullYear(); - const month = String(dt.getMonth() + 1).padStart(2, '0'); - const day = String(dt.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -}; - -const createRoundedDate = (minutesAhead = 0) => { - const dt = new Date(); - dt.setSeconds(0); - dt.setMilliseconds(0); - dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30); - if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead); - return dt; -}; - -// robust onChange for TimeInput (string or event) -const timeChange = (setter) => (valOrEvent) => { - if (typeof valOrEvent === 'string') setter(valOrEvent); - else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value); -}; +import { + RECURRING_DAY_OPTIONS, + toTimeString, +} from '../../utils/dateTimeUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + buildRecurringPayload, + buildSinglePayload, + createRecording, + createRecurringRule, + getChannelsSummary, + getRecurringFormDefaults, + getSingleFormDefaults, + numberedChannelLabel, + recurringFormValidators, + singleFormValidators, + sortedChannelOptions, + timeChange, + updateRecording, +} from '../../utils/forms/RecordingUtils.js'; const RecordingModal = ({ recording = null, @@ -98,117 +52,29 @@ const RecordingModal = ({ const [mode, setMode] = useState('single'); const [submitting, setSubmitting] = useState(false); - const defaultStart = createRoundedDate(); - const defaultEnd = createRoundedDate(60); - const defaultDate = new Date(); - - // One-time form const singleForm = useForm({ mode: 'controlled', - initialValues: { - channel_id: recording - ? `${recording.channel}` - : channel - ? `${channel.id}` - : '', - start_time: recording - ? asDate(recording.start_time) || defaultStart - : defaultStart, - end_time: recording - ? asDate(recording.end_time) || defaultEnd - : defaultEnd, - }, - validate: { - channel_id: isNotEmpty('Select a channel'), - start_time: isNotEmpty('Select a start time'), - end_time: (value, values) => { - const start = asDate(values.start_time); - const end = asDate(value); - if (!end) return 'Select an end time'; - if (start && end <= start) return 'End time must be after start time'; - return null; - }, - }, + initialValues: getSingleFormDefaults(recording, channel), + validate: singleFormValidators, }); - // Recurring form stores times as "HH:mm" strings for stable editing const recurringForm = useForm({ mode: 'controlled', validateInputOnChange: false, validateInputOnBlur: true, - initialValues: { - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: dayjs(defaultStart).format('HH:mm'), - end_time: dayjs(defaultEnd).format('HH:mm'), - rule_name: '', - start_date: defaultDate, - end_date: defaultDate, - }, - validate: { - channel_id: isNotEmpty('Select a channel'), - days_of_week: (value) => - value && value.length ? null : 'Pick at least one day', - start_time: (value) => (value ? null : 'Select a start time'), - end_time: (value, values) => { - if (!value) return 'Select an end time'; - const start = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - start.isValid() && - end.isValid() && - end.diff(start, 'minute') === 0 - ) { - return 'End time must differ from start time'; - } - return null; - }, - end_date: (value, values) => { - const end = asDate(value); - const start = asDate(values.start_date); - if (!end) return 'Select an end date'; - if (start && end < start) return 'End date cannot be before start date'; - return null; - }, - }, + initialValues: getRecurringFormDefaults(channel), + validate: recurringFormValidators, }); useEffect(() => { if (!isOpen) return; - const freshStart = createRoundedDate(); - const freshEnd = createRoundedDate(60); - const freshDate = new Date(); - - if (recording && recording.id) { + if (recording?.id) { setMode('single'); - singleForm.setValues({ - channel_id: `${recording.channel}`, - start_time: asDate(recording.start_time) || defaultStart, - end_time: asDate(recording.end_time) || defaultEnd, - }); + singleForm.setValues(getSingleFormDefaults(recording, channel)); } else { - // Reset forms for fresh open - singleForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - start_time: freshStart, - end_time: freshEnd, - }); - - const startStr = dayjs(freshStart).format('HH:mm'); - recurringForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: startStr, - end_time: dayjs(freshEnd).format('HH:mm'), - rule_name: channel?.name || '', - start_date: freshDate, - end_date: freshDate, - }); + singleForm.setValues(getSingleFormDefaults(null, channel)); + recurringForm.setValues(getRecurringFormDefaults(channel)); setMode('single'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -221,7 +87,7 @@ const RecordingModal = ({ if (!isOpen) return; try { setIsChannelsLoading(true); - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (cancelled) return; setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { @@ -238,19 +104,7 @@ const RecordingModal = ({ }, [isOpen]); const channelOptions = useMemo(() => { - const list = Array.isArray(allChannels) ? [...allChannels] : []; - list.sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum === bNum) return (a.name || '').localeCompare(b.name || ''); - return aNum - bNum; - }); - return list.map((item) => ({ - value: `${item.id}`, - label: item.channel_number - ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` - : item.name || `Channel ${item.id}`, - })); + return sortedChannelOptions(allChannels, numberedChannelLabel); }, [allChannels]); const resetForms = () => { @@ -267,25 +121,18 @@ const RecordingModal = ({ const handleSingleSubmit = async (values) => { try { setSubmitting(true); + const payload = buildSinglePayload(values); if (recording && recording.id) { - await API.updateRecording(recording.id, { - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await updateRecording(recording.id, payload); + showNotification({ title: 'Recording updated', message: 'Recording schedule updated successfully', color: 'green', autoClose: 2500, }); } else { - await API.createRecording({ - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await createRecording(payload); + showNotification({ title: 'Recording scheduled', message: 'One-time recording added to DVR queue', color: 'green', @@ -304,18 +151,10 @@ const RecordingModal = ({ const handleRecurringSubmit = async (values) => { try { setSubmitting(true); - await API.createRecurringRule({ - channel: values.channel_id, - days_of_week: (values.days_of_week || []).map((d) => Number(d)), - start_time: toTimeString(values.start_time), - end_time: toTimeString(values.end_time), - start_date: toDateString(values.start_date), - end_date: toDateString(values.end_date), - name: values.rule_name?.trim() || '', - }); + await createRecurringRule(buildRecurringPayload(values)); await Promise.all([fetchRecurringRules(), fetchRecordings()]); - notifications.show({ + showNotification({ title: 'Recurring rule saved', message: 'Future slots will be scheduled automatically', color: 'green', @@ -427,7 +266,10 @@ const RecordingModal = ({ key={recurringForm.key('days_of_week')} label="Every" placeholder="Select days" - data={DAY_OPTIONS} + data={RECURRING_DAY_OPTIONS.map((opt) => ({ + value: String(opt.value), + label: opt.label, + }))} searchable clearable nothingFoundMessage="No match" diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index bc8b245f..43351765 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -1,11 +1,13 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api'; import { + format, + isAfter, + isBefore, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; -import React from 'react'; -import { Pencil, RefreshCcw, Check, X } from 'lucide-react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Check, Pencil, RefreshCcw, X } from 'lucide-react'; import { ActionIcon, Badge, @@ -21,7 +23,6 @@ import { TextInput, } from '@mantine/core'; import useVideoStore from '../../store/useVideoStore.jsx'; -import { notifications } from '@mantine/notifications'; import defaultLogo from '../../images/logo.png'; import { deleteRecordingById, @@ -33,10 +34,14 @@ import { runComSkip, } from '../../utils/cards/RecordingCardUtils.js'; import { + getChannel, getRating, getStatRows, getUpcomingEpisodes, + refreshArtwork, + updateRecordingMetadata, } from '../../utils/forms/RecordingDetailsModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; const RecordingDetailsModal = ({ opened, @@ -51,21 +56,21 @@ const RecordingDetailsModal = ({ }) => { const allRecordings = useChannelsStore((s) => s.recordings); // Local channel cache to avoid the global channels map - const [channelsById, setChannelsById] = React.useState({}); + const [channelsById, setChannelsById] = useState({}); const { toUserTime, userNow } = useTimeHelpers(); - const [childOpen, setChildOpen] = React.useState(false); - const [childRec, setChildRec] = React.useState(null); + const [childOpen, setChildOpen] = useState(false); + const [childRec, setChildRec] = useState(null); const { timeFormat: timeformat, dateFormat: dateformat } = useDateTimeFormat(); - const [editing, setEditing] = React.useState(false); + const [editing, setEditing] = useState(false); // Prefer the store version of the recording for live updates // (e.g., after artwork refresh or metadata edit via WebSocket). // Preserve _group_count from the categorized prop — the store version // doesn't carry this client-side field, so without merging it back // isSeriesGroup would always be false and the episode list hidden. - const safeRecording = React.useMemo(() => { + const safeRecording = useMemo(() => { if (recording?.id && Array.isArray(allRecordings)) { const found = allRecordings.find((r) => r.id === recording.id); if (found) { @@ -81,7 +86,7 @@ const RecordingDetailsModal = ({ const program = customProps.program || {}; // Derive poster URL from live store data instead of the stale prop snapshot. - const livePosterUrl = React.useMemo( + const livePosterUrl = useMemo( () => getPosterUrl( customProps.poster_logo_id, @@ -93,17 +98,17 @@ const RecordingDetailsModal = ({ // Optimistic overrides — show saved values immediately without waiting // for the WebSocket round-trip to refresh the store. - const [savedTitle, setSavedTitle] = React.useState(null); - const [savedDescription, setSavedDescription] = React.useState(null); + const [savedTitle, setSavedTitle] = useState(null); + const [savedDescription, setSavedDescription] = useState(null); const recordingName = savedTitle ?? (program.title || 'Custom Recording'); const description = savedDescription ?? (program.description || customProps.description || ''); - const [editTitle, setEditTitle] = React.useState(''); - const [editDescription, setEditDescription] = React.useState(''); + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); // Reset optimistic state when the recording changes - React.useEffect(() => { + useEffect(() => { setSavedTitle(null); setSavedDescription(null); setEditing(false); @@ -129,7 +134,7 @@ const RecordingDetailsModal = ({ const isSeriesGroup = Boolean( safeRecording._group_count && safeRecording._group_count > 1 ); - const upcomingEpisodes = React.useMemo(() => { + const upcomingEpisodes = useMemo(() => { return getUpcomingEpisodes( isSeriesGroup, allRecordings, @@ -147,7 +152,7 @@ const RecordingDetailsModal = ({ ]); // Ensure channel is available for a given id - const loadChannel = React.useCallback( + const loadChannel = useCallback( async (id) => { if (!id) { return null; @@ -159,7 +164,7 @@ const RecordingDetailsModal = ({ } try { - const ch = await API.getChannel(id); + const ch = await getChannel(id); if (ch && ch.id === id) { setChannelsById((prev) => ({ ...prev, [id]: ch })); return ch; @@ -177,7 +182,7 @@ const RecordingDetailsModal = ({ ); // When opening a child episode, fetch that episode's channel - React.useEffect(() => { + useEffect(() => { if (!childOpen || !childRec) return; loadChannel(childRec.channel); }, [childOpen, childRec, loadChannel]); @@ -188,7 +193,7 @@ const RecordingDetailsModal = ({ const s = toUserTime(rec.start_time); const e = toUserTime(rec.end_time); - if (now.isAfter(s) && now.isBefore(e)) { + if (isAfter(now, s) && isBefore(now, e)) { const ch = channelsById[rec.channel] || (rec.channel === recording?.channel ? channel : null); @@ -228,14 +233,11 @@ const RecordingDetailsModal = ({ const saveMetadata = async () => { try { - await API.updateRecordingMetadata(recording.id, { - title: editTitle || 'Custom Recording', - description: editDescription, - }); + await updateRecordingMetadata(recording, editTitle, editDescription); setSavedTitle(editTitle || 'Custom Recording'); setSavedDescription(editDescription); setEditing(false); - notifications.show({ + showNotification({ title: 'Saved', message: 'Recording metadata updated', color: 'green', @@ -249,8 +251,8 @@ const RecordingDetailsModal = ({ const handleRefreshArtwork = async (e) => { e.stopPropagation?.(); try { - await API.refreshArtwork(recording.id); - notifications.show({ + await refreshArtwork(recording.id); + showNotification({ title: 'Refreshing artwork', message: 'Poster resolution started', color: 'blue.5', @@ -265,7 +267,7 @@ const RecordingDetailsModal = ({ e.stopPropagation?.(); try { await runComSkip(recording); - notifications.show({ + showNotification({ title: 'Removing commercials', message: 'Queued comskip for this recording', color: 'blue.5', @@ -344,8 +346,8 @@ const RecordingDetailsModal = ({ )} - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} @@ -468,7 +470,7 @@ const RecordingDetailsModal = ({ {onWatchLive && } {onWatchRecording && } - {onEdit && start.isAfter(userNow()) && } + {onEdit && isAfter(start, userNow()) && } {(customProps.status === 'completed' || customProps.status === 'stopped' || customProps.status === 'interrupted') && @@ -486,8 +488,8 @@ const RecordingDetailsModal = ({ - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} {rating && ( @@ -599,7 +601,7 @@ const RecordingDetailsModal = ({ title: { color: 'white' }, }} > - {isSeriesGroup ? Series() : Movie()} + {isSeriesGroup ? : } ); }; diff --git a/frontend/src/components/forms/RecurringRuleModal.jsx b/frontend/src/components/forms/RecurringRuleModal.jsx index 9fe49220..d9007d34 100644 --- a/frontend/src/components/forms/RecurringRuleModal.jsx +++ b/frontend/src/components/forms/RecurringRuleModal.jsx @@ -1,16 +1,15 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api.js'; import { - parseDate, + format, + getNow, RECURRING_DAY_OPTIONS, + toDate, toTimeString, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from '@mantine/form'; -import dayjs from 'dayjs'; -import { notifications } from '@mantine/notifications'; import { Badge, Button, @@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates'; import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js'; import { deleteRecurringRuleById, - getChannelOptions, + getFormDefaults, getUpcomingOccurrences, updateRecurringRule, updateRecurringRuleEnabled, } from '../../utils/forms/RecurringRuleModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + getChannelsSummary, + getRecurringFormDefaults, + recurringFormValidators, + sortedChannelOptions, +} from '../../utils/forms/RecordingUtils.js'; const RecurringRuleModal = ({ opened, @@ -56,66 +62,18 @@ const RecurringRuleModal = ({ const rule = recurringRules.find((r) => r.id === ruleId); const channelOptions = useMemo(() => { - return getChannelOptions(allChannels); + return sortedChannelOptions(allChannels); }, [allChannels]); const form = useForm({ mode: 'controlled', - initialValues: { - channel_id: '', - days_of_week: [], - rule_name: '', - start_time: dayjs().startOf('hour').format('HH:mm'), - end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'), - start_date: dayjs().toDate(), - end_date: dayjs().toDate(), - enabled: true, - }, - validate: { - channel_id: (value) => (value ? null : 'Select a channel'), - days_of_week: (value) => - value && value.length ? null : 'Pick at least one day', - end_time: (value, values) => { - if (!value) return 'Select an end time'; - const startValue = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - startValue.isValid() && - endValue.isValid() && - endValue.diff(startValue, 'minute') === 0 - ) { - return 'End time must differ from start time'; - } - return null; - }, - end_date: (value, values) => { - const endDate = dayjs(value); - const startDate = dayjs(values.start_date); - if (!value) return 'Select an end date'; - if (startDate.isValid() && endDate.isBefore(startDate, 'day')) { - return 'End date cannot be before start date'; - } - return null; - }, - }, + initialValues: { ...getRecurringFormDefaults(), enabled: true }, + validate: recurringFormValidators, }); useEffect(() => { if (opened && rule) { - form.setValues({ - channel_id: `${rule.channel}`, - days_of_week: (rule.days_of_week || []).map((d) => String(d)), - rule_name: rule.name || '', - start_time: toTimeString(rule.start_time), - end_time: toTimeString(rule.end_time), - start_date: parseDate(rule.start_date) || dayjs().toDate(), - end_date: parseDate(rule.end_date), - enabled: Boolean(rule.enabled), - }); + form.setValues(getFormDefaults(rule)); } else { form.reset(); } @@ -127,7 +85,7 @@ const RecurringRuleModal = ({ let cancelled = false; (async () => { try { - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { console.warn('Failed to load channels for recurring rule modal', e); @@ -149,7 +107,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRule(ruleId, values); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule updated', message: 'Schedule adjustments saved', color: 'green', @@ -169,7 +127,7 @@ const RecurringRuleModal = ({ try { await deleteRecurringRuleById(ruleId); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule removed', message: 'All future occurrences were cancelled', color: 'red', @@ -189,7 +147,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRuleEnabled(ruleId, checked); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: checked ? 'Recurring rule enabled' : 'Recurring rule paused', message: checked ? 'Future occurrences will resume' @@ -210,7 +168,7 @@ const RecurringRuleModal = ({ try { await deleteRecordingById(occurrence.id); // recording_cancelled WS event handles recording list update - notifications.show({ + showNotification({ title: 'Occurrence cancelled', message: 'The selected airing was removed', color: 'yellow', @@ -246,7 +204,7 @@ const RecurringRuleModal = ({ setDeleting(true); try { await deleteRecordingById(sourceRecording.id); - notifications.show({ + showNotification({ title: 'Recording deleted', color: 'green', autoClose: 2500, @@ -275,7 +233,7 @@ const RecurringRuleModal = ({ }; const handleStartDateChange = (value) => { - form.setFieldValue('start_date', value || dayjs().toDate()); + form.setFieldValue('start_date', value || toDate(getNow())); }; const handleEndDateChange = (value) => { @@ -302,10 +260,11 @@ const RecurringRuleModal = ({ - {occStart.format(`${dateformat}, YYYY`)} + {format(occStart, `${dateformat}, YYYY`)} - {occStart.format(timeformat)} – {occEnd.format(timeformat)} + {format(occStart, timeformat)} –{' '} + {format(occEnd, timeformat)} diff --git a/frontend/src/components/forms/ScheduleInput.jsx b/frontend/src/components/forms/ScheduleInput.jsx index eb4aa70d..a0cbb854 100644 --- a/frontend/src/components/forms/ScheduleInput.jsx +++ b/frontend/src/components/forms/ScheduleInput.jsx @@ -38,8 +38,8 @@ import { Popover, ActionIcon, Group, - Divider, - SimpleGrid, + PopoverTarget, + PopoverDropdown, } from '@mantine/core'; import { Info } from 'lucide-react'; import { validateCronExpression } from '../../utils/cronUtils'; @@ -118,12 +118,12 @@ export default function ScheduleInput({ Cron Expression - + - - + + COMMON EXAMPLES @@ -169,7 +169,7 @@ export default function ScheduleInput({ 30 14 1 * * - + } diff --git a/frontend/src/components/forms/SeriesRecordingModal.jsx b/frontend/src/components/forms/SeriesRecordingModal.jsx index 67aaced5..6ec6cea4 100644 --- a/frontend/src/components/forms/SeriesRecordingModal.jsx +++ b/frontend/src/components/forms/SeriesRecordingModal.jsx @@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = { regex: 'Title regex', }; +const renderRuleSummary = (r) => { + const titleMode = (r.title_mode || 'exact').toLowerCase(); + const parts = []; + parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); + if (r.title) { + parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); + } + if (r.description) { + parts.push(`Description: "${r.description}"`); + } + return parts.join(' | '); +}; + export default function SeriesRecordingModal({ opened, onClose, @@ -59,19 +72,6 @@ export default function SeriesRecordingModal({ onRulesUpdate(updated); }; - const renderRuleSummary = (r) => { - const titleMode = (r.title_mode || 'exact').toLowerCase(); - const parts = []; - parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); - if (r.title) { - parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); - } - if (r.description) { - parts.push(`Description: "${r.description}"`); - } - return parts.join(' | '); - }; - return ( <> ${e.toLocaleString()}`; - } catch { - return `${start} - ${end}`; - } -} +import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js'; +import { + createSeriesRule, + evaluateSeriesRulesByTvgId, +} from '../../utils/guideUtils.js'; +import { + DESCRIPTION_MODES, + EPISODE_MODES, + formatRange, + getChannelOptions, + getTvgOptions, + previewSeriesRule, + TITLE_MODES, +} from '../../utils/forms/SeriesRuleEditorModalUtils.js'; export default function SeriesRuleEditorModal({ opened, @@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({ useEffect(() => { if (!opened) return; let cancelled = false; - API.getChannelsSummary() + getChannelsSummary() .then((chans) => { if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); }) @@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({ setPreviewLoading(true); setPreviewError(null); - API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal }) + previewSeriesRule(debouncedPreviewKey, controller) .then((resp) => { if (controller.signal.aborted) return; setPreview(resp || { matches: [], total: 0 }); @@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({ // EPG channel options for the tvg_id selector. Deduplicate by tvg_id value // since the same channel can appear across multiple EPG sources. const tvgOptions = useMemo(() => { - const seen = new Set(); - const options = []; - for (const t of tvgs || []) { - if (!t.tvg_id || seen.has(t.tvg_id)) continue; - seen.add(t.tvg_id); - options.push({ - value: t.tvg_id, - label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id, - }); - } - return options.sort((a, b) => a.label.localeCompare(b.label)); + return getTvgOptions(tvgs); }, [tvgs]); - // Channel select options: prefer channels matching the selected tvg_id. const channelOptions = useMemo(() => { - const sorted = [...allChannels].sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum !== bNum) return aNum - bNum; - return (a.name || '').localeCompare(b.name || ''); - }); - const matching = []; - const others = []; - for (const c of sorted) { - const item = { - value: String(c.id), - label: c.channel_number - ? `${c.channel_number} - ${c.name || `Channel ${c.id}`}` - : c.name || `Channel ${c.id}`, - }; - const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null; - if (tvgId && cTvg && String(cTvg) === String(tvgId)) { - matching.push(item); - } else { - others.push(item); - } - } - return [...matching, ...others]; + return getChannelOptions(allChannels, tvgsById, tvgId); }, [allChannels, tvgsById, tvgId]); const canSave = !!(payload.title || payload.description); @@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({ const handleSave = async () => { setSaving(true); try { - await API.createSeriesRule(payload); + await createSeriesRule(payload); // Trigger evaluation so matching upcoming programs get scheduled. try { - await API.evaluateSeriesRules(payload.tvg_id); + await evaluateSeriesRulesByTvgId(payload.tvg_id); await useChannelsStore.getState().fetchRecordings(); } catch (e) { console.warn('Failed to evaluate after save', e); @@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({ showNotification({ title: 'Series rule saved' }); if (onSaved) await onSaved(); onClose(); + } catch (e) { + console.error('Failed to save series rule', e); } finally { setSaving(false); } @@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({ )} - + {(preview.matches || []).map((p) => ( @@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({ )} - +