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.
154 lines
5.4 KiB
TypeScript
154 lines
5.4 KiB
TypeScript
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>
|
|
)
|
|
}
|