Sync from /srv/compose/unified-media-manager

This commit is contained in:
Christopher Mayor
2026-04-24 10:45:19 -07:00
commit 7dbd00e537
132 changed files with 25394 additions and 0 deletions

View 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>
)
}