Adiciona layout mobile responsivo e ícones PWA

---

- Adicionado FABs flutuantes (Refresh e Add) no canto inferior direito para telas abaixo de 640px;
- Ocultados botões de Refresh e Add do header no mobile (hidden sm:flex);
- Cards passam a ocupar largura total no mobile (sm:max-w-[...] com prefixo responsivo em page.tsx e StreamCard.tsx);
- Adicionado overflow-x: hidden no body para eliminar scroll horizontal no mobile;
- Seletor de tamanho de card ocultado no popup de Settings no mobile (hidden sm:flex);
- Adicionados ícones apple-touch-icon.png e apple-touch-icon-dark.png em public/ para instalação como PWA;
- Registrados os ícones no metadata do layout com suporte a light/dark via media query;
- Corrigido estilo do toggle de GPU no StreamForm para transição mais consistente;
- Reduzido shm_size de 2gb para 1gb no exemplo do compose no CHANGELOG.md;
- Atualizada seção de screenshots no README para incluir dashboard-config.png em layout de 3 colunas;

---
This commit is contained in:
2026-04-28 00:48:42 -03:00
parent 4918fa091e
commit 89ddf24021
13 changed files with 62 additions and 31 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

+1
View File
@@ -31,6 +31,7 @@ body {
background: var(--background);
color: var(--foreground);
margin: 0;
overflow-x: hidden;
}
button {
+6
View File
@@ -8,6 +8,12 @@ const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"]
export const metadata: Metadata = {
title: "DecapStream",
description: "Headless stream manager",
icons: {
apple: [
{ url: "/apple-touch-icon.png", sizes: "180x180" },
{ url: "/apple-touch-icon-dark.png", sizes: "180x180", media: "(prefers-color-scheme: dark)" },
],
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
+46 -17
View File
@@ -1,7 +1,8 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import { useEffect, useState, useCallback, useRef } from "react"
import { Plus, Download, RefreshCw, Settings, X, LogOut } from "lucide-react"
import { cn } from "@/lib/utils"
import { StreamCard } from "@/components/StreamCard"
import { Toggle } from "@/components/Toggle"
import type { Stream } from "@/types/stream"
@@ -15,7 +16,7 @@ type GlobalPrefs = { pureMode: boolean; newTab: boolean; autoReload: boolean; re
const DEFAULT_GLOBAL_PREFS: GlobalPrefs = { pureMode: false, newTab: false, autoReload: false, reloadInterval: 2 }
const CARD_WIDTHS: Record<CardSize, string> = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" }
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]" }
function SortableStreamCard(props: {
stream: Stream
@@ -41,7 +42,7 @@ function SortableStreamCard(props: {
}
function SkeletonCard({ size = "sm" }: { size?: CardSize }) {
const widths = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" }
const widths = { mini: "sm:max-w-[200px]", sm: "sm:max-w-[240px]", md: "sm:max-w-[300px]", lg: "sm:max-w-[380px]" }
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">
@@ -82,7 +83,7 @@ function SettingsPopup({ cardSize, onCardSize, globalPrefs, onGlobalPrefs, authE
</button>
</div>
<div className="flex flex-col gap-2">
<div className="hidden sm:flex flex-col gap-2">
<p className="text-xs text-muted-foreground tracking-wider uppercase">Card size</p>
<div className="flex gap-2">
{(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => (
@@ -154,6 +155,8 @@ export default function GalleryPage() {
const [localStatuses, setLocalStatuses] = useState<Record<string, string | null>>({})
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [spinningFab, setSpinningFab] = useState(false)
const spinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [cardSize, setCardSize] = useState<CardSize>("md")
const [settingsOpen, setSettingsOpen] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false)
@@ -224,6 +227,15 @@ export default function GalleryPage() {
setLocalStatuses((prev) => ({ ...prev, [id]: s }))
}, [])
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)
}
function downloadPlaylist() {
window.location.href = `/api/streams/playlist?host=${window.location.hostname}&port=8888`
}
@@ -235,21 +247,21 @@ export default function GalleryPage() {
return (
<div className="min-h-screen flex flex-col">
<header className="border-b border-border px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<img src="/web-app-manifest-192x192.png" alt="Decap Stream" className="w-6 h-6 rounded" />
<h1 className="text-lg font-semibold tracking-tight">Decap Stream</h1>
<span className="text-muted-foreground/40 text-sm select-none">·</span>
<a href="https://riguetto.dev" target="_blank" rel="noopener noreferrer" className="text-xs text-[#888] hover:text-[#ededed] hover:underline transition-colors">riguetto.dev</a>
<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>
</div>
<div className="flex items-center gap-2">
<button onClick={() => fetchStreams(true)} className={btnBase} title="Refresh">
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
<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" : ""}`} />
</button>
<button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings">
<Settings className="w-3.5 h-3.5" />
</button>
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary} title="New stream">
<button onClick={() => window.location.href = "/streams/new"} className={cn(btnPrimary, "hidden sm:flex")} title="New stream">
<Plus className="w-3.5 h-3.5" />
</button>
</div>
@@ -268,9 +280,9 @@ export default function GalleryPage() {
/>
)}
<main className="flex-1 p-6">
<main className="flex-1 px-3 pt-3 pb-40 sm:p-6">
{showSkeleton ? (
<div className="flex flex-wrap gap-4">
<div className="flex flex-wrap gap-3 sm:gap-4 justify-center sm:justify-start">
{[...Array(refreshing ? Math.max(streams.length, 1) : 4)].map((_, i) => (
<SkeletonCard key={i} size={cardSize} />
))}
@@ -285,7 +297,7 @@ export default function GalleryPage() {
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={streams.map((s) => s.id)} strategy={rectSortingStrategy}>
<div className="flex flex-wrap gap-4">
<div className="flex flex-wrap gap-3 sm:gap-4 justify-center sm:justify-start">
{streams.map((s) => (
<SortableStreamCard
key={s.id}
@@ -303,6 +315,23 @@ export default function GalleryPage() {
</DndContext>
)}
</main>
<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>
</div>
)
}