Files
unified-media-manager/frontend/src/pages/Calendar.tsx
2026-04-24 10:45:19 -07:00

234 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { fetchAPI } from '../api/client'
import ErrorBanner from '../components/ErrorBanner'
interface CalendarEvent {
id: number
media_type: string
title: string
date: string
year: number | null
status: string
poster_url: string
}
interface CalendarResponse {
data: CalendarEvent[]
month: string
}
const typeColors: Record<string, { bg: string; border: string; text: string }> = {
movie: { bg: 'bg-indigo-600/20', border: 'border-l-indigo-500', text: 'text-indigo-300' },
series: { bg: 'bg-emerald-600/20', border: 'border-l-emerald-500', text: 'text-emerald-300' },
music: { bg: 'bg-amber-600/20', border: 'border-l-amber-500', text: 'text-amber-300' },
audiobook: { bg: 'bg-rose-600/20', border: 'border-l-rose-500', text: 'text-rose-300' },
podcast: { bg: 'bg-violet-600/20', border: 'border-l-violet-500', text: 'text-violet-300' },
}
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
export default function Calendar() {
const navigate = useNavigate()
const now = useMemo(() => new Date(), [])
const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth())
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const monthParam = `${year}-${String(month + 1).padStart(2, '0')}`
const fetchData = useCallback(() => {
setLoading(true)
setError(null)
fetchAPI<CalendarResponse>(`/api/calendar?month=${monthParam}`)
.then(res => {
setEvents(res.data ?? [])
})
.catch(err => setError(err.message || 'Failed to load calendar events'))
.finally(() => setLoading(false))
}, [monthParam])
useEffect(() => {
fetchData()
}, [fetchData])
const goToPrevMonth = () => {
if (month === 0) {
setMonth(11)
setYear(y => y - 1)
} else {
setMonth(m => m - 1)
}
}
const goToNextMonth = () => {
if (month === 11) {
setMonth(0)
setYear(y => y + 1)
} else {
setMonth(m => m + 1)
}
}
const goToToday = () => {
setYear(now.getFullYear())
setMonth(now.getMonth())
}
const handleEventClick = (event: CalendarEvent) => {
navigate(`/library/${event.media_type}/${event.id}`)
}
// Calendar grid calculations
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const today = new Date()
const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month
const todayDate = today.getDate()
// Group events by day
const eventsByDay = useMemo(() => {
const map = new Map<number, CalendarEvent[]>()
for (const event of events) {
const day = parseInt(event.date.split('-')[2] ?? '0', 10)
if (day > 0) {
const existing = map.get(day) ?? []
existing.push(event)
map.set(day, existing)
}
}
return map
}, [events])
// Build the 42-cell grid (6 rows × 7 cols)
const cells: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
cells.push(null)
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push(d)
}
while (cells.length < 42) {
cells.push(null)
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-semibold text-gray-100">Calendar</h2>
<p className="text-gray-500 text-sm">Upcoming release dates for monitored media</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={goToToday}
className="px-3 py-1.5 text-sm font-medium bg-gray-800 border border-gray-700 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
Today
</button>
<div className="flex items-center gap-2">
<button
onClick={goToPrevMonth}
aria-label="Previous month"
className="p-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-gray-100 font-medium min-w-[140px] text-center">
{monthNames[month]} {year}
</span>
<button
onClick={goToNextMonth}
aria-label="Next month"
className="p-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
{error && <ErrorBanner error={error} onRetry={fetchData} />}
{/* Day-of-week headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{dayLabels.map(d => (
<div key={d} className="text-center text-xs font-medium text-gray-500 py-2">
{d}
</div>
))}
</div>
{loading ? (
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: 42 }).map((_, i) => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg min-h-[100px] p-2 animate-pulse">
<div className="h-3 w-6 bg-gray-800 rounded" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-7 gap-1">
{cells.map((day, idx) => {
if (day === null) {
return <div key={`empty-${idx}`} className="min-h-[100px]" />
}
const dayEvents = eventsByDay.get(day) ?? []
const isToday = isCurrentMonth && day === todayDate
const maxVisible = 3
return (
<div
key={`day-${day}`}
className="bg-gray-900 border border-gray-800 rounded-lg min-h-[100px] p-2"
>
<span
className={`text-xs font-medium ${
isToday
? 'text-indigo-400 ring-1 ring-indigo-500 rounded-full w-5 h-5 flex items-center justify-center'
: 'text-gray-500'
}`}
>
{day}
</span>
<div className="mt-1 space-y-0.5">
{dayEvents.slice(0, maxVisible).map(event => {
const colors = typeColors[event.media_type] ?? { bg: 'bg-gray-600/20', border: 'border-l-gray-500', text: 'text-gray-300' }
return (
<button
key={event.id}
onClick={() => handleEventClick(event)}
className={`w-full text-left text-xs px-1.5 py-0.5 rounded border-l-2 ${colors.bg} ${colors.border} ${colors.text} hover:brightness-125 transition-all line-clamp-1 cursor-pointer`}
title={event.title}
>
{event.title}
</button>
)
})}
{dayEvents.length > maxVisible && (
<span className="text-xs text-gray-500 pl-1">
+{dayEvents.length - maxVisible} more
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}