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
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useRef, useState } from 'react'
import api from '../lib/api'
import { Upload, Trash2, ImageIcon, Info } from 'lucide-react'
export default function Settings() {
const [hasImage, setHasImage] = useState(false)
const [imageUrl, setImageUrl] = useState('')
const [uploading, setUploading] = useState(false)
const [dragging, setDragging] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const checkImage = async () => {
try {
const token = localStorage.getItem('token')
const res = await fetch('/api/admin/settings/blocked-image', {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
setHasImage(true)
// Use the static file URL (no auth needed) for the <img> tag
setImageUrl('/panel/blocked_image.jpg?t=' + Date.now())
} else {
setHasImage(false)
setImageUrl('')
}
} catch {
setHasImage(false)
}
}
useEffect(() => { checkImage() }, [])
const upload = async (file: File) => {
if (!file.type.startsWith('image/')) {
alert('Solo se permiten imágenes (JPEG, PNG, WebP, GIF)')
return
}
setUploading(true)
try {
const form = new FormData()
form.append('file', file)
await api.post('/settings/blocked-image', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
await checkImage()
} catch (e: any) {
alert('Error al subir la imagen: ' + (e.response?.data?.detail || e.message))
} finally {
setUploading(false)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) upload(file)
e.target.value = ''
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files[0]
if (file) upload(file)
}
const deleteImage = async () => {
if (!confirm('¿Eliminar la imagen de bloqueo?')) return
await api.delete('/settings/blocked-image')
setHasImage(false)
setImageUrl('')
}
return (
<div className="p-6 max-w-2xl space-y-6">
<h1 className="text-xl font-bold">Ajustes</h1>
{/* Blocked image section */}
<div className="bg-dark-800 rounded-xl p-6 space-y-4">
<div>
<h2 className="font-semibold text-base flex items-center gap-2">
<ImageIcon size={18} className="text-primary-400" />
Imagen de conexión bloqueada
</h2>
<p className="text-sm text-gray-400 mt-1">
Esta imagen se mostrará como canal de vídeo cuando un usuario intente reproducir
en un segundo dispositivo habiendo alcanzado su límite de conexiones simultáneas.
</p>
</div>
<div className="flex items-start gap-2 text-xs text-blue-400 bg-blue-500/10 rounded-lg px-3 py-2">
<Info size={13} className="mt-0.5 shrink-0" />
<span>
El zapping normal (cambiar de canal en el mismo dispositivo) sigue funcionando
sin interrupciones. La imagen solo aparece cuando se conecta un segundo dispositivo
diferente con la misma cuenta.
</span>
</div>
{/* Upload area */}
<div
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => !uploading && fileRef.current?.click()}
className={`
relative border-2 border-dashed rounded-xl cursor-pointer transition-colors
${dragging ? 'border-primary-400 bg-primary-500/10' : 'border-dark-600 hover:border-dark-500'}
`}
>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
{hasImage && imageUrl ? (
<div className="relative">
<img
src={imageUrl}
alt="Blocked image"
className="w-full rounded-xl object-contain max-h-72"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center">
<span className="text-white text-sm font-medium">Haz clic para cambiar</span>
</div>
</div>
) : (
<div className="py-12 flex flex-col items-center gap-3 text-gray-500">
<Upload size={32} className={dragging ? 'text-primary-400' : ''} />
<div className="text-center">
<p className="font-medium text-sm">Arrastra una imagen o haz clic para subir</p>
<p className="text-xs mt-1">JPEG, PNG, WebP o GIF · máx. 10 MB</p>
</div>
</div>
)}
{uploading && (
<div className="absolute inset-0 bg-dark-900/80 rounded-xl flex items-center justify-center">
<div className="text-sm text-primary-400">Subiendo...</div>
</div>
)}
</div>
{hasImage && (
<div className="flex justify-end">
<button
onClick={deleteImage}
className="flex items-center gap-2 px-3 py-1.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded-lg text-sm transition-colors"
>
<Trash2 size={14} /> Eliminar imagen
</button>
</div>
)}
</div>
</div>
)
}