mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge branch 'main' into m3u-profiles
This commit is contained in:
commit
024c277e7b
2 changed files with 368 additions and 253 deletions
46
core/management/commands/dropdb.py
Normal file
46
core/management/commands/dropdb.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import sys
|
||||
import psycopg2
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Drop the entire database (schema and data) and recreate it. (PostgreSQL only)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
db_settings = settings.DATABASES['default']
|
||||
db_name = db_settings['NAME']
|
||||
user = db_settings['USER']
|
||||
password = db_settings['PASSWORD']
|
||||
host = db_settings.get('HOST', 'localhost')
|
||||
port = db_settings.get('PORT', 5432)
|
||||
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"WARNING: This will irreversibly drop the entire database '{db_name}'!"
|
||||
))
|
||||
confirm = input("Type 'yes' to proceed: ")
|
||||
if confirm.lower() != 'yes':
|
||||
self.stdout.write("Aborted. No changes made.")
|
||||
return
|
||||
|
||||
# Close Django's current connection to the target DB
|
||||
connection.close()
|
||||
|
||||
# For PostgreSQL, we need to connect to a different database (e.g. the maintenance database "postgres")
|
||||
maintenance_db = 'postgres'
|
||||
try:
|
||||
self.stdout.write("Connecting to maintenance database...")
|
||||
conn = psycopg2.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port)
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
self.stdout.write(f"Dropping database '{db_name}'...")
|
||||
cur.execute(f"DROP DATABASE IF EXISTS {db_name};")
|
||||
self.stdout.write(f"Creating database '{db_name}'...")
|
||||
cur.execute(f"CREATE DATABASE {db_name};")
|
||||
cur.close()
|
||||
conn.close()
|
||||
self.stdout.write(self.style.SUCCESS(f"Database '{db_name}' has been dropped and recreated."))
|
||||
self.stdout.write("Now run 'python manage.py migrate' to reapply your migrations.")
|
||||
except Exception as e:
|
||||
self.stderr.write(self.style.ERROR(f"Error dropping/creating database: {e}"))
|
||||
sys.exit(1)
|
||||
|
|
@ -2,37 +2,36 @@ import React, { useMemo, useState, useEffect, useRef } from 'react';
|
|||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Avatar,
|
||||
Paper,
|
||||
Tooltip,
|
||||
Stack,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
Input,
|
||||
MenuItem,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Slide,
|
||||
} from '@mui/material';
|
||||
import dayjs from 'dayjs';
|
||||
import API from '../api';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import logo from '../images/logo.png';
|
||||
|
||||
const CHANNEL_WIDTH = 100;
|
||||
const PROGRAM_HEIGHT = 80;
|
||||
const HOUR_WIDTH = 300;
|
||||
/** Layout constants */
|
||||
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
|
||||
const PROGRAM_HEIGHT = 90; // Height of each channel row
|
||||
const HOUR_WIDTH = 300; // The width for a 1-hour block
|
||||
const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
|
||||
const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
|
||||
// => 300 / 4 = 75px for each 15-minute block
|
||||
|
||||
const ITEM_HEIGHT = 48;
|
||||
const ITEM_PADDING_TOP = 8;
|
||||
const MenuProps = {
|
||||
PaperProps: {
|
||||
style: {
|
||||
// maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
|
||||
// width: 250,
|
||||
},
|
||||
},
|
||||
};
|
||||
// Modal size constants (all modals will be the same size)
|
||||
const MODAL_WIDTH = 600;
|
||||
const MODAL_HEIGHT = 400;
|
||||
|
||||
// Slide transition for Dialog
|
||||
const Transition = React.forwardRef(function Transition(props, ref) {
|
||||
return <Slide direction="up" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
const TVChannelGuide = ({ startDate, endDate }) => {
|
||||
const { channels } = useChannelsStore();
|
||||
|
|
@ -40,141 +39,191 @@ const TVChannelGuide = ({ startDate, endDate }) => {
|
|||
const [programs, setPrograms] = useState([]);
|
||||
const [guideChannels, setGuideChannels] = useState([]);
|
||||
const [now, setNow] = useState(dayjs());
|
||||
const [activeChannels, setActiveChannels] = useState([]);
|
||||
// State for selected program to display in modal
|
||||
const [selectedProgram, setSelectedProgram] = useState(null);
|
||||
|
||||
const guideRef = useRef(null);
|
||||
|
||||
if (!channels || channels.length === 0) {
|
||||
console.warn('No channels provided or empty channels array');
|
||||
}
|
||||
|
||||
const activeChannelChange = (event) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
setActiveChannels(
|
||||
// On autofill we get a stringified value.
|
||||
typeof value === 'string' ? value.split(',') : value
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!channels || channels.length === 0) {
|
||||
console.warn('No channels provided or empty channels array');
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
const programs = await API.getGrid();
|
||||
const programIds = [...new Set(programs.map((prog) => prog.tvg_id))];
|
||||
console.log('Fetching program grid...');
|
||||
const fetchedPrograms = await API.getGrid();
|
||||
console.log(`Received ${fetchedPrograms.length} programs`);
|
||||
|
||||
// Get unique tvg_ids from the returned programs
|
||||
const programIds = [...new Set(fetchedPrograms.map((prog) => prog.tvg_id))];
|
||||
|
||||
// Filter channels to only those that appear in the program list
|
||||
const filteredChannels = channels.filter((ch) =>
|
||||
programIds.includes(ch.tvg_id)
|
||||
);
|
||||
console.log(`found ${filteredChannels.length} channels with matching tvg-ids`);
|
||||
|
||||
setGuideChannels(filteredChannels);
|
||||
setActiveChannels(guideChannels.map((channel) => channel.channel_name));
|
||||
setPrograms(programs);
|
||||
setPrograms(fetchedPrograms);
|
||||
};
|
||||
|
||||
fetchPrograms();
|
||||
}, [channels, activeChannels]);
|
||||
|
||||
const latestHalfHour = new Date();
|
||||
// Default to "today at midnight" -> +24h if not provided
|
||||
const defaultStart = dayjs(startDate || dayjs().startOf('day'));
|
||||
const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour');
|
||||
|
||||
// Round down the minutes to the nearest half hour
|
||||
const minutes = latestHalfHour.getMinutes();
|
||||
const roundedMinutes = minutes < 30 ? 0 : 30;
|
||||
// Find earliest program start and latest program end to expand timeline if needed.
|
||||
const earliestProgramStart = useMemo(() => {
|
||||
if (!programs.length) return defaultStart;
|
||||
return programs.reduce((acc, p) => {
|
||||
const progStart = dayjs(p.start_time);
|
||||
return progStart.isBefore(acc) ? progStart : acc;
|
||||
}, defaultStart);
|
||||
}, [programs, defaultStart]);
|
||||
|
||||
latestHalfHour.setMinutes(roundedMinutes);
|
||||
latestHalfHour.setSeconds(0);
|
||||
latestHalfHour.setMilliseconds(0);
|
||||
const latestProgramEnd = useMemo(() => {
|
||||
if (!programs.length) return defaultEnd;
|
||||
return programs.reduce((acc, p) => {
|
||||
const progEnd = dayjs(p.end_time);
|
||||
return progEnd.isAfter(acc) ? progEnd : acc;
|
||||
}, defaultEnd);
|
||||
}, [programs, defaultEnd]);
|
||||
|
||||
const todayMidnight = dayjs().startOf('day');
|
||||
// Timeline boundaries: use expanded timeline if needed
|
||||
const start = earliestProgramStart.isBefore(defaultStart)
|
||||
? earliestProgramStart
|
||||
: defaultStart;
|
||||
const end = latestProgramEnd.isAfter(defaultEnd)
|
||||
? latestProgramEnd
|
||||
: defaultEnd;
|
||||
|
||||
const start = dayjs(startDate || todayMidnight);
|
||||
const end = endDate ? dayjs(endDate) : start.add(24, 'hour');
|
||||
/**
|
||||
* For program positioning calculations: we step in 15-min increments.
|
||||
*/
|
||||
const programTimeline = useMemo(() => {
|
||||
const times = [];
|
||||
let current = start;
|
||||
while (current.isBefore(end)) {
|
||||
times.push(current);
|
||||
current = current.add(MINUTE_INCREMENT, 'minute');
|
||||
}
|
||||
return times;
|
||||
}, [start, end]);
|
||||
|
||||
const timeline = useMemo(() => {
|
||||
// console.log('Generating timeline...');
|
||||
/**
|
||||
* For the visible timeline at the top: hourly blocks with 4 sub-lines.
|
||||
*/
|
||||
const hourTimeline = useMemo(() => {
|
||||
const hours = [];
|
||||
let current = start;
|
||||
while (current.isBefore(end)) {
|
||||
hours.push(current);
|
||||
current = current.add(1, 'hour');
|
||||
}
|
||||
// console.log('Timeline generated:', hours);
|
||||
return hours;
|
||||
}, [start, end]);
|
||||
|
||||
// Scroll to "now" position on load
|
||||
useEffect(() => {
|
||||
if (guideRef.current) {
|
||||
const nowOffset = dayjs().diff(start, 'minute');
|
||||
const scrollPosition = (nowOffset / 60) * HOUR_WIDTH - HOUR_WIDTH;
|
||||
const scrollPosition =
|
||||
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - MINUTE_BLOCK_WIDTH;
|
||||
guideRef.current.scrollLeft = Math.max(scrollPosition, 0);
|
||||
}
|
||||
}, [programs, start]);
|
||||
|
||||
const renderProgram = (program, channelStart) => {
|
||||
const programStart = dayjs(program.start_time);
|
||||
const programEnd = dayjs(program.end_time);
|
||||
const startOffset = programStart.diff(channelStart, 'minute');
|
||||
const duration = programEnd.diff(programStart, 'minute');
|
||||
|
||||
const now = dayjs();
|
||||
const isLive =
|
||||
dayjs(program.start_time).isBefore(now) &&
|
||||
dayjs(program.end_time).isAfter(now);
|
||||
|
||||
return (
|
||||
// <Tooltip title={`${program.title} - ${program.description}`} arrow>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: (startOffset / 60) * HOUR_WIDTH + 2,
|
||||
width: (duration / 60) * HOUR_WIDTH - 4,
|
||||
top: 2,
|
||||
height: PROGRAM_HEIGHT - 4,
|
||||
padding: 1,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
// borderLeft: '1px solid black',
|
||||
borderRight: '1px solid black',
|
||||
borderRadius: '8px',
|
||||
color: 'primary.contrastText',
|
||||
background: isLive
|
||||
? 'linear-gradient(to right, #1a202c, #1a202c, #002eb3)'
|
||||
: 'linear-gradient(to right, #1a202c, #1a202c)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(to right, #051937, #002360)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{program.title}
|
||||
</Typography>
|
||||
<Typography variant="overline" noWrap>
|
||||
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
|
||||
</Typography>
|
||||
</Box>
|
||||
// </Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// Update "now" every minute
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setNow(dayjs());
|
||||
}, 60000); // Update every minute
|
||||
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Calculate pixel offset for the "now" line
|
||||
const nowPosition = useMemo(() => {
|
||||
if (now.isBefore(start) || now.isAfter(end)) return -1;
|
||||
const totalMinutes = end.diff(start, 'minute');
|
||||
const minutesSinceStart = now.diff(start, 'minute');
|
||||
return (minutesSinceStart / totalMinutes) * (timeline.length * HOUR_WIDTH);
|
||||
}, [now, start, end, timeline.length]);
|
||||
return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
}, [now, start, end]);
|
||||
|
||||
/** Handle program click: scroll program into view and open modal */
|
||||
const handleProgramClick = (program, event) => {
|
||||
// Scroll clicked element into center view
|
||||
event.currentTarget.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
||||
setSelectedProgram(program);
|
||||
};
|
||||
|
||||
/** Close modal */
|
||||
const handleCloseModal = () => {
|
||||
setSelectedProgram(null);
|
||||
};
|
||||
|
||||
/** Render each program block as clickable, opening modal on click */
|
||||
const renderProgram = (program, channelStart) => {
|
||||
const programKey = `${program.tvg_id}-${program.start_time}`;
|
||||
const programStart = dayjs(program.start_time);
|
||||
const programEnd = dayjs(program.end_time);
|
||||
|
||||
const startOffsetMinutes = programStart.diff(channelStart, 'minute');
|
||||
const durationMinutes = programEnd.diff(programStart, 'minute');
|
||||
|
||||
const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
|
||||
|
||||
const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={programKey}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: leftPx,
|
||||
top: 0,
|
||||
width: widthPx,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={(e) => handleProgramClick(program, e)}
|
||||
>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
left: 2,
|
||||
width: widthPx - 4,
|
||||
top: 2,
|
||||
height: PROGRAM_HEIGHT - 4,
|
||||
p: 1,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
borderRadius: '8px',
|
||||
background: isLive
|
||||
? 'linear-gradient(to right, #1e3a8a, #2c5282)'
|
||||
: 'linear-gradient(to right, #2d3748, #2d3748)',
|
||||
color: '#fff',
|
||||
transition: 'background 0.3s ease',
|
||||
'&:hover': {
|
||||
background: isLive
|
||||
? 'linear-gradient(to right, #1e3a8a, #2a4365)'
|
||||
: 'linear-gradient(to right, #2d3748, #1a202c)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" noWrap sx={{ fontWeight: 'bold' }}>
|
||||
{program.title}
|
||||
</Typography>
|
||||
<Typography variant="overline" noWrap>
|
||||
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
|
@ -182,216 +231,236 @@ const TVChannelGuide = ({ startDate, endDate }) => {
|
|||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#171923',
|
||||
backgroundColor: '#1a202c',
|
||||
color: '#fff',
|
||||
fontFamily: 'Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<FormControl sx={{ m: 1, width: 300 }}>
|
||||
<InputLabel id="select-channels-label">Channels</InputLabel>
|
||||
<Select
|
||||
labelId="select-channels-label"
|
||||
id="select-channels"
|
||||
multiple
|
||||
value={activeChannels}
|
||||
onChange={activeChannelChange}
|
||||
input={<Input label="Channels" />}
|
||||
renderValue={(selected) => selected.join(', ')}
|
||||
MenuProps={MenuProps}
|
||||
size="small"
|
||||
>
|
||||
{guideChannels.map((channel) => (
|
||||
<MenuItem key={channel.channel_name} value={channel.channel_name}>
|
||||
<Checkbox
|
||||
checked={activeChannels.includes(channel.channel_name)}
|
||||
/>
|
||||
<ListItemText primary={channel.channel_name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{/* Sticky top bar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2d3748',
|
||||
color: '#fff',
|
||||
p: 2,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 999,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
TV Guide
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{now.format('dddd, MMMM D, YYYY • h:mm A')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Main layout */}
|
||||
<Stack direction="row">
|
||||
<Box>
|
||||
{/* Channel Column */}
|
||||
{/* Channel Logos Column */}
|
||||
<Box sx={{ backgroundColor: '#2d3748', color: '#fff' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: CHANNEL_WIDTH,
|
||||
height: '40px',
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
/>
|
||||
{guideChannels
|
||||
.filter((channel) => activeChannels.includes(channel.channel_name))
|
||||
.map((channel, index) => {
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
// borderTop: '1px solid #ccc',
|
||||
height: PROGRAM_HEIGHT + 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
{guideChannels.map((channel) => (
|
||||
<Box
|
||||
key={channel.channel_name}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
height: PROGRAM_HEIGHT,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: CHANNEL_WIDTH,
|
||||
display: 'flex',
|
||||
p: 1,
|
||||
justifyContent: 'center',
|
||||
maxWidth: CHANNEL_WIDTH * 0.8,
|
||||
maxHeight: PROGRAM_HEIGHT * 0.8,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={channel.logo_url || logo}
|
||||
alt={channel.channel_name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: CHANNEL_WIDTH,
|
||||
display: 'flex',
|
||||
padding: 1,
|
||||
justifyContent: 'center',
|
||||
maxWidth: CHANNEL_WIDTH * 0.75,
|
||||
maxHeight: PROGRAM_HEIGHT * 0.75,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={channel.logo_url || logo}
|
||||
alt={channel.channel_name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'contain', // This ensures aspect ratio is preserved
|
||||
}}
|
||||
/>
|
||||
{/* <Typography variant="body2" sx={{ marginLeft: 1 }}>
|
||||
{channel.channel_name}
|
||||
</Typography> */}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Timeline and Lineup */}
|
||||
{/* Timeline & Program Blocks */}
|
||||
<Box
|
||||
ref={guideRef}
|
||||
sx={{ overflowY: 'auto', height: '100%', overflowX: 'auto' }}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* Sticky timeline header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
backgroundColor: '#171923',
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, display: 'flex' }}>
|
||||
{timeline.map((time, index) => (
|
||||
{hourTimeline.map((time, hourIndex) => (
|
||||
<Box
|
||||
key={time.format()}
|
||||
sx={{
|
||||
width: HOUR_WIDTH,
|
||||
// borderLeft: '1px solid #ddd',
|
||||
padding: 1,
|
||||
backgroundColor: '#171923',
|
||||
color: 'primary.contrastText',
|
||||
height: '40px',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
padding: 0,
|
||||
color: '#a0aec0',
|
||||
borderRight: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#a0aec0',
|
||||
position: 'absolute',
|
||||
left: index == 0 ? 0 : '-18px',
|
||||
top: '50%',
|
||||
left: hourIndex === 0 ? 4 : 'calc(50% - 16px)',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
{time.format('h:mma')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
alignItems: 'end',
|
||||
'grid-template-columns': 'repeat(4, 1fr)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '1px',
|
||||
height: '10px',
|
||||
marginRight: HOUR_WIDTH / 4 + 'px',
|
||||
background: '#718096',
|
||||
}}
|
||||
></Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: '1px',
|
||||
height: '10px',
|
||||
marginRight: HOUR_WIDTH / 4 + 'px',
|
||||
background: '#718096',
|
||||
}}
|
||||
></Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: '1px',
|
||||
height: '10px',
|
||||
marginRight: HOUR_WIDTH / 4 + 'px',
|
||||
background: '#718096',
|
||||
}}
|
||||
></Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: '1px',
|
||||
height: '10px',
|
||||
marginRight: HOUR_WIDTH / 4 + 'px',
|
||||
background: '#718096',
|
||||
}}
|
||||
></Box>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: '1px',
|
||||
height: '10px',
|
||||
backgroundColor: '#718096',
|
||||
marginRight: i < 3 ? (HOUR_WIDTH / 4 - 1) + 'px' : 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Now-position line */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{nowPosition > 0 && (
|
||||
{nowPosition >= 0 && (
|
||||
<Box
|
||||
className="now-position"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: nowPosition,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '3px',
|
||||
backgroundColor: 'rgb(44, 122, 123)',
|
||||
width: '2px',
|
||||
backgroundColor: '#38b2ac',
|
||||
zIndex: 15,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{guideChannels
|
||||
.filter((channel) =>
|
||||
activeChannels.includes(channel.channel_name)
|
||||
)
|
||||
.map((channel, index) => {
|
||||
const channelPrograms = programs.filter(
|
||||
(p) => p.tvg_id === channel.tvg_id
|
||||
);
|
||||
return (
|
||||
<Box key={index} sx={{ display: 'flex' }}>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
minHeight: PROGRAM_HEIGHT + 1,
|
||||
}}
|
||||
>
|
||||
{channelPrograms.map((program) =>
|
||||
renderProgram(program, start)
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Channel rows with program blocks */}
|
||||
{guideChannels.map((channel) => {
|
||||
const channelPrograms = programs.filter(
|
||||
(p) => p.tvg_id === channel.tvg_id
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
key={channel.channel_name}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
minHeight: PROGRAM_HEIGHT,
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
||||
{channelPrograms.map((program) =>
|
||||
renderProgram(program, start)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Modal for program details */}
|
||||
<Dialog
|
||||
open={Boolean(selectedProgram)}
|
||||
onClose={handleCloseModal}
|
||||
TransitionComponent={Transition}
|
||||
keepMounted
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: MODAL_WIDTH,
|
||||
height: MODAL_HEIGHT,
|
||||
m: 'auto',
|
||||
backgroundColor: '#1a202c',
|
||||
border: '2px solid #718096',
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiDialog-container': {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{selectedProgram && (
|
||||
<>
|
||||
<DialogTitle sx={{ color: '#fff' }}>
|
||||
{selectedProgram.title}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ color: '#a0aec0' }}>
|
||||
<Typography variant="caption" display="block">
|
||||
{dayjs(selectedProgram.start_time).format('h:mma')} - {dayjs(selectedProgram.end_time).format('h:mma')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#fff' }}>
|
||||
{selectedProgram.description || 'No description available.'}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseModal} sx={{ color: '#38b2ac' }}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue