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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user