diff --git a/src/components/Bars.tsx b/src/components/Bars.tsx index ed16c7d..001dbd9 100644 --- a/src/components/Bars.tsx +++ b/src/components/Bars.tsx @@ -2,7 +2,14 @@ import { Typewriter } from "./Typewriter"; import { useBars } from "~/context/bars"; import { useAuth } from "~/context/auth"; 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 { insertSoftHyphens, glitchText } from "~/lib/client-utils"; import GitHub from "./icons/GitHub"; @@ -14,6 +21,14 @@ import { SkeletonBox, SkeletonText } from "./SkeletonLoader"; import { env } from "~/env/client"; import { A, useNavigate, useLocation } from "@solidjs/router"; 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 { const domain = url.split("://")[1]?.split(":")[0] ?? url; @@ -74,7 +89,231 @@ function getGtActivityPromise(): Promise { .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) => ( + + {path} + + ); + switch (props.icon) { + case "home": + return common( + "0 0 576 512", + + ); + case "blog": + return common( + "0 0 448 512", + + ); + case "downloads": + return common( + "0 0 512 512", + + ); + case "resume": + return common( + "0 0 384 512", + + ); + case "contact": + return common( + "0 0 512 512", + + ); + case "privacy": + return common( + "0 0 512 512", + + ); + case "deletion": + return common( + "0 0 448 512", + + ); + case "back": + return common( + "0 0 512 512", + + ); + case "github": + return ; + case "linkedin": + return ; + default: + return ; + } +} + +/** + * 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 = ( + <> + + + + + + {props.item.label} + + ); + if (props.item.external) { + return ( + + {inner} + + ); + } + return ( + + {inner} + + ); +} + +/** The shared simplified nav list rendered by both subdomain bars. */ +function SubdomainNavList(props: { onClick: () => void }) { + const site = useSite(); + // Auth items intentionally omitted — subdomains don't use the web (freno.me) + // JWT auth (Nessa uses Clerk; Lineage uses its mobile JWT), so no subdomain + // nav item sets showLoggedIn/showLoggedOut today. filterNavByAuth is therefore + // a no-op right now but is kept so future admin items behave correctly + // without re-touching the renderer. Filtering here keeps LeftBar + RightBar + // consistent. + return ( + + {(item) => } + + ); +} + +/** Brand heading — display name in the site's brand color. */ +function SubdomainBrand() { + const site = useSite(); + const accent = () => `color: ${site().brandColor}`; + return ( +

+ {site().displayName} +

+ ); +} + +/** "Back to freno.me" affordance rendered on every subdomain site. */ +function BackToFrenoLink() { + return ( + + + + + {BACK_TO_FRENO.label} + + ); +} + +/** Inner content for the LeftBar on subdomain sites. */ +function SubdomainLeftBarContent() { + const { setLeftBarVisible } = useBars(); + const handleLinkClick = () => { + if ( + typeof window !== "undefined" && + window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH + ) { + setLeftBarVisible(false); + } + }; + return ( +
+ +
+ {/* Auth items intentionally omitted — see SubdomainNavList. */} + +
+ +
+ +
+ +
+ + {/* Mobile-only secondary column mirror of the right bar. */} +
+ +
+
+ ); +} + +/** 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 ( +
+ + + + +
+
    + +
+
+
+ +
+
+ ); +} + +// ── Main-site RightBar (unchanged) ─────────────────────────────────────── +function MainRightBarContent() { const { setLeftBarVisible } = useBars(); const [githubCommits, setGithubCommits] = createSignal([]); const [giteaCommits, setGiteaCommits] = createSignal([]); @@ -221,7 +460,17 @@ export function RightBarContent() { ); } -export function LeftBar() { +export function RightBarContent() { + const site = useSite(); + return ( + }> + + + ); +} + +// ── Main-site LeftBar content (unchanged) ──────────────────────────────── +function MainLeftBarContent() { const { leftBarVisible, setLeftBarVisible } = useBars(); const location = useLocation(); const { isAuthenticated, email, isAdmin } = useAuth(); @@ -235,11 +484,6 @@ export function LeftBar() { const [signOutLoading, setSignOutLoading] = createSignal(false); const [getLostText, setGetLostText] = createSignal("What's this?"); const [getLostVisible, setGetLostVisible] = createSignal(false); - const [windowWidth, setWindowWidth] = createSignal( - typeof window !== "undefined" - ? window.innerWidth - : BREAKPOINTS.MOBILE_MAX_WIDTH - ); const handleLinkClick = () => { if ( @@ -265,11 +509,6 @@ export function LeftBar() { onMount(() => { setIsMounted(true); - const handleResize = () => { - setWindowWidth(window.innerWidth); - }; - window.addEventListener("resize", handleResize); - const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`"; const originalText = "What's this?"; let glitchInterval: NodeJS.Timeout; @@ -310,6 +549,220 @@ export function LeftBar() { animationFrame = requestAnimationFrame(revealAnimation); }, 500); + const fetchData = async () => { + try { + const posts = await api.blog.getRecentPosts.query(); + setRecentPosts(posts as any[]); + } catch (error) { + console.error("Failed to fetch recent posts:", error); + setRecentPosts([]); + } + }; + + setTimeout(() => { + fetchData(); + }, 0); + }); + + const navigate = useNavigate(); + + return ( + <> + +

+ + {formatDomainName(env.VITE_DOMAIN)} + +

+
+
+ + +
+ + + + +
    +
  • + +
  • +
+ +
+
+ +
+ +
+ +
+
+
+ + ); +} + +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; @@ -346,34 +799,15 @@ export function LeftBar() { 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 () => { - try { - const posts = await api.blog.getRecentPosts.query(); - setRecentPosts(posts as any[]); - } catch (error) { - console.error("Failed to fetch recent posts:", error); - setRecentPosts([]); - } - }; - - setTimeout(() => { - fetchData(); - }, 0); }); - const navigate = useNavigate(); const getMainNavStyles = () => { const baseStyles = { "transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)", @@ -390,6 +824,14 @@ export function LeftBar() { 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 ( ); diff --git a/src/lib/nav-config.test.ts b/src/lib/nav-config.test.ts new file mode 100644 index 0000000..8b129db --- /dev/null +++ b/src/lib/nav-config.test.ts @@ -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"); + }); +}); diff --git a/src/lib/nav-config.ts b/src/lib/nav-config.ts new file mode 100644 index 0000000..3304c64 --- /dev/null +++ b/src/lib/nav-config.ts @@ -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: "back to 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 = { + 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); +}