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