234 lines
7.9 KiB
TypeScript
234 lines
7.9 KiB
TypeScript
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>
|
||
)
|
||
}
|