Repo init
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { MoreHorizontal, Play, Globe, FileVideo, Monitor, Pencil, RotateCcw, Square, Trash2, Circle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Stream } from "@/types/stream"
|
||||
|
||||
interface Props {
|
||||
stream: Stream
|
||||
status?: Record<string, string>
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status?: Record<string, string> }) {
|
||||
if (!status) return null
|
||||
const ffmpeg = status.ffmpeg
|
||||
const color =
|
||||
ffmpeg === "RUNNING" ? "bg-green-500" :
|
||||
ffmpeg === "STARTING" ? "bg-yellow-500" :
|
||||
ffmpeg === "FATAL" ? "bg-red-500" :
|
||||
"bg-zinc-500"
|
||||
const label =
|
||||
ffmpeg === "RUNNING" ? "running" :
|
||||
ffmpeg === "STARTING" ? "starting" :
|
||||
ffmpeg === "FATAL" ? "error" :
|
||||
"stopped"
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Circle className={cn("w-2 h-2 fill-current", color)} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StreamCard({ stream, status, onRefresh }: Props) {
|
||||
const router = useRouter()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [loading, setLoading] = useState<string | null>(null)
|
||||
|
||||
async function action(act: string) {
|
||||
setLoading(act)
|
||||
await fetch(`/api/streams/${stream.id}/${act}`, { method: "POST" })
|
||||
setLoading(null)
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Deletar stream "${stream.name}"?`)) return
|
||||
await fetch(`/api/streams/${stream.id}`, { method: "DELETE" })
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
function openVNC() {
|
||||
const host = window.location.hostname
|
||||
window.open(`http://${host}:${stream.novncPort}/vnc.html`, "_blank")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border bg-card p-4 flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold truncate">{stream.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{stream.id}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={status} />
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="p-1 rounded hover:bg-accent transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<p className="text-xs text-muted-foreground truncate" title={stream.url}>{stream.url}</p>
|
||||
|
||||
{/* Play buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => router.push(`/player/${stream.id}?mode=hls`)}
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
<Play className="w-3 h-3" /> Play HLS
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/player/${stream.id}?mode=html`)}
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
<Globe className="w-3 h-3" /> Play HTML
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/player/${stream.id}?mode=m3u8`)}
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
<FileVideo className="w-3 h-3" /> Play m3u8
|
||||
</button>
|
||||
<button
|
||||
onClick={openVNC}
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded bg-accent hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
<Monitor className="w-3 h-3" /> Open VNC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute top-10 right-4 z-20 min-w-[160px] rounded-lg border border-border bg-card shadow-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); router.push(`/streams/${stream.id}/edit`) }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" /> Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); action("restart") }}
|
||||
disabled={!!loading}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> Restart
|
||||
</button>
|
||||
{status?.ffmpeg === "RUNNING" ? (
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); action("stop") }}
|
||||
disabled={!!loading}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); action("start") }}
|
||||
disabled={!!loading}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" /> Start
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-border" />
|
||||
<button
|
||||
onClick={() => { setMenuOpen(false); remove() }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-accent transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Deletar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { STREAM_DEFAULTS, type Stream, type StreamCreate, type StreamUpdate } from "@/types/stream"
|
||||
|
||||
interface Props {
|
||||
initial?: Stream
|
||||
}
|
||||
|
||||
const SLUG_RE = /^[a-z0-9-]+$/
|
||||
|
||||
const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium"]
|
||||
const TUNES = ["stillimage", "zerolatency", "film", "animation"]
|
||||
|
||||
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded border border-border bg-muted px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function StreamForm({ initial }: Props) {
|
||||
const router = useRouter()
|
||||
const isEdit = !!initial
|
||||
|
||||
const [form, setForm] = useState<StreamCreate>({
|
||||
id: initial?.id ?? "",
|
||||
name: initial?.name ?? "",
|
||||
url: initial?.url ?? "",
|
||||
user: initial?.user ?? "",
|
||||
pass: initial?.pass ?? "",
|
||||
...STREAM_DEFAULTS,
|
||||
...(initial ? {
|
||||
delay: initial.delay,
|
||||
resolution: initial.resolution,
|
||||
scale: initial.scale,
|
||||
fps: initial.fps,
|
||||
bitrate: initial.bitrate,
|
||||
bufsize: initial.bufsize,
|
||||
preset: initial.preset,
|
||||
tune: initial.tune,
|
||||
gop: initial.gop,
|
||||
} : {}),
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function set(key: keyof StreamCreate, value: string | number) {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
setErrors((e) => { const n = { ...e }; delete n[key]; return n })
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const e: Record<string, string> = {}
|
||||
if (!isEdit && !SLUG_RE.test(form.id)) e.id = "Apenas letras minúsculas, números e hífen"
|
||||
if (!form.name.trim()) e.name = "Obrigatório"
|
||||
if (!form.url.trim()) e.url = "Obrigatório"
|
||||
setErrors(e)
|
||||
return Object.keys(e).length === 0
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validate()) return
|
||||
setSaving(true)
|
||||
|
||||
if (isEdit) {
|
||||
const body: StreamUpdate = { ...form }
|
||||
await fetch(`/api/streams/${initial!.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} else {
|
||||
await fetch("/api/streams", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-border px-6 py-4 flex items-center gap-4">
|
||||
<button onClick={() => router.push("/")} className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
← Voltar
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold">{isEdit ? `Editar — ${initial!.name}` : "Nova stream"}</h1>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-6 max-w-2xl mx-auto w-full flex flex-col gap-8">
|
||||
|
||||
{/* Identificação */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Identificação</h2>
|
||||
<Field label="ID (slug)" error={errors.id}>
|
||||
<Input
|
||||
value={form.id}
|
||||
disabled={isEdit}
|
||||
placeholder="mapa-zabbix"
|
||||
onChange={(e) => set("id", e.target.value.toLowerCase())}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Nome" error={errors.name}>
|
||||
<Input value={form.name} placeholder="Mapa Zabbix" onChange={(e) => set("name", e.target.value)} />
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
{/* Fonte */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">Fonte</h2>
|
||||
<Field label="URL" error={errors.url}>
|
||||
<Input value={form.url} placeholder="https://..." onChange={(e) => set("url", e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Usuário (opcional)">
|
||||
<Input value={form.user ?? ""} onChange={(e) => set("user", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Senha (opcional)">
|
||||
<Input type="password" value={form.pass ?? ""} onChange={(e) => set("pass", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FFmpeg */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">FFmpeg / Xvfb</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Resolução (Xvfb/Chrome)">
|
||||
<Input value={form.resolution} placeholder="1920x1080" onChange={(e) => set("resolution", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Scale (stream output)">
|
||||
<Input value={form.scale} placeholder="1280:720" onChange={(e) => set("scale", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="FPS">
|
||||
<Input type="number" value={form.fps} onChange={(e) => set("fps", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Delay (s)">
|
||||
<Input type="number" value={form.delay} onChange={(e) => set("delay", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Bitrate">
|
||||
<Input value={form.bitrate} placeholder="1500k" onChange={(e) => set("bitrate", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Bufsize">
|
||||
<Input value={form.bufsize} placeholder="1500k" onChange={(e) => set("bufsize", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="GOP">
|
||||
<Input type="number" value={form.gop} onChange={(e) => set("gop", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Preset">
|
||||
<Select value={form.preset} onChange={(e) => set("preset", e.target.value)}>
|
||||
{PRESETS.map((p) => <option key={p}>{p}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Tune">
|
||||
<Select value={form.tune} onChange={(e) => set("tune", e.target.value)}>
|
||||
{TUNES.map((t) => <option key={t}>{t}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pb-8">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="px-4 py-2 rounded border border-border text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar stream"}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user