feat(Bars): add site-aware layout and navigation with subdomain nav config
Refactor LeftBar/RightBarContent into main-site and subdomain variants. Subdomain sites render a simplified, brand-colored shell iterating a new pure nav-config module (NAV_CONFIG), with a "back to freno.me" affordance and inline SVG icon resolver. The main site retains its bespoke bars (Recent Posts, auth-aware nav, admin links, widgets) unchanged. Add nav-config.ts as an import-free, unit-testable module defining the per-site link sets (with external/href/auth-gating/icon contract), plus filterNavByAuth helper and BACK_TO_FRENO link.
This commit is contained in:
@@ -2,7 +2,14 @@ import { Typewriter } from "./Typewriter";
|
|||||||
import { useBars } from "~/context/bars";
|
import { useBars } from "~/context/bars";
|
||||||
import { useAuth } from "~/context/auth";
|
import { useAuth } from "~/context/auth";
|
||||||
import { revalidateAuth } from "~/lib/auth-query";
|
import { revalidateAuth } from "~/lib/auth-query";
|
||||||
import { onMount, createSignal, Show, For, onCleanup } from "solid-js";
|
import {
|
||||||
|
onMount,
|
||||||
|
createSignal,
|
||||||
|
Show,
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
type JSX
|
||||||
|
} from "solid-js";
|
||||||
import { api } from "~/lib/api";
|
import { api } from "~/lib/api";
|
||||||
import { insertSoftHyphens, glitchText } from "~/lib/client-utils";
|
import { insertSoftHyphens, glitchText } from "~/lib/client-utils";
|
||||||
import GitHub from "./icons/GitHub";
|
import GitHub from "./icons/GitHub";
|
||||||
@@ -14,6 +21,14 @@ import { SkeletonBox, SkeletonText } from "./SkeletonLoader";
|
|||||||
import { env } from "~/env/client";
|
import { env } from "~/env/client";
|
||||||
import { A, useNavigate, useLocation } from "@solidjs/router";
|
import { A, useNavigate, useLocation } from "@solidjs/router";
|
||||||
import { BREAKPOINTS } from "~/config";
|
import { BREAKPOINTS } from "~/config";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import {
|
||||||
|
NAV_CONFIG,
|
||||||
|
BACK_TO_FRENO,
|
||||||
|
filterNavByAuth,
|
||||||
|
type NavItem,
|
||||||
|
type NavIcon
|
||||||
|
} from "~/lib/nav-config";
|
||||||
|
|
||||||
function formatDomainName(url: string): string {
|
function formatDomainName(url: string): string {
|
||||||
const domain = url.split("://")[1]?.split(":")[0] ?? url;
|
const domain = url.split("://")[1]?.split(":")[0] ?? url;
|
||||||
@@ -74,7 +89,233 @@ function getGtActivityPromise(): Promise<ContributionDay[]> {
|
|||||||
.catch(() => []));
|
.catch(() => []));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightBarContent() {
|
// ── Subdomain nav rendering (task 04) ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The main site retains its bespoke LeftBar / RightBarContent rendering
|
||||||
|
// unchanged (Recent Posts, auth-aware Account/Login/SignOut, admin links,
|
||||||
|
// RecentCommits + ActivityHeatmap widgets, the "What's this?" glitch button).
|
||||||
|
// Subdomain sites render a simplified, brand-colored shell that iterates
|
||||||
|
// `NAV_CONFIG[site]` + a "back to freno.me" affordance, and deliberately
|
||||||
|
// skips the web-auth (freno.me JWT) widgets — Nessa uses Clerk; Lineage uses
|
||||||
|
// its mobile JWT; neither should surface web login state.
|
||||||
|
|
||||||
|
/** Inline icon resolver keyed by `NavIcon` (kept out of the pure nav-config). */
|
||||||
|
function NavIconSvg(props: { icon?: NavIcon; size?: number }): JSX.Element {
|
||||||
|
const size = () => props.size ?? 22;
|
||||||
|
const cls = "shaker rounded-full p-2";
|
||||||
|
const common = (viewBox: string, path: JSX.Element) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height={size()}
|
||||||
|
width={size()}
|
||||||
|
viewBox={viewBox}
|
||||||
|
class="fill-text"
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
switch (props.icon) {
|
||||||
|
case "home":
|
||||||
|
return common(
|
||||||
|
"0 0 576 512",
|
||||||
|
<path d="M543.8 287.6c17 0 32-14 32-32.1 1-9-3-17-11-24L309.5 7c-6-5-14-7-21-7s-15 1-22 8L10 231.5c-7 7-10 15-9 24 0 18 14 32.1 32 32.1l32 0V448c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-160.4 50.8 0zM272 448v-96c0-17.7 14.3-32 32-32s32 14.3 32 32v96H272z" />
|
||||||
|
);
|
||||||
|
case "blog":
|
||||||
|
return common(
|
||||||
|
"0 0 448 512",
|
||||||
|
<path d="M448 336v-288C448 21.49 426.5 0 400 0H96C42.98 0 0 42.98 0 96v320c0 53.02 42.98 96 96 96h320c17.67 0 32-14.33 32-32s-14.33-32-32-32H96c-17.67 0-32-14.33-32-32s14.33-32 32-32h320C426.5 416 448 394.5 448 336zM96 352c-11.28 0-21.94 2.564-32 6.879V96c0-17.67 14.33-32 32-32h320v256H96z" />
|
||||||
|
);
|
||||||
|
case "downloads":
|
||||||
|
return common(
|
||||||
|
"0 0 512 512",
|
||||||
|
<path d="M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32V274.7l-73.4-73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l128-128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L288 274.7V32zM64 352c-35.3 0-64 28.7-64 64v32c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V416c0-35.3-28.7-64-64-64H346.5l-45.3 45.3c-25 25-65.5 25-90.5 0L165.5 352H64zm368 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z" />
|
||||||
|
);
|
||||||
|
case "resume":
|
||||||
|
return common(
|
||||||
|
"0 0 384 512",
|
||||||
|
<path d="M64 0C28.7 0 0 28.7 0 64V448c0 35.3 28.7 64 64 64H320c35.3 0 64-28.7 64-64V160H256c-17.7 0-32-14.3-32-32V0H64zM256 0V128H384L256 0zM112 256H272c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64H272c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64H272c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16z" />
|
||||||
|
);
|
||||||
|
case "contact":
|
||||||
|
return common(
|
||||||
|
"0 0 512 512",
|
||||||
|
<path d="M96 0C60.7 0 32 28.7 32 64V448c0 35.3 28.7 64 64 64H416c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H96zM208 256h96c26.5 0 48 21.5 48 48v8c0 44.2-35.8 80-80 80H240c-44.2 0-80-35.8-80-80v-8c0-26.5 21.5-48 48-48zm48-48c26.5 0 48-21.5 48-48s-21.5-48-48-48-48 21.5-48 48 21.5 48 48 48z" />
|
||||||
|
);
|
||||||
|
case "privacy":
|
||||||
|
return common(
|
||||||
|
"0 0 512 512",
|
||||||
|
<path d="M256 0c4.6 0 9.2 1 13.4 2.9L457.7 82.8c22 9.3 38.4 31 38.3 57.2c-.5 99.2-41.3 280.7-213.6 363.2c-16.7 8-36.1 8-52.8 0C57.3 420.7 16.5 239.2 16 140c-.1-26.2 16.3-47.9 38.3-57.2L242.6 2.9C246.8 1 251.4 0 256 0z" />
|
||||||
|
);
|
||||||
|
case "deletion":
|
||||||
|
return common(
|
||||||
|
"0 0 448 512",
|
||||||
|
<path d="M135.2 17.7L128 32H32C14.3 32 0 46.3 0 64S14.3 96 32 96H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H320l-7.2-14.3C307.4 6.8 296.3 0 284.2 0H163.8c-12.1 0-23.2 6.8-28.6 17.7zM416 128H32L53.2 467c1.6 25.3 22.6 45 47.9 45H346.9c25.3 0 46.3-19.7 47.9-45L416 128z" />
|
||||||
|
);
|
||||||
|
case "back":
|
||||||
|
return common(
|
||||||
|
"0 0 512 512",
|
||||||
|
<path d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256S114.6 512 256 512s256-114.6 256-256zM116.7 244.7l112-112c4.6-4.6 11.9-5.9 17.1-2.5s7.7 10 4.9 16.4l-21.5 50.3H376c13.3 0 24 10.7 24 24s-10.7 24-24 24H229.2l21.5 50.3c2.8 6.4 .3 13-4.9 16.4s-12.5 2.1-17.1-2.5l-112-112c-6.2-6.2-6.2-16.3 0-22.6z" />
|
||||||
|
);
|
||||||
|
case "github":
|
||||||
|
return <GitHub height={22} width={22} fill={`var(--color-text)`} />;
|
||||||
|
case "linkedin":
|
||||||
|
return <LinkedIn height={22} width={22} fill={undefined} />;
|
||||||
|
default:
|
||||||
|
return <span class={cls} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subdomain nav link — renders an internal (`A`) or external (`a`) link with
|
||||||
|
* a resolved icon. Uses the same hover affordances as the main-site links.
|
||||||
|
*/
|
||||||
|
function SubdomainNavLink(props: { item: NavItem; onClick: () => void }) {
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<Show when={props.item.icon}>
|
||||||
|
<span class="shaker rounded-full p-2">
|
||||||
|
<NavIconSvg icon={props.item.icon} />
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span>{props.item.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (props.item.external) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={props.item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="hover:text-subtext0 flex items-center gap-3 transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<A
|
||||||
|
href={props.item.href}
|
||||||
|
onClick={props.onClick}
|
||||||
|
class="hover:text-subtext0 flex items-center gap-3 transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</A>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared simplified nav list rendered by both subdomain bars. */
|
||||||
|
function SubdomainNavList(props: { onClick: () => void }) {
|
||||||
|
const site = useSite();
|
||||||
|
return (
|
||||||
|
<For each={NAV_CONFIG[site().id]}>
|
||||||
|
{(item) => <SubdomainNavLink item={item} onClick={props.onClick} />}
|
||||||
|
</For>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brand heading — display name in the site's brand color. */
|
||||||
|
function SubdomainBrand() {
|
||||||
|
const site = useSite();
|
||||||
|
const accent = () => `color: ${site().brandColor}`;
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
class="w-fit pt-6 text-center text-3xl underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||||
|
style={accent()}
|
||||||
|
>
|
||||||
|
<A href="/">{site().displayName}</A>
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Back to freno.me" affordance rendered on every subdomain site. */
|
||||||
|
function BackToFrenoLink() {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={BACK_TO_FRENO.href}
|
||||||
|
class="hover:text-subtext0 flex items-center gap-3 text-sm transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||||
|
>
|
||||||
|
<span class="shaker rounded-full p-2">
|
||||||
|
<NavIconSvg icon="back" size={18} />
|
||||||
|
</span>
|
||||||
|
<span>{BACK_TO_FRENO.label}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner content for the LeftBar on subdomain sites. */
|
||||||
|
function SubdomainLeftBarContent() {
|
||||||
|
const { setLeftBarVisible } = useBars();
|
||||||
|
const site = useSite();
|
||||||
|
const handleLinkClick = () => {
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||||
|
) {
|
||||||
|
setLeftBarVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div class="text-text flex h-full flex-col px-4 pb-4 text-xl font-bold">
|
||||||
|
<SubdomainBrand />
|
||||||
|
<div class="flex flex-col gap-4 py-8">
|
||||||
|
{/* Auth items intentionally omitted — subdomains don't use web auth.
|
||||||
|
filterNavByAuth is a no-op today (no subdomain item sets
|
||||||
|
showLoggedIn/showLoggedOut) but kept so future admin items
|
||||||
|
behave correctly without re-touching the renderer. */}
|
||||||
|
<For each={filterNavByAuth(NAV_CONFIG[site().id], false)}>
|
||||||
|
{(item) => (
|
||||||
|
<SubdomainNavLink item={item} onClick={handleLinkClick} />
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto flex flex-col gap-4">
|
||||||
|
<BackToFrenoLink />
|
||||||
|
<hr class="border-overlay0" />
|
||||||
|
<DarkModeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile-only secondary column mirror of the right bar. */}
|
||||||
|
<div class="border-overlay0 -mx-4 mt-4 border-t pt-8 md:hidden">
|
||||||
|
<SubdomainRightBarContent />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner content for the RightBar on subdomain sites (desktop only). */
|
||||||
|
function SubdomainRightBarContent() {
|
||||||
|
const { setLeftBarVisible } = useBars();
|
||||||
|
const handleLinkClick = () => {
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||||
|
) {
|
||||||
|
setLeftBarVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="rightbar-content"
|
||||||
|
class="text-text flex h-full flex-col gap-6 overflow-y-auto pb-6 md:w-min"
|
||||||
|
>
|
||||||
|
<Typewriter keepAlive={false} class="z-50 px-4 md:pt-4">
|
||||||
|
<SubdomainBrand />
|
||||||
|
</Typewriter>
|
||||||
|
|
||||||
|
<hr class="border-overlay0" />
|
||||||
|
<ul class="flex flex-col gap-4 px-4">
|
||||||
|
<SubdomainNavList onClick={handleLinkClick} />
|
||||||
|
</ul>
|
||||||
|
<hr class="border-overlay0" />
|
||||||
|
<div class="flex flex-col gap-4 px-4">
|
||||||
|
<BackToFrenoLink />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main-site RightBar (unchanged) ───────────────────────────────────────
|
||||||
|
function MainRightBarContent() {
|
||||||
const { setLeftBarVisible } = useBars();
|
const { setLeftBarVisible } = useBars();
|
||||||
const [githubCommits, setGithubCommits] = createSignal<GitCommit[]>([]);
|
const [githubCommits, setGithubCommits] = createSignal<GitCommit[]>([]);
|
||||||
const [giteaCommits, setGiteaCommits] = createSignal<GitCommit[]>([]);
|
const [giteaCommits, setGiteaCommits] = createSignal<GitCommit[]>([]);
|
||||||
@@ -221,7 +462,17 @@ export function RightBarContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeftBar() {
|
export function RightBarContent() {
|
||||||
|
const site = useSite();
|
||||||
|
return (
|
||||||
|
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>
|
||||||
|
<MainRightBarContent />
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main-site LeftBar content (unchanged) ────────────────────────────────
|
||||||
|
function MainLeftBarContent() {
|
||||||
const { leftBarVisible, setLeftBarVisible } = useBars();
|
const { leftBarVisible, setLeftBarVisible } = useBars();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isAuthenticated, email, isAdmin } = useAuth();
|
const { isAuthenticated, email, isAdmin } = useAuth();
|
||||||
@@ -235,11 +486,6 @@ export function LeftBar() {
|
|||||||
const [signOutLoading, setSignOutLoading] = createSignal(false);
|
const [signOutLoading, setSignOutLoading] = createSignal(false);
|
||||||
const [getLostText, setGetLostText] = createSignal("What's this?");
|
const [getLostText, setGetLostText] = createSignal("What's this?");
|
||||||
const [getLostVisible, setGetLostVisible] = createSignal(false);
|
const [getLostVisible, setGetLostVisible] = createSignal(false);
|
||||||
const [windowWidth, setWindowWidth] = createSignal(
|
|
||||||
typeof window !== "undefined"
|
|
||||||
? window.innerWidth
|
|
||||||
: BREAKPOINTS.MOBILE_MAX_WIDTH
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLinkClick = () => {
|
const handleLinkClick = () => {
|
||||||
if (
|
if (
|
||||||
@@ -265,11 +511,6 @@ export function LeftBar() {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
setWindowWidth(window.innerWidth);
|
|
||||||
};
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
|
|
||||||
const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`";
|
const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`";
|
||||||
const originalText = "What's this?";
|
const originalText = "What's this?";
|
||||||
let glitchInterval: NodeJS.Timeout;
|
let glitchInterval: NodeJS.Timeout;
|
||||||
@@ -310,54 +551,6 @@ export function LeftBar() {
|
|||||||
animationFrame = requestAnimationFrame(revealAnimation);
|
animationFrame = requestAnimationFrame(revealAnimation);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
if (ref) {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const isMobile = window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH;
|
|
||||||
|
|
||||||
if (!isMobile || !leftBarVisible()) return;
|
|
||||||
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
const focusableElements = ref?.querySelectorAll(
|
|
||||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!focusableElements || focusableElements.length === 0) return;
|
|
||||||
|
|
||||||
const firstElement = focusableElements[0] as HTMLElement;
|
|
||||||
const lastElement = focusableElements[
|
|
||||||
focusableElements.length - 1
|
|
||||||
] as HTMLElement;
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
if (document.activeElement === firstElement) {
|
|
||||||
e.preventDefault();
|
|
||||||
lastElement.focus();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (document.activeElement === lastElement) {
|
|
||||||
e.preventDefault();
|
|
||||||
firstElement.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ref.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
ref?.removeEventListener("keydown", handleKeyDown);
|
|
||||||
clearInterval(glitchInterval);
|
|
||||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onCleanup(() => {
|
|
||||||
clearInterval(glitchInterval);
|
|
||||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const posts = await api.blog.getRecentPosts.query();
|
const posts = await api.blog.getRecentPosts.query();
|
||||||
@@ -374,70 +567,8 @@ export function LeftBar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const getMainNavStyles = () => {
|
|
||||||
const baseStyles = {
|
|
||||||
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
||||||
width: "250px",
|
|
||||||
"padding-top": "env(safe-area-inset-top)",
|
|
||||||
"padding-bottom": "env(safe-area-inset-bottom)"
|
|
||||||
};
|
|
||||||
|
|
||||||
const shadowStyle =
|
|
||||||
windowWidth() >= BREAKPOINTS.MOBILE_MAX_WIDTH
|
|
||||||
? { "box-shadow": "inset -6px 0 16px -6px rgba(0, 0, 0, 0.1)" }
|
|
||||||
: { "box-shadow": "0 10px 10px 0 rgba(0, 0, 0, 0.2)" };
|
|
||||||
|
|
||||||
return { ...baseStyles, ...shadowStyle };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
|
||||||
id="navigation"
|
|
||||||
tabindex="-1"
|
|
||||||
ref={ref}
|
|
||||||
aria-label="Main navigation"
|
|
||||||
class="border-r-overlay2 bg-base fixed z-200 h-dvh border-r-2 transition-transform duration-500 ease-out"
|
|
||||||
classList={{
|
|
||||||
"-translate-x-full": !leftBarVisible(),
|
|
||||||
"translate-x-0": leftBarVisible()
|
|
||||||
}}
|
|
||||||
style={getMainNavStyles()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setLeftBarVisible(!leftBarVisible())}
|
|
||||||
class="hamburger-menu-btn absolute top-4 -right-14 z-9999 rounded-md p-2 shadow-md backdrop-blur-2xl transition-transform duration-600 ease-in-out hover:scale-110"
|
|
||||||
classList={{
|
|
||||||
hidden: leftBarVisible()
|
|
||||||
}}
|
|
||||||
aria-label="Toggle navigation menu"
|
|
||||||
style={{
|
|
||||||
display: "none"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="text-text h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-y-auto">
|
|
||||||
<Typewriter speed={10} keepAlive={10000} class="z-50 pr-8 pl-4">
|
|
||||||
<h3 class="hover:text-subtext0 w-fit pt-6 text-center text-3xl underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105">
|
|
||||||
<a href="/" onClick={handleLinkClick}>
|
|
||||||
{formatDomainName(env.VITE_DOMAIN)}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
</Typewriter>
|
|
||||||
|
|
||||||
<div class="text-text flex flex-1 flex-col px-4 pb-4 text-xl font-bold">
|
<div class="text-text flex flex-1 flex-col px-4 pb-4 text-xl font-bold">
|
||||||
<div class="flex flex-col py-8">
|
<div class="flex flex-col py-8">
|
||||||
<span class="text-lg font-semibold">Recent Posts</span>
|
<span class="text-lg font-semibold">Recent Posts</span>
|
||||||
@@ -605,6 +736,141 @@ export function LeftBar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftBar() {
|
||||||
|
const { leftBarVisible, setLeftBarVisible } = useBars();
|
||||||
|
const site = useSite();
|
||||||
|
let ref: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const [windowWidth, setWindowWidth] = createSignal(
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.innerWidth
|
||||||
|
: BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setWindowWidth(window.innerWidth);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
if (ref) {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const isMobile = window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH;
|
||||||
|
|
||||||
|
if (!isMobile || !leftBarVisible()) return;
|
||||||
|
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
const focusableElements = ref?.querySelectorAll(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!focusableElements || focusableElements.length === 0) return;
|
||||||
|
|
||||||
|
const firstElement = focusableElements[0] as HTMLElement;
|
||||||
|
const lastElement = focusableElements[
|
||||||
|
focusableElements.length - 1
|
||||||
|
] as HTMLElement;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastElement.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ref.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
ref?.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMainNavStyles = () => {
|
||||||
|
const baseStyles = {
|
||||||
|
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
|
width: "250px",
|
||||||
|
"padding-top": "env(safe-area-inset-top)",
|
||||||
|
"padding-bottom": "env(safe-area-inset-bottom)"
|
||||||
|
};
|
||||||
|
|
||||||
|
const shadowStyle =
|
||||||
|
windowWidth() >= BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||||
|
? { "box-shadow": "inset -6px 0 16px -6px rgba(0, 0, 0, 0.1)" }
|
||||||
|
: { "box-shadow": "0 10px 10px 0 rgba(0, 0, 0, 0.2)" };
|
||||||
|
|
||||||
|
return { ...baseStyles, ...shadowStyle };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subdomain sites sport an accent border / shadow tinted by the brand color
|
||||||
|
// ("bars render appropriately styled per site — brand color hint from
|
||||||
|
// SITE_CONFIG"). Main keeps the existing neutral styling.
|
||||||
|
const accentBorder = () =>
|
||||||
|
site().id === "main"
|
||||||
|
? undefined
|
||||||
|
: { "border-color": site().brandColor };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
id="navigation"
|
||||||
|
tabindex="-1"
|
||||||
|
ref={ref}
|
||||||
|
aria-label="Main navigation"
|
||||||
|
class="border-r-overlay2 bg-base fixed z-200 h-dvh border-r-2 transition-transform duration-500 ease-out"
|
||||||
|
classList={{
|
||||||
|
"-translate-x-full": !leftBarVisible(),
|
||||||
|
"translate-x-0": leftBarVisible()
|
||||||
|
}}
|
||||||
|
style={{ ...getMainNavStyles(), ...accentBorder() }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setLeftBarVisible(!leftBarVisible())}
|
||||||
|
class="hamburger-menu-btn absolute top-4 -right-14 z-9999 rounded-md p-2 shadow-md backdrop-blur-2xl transition-transform duration-600 ease-in-out hover:scale-110"
|
||||||
|
classList={{
|
||||||
|
hidden: leftBarVisible()
|
||||||
|
}}
|
||||||
|
aria-label="Toggle navigation menu"
|
||||||
|
style={{
|
||||||
|
display: "none"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="text-text h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col overflow-y-auto">
|
||||||
|
<Show
|
||||||
|
when={site().id === "main"}
|
||||||
|
fallback={<SubdomainLeftBarContent />}
|
||||||
|
>
|
||||||
|
<MainLeftBarContent />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
188
src/lib/nav-config.test.ts
Normal file
188
src/lib/nav-config.test.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the per-site navigation configuration (task 04).
|
||||||
|
*
|
||||||
|
* `NAV_CONFIG` + helpers are pure (no solid-js / router / meta imports), so
|
||||||
|
* these mirror the acceptance matrix directly. Integration / visual checks
|
||||||
|
* (rendering on `nessa.localhost:3000`) are covered by the build gate and
|
||||||
|
* manual validation described in the task; here we assert the data layer.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
NAV_CONFIG,
|
||||||
|
BACK_TO_FRENO,
|
||||||
|
filterNavByAuth,
|
||||||
|
navLabelsFor,
|
||||||
|
type NavItem
|
||||||
|
} from "./nav-config";
|
||||||
|
import { SITE_CONFIG, type SiteId } from "./site-context";
|
||||||
|
|
||||||
|
const ALL_SITES: SiteId[] = [
|
||||||
|
"main",
|
||||||
|
"nessa",
|
||||||
|
"lineage",
|
||||||
|
"gaze",
|
||||||
|
"inputhalo"
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("NAV_CONFIG — per-site link sets", () => {
|
||||||
|
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
|
||||||
|
expect(navLabelsFor("main")).toEqual([
|
||||||
|
"Home",
|
||||||
|
"Blog",
|
||||||
|
"Downloads",
|
||||||
|
"Resume",
|
||||||
|
"Contact",
|
||||||
|
"GitHub",
|
||||||
|
"LinkedIn"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nessa → Home, Contact, Privacy (no Blog/Resume/Downloads)", () => {
|
||||||
|
const labels = navLabelsFor("nessa");
|
||||||
|
expect(labels).toEqual(["Home", "Contact", "Privacy"]);
|
||||||
|
expect(labels).not.toContain("Blog");
|
||||||
|
expect(labels).not.toContain("Resume");
|
||||||
|
expect(labels).not.toContain("Downloads");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lineage → Home, Downloads, Contact, Privacy, Account Deletion", () => {
|
||||||
|
expect(navLabelsFor("lineage")).toEqual([
|
||||||
|
"Home",
|
||||||
|
"Downloads",
|
||||||
|
"Contact",
|
||||||
|
"Privacy",
|
||||||
|
"Account Deletion"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gaze → Home, Contact, Privacy", () => {
|
||||||
|
expect(navLabelsFor("gaze")).toEqual(["Home", "Contact", "Privacy"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inputhalo → Home, Contact, Privacy", () => {
|
||||||
|
expect(navLabelsFor("inputhalo")).toEqual(["Home", "Contact", "Privacy"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NAV_CONFIG — href correctness", () => {
|
||||||
|
it("every main internal link is a path (no host), externals are absolute URLs", () => {
|
||||||
|
for (const item of NAV_CONFIG.main) {
|
||||||
|
if (item.external) {
|
||||||
|
expect(item.href).toMatch(/^https?:\/\//);
|
||||||
|
} else {
|
||||||
|
expect(item.href.startsWith("/")).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subdomain nav hrefs are public browser paths, never the internal rewritten prefix", () => {
|
||||||
|
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
||||||
|
for (const item of NAV_CONFIG[id]) {
|
||||||
|
// No subdomain-prefixed paths leak into the public nav.
|
||||||
|
expect(item.href.startsWith(`/${id}/`)).toBe(false);
|
||||||
|
expect(item.href).toMatch(/^\//);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the lineage Account Deletion link points to /deletion (host-scoped route)", () => {
|
||||||
|
const deletion = NAV_CONFIG.lineage.find(
|
||||||
|
(i) => i.label === "Account Deletion"
|
||||||
|
);
|
||||||
|
expect(deletion).toBeDefined();
|
||||||
|
expect(deletion!.href).toBe("/deletion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("main external GitHub + LinkedIn point to the canonical profiles", () => {
|
||||||
|
const gh = NAV_CONFIG.main.find((i) => i.label === "GitHub");
|
||||||
|
expect(gh?.external).toBe(true);
|
||||||
|
expect(gh?.href).toBe("https://github.com/MikeFreno/");
|
||||||
|
const li = NAV_CONFIG.main.find((i) => i.label === "LinkedIn");
|
||||||
|
expect(li?.external).toBe(true);
|
||||||
|
expect(li?.href).toBe(
|
||||||
|
"https://www.linkedin.com/in/michael-freno-176001256/"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NAV_CONFIG — auth-scoping by construction", () => {
|
||||||
|
it("no subdomain nav item sets showLoggedIn / showLoggedOut", () => {
|
||||||
|
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
||||||
|
for (const item of NAV_CONFIG[id]) {
|
||||||
|
expect(item.showLoggedIn).toBeUndefined();
|
||||||
|
expect(item.showLoggedOut).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every site's nav is a non-empty array", () => {
|
||||||
|
for (const id of ALL_SITES) {
|
||||||
|
expect(NAV_CONFIG[id].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every site has a Home item pointing to /", () => {
|
||||||
|
for (const id of ALL_SITES) {
|
||||||
|
const home = NAV_CONFIG[id].find((i) => i.label === "Home");
|
||||||
|
expect(home).toBeDefined();
|
||||||
|
expect(home!.href).toBe("/");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is exhaustively defined for every SiteId", () => {
|
||||||
|
// Every entry in SITE_CONFIG has a NAV_CONFIG entry.
|
||||||
|
for (const id of Object.keys(SITE_CONFIG) as SiteId[]) {
|
||||||
|
expect(NAV_CONFIG[id]).toBeDefined();
|
||||||
|
expect(Array.isArray(NAV_CONFIG[id])).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterNavByAuth", () => {
|
||||||
|
const mixed: NavItem[] = [
|
||||||
|
{ label: "Public", href: "/" },
|
||||||
|
{ label: "Only Logged In", href: "/in", showLoggedIn: true },
|
||||||
|
{ label: "Only Logged Out", href: "/out", showLoggedOut: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
it("shows public items to both audiences", () => {
|
||||||
|
expect(filterNavByAuth(mixed, true).map((i) => i.label)).toContain(
|
||||||
|
"Public"
|
||||||
|
);
|
||||||
|
expect(filterNavByAuth(mixed, false).map((i) => i.label)).toContain(
|
||||||
|
"Public"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows showLoggedIn only when authenticated", () => {
|
||||||
|
expect(filterNavByAuth(mixed, true).map((i) => i.label)).toContain(
|
||||||
|
"Only Logged In"
|
||||||
|
);
|
||||||
|
expect(filterNavByAuth(mixed, false).map((i) => i.label)).not.toContain(
|
||||||
|
"Only Logged In"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows showLoggedOut only when logged out", () => {
|
||||||
|
expect(filterNavByAuth(mixed, false).map((i) => i.label)).toContain(
|
||||||
|
"Only Logged Out"
|
||||||
|
);
|
||||||
|
expect(filterNavByAuth(mixed, true).map((i) => i.label)).not.toContain(
|
||||||
|
"Only Logged Out"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the input array", () => {
|
||||||
|
const before = mixed.map((i) => ({ ...i }));
|
||||||
|
filterNavByAuth(mixed, true);
|
||||||
|
expect(mixed).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BACK_TO_FRENO", () => {
|
||||||
|
it("links to the apex freno.me and is external", () => {
|
||||||
|
expect(BACK_TO_FRENO.href).toBe("https://freno.me");
|
||||||
|
expect(BACK_TO_FRENO.external).toBe(true);
|
||||||
|
expect(BACK_TO_FRENO.icon).toBe("back");
|
||||||
|
});
|
||||||
|
});
|
||||||
143
src/lib/nav-config.ts
Normal file
143
src/lib/nav-config.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Per-site navigation configuration (task 04 — site-aware layout & navigation).
|
||||||
|
*
|
||||||
|
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
|
||||||
|
* so it can be unit-tested in `bun:test` without spinning up the router / Meta
|
||||||
|
* provider, mirroring the pattern established by `page-head-meta.ts`.
|
||||||
|
*
|
||||||
|
* Selection contract:
|
||||||
|
* - `href` values are the **public browser paths** on the subdomain origin
|
||||||
|
* (e.g. `/contact`), NOT the internal rewritten prefixes. vercel.json maps
|
||||||
|
* `nessa.freno.me/contact` → `/nessa/contact` server-side, but the browser
|
||||||
|
* sees (and links must emit) the clean `/contact`. This matches the
|
||||||
|
* canonical-URL derivation rule documented in `page-head-meta.ts`.
|
||||||
|
* - `external: true` means an absolute URL (e.g. GitHub / LinkedIn).
|
||||||
|
* - `showLoggedIn` / `showLoggedOut` gate auth-scoped items. Subdomain sites
|
||||||
|
* do NOT use the web (freno.me) JWT cookies — Nessa uses Clerk, Lineage
|
||||||
|
* uses its mobile JWT — so subdomain nav items never set these, keeping
|
||||||
|
* the "auth-aware items only on main" acceptance criterion satisfied by
|
||||||
|
* construction.
|
||||||
|
* - `icon` is a string key resolved to an SVG by the bar renderer
|
||||||
|
* (`Bars.tsx`), kept here as a string so this module stays import-free.
|
||||||
|
*
|
||||||
|
* The main-site nav is also represented here for parity / unit-testability,
|
||||||
|
* but `Bars.tsx` preserves the main site's pre-existing bespoke rendering
|
||||||
|
* (Recent Posts, auth-aware Account/Login/SignOut, admin Analytics, the
|
||||||
|
* "What's this?" glitch button, + the right-bar widgets). NAV_CONFIG[main] is
|
||||||
|
* authoritative only for the *link set* the unit tests assert against.
|
||||||
|
*/
|
||||||
|
import type { SiteId } from "~/lib/site-context";
|
||||||
|
|
||||||
|
/** Icon keys resolved by the bar renderer to inline SVGs. */
|
||||||
|
export type NavIcon =
|
||||||
|
| "home"
|
||||||
|
| "blog"
|
||||||
|
| "downloads"
|
||||||
|
| "resume"
|
||||||
|
| "contact"
|
||||||
|
| "privacy"
|
||||||
|
| "deletion"
|
||||||
|
| "github"
|
||||||
|
| "linkedin"
|
||||||
|
| "back";
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
label: string;
|
||||||
|
/** Public browser path (subdomain-relative) or absolute URL when external. */
|
||||||
|
href: string;
|
||||||
|
icon?: NavIcon;
|
||||||
|
/** Absolute external link (opens in a new tab). */
|
||||||
|
external?: boolean;
|
||||||
|
/** Only render when the viewer is authenticated (main-site web auth). */
|
||||||
|
showLoggedIn?: boolean;
|
||||||
|
/** Only render when the viewer is logged out (main-site web auth). */
|
||||||
|
showLoggedOut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apex/host link used as a "back to freno.me" affordance on subdomains. */
|
||||||
|
export const BACK_TO_FRENO: NavItem = {
|
||||||
|
label: "freno.me",
|
||||||
|
href: "https://freno.me",
|
||||||
|
icon: "back",
|
||||||
|
external: true
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-site navigation link sets.
|
||||||
|
*
|
||||||
|
* Defined to exactly satisfy the task-04 acceptance matrix:
|
||||||
|
* - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
|
||||||
|
* - nessa: Home, Contact, Privacy
|
||||||
|
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion
|
||||||
|
* - gaze: Home, Contact, Privacy
|
||||||
|
* - inputhalo: Home, Contact, Privacy
|
||||||
|
*/
|
||||||
|
export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
||||||
|
main: [
|
||||||
|
{ label: "Home", href: "/", icon: "home" },
|
||||||
|
{ label: "Blog", href: "/blog", icon: "blog" },
|
||||||
|
{ label: "Downloads", href: "/downloads", icon: "downloads" },
|
||||||
|
{ label: "Resume", href: "/resume", icon: "resume" },
|
||||||
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
|
{
|
||||||
|
label: "GitHub",
|
||||||
|
href: "https://github.com/MikeFreno/",
|
||||||
|
icon: "github",
|
||||||
|
external: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "LinkedIn",
|
||||||
|
href: "https://www.linkedin.com/in/michael-freno-176001256/",
|
||||||
|
icon: "linkedin",
|
||||||
|
external: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
nessa: [
|
||||||
|
{ label: "Home", href: "/", icon: "home" },
|
||||||
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
|
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
||||||
|
],
|
||||||
|
lineage: [
|
||||||
|
{ label: "Home", href: "/", icon: "home" },
|
||||||
|
{ label: "Downloads", href: "/downloads", icon: "downloads" },
|
||||||
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
|
{ label: "Privacy", href: "/privacy", icon: "privacy" },
|
||||||
|
{ label: "Account Deletion", href: "/deletion", icon: "deletion" }
|
||||||
|
],
|
||||||
|
gaze: [
|
||||||
|
{ label: "Home", href: "/", icon: "home" },
|
||||||
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
|
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
||||||
|
],
|
||||||
|
inputhalo: [
|
||||||
|
{ label: "Home", href: "/", icon: "home" },
|
||||||
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
|
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter a site's nav items by the viewer's auth state.
|
||||||
|
*
|
||||||
|
* Used by the renderer so auth-gated items (e.g. an admin link) only appear
|
||||||
|
* for the appropriate audience. Items without `showLoggedIn`/`showLoggedOut`
|
||||||
|
* are always shown.
|
||||||
|
*/
|
||||||
|
export function filterNavByAuth(
|
||||||
|
items: readonly NavItem[],
|
||||||
|
isAuthenticated: boolean
|
||||||
|
): NavItem[] {
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (item.showLoggedIn && !isAuthenticated) return false;
|
||||||
|
if (item.showLoggedOut && isAuthenticated) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Labels for a site's nav — convenience for asserting against in unit tests
|
||||||
|
* without pulling the full NavItem shape.
|
||||||
|
*/
|
||||||
|
export function navLabelsFor(site: SiteId): string[] {
|
||||||
|
return NAV_CONFIG[site].map((item) => item.label);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user