- Implemented a one-shot live catch-up mechanism in `liveCatchup.ts` to enhance user experience during live streaming. - Created a global radio state management system in `radioState.ts` to maintain playback continuity and metadata across different components and tabs. - Bumped version 1.0 -> 1.1
340 lines
16 KiB
Plaintext
340 lines
16 KiB
Plaintext
---
|
||
import { metaData, API_URL } from "../config";
|
||
import { Icon } from "astro-icon/components";
|
||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||
import { userIconSvg, externalLinkIconSvg } from "@/utils/navAssets";
|
||
import "@/assets/styles/nav.css";
|
||
|
||
const user = await requireAuthHook(Astro);
|
||
// Nav is the standard site navigation — whitelabel logic belongs in SubNav
|
||
const isLoggedIn = Boolean(user);
|
||
const userDisplayName = user?.user ?? null;
|
||
const isAdmin = user?.roles?.includes('admin') ?? false;
|
||
|
||
type NavItem = {
|
||
label: string;
|
||
href: string;
|
||
icon?: string;
|
||
auth?: boolean;
|
||
adminOnly?: boolean;
|
||
guestOnly?: boolean;
|
||
onclick?: string;
|
||
children?: NavItem[];
|
||
};
|
||
|
||
const baseNavItems: NavItem[] = [
|
||
{ label: "Home", href: "/" },
|
||
{ label: "Radio", href: "/radio" },
|
||
{ label: "Memes", href: "/memes" },
|
||
{
|
||
label: "TRip",
|
||
href: "/TRip",
|
||
auth: true,
|
||
icon: "pirate",
|
||
children: [
|
||
{ label: "Submit Request", href: "/TRip", auth: true },
|
||
{ label: "Manage Requests", href: "/TRip/requests", auth: true },
|
||
],
|
||
},
|
||
{ label: "Discord Logs", href: "/discord-logs", auth: true },
|
||
{
|
||
label: "Admin",
|
||
href: "javascript:void(0)",
|
||
auth: true,
|
||
adminOnly: true,
|
||
children: [
|
||
{ label: "Lighting", href: "/lighting", auth: true, adminOnly: true },
|
||
{ label: "Glances", href: "https://_gl.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||
{ label: "PSQL", href: "https://_pg.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||
{ label: "SQLite", href: "https://_sqlite.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||
{ label: "qBitTorrent", href: "https://_qb.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||
{ label: "RQ", href: "https://_rq.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||
{ label: "RI", href: "https://_r0.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||
],
|
||
},
|
||
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
||
{ label: "Login", href: "/login", guestOnly: true },
|
||
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
||
];
|
||
|
||
// Fold any adminOnly root items into the Admin group automatically
|
||
const adminContainerIndex = baseNavItems.findIndex((item) => item.label === "Admin");
|
||
const adminContainer: NavItem = adminContainerIndex >= 0
|
||
? baseNavItems[adminContainerIndex]
|
||
: { label: "Admin", href: "#admin", auth: true, adminOnly: true, children: [] };
|
||
|
||
const adminChildren: NavItem[] = [...(adminContainer.children ?? [])];
|
||
const groupedNavItems: NavItem[] = [];
|
||
|
||
baseNavItems.forEach((item, idx) => {
|
||
if (item.label === "Admin") return; // defer insertion
|
||
|
||
if (item.adminOnly) {
|
||
adminChildren.push(item);
|
||
} else {
|
||
groupedNavItems.push(item);
|
||
}
|
||
});
|
||
|
||
if (adminChildren.length > 0) {
|
||
const adminItem: NavItem = { ...adminContainer, children: adminChildren };
|
||
// Insert Admin before Login/Logout (which are always last)
|
||
const loginIdx = groupedNavItems.findIndex((item) => item.label === "Login" || item.label === "Logout");
|
||
const insertAt = loginIdx >= 0 ? loginIdx : groupedNavItems.length;
|
||
groupedNavItems.splice(insertAt, 0, adminItem);
|
||
}
|
||
|
||
const isItemVisible = (item: NavItem): boolean => {
|
||
if (item.auth && !isLoggedIn) return false;
|
||
if (item.adminOnly && !isAdmin) return false;
|
||
if (item.guestOnly && isLoggedIn) return false;
|
||
return true;
|
||
};
|
||
|
||
const visibleNavItems = groupedNavItems
|
||
.filter(isItemVisible)
|
||
.map((item): NavItem => {
|
||
const visibleChildren = item.children?.filter(isItemVisible);
|
||
return visibleChildren ? { ...item, children: visibleChildren } : item;
|
||
})
|
||
.filter((item) => !item.children || item.children.length > 0);
|
||
|
||
const normalize = (url: string) => (url || '/').replace(/\/+$/, '') || '/';
|
||
const normalizedCurrent = normalize(Astro.url.pathname);
|
||
|
||
// For parent items: active if exact match OR if path starts with href (prefix matching)
|
||
const isPathActive = (href: string) => {
|
||
const normalizedHref = normalize(href);
|
||
if (!href || href.startsWith('http')) return false;
|
||
return normalizedHref === '/'
|
||
? normalizedCurrent === '/'
|
||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(`${normalizedHref}/`);
|
||
};
|
||
|
||
// For dropdown children: active only on exact match (no prefix matching)
|
||
const isPathActiveExact = (href: string) => {
|
||
const normalizedHref = normalize(href);
|
||
if (!href || href.startsWith('http')) return false;
|
||
return normalizedCurrent === normalizedHref;
|
||
};
|
||
|
||
---
|
||
|
||
<script src="/scripts/nav-controls.js" defer data-api-url={API_URL}></script>
|
||
|
||
<nav class="w-full px-4 sm:px-6 py-3 backdrop-blur-xl bg-white/75 dark:bg-[#0a0a0a]/75 border-b border-neutral-200/40 dark:border-neutral-800/40 shadow-sm shadow-neutral-900/5 dark:shadow-black/20">
|
||
<div class="max-w-7xl mx-auto">
|
||
<div class="nav-bar-row flex items-center gap-4 justify-between">
|
||
<!-- Logo/Brand -->
|
||
<a
|
||
href="/"
|
||
class="text-xl sm:text-2xl font-bold tracking-tight bg-gradient-to-r from-neutral-900 to-neutral-600 dark:from-white dark:to-neutral-400 bg-clip-text text-transparent whitespace-nowrap hover:opacity-80 transition-opacity font-['IBM_Plex_Sans',sans-serif]">
|
||
{metaData.title}
|
||
</a>
|
||
|
||
<!-- Desktop Navigation -->
|
||
<div class="desktop-nav flex items-center">
|
||
<ul class="desktop-nav-list">
|
||
{visibleNavItems.map((item) => {
|
||
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||
const isExternal = item.href?.startsWith("http");
|
||
const isAuthedPath = item.auth ?? false;
|
||
const childActive = hasChildren && item.children?.some((child) => isPathActive(child.href));
|
||
const isActive = childActive || (!isExternal && isPathActive(item.href));
|
||
|
||
return (
|
||
<li class:list={["nav-item", hasChildren && "nav-item--has-children"]}>
|
||
<a
|
||
href={item.href}
|
||
class={isActive
|
||
? "flex items-center gap-1 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 shadow-sm font-['IBM_Plex_Sans',sans-serif]"
|
||
: "flex items-center gap-1 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800/60 font-['IBM_Plex_Sans',sans-serif]"
|
||
}
|
||
target={isExternal ? "_blank" : undefined}
|
||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||
onclick={item.onclick}
|
||
aria-haspopup={hasChildren ? "true" : undefined}
|
||
aria-expanded={hasChildren ? isActive : undefined}
|
||
>
|
||
{item.label}
|
||
{hasChildren && <span class="nav-caret" aria-hidden="true">▼</span>}
|
||
{item.icon === "external" && (
|
||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||
)}
|
||
{item.icon === "pirate" && (
|
||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||
)}
|
||
</a>
|
||
|
||
{hasChildren && (
|
||
<div class="nav-dropdown" role="menu">
|
||
<ul>
|
||
{item.children?.map((child) => {
|
||
const childExternal = child.href?.startsWith("http");
|
||
const childAuthedPath = child.auth ?? false;
|
||
const childIsActive = !childExternal && isPathActiveExact(child.href);
|
||
|
||
return (
|
||
<li>
|
||
<a
|
||
href={child.href}
|
||
class={childIsActive
|
||
? "flex items-center gap-1 px-3 py-2 rounded-md text-sm font-semibold transition-all duration-150 text-white bg-neutral-900 dark:text-neutral-900 dark:bg-white"
|
||
: "flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800/80"}
|
||
target={childExternal ? "_blank" : undefined}
|
||
rel={(childExternal || childAuthedPath) ? "external" : undefined}
|
||
onclick={childIsActive ? "event.preventDefault()" : undefined}
|
||
>
|
||
{child.label}
|
||
{child.icon === "external" && (
|
||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||
)}
|
||
{child.icon === "pirate" && (
|
||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||
)}
|
||
</a>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
{isLoggedIn && userDisplayName && (
|
||
<div class:list={['nav-user-inline', isAdmin && 'nav-user-inline--admin']} title={`Logged in as ${userDisplayName}`}>
|
||
<span class="nav-user-inline__icon" aria-hidden="true" set:html={userIconSvg}></span>
|
||
<span class="nav-user-inline__name">{userDisplayName}</span>
|
||
</div>
|
||
)}
|
||
|
||
<!-- Theme Toggle Desktop -->
|
||
<button
|
||
aria-label="Toggle theme"
|
||
type="button"
|
||
class="flex items-center justify-center w-8 h-8 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||
onclick="toggleTheme()"
|
||
>
|
||
<Icon
|
||
name="fa6-solid:circle-half-stroke"
|
||
class="h-4 w-4 text-[#1c1c1c] dark:text-[#D4D4D4]"
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Mobile Menu Button -->
|
||
<div class="mobile-nav flex items-center gap-2">
|
||
<!-- Theme Toggle Mobile (visible) -->
|
||
<button
|
||
aria-label="Toggle theme"
|
||
type="button"
|
||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||
onclick="toggleTheme()"
|
||
>
|
||
<Icon
|
||
name="fa6-solid:circle-half-stroke"
|
||
class="h-5 w-5 text-[#1c1c1c] dark:text-[#D4D4D4]"
|
||
/>
|
||
</button>
|
||
|
||
<button
|
||
id="mobile-menu-btn"
|
||
aria-label="Toggle menu"
|
||
type="button"
|
||
class="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||
>
|
||
<svg id="menu-icon" class="w-6 h-6 text-neutral-700 dark:text-neutral-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||
</svg>
|
||
<svg id="close-icon" class="w-6 h-6 text-neutral-700 dark:text-neutral-300" style="display: none;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mobile Navigation Menu -->
|
||
<div
|
||
id="mobile-menu"
|
||
class="mobile-menu-dropdown xl:hidden"
|
||
data-lenis-prevent
|
||
>
|
||
{isLoggedIn && userDisplayName && (
|
||
<div class:list={['nav-user-inline', 'nav-user-inline--mobile', isAdmin && 'nav-user-inline--admin']} title={`Logged in as ${userDisplayName}`}>
|
||
<span class="nav-user-inline__icon" aria-hidden="true" set:html={userIconSvg}></span>
|
||
<span class="nav-user-inline__name">{userDisplayName}</span>
|
||
</div>
|
||
)}
|
||
<ul class="flex flex-col gap-1 py-4">
|
||
{visibleNavItems.map((item) => {
|
||
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||
const isExternal = item.href?.startsWith("http");
|
||
const isAuthedPath = item.auth ?? false;
|
||
const childActive = hasChildren && item.children?.some((child) => isPathActive(child.href));
|
||
const isActive = childActive || (!isExternal && isPathActive(item.href));
|
||
|
||
return (
|
||
<li>
|
||
<a
|
||
href={item.href}
|
||
class={isActive
|
||
? "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-white"
|
||
: "flex items-center gap-0 px-4 py-3 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||
}
|
||
style={isActive ? `background: #111827` : undefined}
|
||
target={isExternal ? "_blank" : undefined}
|
||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||
onclick={item.onclick}
|
||
>
|
||
<span style="color:inherit;">{item.label}</span>
|
||
{hasChildren && <span class="mobile-caret" aria-hidden="true">›</span>}
|
||
{item.icon === "external" && (
|
||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||
)}
|
||
{item.icon === "pirate" && (
|
||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||
)}
|
||
</a>
|
||
|
||
{hasChildren && (
|
||
<ul class="mobile-subnav" role="menu">
|
||
{item.children?.map((child) => {
|
||
const childExternal = child.href?.startsWith("http");
|
||
const childAuthedPath = child.auth ?? false;
|
||
const childIsActive = !childExternal && isPathActiveExact(child.href);
|
||
|
||
return (
|
||
<li>
|
||
<a
|
||
href={child.href}
|
||
class={childIsActive
|
||
? "flex items-center gap-1 px-6 py-2 rounded-lg text-base font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900"
|
||
: "flex items-center gap-1 px-6 py-2 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}
|
||
target={childExternal ? "_blank" : undefined}
|
||
rel={(childExternal || childAuthedPath) ? "external" : undefined}
|
||
onclick={childIsActive ? "event.preventDefault()" : undefined}
|
||
>
|
||
<span style="color:inherit;">{child.label}</span>
|
||
{child.icon === "external" && (
|
||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||
)}
|
||
{child.icon === "pirate" && (
|
||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||
)}
|
||
</a>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</nav>
|