2026-04-23 23:40:34 -03:00
|
|
|
"use client"
|
|
|
|
|
|
2026-04-28 00:48:42 -03:00
|
|
|
import { useEffect, useState, useCallback, useRef } from "react"
|
2026-04-26 03:02:31 -03:00
|
|
|
import { Plus, Download, RefreshCw, Settings, X, LogOut } from "lucide-react"
|
2026-04-28 00:48:42 -03:00
|
|
|
import { cn } from "@/lib/utils"
|
2026-04-23 23:40:34 -03:00
|
|
|
import { StreamCard } from "@/components/StreamCard"
|
2026-04-27 17:56:10 -03:00
|
|
|
import { Toggle } from "@/components/Toggle"
|
2026-04-23 23:40:34 -03:00
|
|
|
import type { Stream } from "@/types/stream"
|
2026-04-25 03:24:20 -03:00
|
|
|
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"
|
|
|
|
|
import type { DragEndEvent } from "@dnd-kit/core"
|
|
|
|
|
import { SortableContext, useSortable, rectSortingStrategy, arrayMove } from "@dnd-kit/sortable"
|
|
|
|
|
import { CSS } from "@dnd-kit/utilities"
|
2026-04-23 23:40:34 -03:00
|
|
|
|
2026-04-25 03:24:20 -03:00
|
|
|
type CardSize = "mini" | "sm" | "md" | "lg"
|
2026-04-27 17:56:10 -03:00
|
|
|
type GlobalPrefs = { pureMode: boolean; newTab: boolean; autoReload: boolean; reloadInterval: number }
|
|
|
|
|
|
|
|
|
|
const DEFAULT_GLOBAL_PREFS: GlobalPrefs = { pureMode: false, newTab: false, autoReload: false, reloadInterval: 2 }
|
2026-04-25 03:24:20 -03:00
|
|
|
|
2026-04-28 00:48:42 -03:00
|
|
|
const CARD_WIDTHS: Record<CardSize, string> = { mini: "sm:max-w-[200px]", sm: "sm:max-w-[240px]", md: "sm:max-w-[300px]", lg: "sm:max-w-[380px]" }
|
2026-04-25 03:24:20 -03:00
|
|
|
|
|
|
|
|
function SortableStreamCard(props: {
|
|
|
|
|
stream: Stream
|
|
|
|
|
status?: Record<string, string>
|
|
|
|
|
localStatus?: string | null
|
|
|
|
|
cardSize: CardSize
|
|
|
|
|
onRefresh: () => void
|
|
|
|
|
onLocalStatus: (id: string, s: string | null) => void
|
2026-04-27 17:56:10 -03:00
|
|
|
globalPrefs: GlobalPrefs
|
2026-04-25 03:24:20 -03:00
|
|
|
}) {
|
|
|
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.stream.id })
|
|
|
|
|
const style = { transform: CSS.Transform.toString(transform), transition }
|
|
|
|
|
return (
|
|
|
|
|
<div ref={setNodeRef} style={style} className={`w-full ${CARD_WIDTHS[props.cardSize]}`}>
|
|
|
|
|
<StreamCard
|
|
|
|
|
{...props}
|
|
|
|
|
dragHandleListeners={listeners}
|
|
|
|
|
dragHandleAttributes={attributes}
|
|
|
|
|
isDragging={isDragging}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-04-24 23:08:42 -03:00
|
|
|
|
|
|
|
|
function SkeletonCard({ size = "sm" }: { size?: CardSize }) {
|
2026-04-28 00:48:42 -03:00
|
|
|
const widths = { mini: "sm:max-w-[200px]", sm: "sm:max-w-[240px]", md: "sm:max-w-[300px]", lg: "sm:max-w-[380px]" }
|
2026-04-24 23:08:42 -03:00
|
|
|
return (
|
|
|
|
|
<div className={`rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full ${widths[size]} animate-pulse`}>
|
|
|
|
|
<div className="flex justify-between items-start">
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<div className="h-4 w-28 bg-muted rounded" />
|
|
|
|
|
<div className="h-3 w-16 bg-muted rounded" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-3 w-12 bg-muted rounded" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-3 w-full bg-muted rounded" />
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
{[...Array(4)].map((_, i) => <div key={i} className="h-8 w-full bg-muted rounded" />)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 17:56:10 -03:00
|
|
|
function SettingsPopup({ cardSize, onCardSize, globalPrefs, onGlobalPrefs, authEnabled, onDownloadPlaylist, onLogout, onClose }: {
|
2026-04-24 23:08:42 -03:00
|
|
|
cardSize: CardSize
|
|
|
|
|
onCardSize: (s: CardSize) => void
|
2026-04-27 17:56:10 -03:00
|
|
|
globalPrefs: GlobalPrefs
|
|
|
|
|
onGlobalPrefs: (patch: Partial<GlobalPrefs>) => void
|
|
|
|
|
authEnabled: boolean
|
|
|
|
|
onDownloadPlaylist: () => void
|
|
|
|
|
onLogout: () => void
|
2026-04-24 23:08:42 -03:00
|
|
|
onClose: () => void
|
|
|
|
|
}) {
|
2026-04-27 17:56:10 -03:00
|
|
|
const toggleRow = "flex items-center gap-2 w-full px-1 py-1.5 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer text-sm"
|
|
|
|
|
|
2026-04-24 23:08:42 -03:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
|
|
|
|
|
<div className="fixed top-16 right-6 z-50 w-64 rounded-lg border border-border shadow-2xl p-4 flex flex-col gap-4" style={{ background: "#1c1c1c" }}>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<p className="text-sm font-semibold">Settings</p>
|
|
|
|
|
<button onClick={onClose} className="p-1 rounded hover:bg-[#2a2a2a] cursor-pointer transition-colors">
|
|
|
|
|
<X className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-28 00:48:42 -03:00
|
|
|
<div className="hidden sm:flex flex-col gap-2">
|
2026-04-27 17:56:10 -03:00
|
|
|
<p className="text-xs text-muted-foreground tracking-wider uppercase">Card size</p>
|
2026-04-24 23:08:42 -03:00
|
|
|
<div className="flex gap-2">
|
2026-04-25 03:24:20 -03:00
|
|
|
{(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => (
|
2026-04-24 23:08:42 -03:00
|
|
|
<button
|
|
|
|
|
key={s}
|
|
|
|
|
onClick={() => onCardSize(s)}
|
2026-04-25 03:24:20 -03:00
|
|
|
className="flex-1 py-1.5 rounded border text-xs transition-colors cursor-pointer"
|
|
|
|
|
style={cardSize === s
|
|
|
|
|
? { background: "#ededed", color: "#0a0a0a", borderColor: "#ededed" }
|
|
|
|
|
: {}}
|
2026-04-24 23:08:42 -03:00
|
|
|
>
|
2026-04-25 03:24:20 -03:00
|
|
|
{s === "mini" ? "Mini" : s === "sm" ? "Small" : s === "md" ? "Medium" : "Big"}
|
2026-04-24 23:08:42 -03:00
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-27 17:56:10 -03:00
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<p className="text-xs text-muted-foreground tracking-wider uppercase">Cards</p>
|
|
|
|
|
<button onClick={() => onGlobalPrefs({ pureMode: !globalPrefs.pureMode })} className={toggleRow}>
|
|
|
|
|
<Toggle on={globalPrefs.pureMode} />
|
|
|
|
|
<span>Pure mode</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={() => onGlobalPrefs({ newTab: !globalPrefs.newTab })} className={toggleRow}>
|
|
|
|
|
<Toggle on={globalPrefs.newTab} />
|
|
|
|
|
<span>Open in new tab</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<p className="text-xs text-muted-foreground tracking-wider uppercase">Player</p>
|
|
|
|
|
<button onClick={() => onGlobalPrefs({ autoReload: !globalPrefs.autoReload })} className={toggleRow}>
|
|
|
|
|
<Toggle on={globalPrefs.autoReload} />
|
|
|
|
|
<span>Auto-reload</span>
|
|
|
|
|
</button>
|
|
|
|
|
{globalPrefs.autoReload && (
|
|
|
|
|
<div className="flex items-center gap-2 px-1 py-1">
|
|
|
|
|
<span className="text-xs text-muted-foreground">Interval</span>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min={1}
|
|
|
|
|
value={globalPrefs.reloadInterval}
|
|
|
|
|
onChange={(e) => onGlobalPrefs({ reloadInterval: Math.max(1, Number(e.target.value) || 1) })}
|
|
|
|
|
className="w-14 px-2 py-1 text-xs rounded border border-border bg-muted text-center"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-xs text-muted-foreground">min</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="border-t border-border pt-3 flex flex-col gap-1">
|
|
|
|
|
<button onClick={onDownloadPlaylist} className="flex items-center gap-2 w-full px-1 py-1.5 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer text-sm">
|
|
|
|
|
<Download className="w-3.5 h-3.5" /> Download playlist
|
|
|
|
|
</button>
|
|
|
|
|
{authEnabled && (
|
|
|
|
|
<button onClick={onLogout} className="flex items-center gap-2 w-full px-1 py-1.5 rounded hover:bg-[#2a2a2a] transition-colors cursor-pointer text-sm text-muted-foreground">
|
|
|
|
|
<LogOut className="w-3.5 h-3.5" /> Sign out
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-24 23:08:42 -03:00
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 23:40:34 -03:00
|
|
|
export default function GalleryPage() {
|
|
|
|
|
const [streams, setStreams] = useState<Stream[]>([])
|
|
|
|
|
const [statuses, setStatuses] = useState<Record<string, Record<string, string>>>({})
|
2026-04-24 23:08:42 -03:00
|
|
|
const [localStatuses, setLocalStatuses] = useState<Record<string, string | null>>({})
|
2026-04-23 23:40:34 -03:00
|
|
|
const [loading, setLoading] = useState(true)
|
2026-04-24 23:08:42 -03:00
|
|
|
const [refreshing, setRefreshing] = useState(false)
|
2026-04-28 00:48:42 -03:00
|
|
|
const [spinningFab, setSpinningFab] = useState(false)
|
|
|
|
|
const spinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
2026-04-27 17:56:10 -03:00
|
|
|
const [cardSize, setCardSize] = useState<CardSize>("md")
|
2026-04-24 23:08:42 -03:00
|
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
2026-04-26 03:02:31 -03:00
|
|
|
const [authEnabled, setAuthEnabled] = useState(false)
|
2026-04-27 17:56:10 -03:00
|
|
|
const [globalPrefs, setGlobalPrefs] = useState<GlobalPrefs>(DEFAULT_GLOBAL_PREFS)
|
2026-04-23 23:40:34 -03:00
|
|
|
|
2026-04-25 03:24:20 -03:00
|
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
|
|
|
|
|
|
|
|
|
async function handleDragEnd(event: DragEndEvent) {
|
|
|
|
|
const { active, over } = event
|
|
|
|
|
if (!over || active.id === over.id) return
|
|
|
|
|
const oldIndex = streams.findIndex((s) => s.id === active.id)
|
|
|
|
|
const newIndex = streams.findIndex((s) => s.id === over.id)
|
|
|
|
|
const reordered = arrayMove(streams, oldIndex, newIndex)
|
|
|
|
|
setStreams(reordered)
|
|
|
|
|
await fetch("/api/streams/reorder", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ ids: reordered.map((s) => s.id) }),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 23:08:42 -03:00
|
|
|
useEffect(() => {
|
2026-04-27 17:56:10 -03:00
|
|
|
try {
|
|
|
|
|
const savedSize = localStorage.getItem("cardSize") as CardSize | null
|
|
|
|
|
if (savedSize) setCardSize(savedSize)
|
|
|
|
|
const savedPrefs = localStorage.getItem("global-prefs")
|
|
|
|
|
if (savedPrefs) setGlobalPrefs({ ...DEFAULT_GLOBAL_PREFS, ...JSON.parse(savedPrefs) })
|
|
|
|
|
} catch {}
|
2026-04-24 23:08:42 -03:00
|
|
|
}, [])
|
|
|
|
|
|
2026-04-27 17:56:10 -03:00
|
|
|
function updateGlobalPrefs(patch: Partial<GlobalPrefs>) {
|
|
|
|
|
setGlobalPrefs(prev => {
|
|
|
|
|
const next = { ...prev, ...patch }
|
|
|
|
|
localStorage.setItem("global-prefs", JSON.stringify(next))
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 23:08:42 -03:00
|
|
|
const fetchStreams = useCallback(async (manual = false) => {
|
|
|
|
|
if (manual) setRefreshing(true)
|
2026-04-23 23:40:34 -03:00
|
|
|
const res = await fetch("/api/streams")
|
|
|
|
|
const data: Stream[] = await res.json()
|
|
|
|
|
setStreams(data)
|
|
|
|
|
setLoading(false)
|
2026-04-24 23:08:42 -03:00
|
|
|
if (manual) setRefreshing(false)
|
2026-04-23 23:40:34 -03:00
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const fetchStatuses = useCallback(async (list: Stream[]) => {
|
2026-04-24 23:08:42 -03:00
|
|
|
if (list.length === 0) return
|
2026-04-27 10:41:23 -03:00
|
|
|
const res = await fetch("/api/streams/statuses")
|
|
|
|
|
const data: Record<string, Record<string, string>> = await res.json()
|
|
|
|
|
setStatuses(data)
|
2026-04-23 23:40:34 -03:00
|
|
|
}, [])
|
|
|
|
|
|
2026-04-26 03:02:31 -03:00
|
|
|
useEffect(() => {
|
|
|
|
|
fetchStreams()
|
|
|
|
|
fetch("/api/auth/status").then(r => r.json()).then(d => setAuthEnabled(d.enabled))
|
|
|
|
|
}, [fetchStreams])
|
2026-04-23 23:40:34 -03:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (streams.length === 0) return
|
|
|
|
|
fetchStatuses(streams)
|
|
|
|
|
const interval = setInterval(() => fetchStatuses(streams), 10000)
|
|
|
|
|
return () => clearInterval(interval)
|
|
|
|
|
}, [streams, fetchStatuses])
|
|
|
|
|
|
2026-04-24 23:08:42 -03:00
|
|
|
const setLocalStatus = useCallback((id: string, s: string | null) => {
|
|
|
|
|
setLocalStatuses((prev) => ({ ...prev, [id]: s }))
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-04-28 00:48:42 -03:00
|
|
|
async function handleFabRefresh() {
|
|
|
|
|
if (spinTimerRef.current) clearTimeout(spinTimerRef.current)
|
|
|
|
|
setSpinningFab(true)
|
|
|
|
|
const t0 = Date.now()
|
|
|
|
|
await fetchStreams(true)
|
|
|
|
|
const remaining = Math.max(0, 1000 - (Date.now() - t0))
|
|
|
|
|
spinTimerRef.current = setTimeout(() => setSpinningFab(false), remaining)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 23:40:34 -03:00
|
|
|
function downloadPlaylist() {
|
2026-04-24 23:08:42 -03:00
|
|
|
window.location.href = `/api/streams/playlist?host=${window.location.hostname}&port=8888`
|
2026-04-23 23:40:34 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 23:08:42 -03:00
|
|
|
const showSkeleton = loading || refreshing
|
|
|
|
|
|
|
|
|
|
const btnBase = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-border hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer"
|
|
|
|
|
const btnPrimary = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-primary bg-primary text-primary-foreground hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors cursor-pointer"
|
|
|
|
|
|
2026-04-23 23:40:34 -03:00
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen flex flex-col">
|
2026-04-28 00:48:42 -03:00
|
|
|
<header className="border-b border-border px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between gap-2">
|
|
|
|
|
<div className="flex items-center gap-2 min-w-0 overflow-hidden">
|
|
|
|
|
<img src="/web-app-manifest-192x192.png" alt="Decap Stream" className="w-6 h-6 rounded shrink-0" />
|
|
|
|
|
<h1 className="text-lg font-semibold tracking-tight whitespace-nowrap">Decap Stream</h1>
|
|
|
|
|
<span className="text-muted-foreground/40 text-sm select-none whitespace-nowrap">·</span>
|
|
|
|
|
<a href="https://riguetto.dev" target="_blank" rel="noopener noreferrer" className="text-xs text-[#888] hover:text-[#ededed] hover:underline transition-colors whitespace-nowrap">riguetto.dev</a>
|
2026-04-25 03:24:20 -03:00
|
|
|
</div>
|
2026-04-28 00:48:42 -03:00
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
<button onClick={handleFabRefresh} className={cn(btnBase, "hidden sm:flex")} title="Refresh">
|
|
|
|
|
<RefreshCw className={`w-3.5 h-3.5 ${spinningFab ? "animate-spin" : ""}`} />
|
2026-04-23 23:40:34 -03:00
|
|
|
</button>
|
2026-04-24 23:08:42 -03:00
|
|
|
<button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings">
|
|
|
|
|
<Settings className="w-3.5 h-3.5" />
|
|
|
|
|
</button>
|
2026-04-28 00:48:42 -03:00
|
|
|
<button onClick={() => window.location.href = "/streams/new"} className={cn(btnPrimary, "hidden sm:flex")} title="New stream">
|
2026-04-27 17:56:10 -03:00
|
|
|
<Plus className="w-3.5 h-3.5" />
|
2026-04-23 23:40:34 -03:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
2026-04-24 23:08:42 -03:00
|
|
|
{settingsOpen && (
|
|
|
|
|
<SettingsPopup
|
|
|
|
|
cardSize={cardSize}
|
2026-04-27 17:56:10 -03:00
|
|
|
onCardSize={(s) => { setCardSize(s); localStorage.setItem("cardSize", s) }}
|
|
|
|
|
globalPrefs={globalPrefs}
|
|
|
|
|
onGlobalPrefs={updateGlobalPrefs}
|
|
|
|
|
authEnabled={authEnabled}
|
|
|
|
|
onDownloadPlaylist={downloadPlaylist}
|
|
|
|
|
onLogout={async () => { await fetch("/api/auth/logout", { method: "POST" }); window.location.href = "/login" }}
|
2026-04-24 23:08:42 -03:00
|
|
|
onClose={() => setSettingsOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-28 00:48:42 -03:00
|
|
|
<main className="flex-1 px-3 pt-3 pb-40 sm:p-6">
|
2026-04-24 23:08:42 -03:00
|
|
|
{showSkeleton ? (
|
2026-04-28 00:48:42 -03:00
|
|
|
<div className="flex flex-wrap gap-3 sm:gap-4 justify-center sm:justify-start">
|
2026-04-24 23:08:42 -03:00
|
|
|
{[...Array(refreshing ? Math.max(streams.length, 1) : 4)].map((_, i) => (
|
|
|
|
|
<SkeletonCard key={i} size={cardSize} />
|
|
|
|
|
))}
|
2026-04-23 23:40:34 -03:00
|
|
|
</div>
|
|
|
|
|
) : streams.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center h-64 gap-3 text-muted-foreground">
|
2026-04-24 23:08:42 -03:00
|
|
|
<p className="text-sm">No streams configured.</p>
|
|
|
|
|
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary}>
|
|
|
|
|
<Plus className="w-3.5 h-3.5" /> New stream
|
2026-04-23 23:40:34 -03:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-04-25 03:24:20 -03:00
|
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
|
|
|
<SortableContext items={streams.map((s) => s.id)} strategy={rectSortingStrategy}>
|
2026-04-28 00:48:42 -03:00
|
|
|
<div className="flex flex-wrap gap-3 sm:gap-4 justify-center sm:justify-start">
|
2026-04-25 03:24:20 -03:00
|
|
|
{streams.map((s) => (
|
|
|
|
|
<SortableStreamCard
|
|
|
|
|
key={s.id}
|
|
|
|
|
stream={s}
|
|
|
|
|
status={statuses[s.id]}
|
|
|
|
|
localStatus={localStatuses[s.id] ?? null}
|
|
|
|
|
cardSize={cardSize}
|
|
|
|
|
onRefresh={() => fetchStreams()}
|
|
|
|
|
onLocalStatus={setLocalStatus}
|
2026-04-27 17:56:10 -03:00
|
|
|
globalPrefs={globalPrefs}
|
2026-04-25 03:24:20 -03:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</SortableContext>
|
|
|
|
|
</DndContext>
|
2026-04-23 23:40:34 -03:00
|
|
|
)}
|
|
|
|
|
</main>
|
2026-04-28 00:48:42 -03:00
|
|
|
|
|
|
|
|
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-center gap-3 sm:hidden">
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleFabRefresh}
|
|
|
|
|
className="w-12 h-12 rounded-full border border-[#333] bg-[#1c1c1c] shadow-lg flex items-center justify-center active:bg-[#333] transition-colors"
|
|
|
|
|
title="Refresh"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className={`w-4 h-4 ${spinningFab ? "animate-spin" : ""}`} />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => window.location.href = "/streams/new"}
|
|
|
|
|
className="w-14 h-14 rounded-full border border-[#333] bg-[#1c1c1c] shadow-lg flex items-center justify-center active:bg-[#333] transition-colors"
|
|
|
|
|
title="New stream"
|
|
|
|
|
>
|
|
|
|
|
<Plus className="w-6 h-6" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-04-23 23:40:34 -03:00
|
|
|
</div>
|
|
|
|
|
)
|
2026-04-27 17:56:10 -03:00
|
|
|
}
|