diff --git a/apps/admin-ui/next.config.js b/apps/admin-ui/next.config.js index bf4df47..07e547b 100644 --- a/apps/admin-ui/next.config.js +++ b/apps/admin-ui/next.config.js @@ -2,6 +2,19 @@ const nextConfig = { output: process.env.DOCKER_BUILD === "1" ? "standalone" : undefined, // API calls go through src/app/api/[...path] runtime proxy (not build-time rewrites) + headers: async () => [ + { + source: "/sw.js", + headers: [ + { key: "Cache-Control", value: "public, max-age=0, must-revalidate" }, + { key: "Service-Worker-Allowed", value: "/" }, + ], + }, + { + source: "/manifest.webmanifest", + headers: [{ key: "Content-Type", value: "application/manifest+json" }], + }, + ], }; module.exports = nextConfig; diff --git a/apps/admin-ui/public/icons/apple-touch-icon.png b/apps/admin-ui/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..272534d Binary files /dev/null and b/apps/admin-ui/public/icons/apple-touch-icon.png differ diff --git a/apps/admin-ui/public/icons/icon-192.png b/apps/admin-ui/public/icons/icon-192.png new file mode 100644 index 0000000..9251f49 Binary files /dev/null and b/apps/admin-ui/public/icons/icon-192.png differ diff --git a/apps/admin-ui/public/icons/icon-512.png b/apps/admin-ui/public/icons/icon-512.png new file mode 100644 index 0000000..2b13863 Binary files /dev/null and b/apps/admin-ui/public/icons/icon-512.png differ diff --git a/apps/admin-ui/public/icons/icon-maskable-512.png b/apps/admin-ui/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..ac99d29 Binary files /dev/null and b/apps/admin-ui/public/icons/icon-maskable-512.png differ diff --git a/apps/admin-ui/public/icons/icon.svg b/apps/admin-ui/public/icons/icon.svg new file mode 100644 index 0000000..07f2204 --- /dev/null +++ b/apps/admin-ui/public/icons/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + MC + + diff --git a/apps/admin-ui/public/manifest.webmanifest b/apps/admin-ui/public/manifest.webmanifest new file mode 100644 index 0000000..70f8d67 --- /dev/null +++ b/apps/admin-ui/public/manifest.webmanifest @@ -0,0 +1,39 @@ +{ + "name": "Media Cluster Ops", + "short_name": "MC Ops", + "description": "Private media cluster admin console", + "start_url": "/dashboard", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#04060a", + "theme_color": "#04060a", + "lang": "nl", + "categories": ["utilities", "productivity"], + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/apps/admin-ui/public/sw.js b/apps/admin-ui/public/sw.js new file mode 100644 index 0000000..accda46 --- /dev/null +++ b/apps/admin-ui/public/sw.js @@ -0,0 +1,58 @@ +/* Media Cluster Ops — lightweight PWA service worker */ +const CACHE = "mc-ops-v1"; +const PRECACHE = ["/", "/dashboard", "/manifest.webmanifest", "/icons/icon.svg"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + // Never cache API / auth — always network + if (url.pathname.startsWith("/api/")) return; + + // Navigations: network-first, fallback cache + if (req.mode === "navigate") { + event.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + return res; + }) + .catch(() => caches.match(req).then((r) => r || caches.match("/dashboard"))) + ); + return; + } + + // Static assets: stale-while-revalidate + event.respondWith( + caches.match(req).then((cached) => { + const network = fetch(req) + .then((res) => { + if (res.ok) { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + } + return res; + }) + .catch(() => cached); + return cached || network; + }) + ); +}); diff --git a/apps/admin-ui/src/app/globals.css b/apps/admin-ui/src/app/globals.css index b0ea0a6..e06765d 100644 --- a/apps/admin-ui/src/app/globals.css +++ b/apps/admin-ui/src/app/globals.css @@ -24,6 +24,11 @@ --font-mono: var(--font-jetbrains), ui-monospace, monospace; --radius: 10px; --shadow-panel: 0 0 0 1px var(--border), 0 18px 50px rgba(0, 0, 0, 0.45); + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-left: env(safe-area-inset-left, 0px); + --safe-right: env(safe-area-inset-right, 0px); + --bottomnav-h: 0px; } * { @@ -41,9 +46,14 @@ body { background: var(--bg-void); color: var(--text); min-height: 100vh; + min-height: 100dvh; line-height: 1.45; -webkit-font-smoothing: antialiased; position: relative; + padding-bottom: var(--bottomnav-h); + overflow-x: hidden; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; } /* Atmosphere: grid + radial vignette */ @@ -96,7 +106,9 @@ a:hover { .container { max-width: 1280px; margin: 0 auto; - padding: 1.75rem 1.5rem 3rem; + padding: 1.75rem 1.5rem calc(3rem + var(--bottomnav-h)); + padding-left: max(1.5rem, var(--safe-left)); + padding-right: max(1.5rem, var(--safe-right)); } .page-header { @@ -202,6 +214,9 @@ a:hover { align-items: center; gap: 0.35rem; padding: 0.7rem 1.25rem; + padding-top: max(0.7rem, var(--safe-top)); + padding-left: max(1.25rem, var(--safe-left)); + padding-right: max(1.25rem, var(--safe-right)); background: rgba(7, 11, 18, 0.82); backdrop-filter: blur(14px); border-bottom: 1px solid var(--border); @@ -228,11 +243,13 @@ a:hover { letter-spacing: 0.04em; color: var(--text-bright); text-decoration: none; + min-width: 0; } .topnav .brand-mark { width: 28px; height: 28px; + flex-shrink: 0; border: 1px solid var(--border-strong); border-radius: 6px; display: grid; @@ -308,6 +325,52 @@ a:hover { background: rgba(255, 255, 255, 0.03); } +.nav-burger { + display: none; + margin-left: auto; + width: 42px; + height: 42px; + padding: 0; + border-radius: 8px; + border: 1px solid var(--border-dim); + background: transparent; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; +} + +.nav-burger span { + display: block; + width: 18px; + height: 2px; + background: var(--text-bright); + border-radius: 1px; + transition: transform 0.2s, opacity 0.2s; +} + +.nav-burger.open span:nth-child(1) { + transform: translateY(7px) rotate(45deg); +} +.nav-burger.open span:nth-child(2) { + opacity: 0; +} +.nav-burger.open span:nth-child(3) { + transform: translateY(-7px) rotate(-45deg); +} + +.nav-drawer-backdrop { + display: none; +} + +.nav-drawer { + display: none; +} + +.bottomnav { + display: none; +} + /* ——— Tables ——— */ .table-wrap, .card > table { @@ -623,26 +686,254 @@ pre.token-display { box-shadow: 0 0 10px rgba(34, 211, 238, 0.35); } -@media (max-width: 720px) { - .topnav { - flex-wrap: wrap; - padding: 0.65rem 0.85rem; +@media (max-width: 900px) { + :root { + --bottomnav-h: calc(64px + var(--safe-bottom)); } - .topnav .btn-logout { - margin-left: 0; + .topnav-links-desktop, + .topnav-logout-desktop { + display: none !important; + } + + .nav-burger { + display: flex; + } + + .topnav .brand-text small { + display: none; + } + + .nav-drawer-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 60; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(2px); + } + + .nav-drawer { + display: flex; + flex-direction: column; + position: fixed; + top: 0; + right: 0; + z-index: 70; + width: min(320px, 88vw); + height: 100%; + height: 100dvh; + padding: max(1rem, var(--safe-top)) max(1rem, var(--safe-right)) max(1rem, var(--safe-bottom)) 1rem; + background: var(--bg-panel); + border-left: 1px solid var(--border); + box-shadow: -20px 0 60px rgba(0, 0, 0, 0.45); + transform: translateX(105%); + transition: transform 0.22s ease; + gap: 1rem; + } + + .nav-drawer.open { + transform: translateX(0); + } + + .nav-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + + .nav-drawer-links { + display: flex; + flex-direction: column; + gap: 0.35rem; + flex: 1; + overflow-y: auto; + } + + .nav-drawer-links a { + color: var(--text-muted); + padding: 0.85rem 1rem; + border-radius: 8px; + border: 1px solid transparent; + font-weight: 600; + font-size: 0.95rem; + } + + .nav-drawer-links a.active { + color: var(--accent); + background: var(--accent-dim); + border-color: var(--border); + text-decoration: none; + } + + .nav-drawer-logout { width: 100%; } - .topnav-links { - width: 100%; + .bottomnav { + display: grid; + grid-template-columns: repeat(6, 1fr); + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 55; + height: var(--bottomnav-h); + padding: 0.35rem 0.25rem max(0.35rem, var(--safe-bottom)); + padding-left: max(0.25rem, var(--safe-left)); + padding-right: max(0.25rem, var(--safe-right)); + background: rgba(7, 11, 18, 0.92); + backdrop-filter: blur(16px); + border-top: 1px solid var(--border); + } + + .bottomnav a { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2rem; + color: var(--text-dim); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.02em; + text-decoration: none; + border-radius: 8px; + min-height: 48px; + } + + .bottomnav a .bottomnav-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: transparent; + } + + .bottomnav a.active { + color: var(--accent); + background: var(--accent-dim); + text-decoration: none; + } + + .bottomnav a.active .bottomnav-dot { + background: var(--accent); + box-shadow: 0 0 8px var(--accent); } .container { - padding: 1.25rem 1rem 2.5rem; + padding: 1.1rem 0.9rem calc(1.25rem + var(--bottomnav-h)); + padding-left: max(0.9rem, var(--safe-left)); + padding-right: max(0.9rem, var(--safe-right)); + } + + .page-header { + flex-direction: column; + align-items: stretch; + gap: 0.85rem; + } + + .page-header h1, + .container > h1 { + font-size: 1.35rem; + } + + .page-header form, + .page-header > button { + width: 100%; + } + + .page-header form { + flex-wrap: wrap; + } + + .page-header form input { + flex: 1; + min-width: 0; + width: auto !important; } .grid { - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; + } + + .grid .card .value { + font-size: 1.35rem; + } + + .card { + padding: 1rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + /* Tables scroll horizontally on small screens */ + .card > table, + .table-wrap { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + table { + min-width: 640px; + } + + th, + td { + padding: 0.65rem 0.7rem; + font-size: 0.82rem; + } + + button, + .btn { + min-height: 42px; + } + + input, + select, + textarea { + font-size: 16px; /* voorkomt iOS zoom bij focus */ + min-height: 44px; + } + + .login-container { + padding: max(1rem, var(--safe-top)) max(1rem, var(--safe-right)) max(1rem, var(--safe-bottom)) max(1rem, var(--safe-left)); + align-items: flex-start; + padding-top: max(2.5rem, calc(var(--safe-top) + 1.5rem)); + } + + .login-box { + padding: 1.5rem 1.25rem; + } + + .token-display { + font-size: 0.72rem; + } +} + +@media (max-width: 480px) { + .grid { + grid-template-columns: 1fr; + } + + .bottomnav a { + font-size: 0.58rem; + } + + .topnav .brand-text { + font-size: 0.85rem; + } +} + +/* Prefer reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; } } diff --git a/apps/admin-ui/src/app/layout.tsx b/apps/admin-ui/src/app/layout.tsx index 797f521..800c65a 100644 --- a/apps/admin-ui/src/app/layout.tsx +++ b/apps/admin-ui/src/app/layout.tsx @@ -1,5 +1,6 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { JetBrains_Mono, Outfit } from "next/font/google"; +import { PwaRegister } from "@/components/PwaRegister"; import "./globals.css"; const outfit = Outfit({ @@ -15,14 +16,53 @@ const jetbrains = JetBrains_Mono({ }); export const metadata: Metadata = { - title: "Media Cluster — Ops", + title: { + default: "Media Cluster — Ops", + template: "%s · MC Ops", + }, description: "Private media cluster admin console", + applicationName: "Media Cluster Ops", + manifest: "/manifest.webmanifest", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "MC Ops", + }, + formatDetection: { + telephone: false, + }, + icons: { + icon: [ + { url: "/icons/icon.svg", type: "image/svg+xml" }, + { url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + apple: [{ url: "/icons/apple-touch-icon.png", sizes: "180x180", type: "image/png" }], + }, + other: { + "mobile-web-app-capable": "yes", + }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: dark)", color: "#04060a" }, + { media: "(prefers-color-scheme: light)", color: "#04060a" }, + ], + colorScheme: "dark", + width: "device-width", + initialScale: 1, + maximumScale: 5, + viewportFit: "cover", }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + + {children} + ); } diff --git a/apps/admin-ui/src/components/Nav.tsx b/apps/admin-ui/src/components/Nav.tsx index 7c3f819..b3b21e2 100644 --- a/apps/admin-ui/src/components/Nav.tsx +++ b/apps/admin-ui/src/components/Nav.tsx @@ -1,47 +1,113 @@ "use client"; import Link from "next/link"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; +const links = [ + { href: "/dashboard", label: "Home", short: "Home" }, + { href: "/nodes", label: "Nodes", short: "Nodes" }, + { href: "/movies", label: "Movies", short: "Films" }, + { href: "/series", label: "Series", short: "Series" }, + { href: "/streams", label: "Streams", short: "Live" }, + { href: "/settings", label: "Instellingen", short: "Inst." }, +]; + export function Nav() { const pathname = usePathname(); const router = useRouter(); + const [menuOpen, setMenuOpen] = useState(false); + + useEffect(() => { + setMenuOpen(false); + }, [pathname]); + + useEffect(() => { + if (!menuOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setMenuOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [menuOpen]); async function logout() { await fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" }); router.push("/"); } - const links = [ - { href: "/dashboard", label: "Dashboard" }, - { href: "/nodes", label: "Nodes" }, - { href: "/movies", label: "Movies" }, - { href: "/series", label: "Series" }, - { href: "/streams", label: "Streams" }, - { href: "/settings", label: "Instellingen" }, - ]; - return ( - + + ); } diff --git a/apps/admin-ui/src/components/PwaRegister.tsx b/apps/admin-ui/src/components/PwaRegister.tsx new file mode 100644 index 0000000..8a6b2b8 --- /dev/null +++ b/apps/admin-ui/src/components/PwaRegister.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; + +/** Registers the service worker for PWA install / offline shell. */ +export function PwaRegister() { + useEffect(() => { + if (typeof window === "undefined" || !("serviceWorker" in navigator)) return; + const onLoad = () => { + navigator.serviceWorker.register("/sw.js").catch(() => { + /* ignore — private mode / insecure context */ + }); + }; + if (document.readyState === "complete") onLoad(); + else window.addEventListener("load", onLoad); + return () => window.removeEventListener("load", onLoad); + }, []); + + return null; +}