mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Minor formatting changes
This commit is contained in:
parent
64f19237f9
commit
4dace2d333
10 changed files with 158 additions and 93 deletions
|
|
@ -931,7 +931,10 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
label="Hidden"
|
||||
checked={watch('hidden_from_output')}
|
||||
onChange={(event) =>
|
||||
setValue('hidden_from_output', event.currentTarget.checked)
|
||||
setValue(
|
||||
'hidden_from_output',
|
||||
event.currentTarget.checked
|
||||
)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -285,8 +285,8 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
|||
>
|
||||
<Text size="sm">
|
||||
These patterns are applied to every stream in this playlist. If
|
||||
the search pattern doesn't match a stream URL, the original
|
||||
URL is used as-is.
|
||||
the search pattern doesn't match a stream URL, the original URL
|
||||
is used as-is.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { Box, Button, Text, Group, ActionIcon, Stack } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { GripVertical, Eye, EyeOff } from 'lucide-react';
|
||||
import {
|
||||
|
|
@ -36,7 +29,14 @@ import {
|
|||
import { USER_LEVELS } from '../../../constants';
|
||||
|
||||
const DraggableNavItem = ({ item, isHidden, canHide, onToggleVisibility }) => {
|
||||
const { transform, transition, setNodeRef, isDragging, attributes, listeners } = useSortable({
|
||||
const {
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
} = useSortable({
|
||||
id: item.id,
|
||||
});
|
||||
|
||||
|
|
@ -73,7 +73,9 @@ const DraggableNavItem = ({ item, isHidden, canHide, onToggleVisibility }) => {
|
|||
>
|
||||
<GripVertical size={16} color="#888" />
|
||||
</ActionIcon>
|
||||
{IconComponent && <IconComponent size={18} color={isHidden ? '#666' : '#ccc'} />}
|
||||
{IconComponent && (
|
||||
<IconComponent size={18} color={isHidden ? '#666' : '#ccc'} />
|
||||
)}
|
||||
<Text size="sm" c={isHidden ? 'dimmed' : 'gray.3'}>
|
||||
{item.label}
|
||||
</Text>
|
||||
|
|
@ -140,45 +142,48 @@ const NavOrderForm = ({ active }) => {
|
|||
}, []);
|
||||
|
||||
// Debounced save function
|
||||
const debouncedSave = useCallback(async (newOrder) => {
|
||||
// Clear any pending save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Store the pending order
|
||||
pendingOrderRef.current = newOrder;
|
||||
|
||||
// Schedule save after 800ms of inactivity
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
const orderToSave = pendingOrderRef.current;
|
||||
if (!orderToSave) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await setNavOrder(orderToSave);
|
||||
notifications.show({
|
||||
title: 'Navigation',
|
||||
message: 'Order saved successfully',
|
||||
color: 'green',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch {
|
||||
// Revert on failure
|
||||
const savedOrder = getNavOrder();
|
||||
const orderedItems = getOrderedNavItems(savedOrder, isAdmin);
|
||||
setItems(orderedItems);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to save navigation order',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
pendingOrderRef.current = null;
|
||||
const debouncedSave = useCallback(
|
||||
async (newOrder) => {
|
||||
// Clear any pending save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
}, 800);
|
||||
}, [setNavOrder, getNavOrder, isAdmin]);
|
||||
|
||||
// Store the pending order
|
||||
pendingOrderRef.current = newOrder;
|
||||
|
||||
// Schedule save after 800ms of inactivity
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
const orderToSave = pendingOrderRef.current;
|
||||
if (!orderToSave) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await setNavOrder(orderToSave);
|
||||
notifications.show({
|
||||
title: 'Navigation',
|
||||
message: 'Order saved successfully',
|
||||
color: 'green',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch {
|
||||
// Revert on failure
|
||||
const savedOrder = getNavOrder();
|
||||
const orderedItems = getOrderedNavItems(savedOrder, isAdmin);
|
||||
setItems(orderedItems);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to save navigation order',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
pendingOrderRef.current = null;
|
||||
}
|
||||
}, 800);
|
||||
},
|
||||
[setNavOrder, getNavOrder, isAdmin]
|
||||
);
|
||||
|
||||
const handleDragEnd = ({ active, over }) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
|
|
@ -196,23 +201,26 @@ const NavOrderForm = ({ active }) => {
|
|||
};
|
||||
|
||||
// Wrapped visibility toggle with error handling
|
||||
const handleToggleVisibility = useCallback(async (itemId) => {
|
||||
try {
|
||||
await toggleNavVisibility(itemId);
|
||||
notifications.show({
|
||||
title: 'Navigation',
|
||||
message: 'Visibility updated',
|
||||
color: 'green',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to update visibility',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, [toggleNavVisibility]);
|
||||
const handleToggleVisibility = useCallback(
|
||||
async (itemId) => {
|
||||
try {
|
||||
await toggleNavVisibility(itemId);
|
||||
notifications.show({
|
||||
title: 'Navigation',
|
||||
message: 'Visibility updated',
|
||||
color: 'green',
|
||||
autoClose: 2000,
|
||||
});
|
||||
} catch {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to update visibility',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
},
|
||||
[toggleNavVisibility]
|
||||
);
|
||||
|
||||
const handleReset = async () => {
|
||||
// Cancel any pending debounced save
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ import {
|
|||
getNetworkAccessDefaults,
|
||||
} from '../../../utils/forms/settings/NetworkAccessFormUtils.js';
|
||||
|
||||
const toTags = (str) => (str ? str.split(',').map((s) => s.trim()).filter(Boolean) : []);
|
||||
const toTags = (str) =>
|
||||
str
|
||||
? str
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const toStr = (tags) => (tags || []).join(',');
|
||||
|
||||
const NetworkAccessForm = React.memo(({ active }) => {
|
||||
|
|
@ -116,9 +122,14 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
const saveNetworkAccess = async () => {
|
||||
setSaved(false);
|
||||
setSaving(true);
|
||||
const values = pendingSaveValuesRef.current || Object.fromEntries(
|
||||
Object.entries(networkAccessForm.getValues()).map(([k, v]) => [k, toStr(v)])
|
||||
);
|
||||
const values =
|
||||
pendingSaveValuesRef.current ||
|
||||
Object.fromEntries(
|
||||
Object.entries(networkAccessForm.getValues()).map(([k, v]) => [
|
||||
k,
|
||||
toStr(v),
|
||||
])
|
||||
);
|
||||
try {
|
||||
await updateSetting({
|
||||
...settings['network_access'],
|
||||
|
|
@ -166,10 +177,18 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
))}
|
||||
|
||||
<Flex mih={50} gap="xs" justify="space-between" align="flex-end">
|
||||
<Button variant="subtle" color="gray" onClick={resetNetworkAccessToDefaults}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={resetNetworkAccessToDefaults}
|
||||
>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<Button type="submit" disabled={networkAccessForm.submitting} variant="default">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={networkAccessForm.submitting}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import NetworkAccessForm from '../NetworkAccessForm';
|
|||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../constants.js', () => ({
|
||||
NETWORK_ACCESS_OPTIONS: {
|
||||
M3U_EPG: { label: 'M3U / EPG Endpoints', description: 'Limit M3U/EPG access' },
|
||||
M3U_EPG: {
|
||||
label: 'M3U / EPG Endpoints',
|
||||
description: 'Limit M3U/EPG access',
|
||||
},
|
||||
STREAMS: { label: 'Stream Endpoints', description: 'Limit stream access' },
|
||||
XC_API: { label: 'XC API', description: 'Limit XC API access' },
|
||||
UI: { label: 'UI', description: 'Limit UI access' },
|
||||
|
|
@ -85,7 +88,12 @@ vi.mock('@mantine/core', () => ({
|
|||
),
|
||||
TagsInput: ({ label, placeholder, error, ...rest }) => (
|
||||
<div>
|
||||
<input aria-label={label} placeholder={placeholder} data-error={error} readOnly />
|
||||
<input
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
data-error={error}
|
||||
readOnly
|
||||
/>
|
||||
{error && <span>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -191,7 +191,10 @@ export const buildSubmitValues = (
|
|||
values.is_adult = values.is_adult === 'true';
|
||||
}
|
||||
|
||||
if (values.hidden_from_output === '-1' || values.hidden_from_output === undefined) {
|
||||
if (
|
||||
values.hidden_from_output === '-1' ||
|
||||
values.hidden_from_output === undefined
|
||||
) {
|
||||
delete values.hidden_from_output;
|
||||
} else {
|
||||
values.hidden_from_output = values.hidden_from_output === 'true';
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
|
|||
await updateChannelsWithOverrideRouting(
|
||||
[1, 2],
|
||||
{ name: 'BulkRename' },
|
||||
channelsById,
|
||||
channelsById
|
||||
);
|
||||
|
||||
expect(API.bulkUpdateChannels).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -48,7 +48,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
|
|||
await updateChannelsWithOverrideRouting(
|
||||
[1],
|
||||
{ name: 'ManualRename' },
|
||||
channelsById,
|
||||
channelsById
|
||||
);
|
||||
const body = API.bulkUpdateChannels.mock.calls[0][0];
|
||||
expect(body).toEqual([{ id: 1, name: 'ManualRename' }]);
|
||||
|
|
@ -63,7 +63,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
|
|||
await updateChannelsWithOverrideRouting(
|
||||
[1, 2, 3],
|
||||
{ name: 'Mixed', tvg_id: 'mixed.tvg' },
|
||||
channelsById,
|
||||
channelsById
|
||||
);
|
||||
const body = API.bulkUpdateChannels.mock.calls[0][0];
|
||||
expect(body).toEqual([
|
||||
|
|
@ -82,7 +82,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
|
|||
await updateChannelsWithOverrideRouting(
|
||||
[1],
|
||||
{ hidden_from_output: true, name: 'Renamed' },
|
||||
channelsById,
|
||||
channelsById
|
||||
);
|
||||
const body = API.bulkUpdateChannels.mock.calls[0][0];
|
||||
expect(body).toEqual([
|
||||
|
|
@ -97,7 +97,7 @@ describe('updateChannelsWithOverrideRouting (manual finding #4)', () => {
|
|||
await updateChannelsWithOverrideRouting(
|
||||
[99],
|
||||
{ name: 'Defensive' },
|
||||
{}, // empty lookup
|
||||
{} // empty lookup
|
||||
);
|
||||
const body = API.bulkUpdateChannels.mock.calls[0][0];
|
||||
expect(body).toEqual([{ id: 99, name: 'Defensive' }]);
|
||||
|
|
|
|||
|
|
@ -71,7 +71,10 @@ describe('M3uProfileUtils', () => {
|
|||
|
||||
await updateM3UProfile(m3u.id, makeSubmitValues());
|
||||
|
||||
expect(API.updateM3UProfile).toHaveBeenCalledWith(m3u.id, expect.anything());
|
||||
expect(API.updateM3UProfile).toHaveBeenCalledWith(
|
||||
m3u.id,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('passes submitValues through unmodified', async () => {
|
||||
|
|
@ -153,9 +156,9 @@ describe('M3uProfileUtils', () => {
|
|||
vi.mocked(API.addM3UProfile).mockRejectedValue(new Error('Add failed'));
|
||||
const m3u = makeM3U();
|
||||
|
||||
await expect(
|
||||
addM3UProfile(m3u.id, makeSubmitValues())
|
||||
).rejects.toThrow('Add failed');
|
||||
await expect(addM3UProfile(m3u.id, makeSubmitValues())).rejects.toThrow(
|
||||
'Add failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves without returning a value', async () => {
|
||||
|
|
@ -195,7 +198,10 @@ describe('M3uProfileUtils', () => {
|
|||
|
||||
await deleteM3UProfile(playlist.id, 'profile-1');
|
||||
|
||||
expect(API.deleteM3UProfile).toHaveBeenCalledWith(playlist.id, expect.anything());
|
||||
expect(API.deleteM3UProfile).toHaveBeenCalledWith(
|
||||
playlist.id,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the profile id through unmodified', async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
|
|||
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
|
||||
|
||||
// Default CIDR ranges for M3U/EPG endpoints (local networks only)
|
||||
const M3U_EPG_DEFAULTS = ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '::1/128', 'fc00::/7', 'fe80::/10'];
|
||||
const M3U_EPG_DEFAULTS = [
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'::1/128',
|
||||
'fc00::/7',
|
||||
'fe80::/10',
|
||||
];
|
||||
const OPEN_DEFAULTS = ['0.0.0.0/0', '::/0'];
|
||||
|
||||
const isValidEntry = (entry) =>
|
||||
|
|
@ -22,7 +30,9 @@ export const getNetworkAccessFormValidation = () =>
|
|||
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
|
||||
acc[key] = (tags) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return tags.some((t) => !isValidEntry(t)) ? 'Invalid IP address or CIDR range' : null;
|
||||
return tags.some((t) => !isValidEntry(t))
|
||||
? 'Invalid IP address or CIDR range'
|
||||
: null;
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
|
|
|||
|
|
@ -109,9 +109,13 @@ describe('NetworkAccessFormUtils', () => {
|
|||
NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator(['192.168.1.256.1/24'])).toBe('Invalid IP address or CIDR range');
|
||||
expect(validator(['192.168.1.256.1/24'])).toBe(
|
||||
'Invalid IP address or CIDR range'
|
||||
);
|
||||
expect(validator(['invalid'])).toBe('Invalid IP address or CIDR range');
|
||||
expect(validator(['192.168.1.0/256'])).toBe('Invalid IP address or CIDR range');
|
||||
expect(validator(['192.168.1.0/256'])).toBe(
|
||||
'Invalid IP address or CIDR range'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when any entry in the list is invalid', () => {
|
||||
|
|
@ -119,8 +123,12 @@ describe('NetworkAccessFormUtils', () => {
|
|||
NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator(['192.168.1.0/24', 'invalid'])).toBe('Invalid IP address or CIDR range');
|
||||
expect(validator(['invalid', '192.168.1.0/24'])).toBe('Invalid IP address or CIDR range');
|
||||
expect(validator(['192.168.1.0/24', 'invalid'])).toBe(
|
||||
'Invalid IP address or CIDR range'
|
||||
);
|
||||
expect(validator(['invalid', '192.168.1.0/24'])).toBe(
|
||||
'Invalid IP address or CIDR range'
|
||||
);
|
||||
expect(validator(['192.168.1.0/24', '10.0.0.0/8', 'invalid'])).toBe(
|
||||
'Invalid IP address or CIDR range'
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue