From d53a4f9297016c832e9163ce57c9f3e924e38d6a Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 29 Aug 2026 02:11:40 +0200 Subject: [PATCH] Make admin UI mobile-first with bottom nav and installable PWA support. Co-authored-by: Cursor --- apps/admin-ui/next.config.js | 13 + .../public/icons/apple-touch-icon.png | Bin 0 -> 2287 bytes apps/admin-ui/public/icons/icon-192.png | Bin 0 -> 2430 bytes apps/admin-ui/public/icons/icon-512.png | Bin 0 -> 7756 bytes .../public/icons/icon-maskable-512.png | Bin 0 -> 8248 bytes apps/admin-ui/public/icons/icon.svg | 12 + apps/admin-ui/public/manifest.webmanifest | 39 +++ apps/admin-ui/public/sw.js | 58 ++++ apps/admin-ui/src/app/globals.css | 313 +++++++++++++++++- apps/admin-ui/src/app/layout.tsx | 46 ++- apps/admin-ui/src/components/Nav.tsx | 116 +++++-- apps/admin-ui/src/components/PwaRegister.tsx | 20 ++ 12 files changed, 578 insertions(+), 39 deletions(-) create mode 100644 apps/admin-ui/public/icons/apple-touch-icon.png create mode 100644 apps/admin-ui/public/icons/icon-192.png create mode 100644 apps/admin-ui/public/icons/icon-512.png create mode 100644 apps/admin-ui/public/icons/icon-maskable-512.png create mode 100644 apps/admin-ui/public/icons/icon.svg create mode 100644 apps/admin-ui/public/manifest.webmanifest create mode 100644 apps/admin-ui/public/sw.js create mode 100644 apps/admin-ui/src/components/PwaRegister.tsx 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 0000000000000000000000000000000000000000..272534d350d93348aaaac2d00d4364d3fccd3ad6 GIT binary patch literal 2287 zcmd5;X;hO*7LA8RWM5qfpg=1FBs2&uLfC==fo@hiNDL4$LfFDi5|)4x0Ub6s99e=H zKpI)qgs_D;2O4N)3lak+4XA8E2q++%Xy%JEf94#2&&-diI=AZ8t@~cpd9QBxc)2Ob zYs-T`ASHLiSwCQ8NSd51(Ay8mo&<({iGFU*pr&D+MF2?S;GS?0=t+^nUWg2U55yr* zi6D@2kEHGEkFC870?A!-KMVhr6fFAFF;5qHxc{T3ex<(asmCEMSxtw1fGTd-_o;P;I ze*Mp7V%RWMDY%Wa_)xaCH+v-$bT6OgtCi)7l+#A)hyR-W9dMh%-kl426POGxcjm-3 z3*KmE<)lr@{&rbr@{1)+v&Awi=jTYAw9?GkaiON~TotdUt-mK?HiA1nMfo&~va-y*#~KOau*|RAz>b8L zEw+SD3Y<8?JJtzm;%=Vr>?8viyAW6d`nclcb}n!`T= zuR6U%nBhiEXTCz80VsR}Np|A}1P_w>%bLa&=0U{s|I-E+yOanZvzs0j(;a={}aPhXpCcIjNRu}8>=+_^0Z#S zLU}C}dP3M$wSpo2btWwK;*h$TW4djeUH#5{2WioZF0M{v+3nxz?7v^cf9;V~v6OoY zJfsMpcGNrVWo$VJa~4Ja1V5^0FKjGYS8IkV@g`Z1Bp@t*{7@xO({xA+zcasDshX^+KCJN~{a`uRF5L zwxym=$IK3{+g(!Tri?wJHbR7cf_8D_ppVN`yi{(Gopq%TC9#P*m*##6cF7RA9tC2u>3Hh&ob=GFoW!dD7U8YQ9*FW zbgP8WMfQqUEV*+6DvqFwIoiP<$FS#zu3y4j>sjzBu`c4Hknr526^qF>fMYjpwT#1+fp)5+aT!;av!CzJqAp9Vct4EdQx@W*pLn!e8w)g4rcO4A){Xhd+_iD;oxa(8`5q3Rw4M8{nU-@#}mzM zWFJ_)ZTON_OsYWb*-PSdY-x)N2MY*O=I`F6ZLy)FcPcZpt%vv~hmy9cFW9!wUr?BC z7Mlt;gz@_ekB~=>Wa(8A{K=eoBN_9@Ssi zo=qErq2ZiOb3&_bjz-8VsRo6Xq3D%_KH?7ht1%cNbC%l_@j;2QZALC4AS#4A%u{2` zMG6WGw8uzJH7LUB|6*r{u+kPF`OEnLDVQ5bd z@AN5e=(}cWhc*y|-Yn)6%1bHJJ;*@vnDK#zfpe9uyNjxx{T2-x6Dtt3NxMfqwb4Yl z%_hM3jJ}?dZXx3N1u1a-IVoOYU9c9 zx##eK$CGlu(3$E|z6)k2;-?fNaZ8F9SbP){d^Li1PZhC3yaMTF0nQf7+9#uRvEq=@ zFwek5^zQP*@O2YF95`uBBxw6avf)(W&D~`ISHJ@&y<_%te49NfWwRx6n`a#WjCGQ_ zk!JBhz(Z5NT1~y5;xQOiMPRk#E1Q3-Vuz$XRd?&5W}$@yZ|KLKUySOfq7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9251f4926618f3eef10914d440133bf995ad2a05 GIT binary patch literal 2430 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 E0=nUbApigX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ac99d29d26409d3cbcf6cfeca828d66b366f3044 GIT binary patch literal 8248 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 ZZs%drxONZTFo+vjy7>D=ISV%I|92IB6iEO8 literal 0 HcmV?d00001 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; +}