Sync from /srv/compose/unified-media-manager
This commit is contained in:
233
frontend/src/pages/Calendar.tsx
Normal file
233
frontend/src/pages/Calendar.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user