diff --git a/core/management/commands/dropdb.py b/core/management/commands/dropdb.py
new file mode 100644
index 00000000..1b39a58d
--- /dev/null
+++ b/core/management/commands/dropdb.py
@@ -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)
diff --git a/frontend/src/pages/Guide.js b/frontend/src/pages/Guide.js
index 77955963..4ec5bc69 100644
--- a/frontend/src/pages/Guide.js
+++ b/frontend/src/pages/Guide.js
@@ -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 ;
+});
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 (
- //
-
-
- {program.title}
-
-
- {programStart.format('h:mma')} - {programEnd.format('h:mma')}
-
-
- //
- );
- };
-
+ // 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 (
+ handleProgramClick(program, e)}
+ >
+
+
+ {program.title}
+
+
+ {programStart.format('h:mma')} - {programEnd.format('h:mma')}
+
+
+
+ );
+ };
return (
{
overflow: 'hidden',
width: '100%',
height: '100%',
- backgroundColor: '#171923',
+ backgroundColor: '#1a202c',
+ color: '#fff',
+ fontFamily: 'Roboto, sans-serif',
}}
>
-
-
- Channels
- }
- renderValue={(selected) => selected.join(', ')}
- MenuProps={MenuProps}
- size="small"
- >
- {guideChannels.map((channel) => (
-
- ))}
-
-
+ {/* Sticky top bar */}
+
+
+ TV Guide
+
+
+ {now.format('dddd, MMMM D, YYYY • h:mm A')}
+
+ {/* Main layout */}
-
- {/* Channel Column */}
+ {/* Channel Logos Column */}
+
- {guideChannels
- .filter((channel) => activeChannels.includes(channel.channel_name))
- .map((channel, index) => {
- return (
- (
+
+
+
-
-
- {/*
- {channel.channel_name}
- */}
-
-
- );
- })}
+ />
+
+
+ ))}
- {/* Timeline and Lineup */}
+ {/* Timeline & Program Blocks */}
+ {/* Sticky timeline header */}
- {timeline.map((time, index) => (
+ {hourTimeline.map((time, hourIndex) => (
{time.format('h:mma')}
-
-
-
-
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
))}
+ {/* Now-position line */}
- {nowPosition > 0 && (
+ {nowPosition >= 0 && (
)}
- {guideChannels
- .filter((channel) =>
- activeChannels.includes(channel.channel_name)
- )
- .map((channel, index) => {
- const channelPrograms = programs.filter(
- (p) => p.tvg_id === channel.tvg_id
- );
- return (
-
-
- {channelPrograms.map((program) =>
- renderProgram(program, start)
- )}
-
+
+ {/* Channel rows with program blocks */}
+ {guideChannels.map((channel) => {
+ const channelPrograms = programs.filter(
+ (p) => p.tvg_id === channel.tvg_id
+ );
+ return (
+
+
+ {channelPrograms.map((program) =>
+ renderProgram(program, start)
+ )}
- );
- })}
+
+ );
+ })}
+
+ {/* Modal for program details */}
+
);
};