Make admin UI mobile-first with bottom nav and installable PWA support.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f1b6923287
commit
d53a4f9297
12 changed files with 578 additions and 39 deletions
|
|
@ -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;
|
||||
|
|
|
|||
BIN
apps/admin-ui/public/icons/apple-touch-icon.png
Normal file
BIN
apps/admin-ui/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/admin-ui/public/icons/icon-192.png
Normal file
BIN
apps/admin-ui/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
apps/admin-ui/public/icons/icon-512.png
Normal file
BIN
apps/admin-ui/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/admin-ui/public/icons/icon-maskable-512.png
Normal file
BIN
apps/admin-ui/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
12
apps/admin-ui/public/icons/icon.svg
Normal file
12
apps/admin-ui/public/icons/icon.svg
Normal 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 |
39
apps/admin-ui/public/manifest.webmanifest
Normal file
39
apps/admin-ui/public/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
58
apps/admin-ui/public/sw.js
Normal file
58
apps/admin-ui/public/sw.js
Normal 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;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<html lang="nl" className={`${outfit.variable} ${jetbrains.variable}`}>
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<PwaRegister />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<nav className="topnav">
|
||||
<Link href="/dashboard" className="brand">
|
||||
<span className="brand-mark">MC</span>
|
||||
<span className="brand-text">
|
||||
Media Cluster
|
||||
<small>ops console</small>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="topnav-links">
|
||||
<>
|
||||
<nav className="topnav">
|
||||
<Link href="/dashboard" className="brand">
|
||||
<span className="brand-mark">MC</span>
|
||||
<span className="brand-text">
|
||||
Media Cluster
|
||||
<small>ops console</small>
|
||||
</span>
|
||||
</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) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="btn-logout" onClick={logout}>
|
||||
Uitloggen
|
||||
</button>
|
||||
</nav>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
20
apps/admin-ui/src/components/PwaRegister.tsx
Normal file
20
apps/admin-ui/src/components/PwaRegister.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue