mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Refactored components
This commit is contained in:
parent
688ca2b405
commit
e766c4dd54
11 changed files with 270 additions and 679 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
)}
|
||||
</Group>
|
||||
<Text size="xs">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{end.format(timeformat)}
|
||||
{format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{format(end, timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
|
|
@ -468,7 +470,7 @@ const RecordingDetailsModal = ({
|
|||
<Group gap={8}>
|
||||
{onWatchLive && <WatchLive />}
|
||||
{onWatchRecording && <WatchRecording />}
|
||||
{onEdit && start.isAfter(userNow()) && <Edit />}
|
||||
{onEdit && isAfter(start, userNow()) && <Edit />}
|
||||
{(customProps.status === 'completed' ||
|
||||
customProps.status === 'stopped' ||
|
||||
customProps.status === 'interrupted') &&
|
||||
|
|
@ -486,8 +488,8 @@ const RecordingDetailsModal = ({
|
|||
</Group>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{end.format(timeformat)}
|
||||
{format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{format(end, timeformat)}
|
||||
</Text>
|
||||
{rating && (
|
||||
<Group gap={8}>
|
||||
|
|
@ -599,7 +601,7 @@ const RecordingDetailsModal = ({
|
|||
title: { color: 'white' },
|
||||
}}
|
||||
>
|
||||
{isSeriesGroup ? Series() : Movie()}
|
||||
{isSeriesGroup ? <Series /> : <Movie />}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2} flex={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{occStart.format(`${dateformat}, YYYY`)}
|
||||
{format(occStart, `${dateformat}, YYYY`)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{occStart.format(timeformat)} – {occEnd.format(timeformat)}
|
||||
{format(occStart, timeformat)} –{' '}
|
||||
{format(occEnd, timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<Group gap="xs">
|
||||
Cron Expression
|
||||
<Popover width={320} position="top" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<PopoverTarget>
|
||||
<ActionIcon variant="subtle" size="xs" color="gray">
|
||||
<Info size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="sm">
|
||||
</PopoverTarget>
|
||||
<PopoverDropdown p="sm">
|
||||
<Text size="xs" fw={600} mb="xs" c="dimmed">
|
||||
COMMON EXAMPLES
|
||||
</Text>
|
||||
|
|
@ -169,7 +169,7 @@ export default function ScheduleInput({
|
|||
<Code size="xs">30 14 1 * *</Code>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</PopoverDropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<Modal
|
||||
|
|
|
|||
|
|
@ -1,64 +1,37 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollAreaAutosize,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
SegmentedControl,
|
||||
Group,
|
||||
Button,
|
||||
Select,
|
||||
Badge,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
Alert,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import API from '../../api.js';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import useEPGsStore from '../../store/epgs.jsx';
|
||||
import { useDebounce } from '../../utils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
|
||||
const TITLE_MODES = [
|
||||
{ label: 'Exact', value: 'exact' },
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Whole word', value: 'search' },
|
||||
{ label: 'Regex', value: 'regex' },
|
||||
];
|
||||
|
||||
const DESCRIPTION_MODES = [
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Whole word', value: 'search' },
|
||||
{ label: 'Regex', value: 'regex' },
|
||||
];
|
||||
|
||||
const EPISODE_MODES = [
|
||||
{ label: 'All episodes', value: 'all' },
|
||||
{ label: 'New only', value: 'new' },
|
||||
];
|
||||
|
||||
function formatRange(start, end) {
|
||||
try {
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
const sameDay = s.toDateString() === e.toDateString();
|
||||
const dateStr = s.toLocaleDateString();
|
||||
const startStr = s.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const endStr = e.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return sameDay
|
||||
? `${dateStr} ${startStr} - ${endStr}`
|
||||
: `${dateStr} ${startStr} -> ${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({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<ScrollArea.Autosize mah={240}>
|
||||
<ScrollAreaAutosize mah={240}>
|
||||
<Stack gap={4}>
|
||||
{(preview.matches || []).map((p) => (
|
||||
<Group key={p.id} gap="xs" wrap="nowrap" align="flex-start">
|
||||
|
|
@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({
|
|||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</ScrollAreaAutosize>
|
||||
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
// Modal.js
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
|
||||
import { Button, Flex, Modal, Select, TextInput } from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string().required('URL is required').min(0),
|
||||
});
|
||||
import {
|
||||
addStream,
|
||||
getResolver,
|
||||
updateStream,
|
||||
} from '../../utils/forms/StreamUtils.js';
|
||||
|
||||
const Stream = ({ stream = null, isOpen, onClose }) => {
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
|
|
@ -40,7 +37,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
|
|
@ -58,9 +55,9 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
if (stream?.id) {
|
||||
await API.updateStream({ id: stream.id, ...payload });
|
||||
await updateStream(stream.id, payload);
|
||||
} else {
|
||||
await API.addStream(payload);
|
||||
await addStream(payload);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
|
|
|||
|
|
@ -1,50 +1,25 @@
|
|||
// StreamProfile form
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import {
|
||||
Modal,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Select,
|
||||
Button,
|
||||
Flex,
|
||||
Stack,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
|
||||
// Built-in commands supported by Dispatcharr out of the box.
|
||||
const BUILT_IN_COMMANDS = [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: 'streamlink', label: 'Streamlink' },
|
||||
{ value: 'cvlc', label: 'VLC' },
|
||||
{ value: 'yt-dlp', label: 'yt-dlp' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
];
|
||||
|
||||
// Default parameter examples for each built-in command.
|
||||
const COMMAND_EXAMPLES = {
|
||||
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
|
||||
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
|
||||
cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}',
|
||||
'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}',
|
||||
};
|
||||
|
||||
// Returns '__custom__' when the command isn't one of the built-ins,
|
||||
// otherwise returns the command value itself.
|
||||
const toCommandSelection = (command) =>
|
||||
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
|
||||
? command
|
||||
: '__custom__';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string(),
|
||||
});
|
||||
import {
|
||||
addStreamProfile,
|
||||
BUILT_IN_COMMANDS,
|
||||
COMMAND_EXAMPLES,
|
||||
getResolver,
|
||||
toCommandSelection,
|
||||
updateStreamProfile,
|
||||
} from '../../utils/forms/StreamProfileUtils.js';
|
||||
|
||||
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
|
|
@ -73,7 +48,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
// Sync form + dropdown selection whenever the target profile or modal state changes
|
||||
|
|
@ -84,9 +59,9 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
|
||||
const onSubmit = async (values) => {
|
||||
if (profile?.id) {
|
||||
await API.updateStreamProfile({ id: profile.id, ...values });
|
||||
await updateStreamProfile(profile.id, values);
|
||||
} else {
|
||||
await API.addStreamProfile(values);
|
||||
await addStreamProfile(values);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
|
@ -102,6 +77,17 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
const userAgentValue = watch('user_agent');
|
||||
const isActiveValue = watch('is_active');
|
||||
|
||||
const handleOnChangeCommand = (val) => {
|
||||
setCommandSelection(val);
|
||||
// For built-in selections, write the real command value immediately
|
||||
if (val !== '__custom__') {
|
||||
setValue('command', val, { shouldValidate: true });
|
||||
} else {
|
||||
// Clear so the user enters their own value
|
||||
setValue('command', '', { shouldValidate: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
|
|
@ -127,16 +113,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
data={BUILT_IN_COMMANDS}
|
||||
disabled={isLocked}
|
||||
value={commandSelection}
|
||||
onChange={(val) => {
|
||||
setCommandSelection(val);
|
||||
// For built-in selections, write the real command value immediately
|
||||
if (val !== '__custom__') {
|
||||
setValue('command', val, { shouldValidate: true });
|
||||
} else {
|
||||
// Clear so the user enters their own value
|
||||
setValue('command', '', { shouldValidate: false });
|
||||
}
|
||||
}}
|
||||
onChange={handleOnChangeCommand}
|
||||
error={isCustom ? undefined : errors.command?.message}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,21 @@ import useAuthStore from '../../store/auth';
|
|||
import useSettingsStore from '../../store/settings';
|
||||
import logo from '../../assets/logo.png';
|
||||
|
||||
const createSuperUser = (formData) => {
|
||||
return API.createSuperUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
};
|
||||
|
||||
function SuperuserForm() {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [_error, setError] = useState('');
|
||||
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
|
||||
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
|
||||
const storedVersion = useSettingsStore((s) => s.version);
|
||||
|
|
@ -43,11 +51,7 @@ function SuperuserForm() {
|
|||
e.preventDefault();
|
||||
try {
|
||||
console.log(formData);
|
||||
const response = await API.createSuperUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
const response = await createSuperUser(formData);
|
||||
if (response.superuser_exists) {
|
||||
setSuperuserExists(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,41 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
PasswordInput,
|
||||
Group,
|
||||
Stack,
|
||||
MultiSelect,
|
||||
ActionIcon,
|
||||
Switch,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsPanel,
|
||||
TabsTab,
|
||||
TagsInput,
|
||||
Text,
|
||||
TextInput,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { RotateCcwKey, X } from 'lucide-react';
|
||||
import { Copy, Key } from 'lucide-react';
|
||||
import { Copy, Key, RotateCcwKey, X } from 'lucide-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import useOutputProfilesStore from '../../store/outputProfiles';
|
||||
import {
|
||||
USER_LEVELS,
|
||||
USER_LEVEL_LABELS,
|
||||
NETWORK_ACCESS_OPTIONS,
|
||||
} from '../../constants';
|
||||
import { USER_LEVEL_LABELS, USER_LEVELS, } from '../../constants';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import { copyToClipboard } from '../../utils';
|
||||
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils';
|
||||
|
||||
const isValidNetworkEntry = (entry) =>
|
||||
entry.match(IPV4_CIDR_REGEX) ||
|
||||
entry.match(IPV6_CIDR_REGEX) ||
|
||||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
|
||||
(entry + '/128').match(IPV6_CIDR_REGEX);
|
||||
|
||||
const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
|
||||
import {
|
||||
createUser,
|
||||
formValuesToPayload,
|
||||
generateApiKey,
|
||||
getFormInitialValues,
|
||||
getFormValidators,
|
||||
revokeApiKey,
|
||||
updateUser,
|
||||
userToFormValues,
|
||||
} from '../../utils/forms/UserUtils.js';
|
||||
|
||||
const User = ({ user = null, isOpen, onClose }) => {
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
|
|
@ -48,52 +46,15 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
const [, setEnableXC] = useState(false);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState(new Set());
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generatedKey, setGeneratedKey] = useState(null);
|
||||
const [_generatedKey, setGeneratedKey] = useState(null);
|
||||
const [userAPIKey, setUserAPIKey] = useState(user?.api_key || null);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
initialValues: {
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
user_level: '0',
|
||||
stream_limit: 0,
|
||||
password: '',
|
||||
xc_password: '',
|
||||
output_format: '',
|
||||
output_profile: '',
|
||||
channel_profiles: [],
|
||||
hide_adult_content: false,
|
||||
epg_days: 0,
|
||||
epg_prev_days: 0,
|
||||
allowed_ips: [],
|
||||
},
|
||||
|
||||
validate: (values) => ({
|
||||
username: !values.username
|
||||
? 'Username is required'
|
||||
: values.user_level == USER_LEVELS.STREAMER &&
|
||||
!values.username.match(/^[a-z0-9]+$/i)
|
||||
? 'Streamer username must be alphanumeric'
|
||||
: null,
|
||||
password:
|
||||
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
|
||||
? 'Password is required'
|
||||
: null,
|
||||
xc_password:
|
||||
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
|
||||
? 'XC password must be alphanumeric'
|
||||
: null,
|
||||
allowed_ips: (values.allowed_ips || []).some(
|
||||
(t) => !isValidNetworkEntry(t)
|
||||
)
|
||||
? 'Invalid IP address or CIDR range'
|
||||
: null,
|
||||
}),
|
||||
initialValues: getFormInitialValues(),
|
||||
validate: getFormValidators(user),
|
||||
});
|
||||
|
||||
const onChannelProfilesChange = (values) => {
|
||||
|
|
@ -110,65 +71,18 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
const payload = formValuesToPayload(form.getValues(), user);
|
||||
|
||||
const customProps = user?.custom_properties || {};
|
||||
|
||||
customProps.xc_password = values.xc_password || '';
|
||||
delete values.xc_password;
|
||||
|
||||
customProps.output_format = values.output_format || null;
|
||||
delete values.output_format;
|
||||
|
||||
customProps.output_profile = values.output_profile
|
||||
? parseInt(values.output_profile, 10)
|
||||
: null;
|
||||
delete values.output_profile;
|
||||
|
||||
customProps.hide_adult_content = values.hide_adult_content || false;
|
||||
delete values.hide_adult_content;
|
||||
|
||||
customProps.epg_days = values.epg_days || 0;
|
||||
delete values.epg_days;
|
||||
customProps.epg_prev_days = values.epg_prev_days || 0;
|
||||
delete values.epg_prev_days;
|
||||
|
||||
values.custom_properties = customProps;
|
||||
|
||||
// Serialize per-user network restrictions into custom_properties (same list for all types)
|
||||
const joined = (values.allowed_ips || []).join(',');
|
||||
delete values.allowed_ips;
|
||||
const allowed_networks = {};
|
||||
if (joined)
|
||||
NETWORK_KEYS.forEach((key) => {
|
||||
allowed_networks[key] = joined;
|
||||
});
|
||||
customProps.allowed_networks = allowed_networks;
|
||||
|
||||
if (values.channel_profiles.includes('0')) {
|
||||
values.channel_profiles = [];
|
||||
}
|
||||
|
||||
if (!user && values.user_level == USER_LEVELS.STREAMER) {
|
||||
values.password = Math.random().toString(36).slice(2);
|
||||
if (!user && payload.user_level == USER_LEVELS.STREAMER) {
|
||||
payload.password = Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
await API.createUser(values);
|
||||
await createUser(payload);
|
||||
} else {
|
||||
if (!values.password) {
|
||||
delete values.password;
|
||||
}
|
||||
|
||||
const response = await API.updateUser(
|
||||
user.id,
|
||||
values,
|
||||
isAdmin ? false : authUser.id === user.id
|
||||
);
|
||||
|
||||
if (user.id == authUser.id) {
|
||||
setUser(response);
|
||||
}
|
||||
if (!payload.password) delete payload.password;
|
||||
const response = await updateUser(user.id, payload, isAdmin, authUser);
|
||||
if (user.id == authUser.id) setUser(response);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
|
@ -178,38 +92,9 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
const customProps = user.custom_properties || {};
|
||||
const networks = customProps.allowed_networks || {};
|
||||
form.setValues(userToFormValues(user));
|
||||
|
||||
form.setValues({
|
||||
username: user.username,
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email,
|
||||
user_level: `${user.user_level}`,
|
||||
stream_limit: user.stream_limit || 0,
|
||||
channel_profiles:
|
||||
user.channel_profiles.length > 0
|
||||
? user.channel_profiles.map((id) => `${id}`)
|
||||
: ['0'],
|
||||
xc_password: customProps.xc_password || '',
|
||||
output_format: customProps.output_format || '',
|
||||
output_profile: customProps.output_profile
|
||||
? `${customProps.output_profile}`
|
||||
: '',
|
||||
hide_adult_content: customProps.hide_adult_content || false,
|
||||
epg_days: customProps.epg_days || 0,
|
||||
epg_prev_days: customProps.epg_prev_days || 0,
|
||||
allowed_ips: [
|
||||
...new Set(
|
||||
NETWORK_KEYS.flatMap((key) =>
|
||||
networks[key] ? networks[key].split(',').filter(Boolean) : []
|
||||
)
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
if (customProps.xc_password) {
|
||||
if (user.custom_properties?.xc_password) {
|
||||
setEnableXC(true);
|
||||
}
|
||||
|
||||
|
|
@ -248,13 +133,13 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
payload.user_id = user.id;
|
||||
}
|
||||
|
||||
const resp = await API.generateApiKey(payload);
|
||||
const resp = await generateApiKey(payload);
|
||||
const newKey = resp && (resp.key || resp.raw_key);
|
||||
if (newKey) {
|
||||
setGeneratedKey(newKey);
|
||||
setUserAPIKey(newKey);
|
||||
console.log(setUserAPIKey(newKey));
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// API shows notifications
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
|
|
@ -271,7 +156,8 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
payload.user_id = user.id;
|
||||
}
|
||||
|
||||
const resp = await API.revokeApiKey(payload);
|
||||
const resp = await revokeApiKey(payload);
|
||||
// backend returns { success: true } - clear local state
|
||||
if (resp && resp.success) {
|
||||
setGeneratedKey(null);
|
||||
setUserAPIKey(null);
|
||||
|
|
@ -280,7 +166,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
setUser({ ...authUser, api_key: null });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// API shows notifications
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
|
|
@ -291,16 +177,16 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Tabs defaultValue="account">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="account">Account</Tabs.Tab>
|
||||
<TabsList mb="md">
|
||||
<TabsTab value="account">Account</TabsTab>
|
||||
{showPermissions && (
|
||||
<Tabs.Tab value="permissions">Permissions</Tabs.Tab>
|
||||
<TabsTab value="permissions">Permissions</TabsTab>
|
||||
)}
|
||||
<Tabs.Tab value="epg">EPG Defaults</Tabs.Tab>
|
||||
<Tabs.Tab value="api">API & XC</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<TabsTab value="epg">EPG Defaults</TabsTab>
|
||||
<TabsTab value="api">API & XC</TabsTab>
|
||||
</TabsList>
|
||||
|
||||
<Tabs.Panel value="account">
|
||||
<TabsPanel value="account">
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
|
|
@ -335,10 +221,10 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
disabled={form.getValues().user_level == USER_LEVELS.STREAMER}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
|
||||
{showPermissions && (
|
||||
<Tabs.Panel value="permissions">
|
||||
<TabsPanel value="permissions">
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
|
|
@ -375,10 +261,10 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
key={form.key('hide_adult_content')}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
)}
|
||||
|
||||
<Tabs.Panel value="epg">
|
||||
<TabsPanel value="epg">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
These defaults apply when no URL parameters are specified and
|
||||
|
|
@ -404,9 +290,9 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
|
||||
<Tabs.Panel value="api">
|
||||
<TabsPanel value="api">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="XC Password"
|
||||
|
|
@ -524,7 +410,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
|
|
|
|||
|
|
@ -1,26 +1,8 @@
|
|||
// Modal.js
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
import { NETWORK_ACCESS_OPTIONS } from '../../constants';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
});
|
||||
import { Button, Checkbox, Flex, Modal, Space, TextInput, } from '@mantine/core';
|
||||
import { addUserAgent, getResolver, updateUserAgent } from '../../utils/forms/UserAgentUtils.js';
|
||||
|
||||
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
||||
const defaultValues = useMemo(
|
||||
|
|
@ -42,14 +24,14 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
if (userAgent?.id) {
|
||||
await API.updateUserAgent({ id: userAgent.id, ...values });
|
||||
await updateUserAgent(userAgent.id, values);
|
||||
} else {
|
||||
await API.addUserAgent(values);
|
||||
await addUserAgent(values);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue