Adiciona features de ordenação, drag-and-drop e melhorias de UX
--- - Adicionado campo order em Stream e migração automática em readStreams() para ordenação persistida no streams.json; - Implementado drag-and-drop de cards com @dnd-kit/core + @dnd-kit/sortable, com faixa de drag dedicada no topo de cada card; - Adicionado endpoint PUT /api/streams/reorder para persistir a nova ordem no servidor; - Atualizada playlist M3U para respeitar a ordem dos cards e incluir tvg-chno com número de canal; - Corrigida geração de thumbnail para capturar via ffmpeg -f x11grab direto do Xvfb, usando arquivo temporário thumb.tmp.jpg; - Adicionada política gerenciada do Chromium no Dockerfile para suprimir diálogo de salvar senha; - Adicionadas flags --password-store=basic e --disable-features=PasswordManagerRedesign no template do Chromium; - Substituído confirm() nativo por modal de confirmação customizado no delete de stream; - Adicionado tamanho mini e redefinidos os tamanhos de card; padrão alterado para md (300px); - Adicionado logo do projeto no header e ícone GripVertical na faixa de drag; - Erros de validação do formulário agora exibidos em vermelho negrito; ---
This commit is contained in:
+141
-63
@@ -1,17 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp } from "lucide-react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { MoreHorizontal, Play, Globe, Monitor, Pencil, RotateCcw, Square, Trash2, Circle, Copy, Check, Video, ImageUp, GripVertical } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Stream } from "@/types/stream"
|
||||
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"
|
||||
import type { DraggableAttributes } from "@dnd-kit/core"
|
||||
|
||||
interface Props {
|
||||
stream: Stream
|
||||
status?: Record<string, string>
|
||||
localStatus?: string | null
|
||||
cardSize?: "sm" | "md" | "lg"
|
||||
cardSize?: "mini" | "sm" | "md" | "lg"
|
||||
onRefresh: () => void
|
||||
onLocalStatus: (id: string, s: string | null) => void
|
||||
dragHandleListeners?: SyntheticListenerMap
|
||||
dragHandleAttributes?: DraggableAttributes
|
||||
isDragging?: boolean
|
||||
}
|
||||
|
||||
function StatusBadge({ status, localStatus }: { status?: Record<string, string>; localStatus?: string | null }) {
|
||||
@@ -51,14 +56,46 @@ function copyToClipboard(text: string) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const CARD_WIDTHS = { sm: "max-w-[200px]", md: "max-w-[240px]", lg: "max-w-[300px]" }
|
||||
const CARD_WIDTHS = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" }
|
||||
|
||||
export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRefresh, onLocalStatus }: Props) {
|
||||
function ConfirmDeleteModal({ name, onConfirm, onCancel }: { name: string; onConfirm: () => void; onCancel: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onCancel} />
|
||||
<div className="relative z-10 w-80 rounded-xl border border-border shadow-2xl p-6 flex flex-col gap-4" style={{ background: "#1c1c1c" }}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-semibold text-sm">Delete stream</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete <span className="text-foreground font-medium">{name}</span>? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-1.5 rounded border border-border text-sm hover:bg-[#2a2a2a] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-1.5 rounded border border-destructive bg-destructive/10 text-destructive text-sm hover:bg-destructive hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StreamCard({ stream, status, localStatus, cardSize = "md", onRefresh, onLocalStatus, dragHandleListeners, dragHandleAttributes, isDragging }: Props) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [thumbKey, setThumbKey] = useState(0)
|
||||
const [thumbError, setThumbError] = useState(false)
|
||||
const [thumbCapturing, setThumbCapturing] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!thumbError || thumbCapturing) return
|
||||
@@ -66,6 +103,8 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
|
||||
return () => clearInterval(interval)
|
||||
}, [thumbError, thumbCapturing])
|
||||
|
||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||
|
||||
async function action(act: string, optimisticStatus: string) {
|
||||
onLocalStatus(stream.id, optimisticStatus)
|
||||
setMenuOpen(false)
|
||||
@@ -75,7 +114,12 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Delete stream "${stream.name}"?`)) return
|
||||
setMenuOpen(false)
|
||||
setConfirmDelete(true)
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
setConfirmDelete(false)
|
||||
await fetch(`/api/streams/${stream.id}`, { method: "DELETE" })
|
||||
onRefresh()
|
||||
}
|
||||
@@ -95,14 +139,22 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
|
||||
|
||||
async function refreshThumb() {
|
||||
setMenuOpen(false)
|
||||
setThumbCapturing(true)
|
||||
setThumbError(false)
|
||||
setThumbCapturing(true)
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
await fetch(`/api/streams/${stream.id}/thumb`, { method: "POST" })
|
||||
// 5s delay in backend + a few seconds for ffmpeg to process
|
||||
setTimeout(() => {
|
||||
setThumbKey((k) => k + 1)
|
||||
setThumbCapturing(false)
|
||||
}, 9000)
|
||||
const deadline = Date.now() + 30000
|
||||
pollRef.current = setInterval(async () => {
|
||||
const res = await fetch(`/api/streams/${stream.id}/thumb?t=${Date.now()}`, { cache: "no-store" })
|
||||
if (res.ok) {
|
||||
clearInterval(pollRef.current!); pollRef.current = null
|
||||
setThumbKey((k) => k + 1)
|
||||
setThumbCapturing(false)
|
||||
} else if (Date.now() >= deadline) {
|
||||
clearInterval(pollRef.current!); pollRef.current = null
|
||||
setThumbCapturing(false)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function play(mode: string) {
|
||||
@@ -113,22 +165,46 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
|
||||
const menuItem = "w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
|
||||
|
||||
return (
|
||||
<div className={cn("relative rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full", CARD_WIDTHS[cardSize])}>
|
||||
<>
|
||||
{confirmDelete && (
|
||||
<ConfirmDeleteModal
|
||||
name={stream.name}
|
||||
onConfirm={confirmRemove}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("relative rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full transition-opacity", CARD_WIDTHS[cardSize], isDragging && "opacity-40")}>
|
||||
|
||||
{/* Drag handle strip */}
|
||||
{(dragHandleListeners || dragHandleAttributes) && (
|
||||
<div
|
||||
{...dragHandleListeners}
|
||||
{...dragHandleAttributes}
|
||||
className="-mx-3 -mt-3 h-7 flex items-center justify-center rounded-t-lg cursor-grab active:cursor-grabbing hover:bg-white/[0.05] transition-colors border-b border-border/40 group"
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground/30 group-hover:text-muted-foreground/65 transition-colors" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="w-full aspect-video rounded overflow-hidden bg-muted flex items-center justify-center">
|
||||
<div className="w-full aspect-video rounded overflow-hidden bg-muted flex items-center justify-center relative">
|
||||
{thumbCapturing ? (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">Capturing...</span>
|
||||
) : thumbError ? (
|
||||
<Video className="w-5 h-5 text-muted-foreground/25" />
|
||||
) : (
|
||||
<img
|
||||
key={thumbKey}
|
||||
src={`/api/streams/${stream.id}/thumb?t=${thumbKey}`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setThumbError(true)}
|
||||
onLoad={() => setThumbError(false)}
|
||||
/>
|
||||
<>
|
||||
{thumbError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Video className="w-5 h-5 text-muted-foreground/25" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
key={thumbKey}
|
||||
src={`/api/streams/${stream.id}/thumb?t=${thumbKey}`}
|
||||
className={cn("w-full h-full object-cover", thumbError && "invisible")}
|
||||
onError={() => setThumbError(true)}
|
||||
onLoad={() => setThumbError(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -139,9 +215,47 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<StatusBadge status={status} localStatus={localStatus} />
|
||||
<button onClick={() => setMenuOpen((v) => !v)} className="p-1 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button onClick={() => setMenuOpen((v) => !v)} className="p-1 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute top-full right-0 mt-1 z-50 min-w-[180px] rounded-lg border border-border shadow-2xl overflow-hidden"
|
||||
style={{ background: "#1c1c1c" }}>
|
||||
<button onClick={() => { setMenuOpen(false); window.location.href = `/streams/${stream.id}/edit` }} className={menuItem}>
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
<button onClick={() => action("restart", "restarting")} className={menuItem}>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> Restart
|
||||
</button>
|
||||
{status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (
|
||||
<button onClick={() => action("stop", "stopping")} className={menuItem}>
|
||||
<Square className="w-3.5 h-3.5" /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => action("start", "starting")} className={menuItem}>
|
||||
<Play className="w-3.5 h-3.5" /> Start
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { setMenuOpen(false); copyRTMP() }} className={menuItem}>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? "Copied!" : "Copy RTMP"}
|
||||
</button>
|
||||
<div className="border-t border-border" />
|
||||
<button onClick={refreshThumb} disabled={thumbCapturing} className={cn(menuItem, thumbCapturing && "opacity-50")}>
|
||||
<ImageUp className="w-3.5 h-3.5" />
|
||||
{thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
|
||||
</button>
|
||||
<div className="border-t border-border" />
|
||||
<button onClick={remove} className={cn(menuItem, "text-destructive")}>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,43 +266,7 @@ export function StreamCard({ stream, status, localStatus, cardSize = "sm", onRef
|
||||
<button onClick={() => play("html")} className={playBtn}><Globe className="w-3 h-3 shrink-0" /> Run HTML</button>
|
||||
<button onClick={openVNC} className={playBtn}><Monitor className="w-3 h-3 shrink-0" /> Open VNC</button>
|
||||
</div>
|
||||
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute top-10 right-2 z-50 min-w-[180px] rounded-lg border border-border shadow-2xl overflow-hidden"
|
||||
style={{ background: "#1c1c1c" }}>
|
||||
<button onClick={() => { setMenuOpen(false); window.location.href = `/streams/${stream.id}/edit` }} className={menuItem}>
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
<button onClick={() => action("restart", "restarting")} className={menuItem}>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> Restart
|
||||
</button>
|
||||
{status?.ffmpeg === "RUNNING" || localStatus === "restarting" ? (
|
||||
<button onClick={() => action("stop", "stopping")} className={menuItem}>
|
||||
<Square className="w-3.5 h-3.5" /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => action("start", "starting")} className={menuItem}>
|
||||
<Play className="w-3.5 h-3.5" /> Start
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { setMenuOpen(false); copyRTMP() }} className={menuItem}>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? "Copied!" : "Copy RTMP"}
|
||||
</button>
|
||||
<div className="border-t border-border" />
|
||||
<button onClick={refreshThumb} disabled={thumbCapturing} className={cn(menuItem, thumbCapturing && "opacity-50")}>
|
||||
<ImageUp className="w-3.5 h-3.5" />
|
||||
{thumbCapturing ? "Capturing..." : "Refresh thumbnail"}
|
||||
</button>
|
||||
<div className="border-t border-border" />
|
||||
<button onClick={() => { setMenuOpen(false); remove() }} className={cn(menuItem, "text-destructive")}>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user