Make admin UI mobile-first with bottom nav and installable PWA support.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-29 02:11:40 +02:00
parent f1b6923287
commit d53a4f9297
12 changed files with 578 additions and 39 deletions

View file

@ -2,6 +2,19 @@
const nextConfig = { const nextConfig = {
output: process.env.DOCKER_BUILD === "1" ? "standalone" : undefined, output: process.env.DOCKER_BUILD === "1" ? "standalone" : undefined,
// API calls go through src/app/api/[...path] runtime proxy (not build-time rewrites) // 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; module.exports = nextConfig;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Media Cluster">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0b1019"/>
<stop offset="100%" stop-color="#04060a"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="url(#g)"/>
<rect x="36" y="36" width="440" height="440" rx="80" fill="none" stroke="#22d3ee" stroke-opacity="0.35" stroke-width="8"/>
<text x="256" y="292" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="168" font-weight="700" fill="#22d3ee">MC</text>
<circle cx="400" cy="128" r="18" fill="#34d399"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

View file

@ -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"
}
]
}

View file

@ -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;
})
);
});

View file

@ -24,6 +24,11 @@
--font-mono: var(--font-jetbrains), ui-monospace, monospace; --font-mono: var(--font-jetbrains), ui-monospace, monospace;
--radius: 10px; --radius: 10px;
--shadow-panel: 0 0 0 1px var(--border), 0 18px 50px rgba(0, 0, 0, 0.45); --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); background: var(--bg-void);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
line-height: 1.45; line-height: 1.45;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
position: relative; position: relative;
padding-bottom: var(--bottomnav-h);
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
} }
/* Atmosphere: grid + radial vignette */ /* Atmosphere: grid + radial vignette */
@ -96,7 +106,9 @@ a:hover {
.container { .container {
max-width: 1280px; max-width: 1280px;
margin: 0 auto; 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 { .page-header {
@ -202,6 +214,9 @@ a:hover {
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
padding: 0.7rem 1.25rem; 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); background: rgba(7, 11, 18, 0.82);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@ -228,11 +243,13 @@ a:hover {
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--text-bright); color: var(--text-bright);
text-decoration: none; text-decoration: none;
min-width: 0;
} }
.topnav .brand-mark { .topnav .brand-mark {
width: 28px; width: 28px;
height: 28px; height: 28px;
flex-shrink: 0;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: 6px; border-radius: 6px;
display: grid; display: grid;
@ -308,6 +325,52 @@ a:hover {
background: rgba(255, 255, 255, 0.03); 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 ——— */ /* ——— Tables ——— */
.table-wrap, .table-wrap,
.card > table { .card > table {
@ -623,26 +686,254 @@ pre.token-display {
box-shadow: 0 0 10px rgba(34, 211, 238, 0.35); box-shadow: 0 0 10px rgba(34, 211, 238, 0.35);
} }
@media (max-width: 720px) { @media (max-width: 900px) {
.topnav { :root {
flex-wrap: wrap; --bottomnav-h: calc(64px + var(--safe-bottom));
padding: 0.65rem 0.85rem;
} }
.topnav .btn-logout { .topnav-links-desktop,
margin-left: 0; .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%; width: 100%;
} }
.topnav-links { .bottomnav {
width: 100%; 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 { .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 {
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;
} }
} }

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { JetBrains_Mono, Outfit } from "next/font/google"; import { JetBrains_Mono, Outfit } from "next/font/google";
import { PwaRegister } from "@/components/PwaRegister";
import "./globals.css"; import "./globals.css";
const outfit = Outfit({ const outfit = Outfit({
@ -15,14 +16,53 @@ const jetbrains = JetBrains_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Media Cluster — Ops", title: {
default: "Media Cluster — Ops",
template: "%s · MC Ops",
},
description: "Private media cluster admin console", 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 }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="nl" className={`${outfit.variable} ${jetbrains.variable}`}> <html lang="nl" className={`${outfit.variable} ${jetbrains.variable}`}>
<body>{children}</body> <body>
<PwaRegister />
{children}
</body>
</html> </html>
); );
} }

View file

@ -1,47 +1,113 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation"; 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() { export function Nav() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); 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() { async function logout() {
await fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" }); await fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" });
router.push("/"); 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 ( return (
<nav className="topnav"> <>
<Link href="/dashboard" className="brand"> <nav className="topnav">
<span className="brand-mark">MC</span> <Link href="/dashboard" className="brand">
<span className="brand-text"> <span className="brand-mark">MC</span>
Media Cluster <span className="brand-text">
<small>ops console</small> Media Cluster
</span> <small>ops console</small>
</Link> </span>
<div className="topnav-links"> </Link>
<div className="topnav-links topnav-links-desktop">
{links.map((l) => (
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
{l.label}
</Link>
))}
</div>
<button type="button" className="btn-logout topnav-logout-desktop" onClick={logout}>
Uitloggen
</button>
<button
type="button"
className={`nav-burger${menuOpen ? " open" : ""}`}
aria-label={menuOpen ? "Menu sluiten" : "Menu openen"}
aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)}
>
<span />
<span />
<span />
</button>
</nav>
{menuOpen && (
<div className="nav-drawer-backdrop" onClick={() => setMenuOpen(false)} aria-hidden />
)}
<aside className={`nav-drawer${menuOpen ? " open" : ""}`} aria-hidden={!menuOpen}>
<div className="nav-drawer-head">
<span className="page-kicker">Navigatie</span>
<button type="button" className="btn-logout" onClick={() => setMenuOpen(false)}>
Sluiten
</button>
</div>
<div className="nav-drawer-links">
{links.map((l) => (
<Link
key={l.href}
href={l.href}
className={pathname === l.href ? "active" : ""}
onClick={() => setMenuOpen(false)}
>
{l.label}
</Link>
))}
</div>
<button type="button" className="btn-logout nav-drawer-logout" onClick={logout}>
Uitloggen
</button>
</aside>
<nav className="bottomnav" aria-label="Hoofdnavigatie">
{links.map((l) => ( {links.map((l) => (
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}> <Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
{l.label} <span className="bottomnav-dot" aria-hidden />
<span>{l.short}</span>
</Link> </Link>
))} ))}
</div> </nav>
<button type="button" className="btn-logout" onClick={logout}> </>
Uitloggen
</button>
</nav>
); );
} }

View file

@ -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;
}