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 - - + {/* 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} - - {channel.channel_name} - {/* - {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 */} + + {selectedProgram && ( + <> + + {selectedProgram.title} + + + + {dayjs(selectedProgram.start_time).format('h:mma')} - {dayjs(selectedProgram.end_time).format('h:mma')} + + + {selectedProgram.description || 'No description available.'} + + + + + + + )} + ); };