f5d6745ed0
Full-stack IPTV management system with Xtream Codes API compatibility, multi-account provider fan-out, Jellyfin local library integration, real-time dashboard, EPG support, and A/V-sync-safe stream recovery.
338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import api from '../lib/api'
|
|
import { Plus, Trash2, Search, X, Tv, Film, Clapperboard, Star } from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
|
|
type CatType = 'live' | 'movie' | 'series'
|
|
|
|
interface Category {
|
|
id: number
|
|
name: string
|
|
type: CatType
|
|
is_visible: boolean
|
|
item_count: number
|
|
}
|
|
|
|
interface CategoryChannel {
|
|
item_id: number
|
|
channel_id: number
|
|
name: string
|
|
logo: string
|
|
type: string
|
|
}
|
|
|
|
interface SearchChannel {
|
|
id: number
|
|
name: string
|
|
logo: string
|
|
type: string
|
|
}
|
|
|
|
const TYPE_LABELS: Record<CatType, string> = { live: 'Directo', movie: 'Película', series: 'Serie' }
|
|
const TYPE_ICONS: Record<CatType, typeof Tv> = { live: Tv, movie: Film, series: Clapperboard }
|
|
const TYPE_COLORS: Record<CatType, string> = {
|
|
live: 'bg-blue-500/20 text-blue-400',
|
|
movie: 'bg-purple-500/20 text-purple-400',
|
|
series: 'bg-orange-500/20 text-orange-400',
|
|
}
|
|
|
|
export default function CustomCategories() {
|
|
const [categories, setCategories] = useState<Category[]>([])
|
|
const [selected, setSelected] = useState<Category | null>(null)
|
|
const [channels, setChannels] = useState<CategoryChannel[]>([])
|
|
const [searchQ, setSearchQ] = useState('')
|
|
const [searchType, setSearchType] = useState<CatType | ''>('')
|
|
const [searchResults, setSearchResults] = useState<SearchChannel[]>([])
|
|
const [showSearch, setShowSearch] = useState(false)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [newName, setNewName] = useState('')
|
|
const [newType, setNewType] = useState<CatType>('live')
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
const loadCategories = useCallback(async () => {
|
|
const { data } = await api.get('/custom-categories')
|
|
setCategories(data)
|
|
}, [])
|
|
|
|
const loadChannels = useCallback(async (cat: Category) => {
|
|
const { data } = await api.get(`/custom-categories/${cat.id}/channels`)
|
|
setChannels(data)
|
|
}, [])
|
|
|
|
useEffect(() => { loadCategories() }, [loadCategories])
|
|
|
|
useEffect(() => {
|
|
if (selected) loadChannels(selected)
|
|
}, [selected, loadChannels])
|
|
|
|
const selectCategory = (cat: Category) => {
|
|
setSelected(cat)
|
|
setShowSearch(false)
|
|
setSearchQ('')
|
|
setSearchResults([])
|
|
}
|
|
|
|
const createCategory = async () => {
|
|
if (!newName.trim()) return
|
|
await api.post('/custom-categories', { name: newName.trim(), type: newType })
|
|
setNewName('')
|
|
setShowCreate(false)
|
|
await loadCategories()
|
|
}
|
|
|
|
const deleteCategory = async (cat: Category) => {
|
|
if (!confirm(`¿Eliminar categoría "${cat.name}"?`)) return
|
|
await api.delete(`/custom-categories/${cat.id}`)
|
|
if (selected?.id === cat.id) setSelected(null)
|
|
await loadCategories()
|
|
}
|
|
|
|
const toggleVisible = async (cat: Category) => {
|
|
await api.patch(`/custom-categories/${cat.id}`, { is_visible: !cat.is_visible })
|
|
await loadCategories()
|
|
if (selected?.id === cat.id) setSelected({ ...cat, is_visible: !cat.is_visible })
|
|
}
|
|
|
|
const doSearch = useCallback(async (q: string, t: string) => {
|
|
if (!q && !t) { setSearchResults([]); return }
|
|
const { data } = await api.get('/custom-categories/channels/search', { params: { q, type: t } })
|
|
setSearchResults(data)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const id = setTimeout(() => doSearch(searchQ, searchType), 300)
|
|
return () => clearTimeout(id)
|
|
}, [searchQ, searchType, doSearch])
|
|
|
|
const addChannel = async (ch: SearchChannel) => {
|
|
if (!selected) return
|
|
try {
|
|
await api.post(`/custom-categories/${selected.id}/channels`, { channel_id: ch.id })
|
|
await loadChannels(selected)
|
|
await loadCategories()
|
|
} catch (e: any) {
|
|
if (e.response?.status !== 409) alert('Error al añadir canal')
|
|
}
|
|
}
|
|
|
|
const removeChannel = async (item: CategoryChannel) => {
|
|
if (!selected) return
|
|
await api.delete(`/custom-categories/${selected.id}/channels/${item.item_id}`)
|
|
await loadChannels(selected)
|
|
await loadCategories()
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full">
|
|
{/* Left: Category list */}
|
|
<aside className="w-72 border-r border-dark-700 flex flex-col bg-dark-800">
|
|
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Star size={18} className="text-yellow-400" />
|
|
<h2 className="font-semibold text-sm">Categorías personalizadas</h2>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
|
|
>
|
|
<Plus size={15} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Create form */}
|
|
{showCreate && (
|
|
<div className="p-3 border-b border-dark-700 bg-dark-900 space-y-2">
|
|
<input
|
|
autoFocus
|
|
value={newName}
|
|
onChange={e => setNewName(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && createCategory()}
|
|
placeholder="Nombre de la categoría"
|
|
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-primary-500"
|
|
/>
|
|
<select
|
|
value={newType}
|
|
onChange={e => setNewType(e.target.value as CatType)}
|
|
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none"
|
|
>
|
|
<option value="live">Directo</option>
|
|
<option value="movie">Película</option>
|
|
<option value="series">Serie</option>
|
|
</select>
|
|
<div className="flex gap-2">
|
|
<button onClick={createCategory} className="flex-1 bg-primary-500 hover:bg-primary-600 text-white text-xs py-1.5 rounded-lg transition-colors">
|
|
Crear
|
|
</button>
|
|
<button onClick={() => setShowCreate(false)} className="flex-1 bg-dark-700 hover:bg-dark-600 text-xs py-1.5 rounded-lg transition-colors">
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
|
{categories.length === 0 && (
|
|
<p className="text-xs text-gray-500 text-center py-8">No hay categorías personalizadas</p>
|
|
)}
|
|
{categories.map(cat => {
|
|
const Icon = TYPE_ICONS[cat.type]
|
|
return (
|
|
<div
|
|
key={cat.id}
|
|
onClick={() => selectCategory(cat)}
|
|
className={clsx(
|
|
'flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer group transition-colors',
|
|
selected?.id === cat.id ? 'bg-primary-500/20' : 'hover:bg-dark-700'
|
|
)}
|
|
>
|
|
<Icon size={14} className={TYPE_COLORS[cat.type].split(' ')[1]} />
|
|
<div className="flex-1 min-w-0">
|
|
<p className={clsx('text-sm font-medium truncate', selected?.id === cat.id ? 'text-primary-300' : 'text-gray-200')}>
|
|
{cat.name}
|
|
</p>
|
|
<p className="text-xs text-gray-500">{TYPE_LABELS[cat.type]} · {cat.item_count} canales</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => toggleVisible(cat)}
|
|
className={clsx('text-xs px-1.5 py-0.5 rounded', cat.is_visible ? 'text-green-400' : 'text-gray-600')}
|
|
title={cat.is_visible ? 'Visible (clic para ocultar)' : 'Oculto (clic para mostrar)'}
|
|
>
|
|
{cat.is_visible ? '●' : '○'}
|
|
</button>
|
|
<button onClick={() => deleteCategory(cat)} className="text-red-500 hover:text-red-400 p-0.5 rounded">
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Right: Channel list */}
|
|
<main className="flex-1 flex flex-col overflow-hidden">
|
|
{!selected ? (
|
|
<div className="flex-1 flex items-center justify-center text-gray-500">
|
|
<div className="text-center">
|
|
<Star size={40} className="mx-auto mb-3 text-gray-700" />
|
|
<p>Selecciona una categoría para gestionar sus canales</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Header */}
|
|
<div className="p-4 border-b border-dark-700 flex items-center justify-between bg-dark-800">
|
|
<div>
|
|
<h2 className="font-semibold flex items-center gap-2">
|
|
{(() => { const Icon = TYPE_ICONS[selected.type]; return <Icon size={16} className={TYPE_COLORS[selected.type].split(' ')[1]} /> })()}
|
|
{selected.name}
|
|
<span className={clsx('text-xs px-2 py-0.5 rounded-full', TYPE_COLORS[selected.type])}>{TYPE_LABELS[selected.type]}</span>
|
|
</h2>
|
|
<p className="text-xs text-gray-500 mt-0.5">{channels.length} canales asignados</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowSearch(!showSearch)}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 rounded-lg text-sm transition-colors"
|
|
>
|
|
<Plus size={14} /> Añadir canales
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search panel */}
|
|
{showSearch && (
|
|
<div className="border-b border-dark-700 bg-dark-900 p-4 space-y-3">
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
|
<input
|
|
autoFocus
|
|
value={searchQ}
|
|
onChange={e => setSearchQ(e.target.value)}
|
|
placeholder="Buscar canal..."
|
|
className="w-full bg-dark-700 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:border-primary-500"
|
|
/>
|
|
</div>
|
|
<select
|
|
value={searchType}
|
|
onChange={e => setSearchType(e.target.value as CatType | '')}
|
|
className="bg-dark-700 border border-dark-600 rounded-lg px-3 text-sm focus:outline-none"
|
|
>
|
|
<option value="">Todos los tipos</option>
|
|
<option value="live">Directo</option>
|
|
<option value="movie">Película</option>
|
|
<option value="series">Serie</option>
|
|
</select>
|
|
<button onClick={() => setShowSearch(false)} className="text-gray-500 hover:text-gray-300 p-2">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-1 max-h-48 overflow-y-auto">
|
|
{searchResults.map(ch => {
|
|
const already = channels.some(c => c.channel_id === ch.id)
|
|
return (
|
|
<button
|
|
key={ch.id}
|
|
onClick={() => !already && addChannel(ch)}
|
|
disabled={already}
|
|
className={clsx(
|
|
'flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors',
|
|
already ? 'opacity-40 cursor-not-allowed bg-dark-700' : 'bg-dark-700 hover:bg-dark-600 cursor-pointer'
|
|
)}
|
|
>
|
|
{ch.logo ? (
|
|
<img src={ch.logo} alt="" className="w-6 h-6 object-contain rounded" onError={e => (e.currentTarget.style.display = 'none')} />
|
|
) : (
|
|
<div className="w-6 h-6 bg-dark-600 rounded" />
|
|
)}
|
|
<span className="truncate flex-1">{ch.name}</span>
|
|
{already && <span className="text-xs text-green-500">✓</span>}
|
|
</button>
|
|
)
|
|
})}
|
|
{searchResults.length === 0 && (searchQ || searchType) && (
|
|
<p className="col-span-2 text-xs text-gray-500 text-center py-4">Sin resultados</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Channel list */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{channels.length === 0 ? (
|
|
<div className="text-center py-16 text-gray-500">
|
|
<p>Esta categoría está vacía.</p>
|
|
<p className="text-xs mt-1">Usa "Añadir canales" para añadir contenido.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-1">
|
|
{channels.map((ch, idx) => (
|
|
<div key={ch.item_id} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-dark-800 hover:bg-dark-700 group transition-colors">
|
|
<span className="text-xs text-gray-600 w-6 text-right">{idx + 1}</span>
|
|
{ch.logo ? (
|
|
<img src={ch.logo} alt="" className="w-8 h-8 object-contain rounded" onError={e => (e.currentTarget.style.display = 'none')} />
|
|
) : (
|
|
<div className="w-8 h-8 bg-dark-600 rounded" />
|
|
)}
|
|
<span className="flex-1 text-sm">{ch.name}</span>
|
|
<span className={clsx('text-xs px-1.5 py-0.5 rounded', TYPE_COLORS[ch.type as CatType] || 'bg-gray-700 text-gray-400')}>
|
|
{TYPE_LABELS[ch.type as CatType] || ch.type}
|
|
</span>
|
|
<button
|
|
onClick={() => removeChannel(ch)}
|
|
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-400 p-1 transition-all"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|