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 = {
|
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;
|
||||||
|
|
|
||||||
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;
|
--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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,43 @@
|
||||||
"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">
|
<nav className="topnav">
|
||||||
<Link href="/dashboard" className="brand">
|
<Link href="/dashboard" className="brand">
|
||||||
<span className="brand-mark">MC</span>
|
<span className="brand-mark">MC</span>
|
||||||
|
|
@ -31,17 +46,68 @@ export function Nav() {
|
||||||
<small>ops console</small>
|
<small>ops console</small>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="topnav-links">
|
|
||||||
|
<div className="topnav-links topnav-links-desktop">
|
||||||
{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}
|
{l.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn-logout" onClick={logout}>
|
|
||||||
|
<button type="button" className="btn-logout topnav-logout-desktop" onClick={logout}>
|
||||||
Uitloggen
|
Uitloggen
|
||||||
</button>
|
</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>
|
</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" : ""}>
|
||||||
|
<span className="bottomnav-dot" aria-hidden />
|
||||||
|
<span>{l.short}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</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