Initial commit: KiraTV IPTV proxy server

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.
This commit is contained in:
joaquin
2026-05-17 23:22:05 +02:00
commit f5d6745ed0
62 changed files with 8567 additions and 0 deletions
+337
View File
@@ -0,0 +1,337 @@
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>
)
}