From 6d5a5a549ad41e834540d98c32686f276d7c89ec Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:17:11 -0700 Subject: [PATCH 1/5] Extracted utils --- .../src/utils/forms/OutputProfileUtils.js | 36 +++++++++++++++++++ frontend/src/utils/forms/ServerGroupUtils.js | 16 +++++++++ frontend/src/utils/pages/PluginsUtils.js | 9 +++++ 3 files changed, 61 insertions(+) create mode 100644 frontend/src/utils/forms/OutputProfileUtils.js create mode 100644 frontend/src/utils/forms/ServerGroupUtils.js diff --git a/frontend/src/utils/forms/OutputProfileUtils.js b/frontend/src/utils/forms/OutputProfileUtils.js new file mode 100644 index 00000000..b95e7664 --- /dev/null +++ b/frontend/src/utils/forms/OutputProfileUtils.js @@ -0,0 +1,36 @@ +import * as Yup from 'yup'; +import API from '../../api'; +import { yupResolver } from '@hookform/resolvers/yup'; + +export const BUILT_IN_COMMANDS = [ + { value: 'ffmpeg', label: 'FFmpeg' }, + { value: '__custom__', label: 'Custom…' }, +]; + +export const COMMAND_EXAMPLES = { + ffmpeg: + '-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1', +}; + +export const toCommandSelection = (command) => + BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__') + ? command + : '__custom__'; + +export const schema = Yup.object({ + name: Yup.string().required('Name is required'), + command: Yup.string().required('Command is required'), + parameters: Yup.string(), +}); + +export const addOutputProfile = (values) => { + return API.addOutputProfile(values); +}; + +export const updateOutputProfile = (values) => { + return API.updateOutputProfile(values); +}; + +export const getResolver = () => { + return yupResolver(schema); +}; diff --git a/frontend/src/utils/forms/ServerGroupUtils.js b/frontend/src/utils/forms/ServerGroupUtils.js new file mode 100644 index 00000000..55cbf5c4 --- /dev/null +++ b/frontend/src/utils/forms/ServerGroupUtils.js @@ -0,0 +1,16 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import * as Yup from 'yup'; +import API from '../../api'; + +const schema = Yup.object({ + name: Yup.string().required('Name is required'), +}); +export const getResolver = () => { + return yupResolver(schema); +}; +export const updateServerGroup = (values) => { + return API.updateServerGroup(values); +}; +export const addServerGroup = (values) => { + return API.addServerGroup(values); +}; diff --git a/frontend/src/utils/pages/PluginsUtils.js b/frontend/src/utils/pages/PluginsUtils.js index 3f11bfd2..54942385 100644 --- a/frontend/src/utils/pages/PluginsUtils.js +++ b/frontend/src/utils/pages/PluginsUtils.js @@ -25,3 +25,12 @@ export const deletePluginByKey = (key) => { export const getPluginDetailManifest = (repoId, manifestUrl) => { return API.getPluginDetailManifest(repoId, manifestUrl); }; +export const getPluginRepoSettings = () => { + return API.getPluginRepoSettings(); +}; +export const updatePluginRepoSettings = (values) => { + return API.updatePluginRepoSettings(values); +}; +export const previewPluginRepo = (url, publicKey) => { + return API.previewPluginRepo(url, publicKey); +}; From f6ad115a1fc34a3dc65fe6d9a104fd6356021e41 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:17:25 -0700 Subject: [PATCH 2/5] Added tests for utils --- .../__tests__/OutputProfileUtils.test.js | 210 ++++++++++++++++++ .../forms/__tests__/ServerGroupUtils.test.js | 114 ++++++++++ .../pages/__tests__/PluginsUtils.test.js | 85 +++++++ 3 files changed, 409 insertions(+) create mode 100644 frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js diff --git a/frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js b/frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js new file mode 100644 index 00000000..86eb2c00 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/OutputProfileUtils.test.js @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +vi.mock('../../../api', () => ({ + default: { + addOutputProfile: vi.fn(), + updateOutputProfile: vi.fn(), + }, +})); + +const mockResolver = vi.fn(); +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn(() => mockResolver), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── + +import API from '../../../api'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + BUILT_IN_COMMANDS, + COMMAND_EXAMPLES, + toCommandSelection, + schema, + addOutputProfile, + updateOutputProfile, + getResolver, +} from '../OutputProfileUtils'; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('OutputProfileUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(yupResolver).mockReturnValue(mockResolver); + }); + + // ── Constants ────────────────────────────────────────────────────────────── + + describe('BUILT_IN_COMMANDS', () => { + it('includes the ffmpeg entry', () => { + expect(BUILT_IN_COMMANDS).toContainEqual({ + value: 'ffmpeg', + label: 'FFmpeg', + }); + }); + + it('includes the custom entry', () => { + expect(BUILT_IN_COMMANDS).toContainEqual({ + value: '__custom__', + label: 'Custom…', + }); + }); + }); + + describe('COMMAND_EXAMPLES', () => { + it('has a non-empty string example for ffmpeg', () => { + expect(typeof COMMAND_EXAMPLES.ffmpeg).toBe('string'); + expect(COMMAND_EXAMPLES.ffmpeg.length).toBeGreaterThan(0); + }); + }); + + // ── toCommandSelection ───────────────────────────────────────────────────── + + describe('toCommandSelection', () => { + it('returns the command value when it matches a non-custom built-in', () => { + expect(toCommandSelection('ffmpeg')).toBe('ffmpeg'); + }); + + it('returns "__custom__" when command is "__custom__"', () => { + // __custom__ is in BUILT_IN_COMMANDS but excluded by the o.value !== '__custom__' guard + expect(toCommandSelection('__custom__')).toBe('__custom__'); + }); + + it('returns "__custom__" for an unrecognized command string', () => { + expect(toCommandSelection('my-arbitrary-tool')).toBe('__custom__'); + }); + + it('returns "__custom__" for an empty string', () => { + expect(toCommandSelection('')).toBe('__custom__'); + }); + + it('returns "__custom__" for undefined', () => { + expect(toCommandSelection(undefined)).toBe('__custom__'); + }); + }); + + // ── schema ───────────────────────────────────────────────────────────────── + + describe('schema', () => { + it('validates a fully populated object', async () => { + await expect( + schema.validate({ + name: 'HD Profile', + command: 'ffmpeg', + parameters: '-c:v copy', + }) + ).resolves.toMatchObject({ + name: 'HD Profile', + command: 'ffmpeg', + parameters: '-c:v copy', + }); + }); + + it('validates when parameters is omitted (optional)', async () => { + await expect( + schema.validate({ name: 'HD Profile', command: 'ffmpeg' }) + ).resolves.toMatchObject({ name: 'HD Profile', command: 'ffmpeg' }); + }); + + it('rejects when name is missing', async () => { + await expect(schema.validate({ command: 'ffmpeg' })).rejects.toThrow( + 'Name is required' + ); + }); + + it('rejects when name is an empty string', async () => { + await expect( + schema.validate({ name: '', command: 'ffmpeg' }) + ).rejects.toThrow('Name is required'); + }); + + it('rejects when command is missing', async () => { + await expect(schema.validate({ name: 'HD Profile' })).rejects.toThrow( + 'Command is required' + ); + }); + + it('rejects when command is an empty string', async () => { + await expect( + schema.validate({ name: 'HD Profile', command: '' }) + ).rejects.toThrow('Command is required'); + }); + }); + + // ── addOutputProfile ─────────────────────────────────────────────────────── + + describe('addOutputProfile', () => { + it('calls API.addOutputProfile with the provided values', async () => { + const values = { name: 'New Profile', command: 'ffmpeg', parameters: '' }; + vi.mocked(API.addOutputProfile).mockResolvedValue({ id: 1, ...values }); + + await addOutputProfile(values); + + expect(API.addOutputProfile).toHaveBeenCalledWith(values); + expect(API.addOutputProfile).toHaveBeenCalledTimes(1); + }); + + it('returns the API response', async () => { + const values = { name: 'New Profile', command: 'ffmpeg' }; + const response = { id: 42, ...values }; + vi.mocked(API.addOutputProfile).mockResolvedValue(response); + + const result = await addOutputProfile(values); + + expect(result).toEqual(response); + }); + + it('propagates errors thrown by API.addOutputProfile', async () => { + vi.mocked(API.addOutputProfile).mockRejectedValue( + new Error('Network error') + ); + + await expect(addOutputProfile({})).rejects.toThrow('Network error'); + }); + }); + + // ── updateOutputProfile ──────────────────────────────────────────────────── + + describe('updateOutputProfile', () => { + it('calls API.updateOutputProfile with the provided values', async () => { + const values = { id: 1, name: 'Updated Profile', command: 'ffmpeg' }; + vi.mocked(API.updateOutputProfile).mockResolvedValue(values); + + await updateOutputProfile(values); + + expect(API.updateOutputProfile).toHaveBeenCalledWith(values); + expect(API.updateOutputProfile).toHaveBeenCalledTimes(1); + }); + + it('returns the API response', async () => { + const values = { id: 7, name: 'Updated Profile', command: 'ffmpeg' }; + vi.mocked(API.updateOutputProfile).mockResolvedValue(values); + + const result = await updateOutputProfile(values); + + expect(result).toEqual(values); + }); + + it('propagates errors thrown by API.updateOutputProfile', async () => { + vi.mocked(API.updateOutputProfile).mockRejectedValue( + new Error('Update failed') + ); + + await expect(updateOutputProfile({})).rejects.toThrow('Update failed'); + }); + }); + + // ── getResolver ──────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('returns the result of yupResolver called with schema', () => { + const resolver = getResolver(); + + expect(yupResolver).toHaveBeenCalledWith(schema); + expect(resolver).toBe(mockResolver); + }); + }); +}); diff --git a/frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js b/frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js new file mode 100644 index 00000000..db94a8cd --- /dev/null +++ b/frontend/src/utils/forms/__tests__/ServerGroupUtils.test.js @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ────────────────────────────────────────────────────────────────────── + +vi.mock('../../../api', () => ({ + default: { + addServerGroup: vi.fn(), + updateServerGroup: vi.fn(), + }, +})); + +const mockResolver = vi.fn(); +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn(() => mockResolver), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── + +import API from '../../../api'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { + getResolver, + addServerGroup, + updateServerGroup, +} from '../ServerGroupUtils'; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('ServerGroupUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(yupResolver).mockReturnValue(mockResolver); + }); + + // ── getResolver ──────────────────────────────────────────────────────────── + + describe('getResolver', () => { + it('calls yupResolver with a schema and returns the result', () => { + const resolver = getResolver(); + + expect(yupResolver).toHaveBeenCalledTimes(1); + expect(yupResolver).toHaveBeenCalledWith(expect.any(Object)); + expect(resolver).toBe(mockResolver); + }); + + it('returns a new resolver on each call', () => { + const resolverA = getResolver(); + const resolverB = getResolver(); + + expect(yupResolver).toHaveBeenCalledTimes(2); + expect(resolverA).toBe(mockResolver); + expect(resolverB).toBe(mockResolver); + }); + }); + + // ── addServerGroup ───────────────────────────────────────────────────────── + + describe('addServerGroup', () => { + it('calls API.addServerGroup with the provided values', async () => { + const values = { name: 'US East' }; + vi.mocked(API.addServerGroup).mockResolvedValue({ id: 1, ...values }); + + await addServerGroup(values); + + expect(API.addServerGroup).toHaveBeenCalledWith(values); + expect(API.addServerGroup).toHaveBeenCalledTimes(1); + }); + + it('returns the API response', async () => { + const values = { name: 'US East' }; + const response = { id: 1, ...values }; + vi.mocked(API.addServerGroup).mockResolvedValue(response); + + const result = await addServerGroup(values); + + expect(result).toEqual(response); + }); + + it('propagates errors thrown by API.addServerGroup', async () => { + vi.mocked(API.addServerGroup).mockRejectedValue(new Error('Network error')); + + await expect(addServerGroup({ name: 'Test' })).rejects.toThrow('Network error'); + }); + }); + + // ── updateServerGroup ────────────────────────────────────────────────────── + + describe('updateServerGroup', () => { + it('calls API.updateServerGroup with the provided values', async () => { + const values = { id: 5, name: 'EU West' }; + vi.mocked(API.updateServerGroup).mockResolvedValue(values); + + await updateServerGroup(values); + + expect(API.updateServerGroup).toHaveBeenCalledWith(values); + expect(API.updateServerGroup).toHaveBeenCalledTimes(1); + }); + + it('returns the API response', async () => { + const values = { id: 5, name: 'EU West' }; + vi.mocked(API.updateServerGroup).mockResolvedValue(values); + + const result = await updateServerGroup(values); + + expect(result).toEqual(values); + }); + + it('propagates errors thrown by API.updateServerGroup', async () => { + vi.mocked(API.updateServerGroup).mockRejectedValue(new Error('Update failed')); + + await expect(updateServerGroup({ id: 1, name: 'Test' })).rejects.toThrow('Update failed'); + }); + }); +}); diff --git a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js index 7d175451..bc27e005 100644 --- a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js @@ -11,6 +11,9 @@ vi.mock('../../../api.js', () => ({ reloadPlugins: vi.fn(), deletePlugin: vi.fn(), getPluginDetailManifest: vi.fn(), + getPluginRepoSettings: vi.fn(), + updatePluginRepoSettings: vi.fn(), + previewPluginRepo: vi.fn(), }, })); @@ -344,4 +347,86 @@ describe('PluginsUtils', () => { expect(API.getPluginDetailManifest).toHaveBeenCalledWith(null, null); }); }); + + describe('getPluginRepoSettings', () => { + it('should call API getPluginRepoSettings', () => { + PluginsUtils.getPluginRepoSettings(); + + expect(API.getPluginRepoSettings).toHaveBeenCalledTimes(1); + }); + + it('should return API response', () => { + const mockResponse = { repos: [] }; + + API.getPluginRepoSettings.mockReturnValue(mockResponse); + + const result = PluginsUtils.getPluginRepoSettings(); + + expect(result).toEqual(mockResponse); + }); + }); + + describe('updatePluginRepoSettings', () => { + it('should call API updatePluginRepoSettings with values', () => { + const values = { repos: ['https://example.com/repo.json'] }; + + PluginsUtils.updatePluginRepoSettings(values); + + expect(API.updatePluginRepoSettings).toHaveBeenCalledWith(values); + expect(API.updatePluginRepoSettings).toHaveBeenCalledTimes(1); + }); + + it('should return API response', () => { + const values = { repos: ['https://example.com/repo.json'] }; + const mockResponse = { success: true }; + + API.updatePluginRepoSettings.mockReturnValue(mockResponse); + + const result = PluginsUtils.updatePluginRepoSettings(values); + + expect(result).toEqual(mockResponse); + }); + }); + + describe('previewPluginRepo', () => { + it('should call API previewPluginRepo with url and publicKey', () => { + const url = 'https://example.com/repo.json'; + const publicKey = 'public-key'; + + PluginsUtils.previewPluginRepo(url, publicKey); + + expect(API.previewPluginRepo).toHaveBeenCalledWith(url, publicKey); + expect(API.previewPluginRepo).toHaveBeenCalledTimes(1); + }); + + it('should return API response', () => { + const url = 'https://example.com/repo.json'; + const publicKey = 'public-key'; + const mockResponse = { name: 'Test Repo', plugins: [] }; + + API.previewPluginRepo.mockReturnValue(mockResponse); + + const result = PluginsUtils.previewPluginRepo(url, publicKey); + + expect(result).toEqual(mockResponse); + }); + + it('should handle empty string url and publicKey', () => { + const url = ''; + const publicKey = ''; + + PluginsUtils.previewPluginRepo(url, publicKey); + + expect(API.previewPluginRepo).toHaveBeenCalledWith('', ''); + }); + + it('should handle null url and publicKey', () => { + const url = null; + const publicKey = null; + + PluginsUtils.previewPluginRepo(url, publicKey); + + expect(API.previewPluginRepo).toHaveBeenCalledWith(null, null); + }); + }); }); From 99fc71de993f59368a164e5f2cf529f620a2a26f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:18:53 -0700 Subject: [PATCH 3/5] Slight refactoring of components --- .../src/components/forms/OutputProfile.jsx | 38 ++++++------------- frontend/src/components/forms/ServerGroup.jsx | 30 +++++---------- .../components/modals/CreateChannelModal.jsx | 9 ++--- .../src/components/modals/ProfileModal.jsx | 20 +++++++--- frontend/src/hooks/useEpgPreview.jsx | 6 ++- frontend/src/pages/Connect.jsx | 15 ++++++-- frontend/src/pages/ConnectLogs.jsx | 6 ++- 7 files changed, 59 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/forms/OutputProfile.jsx b/frontend/src/components/forms/OutputProfile.jsx index 541c911b..610c0ef4 100644 --- a/frontend/src/components/forms/OutputProfile.jsx +++ b/frontend/src/components/forms/OutputProfile.jsx @@ -1,8 +1,5 @@ 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 { Modal, TextInput, @@ -13,27 +10,14 @@ import { Stack, Checkbox, } from '@mantine/core'; - -const BUILT_IN_COMMANDS = [ - { value: 'ffmpeg', label: 'FFmpeg' }, - { value: '__custom__', label: 'Custom…' }, -]; - -const COMMAND_EXAMPLES = { - ffmpeg: - '-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1', -}; - -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 { + addOutputProfile, + BUILT_IN_COMMANDS, + COMMAND_EXAMPLES, + getResolver, + toCommandSelection, + updateOutputProfile, +} from '../../utils/forms/OutputProfileUtils'; const OutputProfile = ({ profile = null, isOpen, onClose }) => { const [commandSelection, setCommandSelection] = useState('ffmpeg'); @@ -57,7 +41,7 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => { watch, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); useEffect(() => { @@ -67,9 +51,9 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => { const onSubmit = async (values) => { if (profile?.id) { - await API.updateOutputProfile({ id: profile.id, ...values }); + await updateOutputProfile({ id: profile.id, ...values }); } else { - await API.addOutputProfile(values); + await addOutputProfile(values); } reset(); onClose(); diff --git a/frontend/src/components/forms/ServerGroup.jsx b/frontend/src/components/forms/ServerGroup.jsx index 80c8c1a4..0d5ecd05 100644 --- a/frontend/src/components/forms/ServerGroup.jsx +++ b/frontend/src/components/forms/ServerGroup.jsx @@ -1,20 +1,13 @@ 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 { Button, Flex, Modal, TextInput } from '@mantine/core'; +import { + getResolver, + updateServerGroup, + addServerGroup, +} from '../../utils/forms/ServerGroupUtils'; -const schema = Yup.object({ - name: Yup.string().required('Name is required'), -}); - -const ServerGroupForm = ({ - serverGroup = null, - isOpen, - onClose, - onSaved, -}) => { +const ServerGroupForm = ({ serverGroup = null, isOpen, onClose, onSaved }) => { const defaultValues = useMemo( () => ({ name: serverGroup?.name || '', @@ -29,16 +22,13 @@ const ServerGroupForm = ({ reset, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); const onSubmit = async (values) => { - let response; - if (serverGroup?.id) { - response = await API.updateServerGroup({ id: serverGroup.id, ...values }); - } else { - response = await API.addServerGroup(values); - } + const response = serverGroup?.id + ? await updateServerGroup({ id: serverGroup.id, ...values }) + : await addServerGroup(values); if (response) { onSaved?.(response); diff --git a/frontend/src/components/modals/CreateChannelModal.jsx b/frontend/src/components/modals/CreateChannelModal.jsx index a1a42b29..4f4d9d4e 100644 --- a/frontend/src/components/modals/CreateChannelModal.jsx +++ b/frontend/src/components/modals/CreateChannelModal.jsx @@ -4,6 +4,7 @@ import { Stack, Text, Radio, + RadioGroup, NumberInput, Checkbox, Group, @@ -101,11 +102,7 @@ const CreateChannelModal = ({ - + - + {mode === customModeValue && ( { + return API.updateChannelProfile(values); +} + +const duplicateChannelProfile = (profileId, newName) => { + return API.duplicateChannelProfile(profileId, newName); +} + const ProfileModal = ({ opened, onClose, mode, profile }) => { const [profileNameInput, setProfileNameInput] = useState(''); const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId); @@ -40,7 +48,7 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => { if (!mode || !profile) return; if (!trimmedName) { - notifications.show({ + showNotification({ title: 'Profile name is required', color: 'red.5', }); @@ -53,13 +61,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => { return; } - const updatedProfile = await API.updateChannelProfile({ + const updatedProfile = await updateChannelProfile({ id: profile.id, name: trimmedName, }); if (updatedProfile) { - notifications.show({ + showNotification({ title: 'Profile renamed', message: `${profile.name} → ${trimmedName}`, color: 'green.5', @@ -69,13 +77,13 @@ const ProfileModal = ({ opened, onClose, mode, profile }) => { } if (mode === 'duplicate') { - const duplicatedProfile = await API.duplicateChannelProfile( + const duplicatedProfile = await duplicateChannelProfile( profile.id, trimmedName ); if (duplicatedProfile) { - notifications.show({ + showNotification({ title: 'Profile duplicated', message: `${profile.name} copied to ${duplicatedProfile.name}`, color: 'green.5', diff --git a/frontend/src/hooks/useEpgPreview.jsx b/frontend/src/hooks/useEpgPreview.jsx index f2cab900..3c804ea3 100644 --- a/frontend/src/hooks/useEpgPreview.jsx +++ b/frontend/src/hooks/useEpgPreview.jsx @@ -1,6 +1,10 @@ import { useEffect, useState } from 'react'; import API from '../api'; +const getCurrentProgramForEpg = (epgId) => { + return API.getCurrentProgramForEpg(epgId); +}; + export const useEpgPreview = (epgDataId) => { const [currentProgram, setCurrentProgram] = useState(null); const [isLoadingProgram, setIsLoadingProgram] = useState(false); @@ -28,7 +32,7 @@ export const useEpgPreview = (epgDataId) => { if (cancelled || Date.now() - startTime > deadlineMs) break; try { - const program = await API.getCurrentProgramForEpg(epgDataId); + const program = await getCurrentProgramForEpg(epgDataId); if (cancelled) return; if (program && program.parsing && attempt < maxRetries) { diff --git a/frontend/src/pages/Connect.jsx b/frontend/src/pages/Connect.jsx index 099546af..293c2e57 100644 --- a/frontend/src/pages/Connect.jsx +++ b/frontend/src/pages/Connect.jsx @@ -18,6 +18,14 @@ import { SquarePlus, Webhook, FileCode, Logs } from 'lucide-react'; import ConnectionForm from '../components/forms/Connection'; import { SUBSCRIPTION_EVENTS } from '../constants'; +const deleteConnectIntegration = (id) => { + return API.deleteConnectIntegration(id); +}; + +const updateConnectIntegration = (id, values) => { + return API.updateConnectIntegration(id, values); +}; + export default function ConnectPage() { const { integrations, isLoading, fetchIntegrations } = useConnectStore(); const theme = useMantineTheme(); @@ -40,7 +48,7 @@ export default function ConnectPage() { const deleteConnection = async (id) => { console.log('Deleting connection', id); - await API.deleteConnectIntegration(id); + await deleteConnectIntegration(id); }; return ( @@ -99,15 +107,14 @@ function IntegrationRow({ integration, editConnection, deleteConnection }) { const toggleIntegration = async () => { try { - await API.updateConnectIntegration(integration.id, { + await updateConnectIntegration(integration.id, { ...integration, enabled: !enabled, }); setEnabled(!enabled); } catch (error) { console.error('Failed to update integration', error); - } finally { - } + } }; return ( diff --git a/frontend/src/pages/ConnectLogs.jsx b/frontend/src/pages/ConnectLogs.jsx index 08705a13..eccc3672 100644 --- a/frontend/src/pages/ConnectLogs.jsx +++ b/frontend/src/pages/ConnectLogs.jsx @@ -18,6 +18,10 @@ import { SUBSCRIPTION_EVENTS } from '../constants'; import { CustomTable, useTable } from '../components/tables/CustomTable'; import { copyToClipboard } from '../utils'; +const getConnectLogs = (params) => { + return API.getConnectLogs(params); +}; + export default function ConnectLogsPage() { const { integrations, fetchIntegrations } = useConnectStore(); @@ -51,7 +55,7 @@ export default function ConnectLogsPage() { if (filters.type) params.type = filters.type; if (filters.integration) params.integration = filters.integration; - const data = await API.getConnectLogs(params); + const data = await getConnectLogs(params); const results = Array.isArray(data) ? data : data?.results || []; setLogs(results); setCount(data?.count || results.length || 0); From 07d17a5ffc1d3b10336ae5e4deac58bdf3602736 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:19:47 -0700 Subject: [PATCH 4/5] Extracted ManageReposModal from PluginBrowse --- .../components/modals/ManageReposModal.jsx | 567 ++++++++++++ frontend/src/pages/PluginBrowse.jsx | 815 +++--------------- 2 files changed, 702 insertions(+), 680 deletions(-) create mode 100644 frontend/src/components/modals/ManageReposModal.jsx diff --git a/frontend/src/components/modals/ManageReposModal.jsx b/frontend/src/components/modals/ManageReposModal.jsx new file mode 100644 index 00000000..2b823b32 --- /dev/null +++ b/frontend/src/components/modals/ManageReposModal.jsx @@ -0,0 +1,567 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + ActionIcon, + Badge, + Box, + Button, + Group, + Loader, + Modal, + NumberInput, + Stack, + Text, + Textarea, + TextInput, +} from '@mantine/core'; +import { KeyRound, Plus, ShieldAlert, ShieldCheck, Trash2 } from 'lucide-react'; +import ConfirmationDialog from '../ConfirmationDialog.jsx'; +import { usePluginStore } from '../../store/plugins.jsx'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + getPluginRepoSettings, + previewPluginRepo, + updatePluginRepoSettings, +} from '../../utils/pages/PluginsUtils.js'; + +export default function ManageReposModal({ opened, onClose }) { + const repos = usePluginStore((s) => s.repos); + const reposLoading = usePluginStore((s) => s.reposLoading); + const fetchAvailablePlugins = usePluginStore((s) => s.fetchAvailablePlugins); + const refreshRepo = usePluginStore((s) => s.refreshRepo); + const addRepo = usePluginStore((s) => s.addRepo); + const removeRepo = usePluginStore((s) => s.removeRepo); + const updateRepo = usePluginStore((s) => s.updateRepo); + + const [refreshInterval, setRefreshInterval] = useState(6); + const [savingInterval, setSavingInterval] = useState(false); + const saveIntervalTimer = useRef(null); + + const [editingKeyRepoId, setEditingKeyRepoId] = useState(null); + const [editKeyValue, setEditKeyValue] = useState(''); + const [savingKey, setSavingKey] = useState(false); + + const [showAddRepo, setShowAddRepo] = useState(false); + const [newRepoUrl, setNewRepoUrl] = useState(''); + const [newRepoPublicKey, setNewRepoPublicKey] = useState(''); + const [addingRepo, setAddingRepo] = useState(false); + const [gpgKeyFocused, setGpgKeyFocused] = useState(false); + const [repoPreview, setRepoPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const previewTimer = useRef(null); + + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const loadRepoSettings = useCallback(async () => { + const data = await getPluginRepoSettings(); + if (data) setRefreshInterval(data.refresh_interval_hours ?? 6); + }, []); + + const handleSaveInterval = useCallback((val) => { + const hours = val ?? 0; + setRefreshInterval(hours); + if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current); + saveIntervalTimer.current = setTimeout(async () => { + setSavingInterval(true); + try { + await updatePluginRepoSettings({ refresh_interval_hours: hours }); + } catch { + // Error notification handled by API layer + } finally { + setSavingInterval(false); + } + }, 800); + }, []); + + // Debounced manifest preview + const fetchPreview = useCallback((url, publicKey) => { + if (previewTimer.current) clearTimeout(previewTimer.current); + if (!url.trim() || !url.match(/^https?:\/\/.+/i)) { + setRepoPreview(null); + setPreviewLoading(false); + return; + } + setPreviewLoading(true); + previewTimer.current = setTimeout(async () => { + const result = await previewPluginRepo(url.trim(), publicKey?.trim()); + setRepoPreview(result); + setPreviewLoading(false); + }, 600); + }, []); + + const handleAddRepo = useCallback(async () => { + if (!newRepoUrl.trim()) return; + setAddingRepo(true); + try { + await addRepo({ + url: newRepoUrl.trim(), + public_key: newRepoPublicKey.trim(), + }); + setNewRepoUrl(''); + setNewRepoPublicKey(''); + setRepoPreview(null); + setShowAddRepo(false); + await fetchAvailablePlugins(); + showNotification({ + title: 'Added', + message: 'Plugin repo added', + color: 'green', + }); + } catch { + // Error notification handled by API layer + } finally { + setAddingRepo(false); + } + }, [newRepoUrl, newRepoPublicKey, addRepo, fetchAvailablePlugins]); + + const handleDeleteRepo = useCallback( + async (id) => { + await removeRepo(id); + setDeleteConfirmId(null); + await fetchAvailablePlugins(); + showNotification({ + title: 'Removed', + message: 'Plugin repo removed', + color: 'green', + }); + }, + [removeRepo, fetchAvailablePlugins] + ); + + const handleEditKey = useCallback((repo) => { + setEditingKeyRepoId(repo.id); + setEditKeyValue(repo.public_key || ''); + }, []); + + const handleSaveKey = useCallback(async () => { + if (editingKeyRepoId == null) return; + setSavingKey(true); + try { + await updateRepo(editingKeyRepoId, { public_key: editKeyValue }); + await refreshRepo(editingKeyRepoId); + await fetchAvailablePlugins(); + showNotification({ + title: 'Updated', + message: 'Public key updated', + color: 'green', + }); + setEditingKeyRepoId(null); + setEditKeyValue(''); + } catch { + showNotification({ + title: 'Error', + message: 'Failed to update key', + color: 'red', + }); + } finally { + setSavingKey(false); + } + }, [ + editingKeyRepoId, + editKeyValue, + updateRepo, + refreshRepo, + fetchAvailablePlugins, + ]); + + // Load settings when modal opens + useEffect(() => { + if (opened) loadRepoSettings(); + }, [opened, loadRepoSettings]); + + // Cleanup any pending timers on unmount + useEffect(() => { + return () => { + if (previewTimer.current) clearTimeout(previewTimer.current); + if (saveIntervalTimer.current) clearTimeout(saveIntervalTimer.current); + }; + }, []); + + return ( + <> + +
+ Plugin Repositories + + Add third-party plugin repositories or manage existing ones. + Manifests are fetched automatically at the configured interval. + +
+
+ + Refresh Interval + + + + Hours, 0 to disable + +
+ + } + centered + size="lg" + styles={{ + title: { width: '100%' }, + header: { alignItems: 'flex-start' }, + }} + > + + {reposLoading && repos.length === 0 && } + + {repos.map((repo) => ( + + + + + + {repo.name} + + {repo.is_official && ( + + Official Repo + + )} + {repo.signature_verified === true && ( + } + > + Verified Signature + + )} + {repo.signature_verified === false && ( + } + > + Invalid Signature + + )} + + {repo.registry_url ? ( + + + {repo.registry_url} + + + ) : null} + + {repo.url} + + {repo.last_fetched && ( + + Last fetched:{' '} + {new Date(repo.last_fetched).toLocaleString()} + {repo.last_fetch_status && + repo.last_fetch_status !== '200' + ? ` · ${repo.last_fetch_status}` + : repo.plugin_count != null + ? ` · ${repo.plugin_count} plugin${repo.plugin_count !== 1 ? 's' : ''} available` + : ''} + + )} + + {!repo.is_official && ( + + handleEditKey(repo)} + > + + + setDeleteConfirmId(repo.id)} + > + + + + )} + + {editingKeyRepoId === repo.id && ( + +