mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge pull request #787 from nick4810/enhancement/component-cleanup
Enhancement/component cleanup
This commit is contained in:
commit
058de26bdf
15 changed files with 1794 additions and 1489 deletions
|
|
@ -26,10 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Stream log parsing refactored to use factory pattern: Simplified `ChannelService.parse_and_store_stream_info()` to route parsing through specialized log parsers instead of inline program-specific logic (~150 lines of code removed)
|
||||
- Stream profile names in fixtures updated to use proper capitalization (ffmpeg → FFmpeg, streamlink → Streamlink)
|
||||
- Frontend component refactoring for improved code organization and maintainability - Thanks [@nick4810](https://github.com/nick4810)
|
||||
- Extracted large nested components into separate files (RecordingCard, RecordingDetailsModal, RecurringRuleModal, RecordingSynopsis)
|
||||
- Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils)
|
||||
- Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal) with loading fallbacks
|
||||
- Extracted large nested components into separate files (RecordingCard, RecordingDetailsModal, RecurringRuleModal, RecordingSynopsis, GuideRow, HourTimeline, PluginCard, ProgramRecordingModal, SeriesRecordingModal, Field)
|
||||
- Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils, guideUtils, PluginsUtils, PluginCardUtils, notificationUtils)
|
||||
- Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal, ProgramRecordingModal, SeriesRecordingModal, PluginCard) with loading fallbacks
|
||||
- Removed unused Dashboard and Home pages
|
||||
- Guide page refactoring: Extracted GuideRow and HourTimeline components, moved grid calculations and utility functions to guideUtils.js, added loading states for initial data fetching, improved performance through better memoization
|
||||
- Plugins page refactoring: Extracted PluginCard and Field components, added Zustand store for plugin state management, improved plugin action confirmation handling, better separation of concerns between UI and business logic
|
||||
- Logo loading optimization: Logos now load only after both Channels and Streams tables complete loading to prevent blocking initial page render, with rendering gated by table readiness to ensure data loads before visual elements
|
||||
- M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs
|
||||
- Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810)
|
||||
|
|
|
|||
47
frontend/src/components/Field.jsx
Normal file
47
frontend/src/components/Field.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { NumberInput, Select, Switch, TextInput } from '@mantine/core';
|
||||
import React from 'react';
|
||||
|
||||
export const Field = ({ field, value, onChange }) => {
|
||||
const common = { label: field.label, description: field.help_text };
|
||||
const effective = value ?? field.default;
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
checked={!!effective}
|
||||
onChange={(e) => onChange(field.id, e.currentTarget.checked)}
|
||||
label={field.label}
|
||||
description={field.help_text}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
value={value ?? field.default ?? 0}
|
||||
onChange={(v) => onChange(field.id, v)}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
value={(value ?? field.default ?? '') + ''}
|
||||
data={(field.options || []).map((o) => ({
|
||||
value: o.value + '',
|
||||
label: o.label,
|
||||
}))}
|
||||
onChange={(v) => onChange(field.id, v)}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
case 'string':
|
||||
default:
|
||||
return (
|
||||
<TextInput
|
||||
value={value ?? field.default ?? ''}
|
||||
onChange={(e) => onChange(field.id, e.currentTarget.value)}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
206
frontend/src/components/GuideRow.jsx
Normal file
206
frontend/src/components/GuideRow.jsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import React from "react";
|
||||
import {
|
||||
CHANNEL_WIDTH,
|
||||
EXPANDED_PROGRAM_HEIGHT,
|
||||
HOUR_WIDTH,
|
||||
PROGRAM_HEIGHT,
|
||||
} from '../pages/guideUtils.js';
|
||||
import {Box, Flex, Text} from "@mantine/core";
|
||||
import {Play} from "lucide-react";
|
||||
import logo from "../images/logo.png";
|
||||
|
||||
const GuideRow = React.memo(({ index, style, data }) => {
|
||||
const {
|
||||
filteredChannels,
|
||||
programsByChannelId,
|
||||
expandedProgramId,
|
||||
rowHeights,
|
||||
logos,
|
||||
hoveredChannelId,
|
||||
setHoveredChannelId,
|
||||
renderProgram,
|
||||
handleLogoClick,
|
||||
contentWidth,
|
||||
} = data;
|
||||
|
||||
const channel = filteredChannels[index];
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelPrograms = programsByChannelId.get(channel.id) || [];
|
||||
const rowHeight =
|
||||
rowHeights[index] ??
|
||||
(channelPrograms.some((program) => program.id === expandedProgramId)
|
||||
? EXPANDED_PROGRAM_HEIGHT
|
||||
: PROGRAM_HEIGHT);
|
||||
|
||||
const PlaceholderProgram = () => {
|
||||
return <>
|
||||
{Array.from({length: Math.ceil(24 / 2)}).map(
|
||||
(_, placeholderIndex) => (
|
||||
<Box
|
||||
key={`placeholder-${channel.id}-${placeholderIndex}`}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
pos='absolute'
|
||||
left={placeholderIndex * (HOUR_WIDTH * 2)}
|
||||
top={0}
|
||||
w={HOUR_WIDTH * 2}
|
||||
h={rowHeight - 4}
|
||||
bd={'1px dashed #2D3748'}
|
||||
bdrs={4}
|
||||
display={'flex'}
|
||||
c='#4A5568'
|
||||
>
|
||||
<Text size="sm">No program data</Text>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="guide-row"
|
||||
style={{ ...style, width: contentWidth, height: rowHeight }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
borderBottom: '0px solid #27272A',
|
||||
transition: 'height 0.2s ease',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
display={'flex'}
|
||||
h={'100%'}
|
||||
pos='relative'
|
||||
>
|
||||
<Box
|
||||
className="channel-logo"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#18181B',
|
||||
borderRight: '1px solid #27272A',
|
||||
borderBottom: '1px solid #27272A',
|
||||
boxShadow: '2px 0 5px rgba(0,0,0,0.2)',
|
||||
zIndex: 30,
|
||||
transition: 'height 0.2s ease',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
w={CHANNEL_WIDTH}
|
||||
miw={CHANNEL_WIDTH}
|
||||
display={'flex'}
|
||||
left={0}
|
||||
h={'100%'}
|
||||
pos='relative'
|
||||
onClick={(event) => handleLogoClick(channel, event)}
|
||||
onMouseEnter={() => setHoveredChannelId(channel.id)}
|
||||
onMouseLeave={() => setHoveredChannelId(null)}
|
||||
>
|
||||
{hoveredChannelId === channel.id && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
zIndex: 10,
|
||||
animation: 'fadeIn 0.2s',
|
||||
}}
|
||||
pos='absolute'
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
w={'100%'}
|
||||
h={'100%'}
|
||||
>
|
||||
<Play size={32} color="#fff" fill="#fff" />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
zIndex: 5,
|
||||
}}
|
||||
w={'100%'}
|
||||
h={'100%'}
|
||||
p={'4px'}
|
||||
pos='relative'
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
w={'100%'}
|
||||
h={`${rowHeight - 32}px`}
|
||||
display={'flex'}
|
||||
p={'4px'}
|
||||
mb={'4px'}
|
||||
>
|
||||
<img
|
||||
src={logos[channel.logo_id]?.cache_url || logo}
|
||||
alt={channel.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
size="sm"
|
||||
weight={600}
|
||||
style={{
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: '#18181B',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
pos='absolute'
|
||||
bottom={4}
|
||||
left={'50%'}
|
||||
p={'2px 8px'}
|
||||
bdrs={4}
|
||||
fz={'0.85em'}
|
||||
bd={'1px solid #27272A'}
|
||||
h={'24px'}
|
||||
display={'flex'}
|
||||
miw={'36px'}
|
||||
>
|
||||
{channel.channel_number || '-'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
transition: 'height 0.2s ease',
|
||||
}}
|
||||
flex={1}
|
||||
pos='relative'
|
||||
h={'100%'}
|
||||
pl={0}
|
||||
>
|
||||
{channelPrograms.length > 0 ? (
|
||||
channelPrograms.map((program) =>
|
||||
renderProgram(program, undefined, channel)
|
||||
)
|
||||
) : <PlaceholderProgram />}
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default GuideRow;
|
||||
105
frontend/src/components/HourTimeline.jsx
Normal file
105
frontend/src/components/HourTimeline.jsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React from 'react';
|
||||
import { Box, Text } from '@mantine/core';
|
||||
import { format } from '../utils/dateTimeUtils.js';
|
||||
import { HOUR_WIDTH } from '../pages/guideUtils.js';
|
||||
|
||||
const HourBlock = React.memo(({ hourData, timeFormat, formatDayLabel, handleTimeClick }) => {
|
||||
const { time, isNewDay } = hourData;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={format(time)}
|
||||
style={{
|
||||
borderRight: '1px solid #8DAFAA',
|
||||
cursor: 'pointer',
|
||||
borderLeft: isNewDay ? '2px solid #3BA882' : 'none',
|
||||
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421',
|
||||
}}
|
||||
w={HOUR_WIDTH}
|
||||
h={'40px'}
|
||||
pos='relative'
|
||||
c='#a0aec0'
|
||||
onClick={(e) => handleTimeClick(time, e)}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ transform: 'none' }}
|
||||
pos='absolute'
|
||||
top={8}
|
||||
left={4}
|
||||
bdrs={2}
|
||||
lh={1.2}
|
||||
ta='left'
|
||||
>
|
||||
<Text
|
||||
span
|
||||
size="xs"
|
||||
display={'block'}
|
||||
opacity={0.7}
|
||||
fw={isNewDay ? 600 : 400}
|
||||
c={isNewDay ? '#3BA882' : undefined}
|
||||
>
|
||||
{formatDayLabel(time)}
|
||||
</Text>
|
||||
{format(time, timeFormat)}
|
||||
<Text span size="xs" ml={1} opacity={0.7} />
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: '#27272A',
|
||||
zIndex: 10,
|
||||
}}
|
||||
pos='absolute'
|
||||
left={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
w={'1px'}
|
||||
/>
|
||||
|
||||
<Box
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
pos='absolute'
|
||||
bottom={0}
|
||||
w={'100%'}
|
||||
display={'flex'}
|
||||
p={'0 1px'}
|
||||
>
|
||||
{[15, 30, 45].map((minute) => (
|
||||
<Box
|
||||
key={minute}
|
||||
style={{ backgroundColor: '#718096' }}
|
||||
w={'1px'}
|
||||
h={'8px'}
|
||||
pos='absolute'
|
||||
bottom={0}
|
||||
left={`${(minute / 60) * 100}%`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const HourTimeline = React.memo(({
|
||||
hourTimeline,
|
||||
timeFormat,
|
||||
formatDayLabel,
|
||||
handleTimeClick
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{hourTimeline.map((hourData) => (
|
||||
<HourBlock
|
||||
key={format(hourData.time)}
|
||||
hourData={hourData}
|
||||
timeFormat={timeFormat}
|
||||
formatDayLabel={formatDayLabel}
|
||||
handleTimeClick={handleTimeClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default HourTimeline;
|
||||
258
frontend/src/components/cards/PluginCard.jsx
Normal file
258
frontend/src/components/cards/PluginCard.jsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import React, { useState } from 'react';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import { Field } from '../Field.jsx';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
|
||||
|
||||
const PluginFieldList = ({ plugin, settings, updateField }) => {
|
||||
return plugin.fields.map((f) => (
|
||||
<Field
|
||||
key={f.id}
|
||||
field={f}
|
||||
value={settings?.[f.id]}
|
||||
onChange={updateField}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const PluginActionList = ({ plugin, enabled, running, handlePluginRun }) => {
|
||||
return plugin.actions.map((action) => (
|
||||
<Group key={action.id} justify="space-between">
|
||||
<div>
|
||||
<Text>{action.label}</Text>
|
||||
{action.description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{action.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
loading={running}
|
||||
disabled={!enabled}
|
||||
onClick={() => handlePluginRun(action)}
|
||||
size="xs"
|
||||
>
|
||||
{running ? 'Running…' : 'Run'}
|
||||
</Button>
|
||||
</Group>
|
||||
));
|
||||
};
|
||||
|
||||
const PluginActionStatus = ({ running, lastResult }) => {
|
||||
return (
|
||||
<>
|
||||
{running && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Running action… please wait
|
||||
</Text>
|
||||
)}
|
||||
{!running && lastResult?.file && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Output: {lastResult.file}
|
||||
</Text>
|
||||
)}
|
||||
{!running && lastResult?.error && (
|
||||
<Text size="sm" c="red">
|
||||
Error: {String(lastResult.error)}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PluginCard = ({
|
||||
plugin,
|
||||
onSaveSettings,
|
||||
onRunAction,
|
||||
onToggleEnabled,
|
||||
onRequireTrust,
|
||||
onRequestDelete,
|
||||
onRequestConfirm,
|
||||
}) => {
|
||||
const [settings, setSettings] = useState(plugin.settings || {});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [enabled, setEnabled] = useState(!!plugin.enabled);
|
||||
const [lastResult, setLastResult] = useState(null);
|
||||
|
||||
// Keep local enabled state in sync with props (e.g., after import + enable)
|
||||
React.useEffect(() => {
|
||||
setEnabled(!!plugin.enabled);
|
||||
}, [plugin.enabled]);
|
||||
// Sync settings if plugin changes identity
|
||||
React.useEffect(() => {
|
||||
setSettings(plugin.settings || {});
|
||||
}, [plugin.key]);
|
||||
|
||||
const updateField = (id, val) => {
|
||||
setSettings((prev) => ({ ...prev, [id]: val }));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSaveSettings(plugin.key, settings);
|
||||
showNotification({
|
||||
title: 'Saved',
|
||||
message: `${plugin.name} settings updated`,
|
||||
color: 'green',
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const missing = plugin.missing;
|
||||
|
||||
const handleEnableChange = () => {
|
||||
return async (e) => {
|
||||
const next = e.currentTarget.checked;
|
||||
if (next && !plugin.ever_enabled && onRequireTrust) {
|
||||
const ok = await onRequireTrust(plugin);
|
||||
if (!ok) {
|
||||
// Revert
|
||||
setEnabled(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEnabled(next);
|
||||
const resp = await onToggleEnabled(plugin.key, next);
|
||||
if (next && resp?.ever_enabled) {
|
||||
plugin.ever_enabled = true;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handlePluginRun = async (a) => {
|
||||
setRunning(true);
|
||||
setLastResult(null);
|
||||
try {
|
||||
// Determine if confirmation is required from action metadata or fallback field
|
||||
const { requireConfirm, confirmTitle, confirmMessage } =
|
||||
getConfirmationDetails(a, plugin, settings);
|
||||
|
||||
if (requireConfirm) {
|
||||
const confirmed = await onRequestConfirm(confirmTitle, confirmMessage);
|
||||
|
||||
if (!confirmed) {
|
||||
// User canceled, abort the action
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings before running to ensure backend uses latest values
|
||||
try {
|
||||
await onSaveSettings(plugin.key, settings);
|
||||
} catch (e) {
|
||||
/* ignore, run anyway */
|
||||
}
|
||||
const resp = await onRunAction(plugin.key, a.id);
|
||||
if (resp?.success) {
|
||||
setLastResult(resp.result || {});
|
||||
const msg = resp.result?.message || 'Plugin action completed';
|
||||
showNotification({
|
||||
title: plugin.name,
|
||||
message: msg,
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
const err = resp?.error || 'Unknown error';
|
||||
setLastResult({ error: err });
|
||||
showNotification({
|
||||
title: `${plugin.name} error`,
|
||||
message: String(err),
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
opacity={!missing && enabled ? 1 : 0.6}
|
||||
>
|
||||
<Group justify="space-between" mb="xs" align="center">
|
||||
<div>
|
||||
<Text fw={600}>{plugin.name}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs" align="center">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Delete plugin"
|
||||
onClick={() => onRequestDelete && onRequestDelete(plugin)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{plugin.version || '1.0.0'}
|
||||
</Text>
|
||||
<Switch
|
||||
checked={!missing && enabled}
|
||||
onChange={handleEnableChange()}
|
||||
size="xs"
|
||||
onLabel="On"
|
||||
offLabel="Off"
|
||||
disabled={missing}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{missing && (
|
||||
<Text size="sm" c="red">
|
||||
Missing plugin files. Re-import or delete this entry.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!missing && plugin.fields && plugin.fields.length > 0 && (
|
||||
<Stack gap="xs" mt="sm">
|
||||
<PluginFieldList
|
||||
plugin={plugin}
|
||||
settings={settings}
|
||||
updateField={updateField}
|
||||
/>
|
||||
<Group>
|
||||
<Button loading={saving} onClick={save} variant="default" size="xs">
|
||||
Save Settings
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!missing && plugin.actions && plugin.actions.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Stack gap="xs">
|
||||
<PluginActionList
|
||||
plugin={plugin}
|
||||
enabled={enabled}
|
||||
running={running}
|
||||
handlePluginRun={handlePluginRun}
|
||||
/>
|
||||
<PluginActionStatus running={running} lastResult={lastResult} />
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginCard;
|
||||
110
frontend/src/components/forms/ProgramRecordingModal.jsx
Normal file
110
frontend/src/components/forms/ProgramRecordingModal.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import React from 'react';
|
||||
import { Modal, Flex, Button } from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import { deleteSeriesRuleByTvgId } from '../../pages/guideUtils.js';
|
||||
|
||||
export default function ProgramRecordingModal({
|
||||
opened,
|
||||
onClose,
|
||||
program,
|
||||
recording,
|
||||
existingRuleMode,
|
||||
onRecordOne,
|
||||
onRecordSeriesAll,
|
||||
onRecordSeriesNew,
|
||||
onExistingRuleModeChange,
|
||||
}) {
|
||||
const handleRemoveRecording = async () => {
|
||||
try {
|
||||
await deleteRecordingById(recording.id);
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete recording', error);
|
||||
}
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh recordings after delete', error);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRemoveSeries = async () => {
|
||||
await deleteSeriesAndRule({
|
||||
tvg_id: program.tvg_id,
|
||||
title: program.title,
|
||||
});
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh recordings after series delete', error);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRemoveSeriesRule = async () => {
|
||||
await deleteSeriesRuleByTvgId(program.tvg_id);
|
||||
onExistingRuleModeChange(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={`Record: ${program?.title}`}
|
||||
centered
|
||||
radius="md"
|
||||
zIndex={9999}
|
||||
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
|
||||
styles={{
|
||||
content: { backgroundColor: '#18181B', color: 'white' },
|
||||
header: { backgroundColor: '#18181B', color: 'white' },
|
||||
title: { color: 'white' },
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" gap="sm">
|
||||
<Button
|
||||
onClick={() => {
|
||||
onRecordOne();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Just this one
|
||||
</Button>
|
||||
|
||||
<Button variant="light" onClick={() => {
|
||||
onRecordSeriesAll();
|
||||
onClose();
|
||||
}}>
|
||||
Every episode
|
||||
</Button>
|
||||
|
||||
<Button variant="light" onClick={() => {
|
||||
onRecordSeriesNew();
|
||||
onClose();
|
||||
}}>
|
||||
New episodes only
|
||||
</Button>
|
||||
|
||||
{recording && (
|
||||
<>
|
||||
<Button color="orange" variant="light" onClick={handleRemoveRecording}>
|
||||
Remove this recording
|
||||
</Button>
|
||||
<Button color="red" variant="light" onClick={handleRemoveSeries}>
|
||||
Remove this series (scheduled)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{existingRuleMode && (
|
||||
<Button color="red" variant="subtle" onClick={handleRemoveSeriesRule}>
|
||||
Remove series rule ({existingRuleMode})
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/forms/SeriesRecordingModal.jsx
Normal file
91
frontend/src/components/forms/SeriesRecordingModal.jsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import { Modal, Stack, Text, Flex, Group, Button } from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import { evaluateSeriesRulesByTvgId, fetchRules } from '../../pages/guideUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
|
||||
export default function SeriesRecordingModal({
|
||||
opened,
|
||||
onClose,
|
||||
rules,
|
||||
onRulesUpdate
|
||||
}) {
|
||||
const handleEvaluateNow = async (r) => {
|
||||
await evaluateSeriesRulesByTvgId(r.tvg_id);
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh recordings after evaluation', error);
|
||||
}
|
||||
showNotification({
|
||||
title: 'Evaluated',
|
||||
message: 'Checked for episodes',
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveSeries = async (r) => {
|
||||
await deleteSeriesAndRule({ tvg_id: r.tvg_id, title: r.title });
|
||||
try {
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh recordings after bulk removal', error);
|
||||
}
|
||||
const updated = await fetchRules();
|
||||
onRulesUpdate(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Series Recording Rules"
|
||||
centered
|
||||
radius="md"
|
||||
zIndex={9999}
|
||||
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
|
||||
styles={{
|
||||
content: { backgroundColor: '#18181B', color: 'white' },
|
||||
header: { backgroundColor: '#18181B', color: 'white' },
|
||||
title: { color: 'white' },
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{(!rules || rules.length === 0) && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No series rules configured
|
||||
</Text>
|
||||
)}
|
||||
{rules && rules.map((r) => (
|
||||
<Flex
|
||||
key={`${r.tvg_id}-${r.mode}`}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text size="sm">
|
||||
{r.title || r.tvg_id} —{' '}
|
||||
{r.mode === 'new' ? 'New episodes' : 'Every episode'}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => handleEvaluateNow(r)}
|
||||
>
|
||||
Evaluate Now
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
onClick={() => handleRemoveSeries(r)}
|
||||
>
|
||||
Remove this series (scheduled)
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,15 +18,29 @@ import useSettingsStore from '../store/settings';
|
|||
import useVideoStore from '../store/useVideoStore';
|
||||
import RecordingForm from '../components/forms/Recording';
|
||||
import {
|
||||
isAfter,
|
||||
isBefore,
|
||||
useTimeHelpers,
|
||||
} from '../utils/dateTimeUtils.js';
|
||||
const RecordingDetailsModal = lazy(() => import('../components/forms/RecordingDetailsModal'));
|
||||
const RecordingDetailsModal = lazy(() =>
|
||||
import('../components/forms/RecordingDetailsModal'));
|
||||
import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx';
|
||||
import RecordingCard from '../components/cards/RecordingCard.jsx';
|
||||
import { categorizeRecordings } from '../utils/pages/DVRUtils.js';
|
||||
import { getPosterUrl } from '../utils/cards/RecordingCardUtils.js';
|
||||
import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
|
||||
import ErrorBoundary from '../components/ErrorBoundary.jsx';
|
||||
|
||||
const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => {
|
||||
return list.map((rec) => (
|
||||
<RecordingCard
|
||||
key={`rec-${rec.id}`}
|
||||
recording={rec}
|
||||
onOpenDetails={onOpenDetails}
|
||||
onOpenRecurring={onOpenRecurring}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const DVRPage = () => {
|
||||
const theme = useMantineTheme();
|
||||
const recordings = useChannelsStore((s) => s.recordings);
|
||||
|
|
@ -94,46 +108,25 @@ const DVRPage = () => {
|
|||
return categorizeRecordings(recordings, toUserTime, now);
|
||||
}, [recordings, now, toUserTime]);
|
||||
|
||||
const RecordingList = ({ list }) => {
|
||||
return list.map((rec) => (
|
||||
<RecordingCard
|
||||
key={`rec-${rec.id}`}
|
||||
recording={rec}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const handleOnWatchLive = () => {
|
||||
const rec = detailsRecording;
|
||||
const now = userNow();
|
||||
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)) {
|
||||
// call into child RecordingCard behavior by constructing a URL like there
|
||||
const channel = channels[rec.channel];
|
||||
if (!channel) return;
|
||||
let url = `/proxy/ts/stream/${channel.uuid}`;
|
||||
if (useSettingsStore.getState().environment.env_mode === 'dev') {
|
||||
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
|
||||
}
|
||||
const url = getShowVideoUrl(channel, useSettingsStore.getState().environment.env_mode);
|
||||
useVideoStore.getState().showVideo(url, 'live');
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnWatchRecording = () => {
|
||||
let fileUrl =
|
||||
detailsRecording.custom_properties?.file_url ||
|
||||
detailsRecording.custom_properties?.output_file_url;
|
||||
if (!fileUrl) return;
|
||||
if (
|
||||
useSettingsStore.getState().environment.env_mode === 'dev' &&
|
||||
fileUrl.startsWith('/')
|
||||
) {
|
||||
fileUrl = `${window.location.protocol}//${window.location.hostname}:5656${fileUrl}`;
|
||||
}
|
||||
useVideoStore.getState().showVideo(fileUrl, 'vod', {
|
||||
const url = getRecordingUrl(
|
||||
detailsRecording.custom_properties, useSettingsStore.getState().environment.env_mode);
|
||||
if(!url) return;
|
||||
useVideoStore.getState().showVideo(url, 'vod', {
|
||||
name:
|
||||
detailsRecording.custom_properties?.program?.title ||
|
||||
'Recording',
|
||||
|
|
@ -163,7 +156,7 @@ const DVRPage = () => {
|
|||
>
|
||||
New Recording
|
||||
</Button>
|
||||
<Stack gap="lg" style={{ paddingTop: 12 }}>
|
||||
<Stack gap="lg" pt={12}>
|
||||
<div>
|
||||
<Group justify="space-between" mb={8}>
|
||||
<Title order={4}>Currently Recording</Title>
|
||||
|
|
@ -177,7 +170,11 @@ const DVRPage = () => {
|
|||
{ maxWidth: '36rem', cols: 1 },
|
||||
]}
|
||||
>
|
||||
{<RecordingList list={inProgress} />}
|
||||
{<RecordingList
|
||||
list={inProgress}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>}
|
||||
{inProgress.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing recording right now.
|
||||
|
|
@ -199,7 +196,11 @@ const DVRPage = () => {
|
|||
{ maxWidth: '36rem', cols: 1 },
|
||||
]}
|
||||
>
|
||||
{<RecordingList list={upcoming} />}
|
||||
{<RecordingList
|
||||
list={upcoming}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>}
|
||||
{upcoming.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No upcoming recordings.
|
||||
|
|
@ -221,7 +222,11 @@ const DVRPage = () => {
|
|||
{ maxWidth: '36rem', cols: 1 },
|
||||
]}
|
||||
>
|
||||
{<RecordingList list={completed} />}
|
||||
{<RecordingList
|
||||
list={completed}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>}
|
||||
{completed.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No completed recordings yet.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,353 +1,108 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
AppShell,
|
||||
Box,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
AppShellMain,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Select,
|
||||
Divider,
|
||||
ActionIcon,
|
||||
SimpleGrid,
|
||||
Modal,
|
||||
FileInput,
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import API from '../api';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { showNotification, updateNotification, } from '../utils/notificationUtils.js';
|
||||
import { usePluginStore } from '../store/plugins.jsx';
|
||||
import {
|
||||
deletePluginByKey,
|
||||
importPlugin,
|
||||
runPluginAction,
|
||||
setPluginEnabled,
|
||||
updatePluginSettings,
|
||||
} from '../utils/pages/PluginsUtils.js';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import ErrorBoundary from '../components/ErrorBoundary.jsx';
|
||||
const PluginCard = React.lazy(() =>
|
||||
import('../components/cards/PluginCard.jsx'));
|
||||
|
||||
const Field = ({ field, value, onChange }) => {
|
||||
const common = { label: field.label, description: field.help_text };
|
||||
const effective = value ?? field.default;
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
checked={!!effective}
|
||||
onChange={(e) => onChange(field.id, e.currentTarget.checked)}
|
||||
label={field.label}
|
||||
description={field.help_text}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<NumberInput
|
||||
value={value ?? field.default ?? 0}
|
||||
onChange={(v) => onChange(field.id, v)}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
value={(value ?? field.default ?? '') + ''}
|
||||
data={(field.options || []).map((o) => ({
|
||||
value: o.value + '',
|
||||
label: o.label,
|
||||
}))}
|
||||
onChange={(v) => onChange(field.id, v)}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
case 'string':
|
||||
default:
|
||||
return (
|
||||
<TextInput
|
||||
value={value ?? field.default ?? ''}
|
||||
onChange={(e) => onChange(field.id, e.currentTarget.value)}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
|
||||
const plugins = usePluginStore((state) => state.plugins);
|
||||
const loading = usePluginStore((state) => state.loading);
|
||||
const hasFetchedRef = useRef(false);
|
||||
|
||||
const PluginCard = ({
|
||||
plugin,
|
||||
onSaveSettings,
|
||||
onRunAction,
|
||||
onToggleEnabled,
|
||||
onRequireTrust,
|
||||
onRequestDelete,
|
||||
}) => {
|
||||
const [settings, setSettings] = useState(plugin.settings || {});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [enabled, setEnabled] = useState(!!plugin.enabled);
|
||||
const [lastResult, setLastResult] = useState(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [confirmConfig, setConfirmConfig] = useState({
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: null,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!hasFetchedRef.current) {
|
||||
hasFetchedRef.current = true;
|
||||
usePluginStore.getState().fetchPlugins();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keep local enabled state in sync with props (e.g., after import + enable)
|
||||
React.useEffect(() => {
|
||||
setEnabled(!!plugin.enabled);
|
||||
}, [plugin.enabled]);
|
||||
// Sync settings if plugin changes identity
|
||||
React.useEffect(() => {
|
||||
setSettings(plugin.settings || {});
|
||||
}, [plugin.key]);
|
||||
const handleTogglePluginEnabled = async (key, next) => {
|
||||
const resp = await setPluginEnabled(key, next);
|
||||
|
||||
const updateField = (id, val) => {
|
||||
setSettings((prev) => ({ ...prev, [id]: val }));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSaveSettings(plugin.key, settings);
|
||||
notifications.show({
|
||||
title: 'Saved',
|
||||
message: `${plugin.name} settings updated`,
|
||||
color: 'green',
|
||||
if (resp?.success) {
|
||||
usePluginStore.getState().updatePlugin(key, {
|
||||
enabled: next,
|
||||
ever_enabled: resp?.ever_enabled,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const missing = plugin.missing;
|
||||
if (loading && plugins.length === 0) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ opacity: !missing && enabled ? 1 : 0.6 }}
|
||||
>
|
||||
<Group justify="space-between" mb="xs" align="center">
|
||||
<div>
|
||||
<Text fw={600}>{plugin.name}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{plugin.description}
|
||||
<>
|
||||
{plugins.length > 0 &&
|
||||
<SimpleGrid
|
||||
cols={2}
|
||||
spacing="md"
|
||||
breakpoints={[{ maxWidth: '48em', cols: 1 }]}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loader />}>
|
||||
{plugins.map((p) => (
|
||||
<PluginCard
|
||||
key={p.key}
|
||||
plugin={p}
|
||||
onSaveSettings={updatePluginSettings}
|
||||
onRunAction={runPluginAction}
|
||||
onToggleEnabled={handleTogglePluginEnabled}
|
||||
onRequireTrust={onRequireTrust}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onRequestConfirm={onRequestConfirm}
|
||||
/>
|
||||
))}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</SimpleGrid>
|
||||
}
|
||||
|
||||
{plugins.length === 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins found. Drop a plugin into <code>/data/plugins</code>{' '}
|
||||
and reload.
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs" align="center">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Delete plugin"
|
||||
onClick={() => onRequestDelete && onRequestDelete(plugin)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
v{plugin.version || '1.0.0'}
|
||||
</Text>
|
||||
<Switch
|
||||
checked={!missing && enabled}
|
||||
onChange={async (e) => {
|
||||
const next = e.currentTarget.checked;
|
||||
if (next && !plugin.ever_enabled && onRequireTrust) {
|
||||
const ok = await onRequireTrust(plugin);
|
||||
if (!ok) {
|
||||
// Revert
|
||||
setEnabled(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEnabled(next);
|
||||
const resp = await onToggleEnabled(plugin.key, next);
|
||||
if (next && resp?.ever_enabled) {
|
||||
plugin.ever_enabled = true;
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
onLabel="On"
|
||||
offLabel="Off"
|
||||
disabled={missing}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{missing && (
|
||||
<Text size="sm" c="red">
|
||||
Missing plugin files. Re-import or delete this entry.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!missing && plugin.fields && plugin.fields.length > 0 && (
|
||||
<Stack gap="xs" mt="sm">
|
||||
{plugin.fields.map((f) => (
|
||||
<Field
|
||||
key={f.id}
|
||||
field={f}
|
||||
value={settings?.[f.id]}
|
||||
onChange={updateField}
|
||||
/>
|
||||
))}
|
||||
<Group>
|
||||
<Button loading={saving} onClick={save} variant="default" size="xs">
|
||||
Save Settings
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!missing && plugin.actions && plugin.actions.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Stack gap="xs">
|
||||
{plugin.actions.map((a) => (
|
||||
<Group key={a.id} justify="space-between">
|
||||
<div>
|
||||
<Text>{a.label}</Text>
|
||||
{a.description && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{a.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
loading={running}
|
||||
disabled={!enabled}
|
||||
onClick={async () => {
|
||||
setRunning(true);
|
||||
setLastResult(null);
|
||||
try {
|
||||
// Determine if confirmation is required from action metadata or fallback field
|
||||
const actionConfirm = a.confirm;
|
||||
const confirmField = (plugin.fields || []).find(
|
||||
(f) => f.id === 'confirm'
|
||||
);
|
||||
let requireConfirm = false;
|
||||
let confirmTitle = `Run ${a.label}?`;
|
||||
let confirmMessage = `You're about to run "${a.label}" from "${plugin.name}".`;
|
||||
if (actionConfirm) {
|
||||
if (typeof actionConfirm === 'boolean') {
|
||||
requireConfirm = actionConfirm;
|
||||
} else if (typeof actionConfirm === 'object') {
|
||||
requireConfirm = actionConfirm.required !== false;
|
||||
if (actionConfirm.title)
|
||||
confirmTitle = actionConfirm.title;
|
||||
if (actionConfirm.message)
|
||||
confirmMessage = actionConfirm.message;
|
||||
}
|
||||
} else if (confirmField) {
|
||||
const settingVal = settings?.confirm;
|
||||
const effectiveConfirm =
|
||||
(settingVal !== undefined
|
||||
? settingVal
|
||||
: confirmField.default) ?? false;
|
||||
requireConfirm = !!effectiveConfirm;
|
||||
}
|
||||
|
||||
if (requireConfirm) {
|
||||
await new Promise((resolve) => {
|
||||
setConfirmConfig({
|
||||
title: confirmTitle,
|
||||
message: confirmMessage,
|
||||
onConfirm: resolve,
|
||||
});
|
||||
setConfirmOpen(true);
|
||||
});
|
||||
}
|
||||
|
||||
// Save settings before running to ensure backend uses latest values
|
||||
try {
|
||||
await onSaveSettings(plugin.key, settings);
|
||||
} catch (e) {
|
||||
/* ignore, run anyway */
|
||||
}
|
||||
const resp = await onRunAction(plugin.key, a.id);
|
||||
if (resp?.success) {
|
||||
setLastResult(resp.result || {});
|
||||
const msg =
|
||||
resp.result?.message || 'Plugin action completed';
|
||||
notifications.show({
|
||||
title: plugin.name,
|
||||
message: msg,
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
const err = resp?.error || 'Unknown error';
|
||||
setLastResult({ error: err });
|
||||
notifications.show({
|
||||
title: `${plugin.name} error`,
|
||||
message: String(err),
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
>
|
||||
{running ? 'Running…' : 'Run'}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
{running && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Running action… please wait
|
||||
</Text>
|
||||
)}
|
||||
{!running && lastResult?.file && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Output: {lastResult.file}
|
||||
</Text>
|
||||
)}
|
||||
{!running && lastResult?.error && (
|
||||
<Text size="sm" c="red">
|
||||
Error: {String(lastResult.error)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={() => {
|
||||
setConfirmOpen(false);
|
||||
setConfirmConfig({ title: '', message: '', onConfirm: null });
|
||||
}}
|
||||
title={confirmConfig.title}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">{confirmConfig.message}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
setConfirmConfig({ title: '', message: '', onConfirm: null });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const cb = confirmConfig.onConfirm;
|
||||
setConfirmOpen(false);
|
||||
setConfirmConfig({ title: '', message: '', onConfirm: null });
|
||||
cb && cb(true);
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [plugins, setPlugins] = useState([]);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importFile, setImportFile] = useState(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
|
@ -358,118 +113,172 @@ export default function PluginsPage() {
|
|||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [uploadNoticeId, setUploadNoticeId] = useState(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [confirmConfig, setConfirmConfig] = useState({
|
||||
title: '',
|
||||
message: '',
|
||||
resolve: null,
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await API.getPlugins();
|
||||
setPlugins(list);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const handleReload = () => {
|
||||
usePluginStore.getState().invalidatePlugins();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const handleRequestDelete = useCallback((pl) => {
|
||||
setDeleteTarget(pl);
|
||||
setDeleteOpen(true);
|
||||
}, []);
|
||||
|
||||
const requireTrust = (plugin) => {
|
||||
const requireTrust = useCallback((plugin) => {
|
||||
return new Promise((resolve) => {
|
||||
setTrustResolve(() => resolve);
|
||||
setTrustOpen(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showImportForm = useCallback(() => {
|
||||
setImportOpen(true);
|
||||
setImported(null);
|
||||
setImportFile(null);
|
||||
setEnableAfterImport(false);
|
||||
}, []);
|
||||
|
||||
const requestConfirm = useCallback((title, message) => {
|
||||
return new Promise((resolve) => {
|
||||
setConfirmConfig({ title, message, resolve });
|
||||
setConfirmOpen(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleImportPlugin = () => {
|
||||
return async () => {
|
||||
setImporting(true);
|
||||
const id = showNotification({
|
||||
title: 'Uploading plugin',
|
||||
message: 'Backend may restart; please wait…',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
});
|
||||
try {
|
||||
const resp = await importPlugin(importFile);
|
||||
if (resp?.success && resp.plugin) {
|
||||
setImported(resp.plugin);
|
||||
usePluginStore.getState().invalidatePlugins();
|
||||
|
||||
updateNotification({
|
||||
id,
|
||||
loading: false,
|
||||
color: 'green',
|
||||
title: 'Imported',
|
||||
message:
|
||||
'Plugin imported. If the app briefly disconnected, it should be back now.',
|
||||
autoClose: 3000,
|
||||
});
|
||||
} else {
|
||||
updateNotification({
|
||||
id,
|
||||
loading: false,
|
||||
color: 'red',
|
||||
title: 'Import failed',
|
||||
message: resp?.error || 'Unknown error',
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// API.importPlugin already showed a concise error; just update the loading notice
|
||||
updateNotification({
|
||||
id,
|
||||
loading: false,
|
||||
color: 'red',
|
||||
title: 'Import failed',
|
||||
message:
|
||||
(e?.body && (e.body.error || e.body.detail)) ||
|
||||
e?.message ||
|
||||
'Failed',
|
||||
autoClose: 5000,
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleEnablePlugin = () => {
|
||||
return async () => {
|
||||
if (!imported) return;
|
||||
|
||||
const proceed = imported.ever_enabled || (await requireTrust(imported));
|
||||
if (proceed) {
|
||||
const resp = await setPluginEnabled(imported.key, true);
|
||||
if (resp?.success) {
|
||||
usePluginStore.getState().updatePlugin(imported.key, { enabled: true, ever_enabled: true });
|
||||
|
||||
showNotification({
|
||||
title: imported.name,
|
||||
message: 'Plugin enabled',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
setImportOpen(false);
|
||||
setImported(null);
|
||||
setEnableAfterImport(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleDeletePlugin = () => {
|
||||
return async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const resp = await deletePluginByKey(deleteTarget.key);
|
||||
if (resp?.success) {
|
||||
usePluginStore.getState().removePlugin(deleteTarget.key);
|
||||
|
||||
showNotification({
|
||||
title: deleteTarget.name,
|
||||
message: 'Plugin deleted',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
setDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleConfirm = useCallback((confirmed) => {
|
||||
const resolver = confirmConfig.resolve;
|
||||
setConfirmOpen(false);
|
||||
setConfirmConfig({ title: '', message: '', resolve: null });
|
||||
if (resolver) resolver(confirmed);
|
||||
}, [confirmConfig.resolve]);
|
||||
|
||||
return (
|
||||
<AppShell.Main style={{ padding: 16 }}>
|
||||
<AppShellMain p={16}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} size="lg">
|
||||
Plugins
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
setImportOpen(true);
|
||||
setImported(null);
|
||||
setImportFile(null);
|
||||
setEnableAfterImport(false);
|
||||
}}
|
||||
>
|
||||
<Button size="xs" variant="light" onClick={showImportForm}>
|
||||
Import Plugin
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
onClick={async () => {
|
||||
await API.reloadPlugins();
|
||||
await load();
|
||||
}}
|
||||
title="Reload"
|
||||
>
|
||||
<ActionIcon variant="light" onClick={handleReload} title="Reload">
|
||||
<RefreshCcw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{loading ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid
|
||||
cols={2}
|
||||
spacing="md"
|
||||
verticalSpacing="md"
|
||||
breakpoints={[{ maxWidth: '48em', cols: 1 }]}
|
||||
>
|
||||
{plugins.map((p) => (
|
||||
<PluginCard
|
||||
key={p.key}
|
||||
plugin={p}
|
||||
onSaveSettings={API.updatePluginSettings}
|
||||
onRunAction={API.runPluginAction}
|
||||
onToggleEnabled={async (key, next) => {
|
||||
const resp = await API.setPluginEnabled(key, next);
|
||||
if (resp?.ever_enabled !== undefined) {
|
||||
setPlugins((prev) =>
|
||||
prev.map((pl) =>
|
||||
pl.key === key
|
||||
? {
|
||||
...pl,
|
||||
ever_enabled: resp.ever_enabled,
|
||||
enabled: resp.enabled,
|
||||
}
|
||||
: pl
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setPlugins((prev) =>
|
||||
prev.map((pl) =>
|
||||
pl.key === key ? { ...pl, enabled: next } : pl
|
||||
)
|
||||
);
|
||||
}
|
||||
return resp;
|
||||
}}
|
||||
onRequireTrust={requireTrust}
|
||||
onRequestDelete={(plugin) => {
|
||||
setDeleteTarget(plugin);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
{plugins.length === 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins found. Drop a plugin into <code>/data/plugins</code>{' '}
|
||||
and reload.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PluginsList
|
||||
onRequestDelete={handleRequestDelete}
|
||||
onRequireTrust={requireTrust}
|
||||
onRequestConfirm={requestConfirm}
|
||||
/>
|
||||
|
||||
{/* Import Plugin Modal */}
|
||||
<Modal
|
||||
opened={importOpen}
|
||||
|
|
@ -520,61 +329,7 @@ export default function PluginsPage() {
|
|||
size="xs"
|
||||
loading={importing}
|
||||
disabled={!importFile}
|
||||
onClick={async () => {
|
||||
setImporting(true);
|
||||
const id = notifications.show({
|
||||
title: 'Uploading plugin',
|
||||
message: 'Backend may restart; please wait…',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
});
|
||||
setUploadNoticeId(id);
|
||||
try {
|
||||
const resp = await API.importPlugin(importFile);
|
||||
if (resp?.success && resp.plugin) {
|
||||
setImported(resp.plugin);
|
||||
setPlugins((prev) => [
|
||||
resp.plugin,
|
||||
...prev.filter((p) => p.key !== resp.plugin.key),
|
||||
]);
|
||||
notifications.update({
|
||||
id,
|
||||
loading: false,
|
||||
color: 'green',
|
||||
title: 'Imported',
|
||||
message:
|
||||
'Plugin imported. If the app briefly disconnected, it should be back now.',
|
||||
autoClose: 3000,
|
||||
});
|
||||
} else {
|
||||
notifications.update({
|
||||
id,
|
||||
loading: false,
|
||||
color: 'red',
|
||||
title: 'Import failed',
|
||||
message: resp?.error || 'Unknown error',
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// API.importPlugin already showed a concise error; just update the loading notice
|
||||
notifications.update({
|
||||
id,
|
||||
loading: false,
|
||||
color: 'red',
|
||||
title: 'Import failed',
|
||||
message:
|
||||
(e?.body && (e.body.error || e.body.detail)) ||
|
||||
e?.message ||
|
||||
'Failed',
|
||||
autoClose: 5000,
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
setUploadNoticeId(null);
|
||||
}
|
||||
}}
|
||||
onClick={handleImportPlugin()}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
|
|
@ -612,36 +367,7 @@ export default function PluginsPage() {
|
|||
<Button
|
||||
size="xs"
|
||||
disabled={!enableAfterImport}
|
||||
onClick={async () => {
|
||||
if (!imported) return;
|
||||
let proceed = true;
|
||||
if (!imported.ever_enabled) {
|
||||
proceed = await requireTrust(imported);
|
||||
}
|
||||
if (proceed) {
|
||||
const resp = await API.setPluginEnabled(
|
||||
imported.key,
|
||||
true
|
||||
);
|
||||
if (resp?.success) {
|
||||
setPlugins((prev) =>
|
||||
prev.map((p) =>
|
||||
p.key === imported.key
|
||||
? { ...p, enabled: true, ever_enabled: true }
|
||||
: p
|
||||
)
|
||||
);
|
||||
notifications.show({
|
||||
title: imported.name,
|
||||
message: 'Plugin enabled',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
setImportOpen(false);
|
||||
setImported(null);
|
||||
setEnableAfterImport(false);
|
||||
}
|
||||
}}
|
||||
onClick={handleEnablePlugin()}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
|
|
@ -727,33 +453,37 @@ export default function PluginsPage() {
|
|||
size="xs"
|
||||
color="red"
|
||||
loading={deleting}
|
||||
onClick={async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const resp = await API.deletePlugin(deleteTarget.key);
|
||||
if (resp?.success) {
|
||||
setPlugins((prev) =>
|
||||
prev.filter((p) => p.key !== deleteTarget.key)
|
||||
);
|
||||
notifications.show({
|
||||
title: deleteTarget.name,
|
||||
message: 'Plugin deleted',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
setDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
onClick={handleDeletePlugin()}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Confirmation modal */}
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={() => handleConfirm(false)}
|
||||
title={confirmConfig.title}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm">{confirmConfig.message}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={() => handleConfirm(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="xs" onClick={() => handleConfirm(true)}>
|
||||
Confirm
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</AppShellMain>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,26 @@
|
|||
import dayjs from 'dayjs';
|
||||
import {
|
||||
convertToMs,
|
||||
initializeTime,
|
||||
startOfDay,
|
||||
isBefore,
|
||||
isAfter,
|
||||
isSame,
|
||||
add,
|
||||
diff,
|
||||
format,
|
||||
getNow,
|
||||
getNowMs,
|
||||
roundToNearest
|
||||
} from '../utils/dateTimeUtils.js';
|
||||
import API from '../api.js';
|
||||
|
||||
export const PROGRAM_HEIGHT = 90;
|
||||
export const EXPANDED_PROGRAM_HEIGHT = 180;
|
||||
/** Layout constants */
|
||||
export const CHANNEL_WIDTH = 120; // Width of the channel/logo column
|
||||
export const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
|
||||
export const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
|
||||
export const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
|
||||
|
||||
export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
|
||||
const map = new Map();
|
||||
|
|
@ -38,25 +57,32 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) {
|
|||
return map;
|
||||
}
|
||||
|
||||
export function mapProgramsByChannel(programs, channelIdByTvgId) {
|
||||
export const mapProgramsByChannel = (programs, channelIdByTvgId) => {
|
||||
if (!programs?.length || !channelIdByTvgId?.size) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
const nowMs = getNowMs();
|
||||
|
||||
programs.forEach((program) => {
|
||||
const channelIds = channelIdByTvgId.get(String(program.tvg_id));
|
||||
if (!channelIds || channelIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startMs = program.startMs ?? dayjs(program.start_time).valueOf();
|
||||
const endMs = program.endMs ?? dayjs(program.end_time).valueOf();
|
||||
const startMs = program.startMs ?? convertToMs(program.start_time);
|
||||
const endMs = program.endMs ?? convertToMs(program.end_time);
|
||||
|
||||
const programData = {
|
||||
...program,
|
||||
startMs,
|
||||
endMs,
|
||||
programStart: initializeTime(program.startMs),
|
||||
programEnd: initializeTime(program.endMs),
|
||||
// Precompute live/past status
|
||||
isLive: nowMs >= program.startMs && nowMs < program.endMs,
|
||||
isPast: nowMs >= program.endMs,
|
||||
};
|
||||
|
||||
// Add this program to all channels that share the same TVG ID
|
||||
|
|
@ -73,7 +99,7 @@ export function mapProgramsByChannel(programs, channelIdByTvgId) {
|
|||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
};
|
||||
|
||||
export function computeRowHeights(
|
||||
filteredChannels,
|
||||
|
|
@ -94,3 +120,282 @@ export function computeRowHeights(
|
|||
return expanded ? expandedHeight : defaultHeight;
|
||||
});
|
||||
}
|
||||
|
||||
export const fetchPrograms = async () => {
|
||||
console.log('Fetching program grid...');
|
||||
const fetched = await API.getGrid(); // GETs your EPG grid
|
||||
console.log(`Received ${fetched.length} programs`);
|
||||
|
||||
return fetched.map((program) => {
|
||||
return {
|
||||
...program,
|
||||
startMs: convertToMs(program.start_time),
|
||||
endMs: convertToMs(program.end_time),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const sortChannels = (channels) => {
|
||||
// Include ALL channels, sorted by channel number - don't filter by EPG data
|
||||
const sortedChannels = Object.values(channels).sort(
|
||||
(a, b) =>
|
||||
(a.channel_number || Infinity) - (b.channel_number || Infinity)
|
||||
);
|
||||
|
||||
console.log(`Using all ${sortedChannels.length} available channels`);
|
||||
return sortedChannels;
|
||||
}
|
||||
|
||||
export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, selectedProfileId, profiles) => {
|
||||
return guideChannels.filter((channel) => {
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
}
|
||||
|
||||
// Channel group filter
|
||||
if (selectedGroupId !== 'all') {
|
||||
if (channel.channel_group_id !== parseInt(selectedGroupId)) return false;
|
||||
}
|
||||
|
||||
// Profile filter
|
||||
if (selectedProfileId !== 'all') {
|
||||
const profileChannels = profiles[selectedProfileId]?.channels || [];
|
||||
const enabledChannelIds = Array.isArray(profileChannels)
|
||||
? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id)
|
||||
: profiles[selectedProfileId]?.channels instanceof Set
|
||||
? Array.from(profiles[selectedProfileId].channels)
|
||||
: [];
|
||||
|
||||
if (!enabledChannelIds.includes(channel.id)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export const calculateEarliestProgramStart = (programs, defaultStart) => {
|
||||
if (!programs.length) return defaultStart;
|
||||
return programs.reduce((acc, p) => {
|
||||
const s = initializeTime(p.start_time);
|
||||
return isBefore(s, acc) ? s : acc;
|
||||
}, defaultStart);
|
||||
}
|
||||
|
||||
export const calculateLatestProgramEnd = (programs, defaultEnd) => {
|
||||
if (!programs.length) return defaultEnd;
|
||||
return programs.reduce((acc, p) => {
|
||||
const e = initializeTime(p.end_time);
|
||||
return isAfter(e, acc) ? e : acc;
|
||||
}, defaultEnd);
|
||||
}
|
||||
|
||||
export const calculateStart = (earliestProgramStart, defaultStart) => {
|
||||
return isBefore(earliestProgramStart, defaultStart)
|
||||
? earliestProgramStart
|
||||
: defaultStart;
|
||||
}
|
||||
|
||||
export const calculateEnd = (latestProgramEnd, defaultEnd) => {
|
||||
return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd;
|
||||
}
|
||||
|
||||
export const mapChannelsById = (guideChannels) => {
|
||||
const map = new Map();
|
||||
guideChannels.forEach((channel) => {
|
||||
map.set(channel.id, channel);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export const mapRecordingsByProgramId = (recordings) => {
|
||||
const map = new Map();
|
||||
(recordings || []).forEach((recording) => {
|
||||
const programId = recording?.custom_properties?.program?.id;
|
||||
if (programId != null) {
|
||||
map.set(programId, recording);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export const formatTime = (time, dateFormat) => {
|
||||
const today = startOfDay(getNow());
|
||||
const tomorrow = add(today, 1, 'day');
|
||||
const weekLater = add(today, 7, 'day');
|
||||
const day = startOfDay(time);
|
||||
|
||||
if (isSame(day, today, 'day')) {
|
||||
return 'Today';
|
||||
} else if (isSame(day, tomorrow, 'day')) {
|
||||
return 'Tomorrow';
|
||||
} else if (isBefore(day, weekLater)) {
|
||||
// Within a week, show day name
|
||||
return format(time, 'dddd');
|
||||
} else {
|
||||
// Beyond a week, show month and day
|
||||
return format(time, dateFormat);
|
||||
}
|
||||
}
|
||||
|
||||
export const calculateHourTimeline = (start, end, formatDayLabel) => {
|
||||
const hours = [];
|
||||
let current = start;
|
||||
let currentDay = null;
|
||||
|
||||
while (isBefore(current, end)) {
|
||||
// Check if we're entering a new day
|
||||
const day = startOfDay(current);
|
||||
const isNewDay = !currentDay || !isSame(day, currentDay, 'day');
|
||||
|
||||
if (isNewDay) {
|
||||
currentDay = day;
|
||||
}
|
||||
|
||||
// Add day information to our hour object
|
||||
hours.push({
|
||||
time: current,
|
||||
isNewDay,
|
||||
dayLabel: formatDayLabel(current),
|
||||
});
|
||||
|
||||
current = add(current, 1, 'hour');
|
||||
}
|
||||
return hours;
|
||||
}
|
||||
|
||||
export const calculateNowPosition = (now, start, end) => {
|
||||
if (isBefore(now, start) || isAfter(now, end)) return -1;
|
||||
const minutesSinceStart = diff(now, start, 'minute');
|
||||
return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
};
|
||||
|
||||
export const calculateScrollPosition = (now, start) => {
|
||||
const roundedNow = roundToNearest(now, 30);
|
||||
const nowOffset = diff(roundedNow, start, 'minute');
|
||||
const scrollPosition =
|
||||
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
|
||||
|
||||
return Math.max(scrollPosition, 0);
|
||||
};
|
||||
|
||||
export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => {
|
||||
const channelIds = channelIdByTvgId.get(String(tvgId));
|
||||
if (!channelIds || channelIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Return the first channel that matches this TVG ID
|
||||
return channelById.get(channelIds[0]) || null;
|
||||
}
|
||||
|
||||
export const fetchRules = async () => {
|
||||
return await API.listSeriesRules();
|
||||
}
|
||||
|
||||
export const getRuleByProgram = (rules, program) => {
|
||||
return (rules || []).find(
|
||||
(r) =>
|
||||
String(r.tvg_id) === String(program.tvg_id) &&
|
||||
(!r.title || r.title === program.title)
|
||||
);
|
||||
}
|
||||
|
||||
export const createRecording = async (channel, program) => {
|
||||
await API.createRecording({
|
||||
channel: `${channel.id}`,
|
||||
start_time: program.start_time,
|
||||
end_time: program.end_time,
|
||||
custom_properties: { program },
|
||||
});
|
||||
}
|
||||
|
||||
export const createSeriesRule = async (program, mode) => {
|
||||
await API.createSeriesRule({
|
||||
tvg_id: program.tvg_id,
|
||||
mode,
|
||||
title: program.title,
|
||||
});
|
||||
}
|
||||
|
||||
export const evaluateSeriesRule = async (program) => {
|
||||
await API.evaluateSeriesRules(program.tvg_id);
|
||||
}
|
||||
|
||||
export const calculateLeftScrollPosition = (program, start) => {
|
||||
const programStartMs =
|
||||
program.startMs ?? convertToMs(program.start_time);
|
||||
const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000;
|
||||
|
||||
return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
};
|
||||
|
||||
export const calculateDesiredScrollPosition = (leftPx) => {
|
||||
return Math.max(0, leftPx - 20);
|
||||
}
|
||||
|
||||
export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const clickPositionX = event.clientX - rect.left;
|
||||
const percentageAcross = clickPositionX / rect.width;
|
||||
const minuteWithinHour = percentageAcross * 60;
|
||||
|
||||
const snappedMinute = Math.round(minuteWithinHour / 15) * 15;
|
||||
|
||||
const adjustedTime = (snappedMinute === 60)
|
||||
? add(clickedTime, 1, 'hour').minute(0)
|
||||
: clickedTime.minute(snappedMinute);
|
||||
|
||||
const snappedOffset = diff(adjustedTime, start, 'minute');
|
||||
return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
};
|
||||
|
||||
export const getGroupOptions = (channelGroups, guideChannels) => {
|
||||
const options = [{ value: 'all', label: 'All Channel Groups' }];
|
||||
|
||||
if (channelGroups && guideChannels.length > 0) {
|
||||
// Get unique channel group IDs from the channels that have program data
|
||||
const usedGroupIds = new Set();
|
||||
guideChannels.forEach((channel) => {
|
||||
if (channel.channel_group_id) {
|
||||
usedGroupIds.add(channel.channel_group_id);
|
||||
}
|
||||
});
|
||||
// Only add groups that are actually used by channels in the guide
|
||||
Object.values(channelGroups)
|
||||
.filter((group) => usedGroupIds.has(group.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)) // Sort alphabetically
|
||||
.forEach((group) => {
|
||||
options.push({
|
||||
value: group.id.toString(),
|
||||
label: group.name,
|
||||
});
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export const getProfileOptions = (profiles) => {
|
||||
const options = [{ value: 'all', label: 'All Profiles' }];
|
||||
|
||||
if (profiles) {
|
||||
Object.values(profiles).forEach((profile) => {
|
||||
if (profile.id !== '0') {
|
||||
// Skip the 'All' default profile
|
||||
options.push({
|
||||
value: profile.id.toString(),
|
||||
label: profile.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export const deleteSeriesRuleByTvgId = async (tvg_id) => {
|
||||
await API.deleteSeriesRule(tvg_id);
|
||||
}
|
||||
|
||||
export const evaluateSeriesRulesByTvgId = async (tvg_id) => {
|
||||
await API.evaluateSeriesRules(tvg_id);
|
||||
}
|
||||
41
frontend/src/store/plugins.jsx
Normal file
41
frontend/src/store/plugins.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
|
||||
export const usePluginStore = create((set, get) => ({
|
||||
plugins: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchPlugins: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await API.getPlugins();
|
||||
set({ plugins: response || [], loading: false });
|
||||
} catch (error) {
|
||||
set({ error, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updatePlugin: (key, updates) => {
|
||||
set((state) => ({
|
||||
plugins: state.plugins.map((p) =>
|
||||
p.key === key ? { ...p, ...updates } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
addPlugin: (plugin) => {
|
||||
set((state) => ({ plugins: [...state.plugins, plugin] }));
|
||||
},
|
||||
|
||||
removePlugin: (key) => {
|
||||
set((state) => ({
|
||||
plugins: state.plugins.filter((p) => p.key !== key),
|
||||
}));
|
||||
},
|
||||
|
||||
invalidatePlugins: () => {
|
||||
set({ plugins: [] });
|
||||
get().fetchPlugins();
|
||||
},
|
||||
}));
|
||||
24
frontend/src/utils/cards/PluginCardUtils.js
Normal file
24
frontend/src/utils/cards/PluginCardUtils.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export const getConfirmationDetails = (action, plugin, settings) => {
|
||||
const actionConfirm = action.confirm;
|
||||
const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm');
|
||||
let requireConfirm = false;
|
||||
let confirmTitle = `Run ${action.label}?`;
|
||||
let confirmMessage = `You're about to run "${action.label}" from "${plugin.name}".`;
|
||||
|
||||
if (actionConfirm) {
|
||||
if (typeof actionConfirm === 'boolean') {
|
||||
requireConfirm = actionConfirm;
|
||||
} else if (typeof actionConfirm === 'object') {
|
||||
requireConfirm = actionConfirm.required !== false;
|
||||
if (actionConfirm.title) confirmTitle = actionConfirm.title;
|
||||
if (actionConfirm.message) confirmMessage = actionConfirm.message;
|
||||
}
|
||||
} else if (confirmField) {
|
||||
const settingVal = settings?.confirm;
|
||||
const effectiveConfirm =
|
||||
(settingVal !== undefined ? settingVal : confirmField.default) ?? false;
|
||||
requireConfirm = !!effectiveConfirm;
|
||||
}
|
||||
|
||||
return { requireConfirm, confirmTitle, confirmMessage };
|
||||
};
|
||||
|
|
@ -73,15 +73,15 @@ export const useTimeHelpers = () => {
|
|||
(value) => {
|
||||
if (!value) return dayjs.invalid();
|
||||
try {
|
||||
return dayjs(value).tz(timeZone);
|
||||
return initializeTime(value).tz(timeZone);
|
||||
} catch (error) {
|
||||
return dayjs(value);
|
||||
return initializeTime(value);
|
||||
}
|
||||
},
|
||||
[timeZone]
|
||||
);
|
||||
|
||||
const userNow = useCallback(() => dayjs().tz(timeZone), [timeZone]);
|
||||
const userNow = useCallback(() => getNow().tz(timeZone), [timeZone]);
|
||||
|
||||
return { timeZone, toUserTime, userNow };
|
||||
};
|
||||
|
|
@ -113,7 +113,7 @@ export const toTimeString = (value) => {
|
|||
if (parsed.isValid()) return parsed.format('HH:mm');
|
||||
return value;
|
||||
}
|
||||
const parsed = dayjs(value);
|
||||
const parsed = initializeTime(value);
|
||||
return parsed.isValid() ? parsed.format('HH:mm') : '00:00';
|
||||
};
|
||||
|
||||
|
|
|
|||
17
frontend/src/utils/pages/PluginsUtils.js
Normal file
17
frontend/src/utils/pages/PluginsUtils.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import API from '../../api.js';
|
||||
|
||||
export const updatePluginSettings = async (key, settings) => {
|
||||
return await API.updatePluginSettings(key, settings);
|
||||
};
|
||||
export const runPluginAction = async (key, actionId) => {
|
||||
return await API.runPluginAction(key, actionId);
|
||||
};
|
||||
export const setPluginEnabled = async (key, next) => {
|
||||
return await API.setPluginEnabled(key, next);
|
||||
};
|
||||
export const importPlugin = async (importFile) => {
|
||||
return await API.importPlugin(importFile);
|
||||
};
|
||||
export const deletePluginByKey = (key) => {
|
||||
return API.deletePlugin(key);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue