Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 100 additions & 17 deletions app/components/site/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { Menu, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type PointerEvent } from "react";
import { cn } from "../util/cn";
import { FlarialLogo } from "./FlarialLogo";

Expand All @@ -15,15 +15,44 @@ const NAV = [
{ href: "/faq", label: "FAQ" },
];

const docsStorageKey = "flarial:last-docs-article";
const docsSlugs = new Set([
"what-is-flarial",
"usage",
"compatibility",
"configs",
"modules-list",
"flarial-nametag-icon",
"module-blocking",
"scripting-api",
]);

function getRememberedDocsHref() {
if (typeof window === "undefined") {
return "/docs";
}

const slug = window.localStorage.getItem(docsStorageKey);
return slug && docsSlugs.has(slug) ? `/docs/${slug}/` : "/docs";
}

export function Navbar({ onOpenPalette: _ = () => {} }: { onOpenPalette?: () => void } = {}) {
const pathname = usePathname();
const isHome = pathname === "/";
const navRef = useRef<HTMLElement | null>(null);
const navItemRefs = useRef<(HTMLAnchorElement | null)[]>([]);
const lastTouchToggleAtRef = useRef(0);

/* Two thresholds: a small one for the bg-blur tint, a bigger one (home only)
for the slide-in reveal once the visitor leaves the hero. */
const [scrolled, setScrolled] = useState(false);
const [revealed, setRevealed] = useState(!isHome);
const [mobile, setMobile] = useState(false);
const [docsHref, setDocsHref] = useState("/docs");
const [indicator, setIndicator] = useState({ x: 0, width: 0, opacity: 0 });
const activeIndex = NAV.findIndex(
(item) => pathname === item.href || (item.href !== "/" && pathname?.startsWith(item.href)),
);

useEffect(() => {
const onScroll = () => {
Expand All @@ -43,15 +72,64 @@ export function Navbar({ onOpenPalette: _ = () => {} }: { onOpenPalette?: () =>

useEffect(() => {
setMobile(false);
setDocsHref(getRememberedDocsHref());
}, [pathname]);

useEffect(() => {
setDocsHref(getRememberedDocsHref());
}, []);

useLayoutEffect(() => {
const updateIndicator = () => {
const activeItem = activeIndex >= 0 ? navItemRefs.current[activeIndex] : null;
if (!activeItem) {
setIndicator((current) => ({ ...current, opacity: 0 }));
return;
}

setIndicator({
x: activeItem.offsetLeft,
width: activeItem.offsetWidth,
opacity: 1,
});
};

updateIndicator();
window.addEventListener("resize", updateIndicator);
return () => window.removeEventListener("resize", updateIndicator);
}, [activeIndex]);

const toggleMobileMenu = () => {
setMobile((v) => !v);
};

const handleMobilePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
if (event.pointerType !== "touch" && event.pointerType !== "pen") {
return;
}

event.preventDefault();
event.stopPropagation();
lastTouchToggleAtRef.current = Date.now();
toggleMobileMenu();
};

const handleMobileClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (Date.now() - lastTouchToggleAtRef.current < 700) {
return;
}

toggleMobileMenu();
};

return (
<motion.header
initial={false}
animate={{ y: revealed ? 0 : "-110%" }}
transition={{ type: "spring", stiffness: 200, damping: 30, mass: 0.9 }}
className={cn(
"fixed top-0 inset-x-0 z-30 backdrop-blur-md",
"fixed top-0 inset-x-0 z-50 backdrop-blur-md",
scrolled ? "bg-[var(--color-bg-base)]/82" : "bg-[var(--color-bg-base)]/60",
)}
style={{
Expand All @@ -66,26 +144,30 @@ export function Navbar({ onOpenPalette: _ = () => {} }: { onOpenPalette?: () =>
Flarial
</span>
</Link>
<nav className="hidden md:flex items-center gap-1">
{NAV.map((item) => {
<nav ref={navRef} className="relative hidden md:flex items-center gap-1">
<motion.span
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 rounded-[var(--radius-md)]"
animate={indicator}
initial={false}
transition={{ type: "spring", stiffness: 420, damping: 34, mass: 0.8 }}
style={{ background: "var(--color-bg-nav)" }}
/>
{NAV.map((item, index) => {
const active = pathname === item.href || (item.href !== "/" && pathname?.startsWith(item.href));
const href = item.href === "/docs" ? docsHref : item.href;
return (
<Link
key={item.href}
href={item.href}
ref={(node) => {
navItemRefs.current[index] = node;
}}
href={href}
className={cn(
"relative px-4 py-2 rounded-[var(--radius-md)] font-display font-semibold text-[15px] tracking-[-0.01em] transition-colors",
active ? "text-white" : "text-[var(--color-text-mute)] hover:text-white",
)}
>
{active ? (
<motion.span
layoutId="nav-active"
className="absolute inset-0 rounded-[var(--radius-md)]"
style={{ background: "var(--color-bg-nav)" }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
) : null}
<span className="relative z-10">{item.label}</span>
</Link>
);
Expand All @@ -108,9 +190,10 @@ export function Navbar({ onOpenPalette: _ = () => {} }: { onOpenPalette?: () =>
</Link>
<button
type="button"
onClick={() => setMobile((v) => !v)}
className="md:hidden grid place-items-center w-9 h-9 rounded-[var(--radius-md)] text-white"
style={{ background: "var(--color-bg-nav)" }}
onPointerDown={handleMobilePointerDown}
onClick={handleMobileClick}
className="relative z-10 md:hidden grid h-11 w-11 touch-manipulation select-none cursor-pointer place-items-center rounded-[var(--radius-md)] text-white"
style={{ background: "var(--color-bg-nav)", WebkitTapHighlightColor: "transparent" }}
aria-label={mobile ? "Close menu" : "Open menu"}
aria-expanded={mobile}
>
Expand All @@ -133,7 +216,7 @@ export function Navbar({ onOpenPalette: _ = () => {} }: { onOpenPalette?: () =>
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
href={item.href === "/docs" ? docsHref : item.href}
className="px-3 py-2.5 rounded-[var(--radius-md)] font-mono text-[12px] uppercase tracking-widest text-white hover:bg-black/30"
>
{item.label}
Expand Down
5 changes: 4 additions & 1 deletion app/components/site/SiteFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export function SiteFrame({ children }: { children: ReactNode }) {
let lenis: { destroy?: () => void; raf?: (t: number) => void } | null = null;
let raf = 0;
let cancelled = false;
if (pathname.startsWith("/docs")) {
return;
}
if (
typeof window !== "undefined" &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
Expand All @@ -39,7 +42,7 @@ export function SiteFrame({ children }: { children: ReactNode }) {
cancelAnimationFrame(raf);
(lenis as unknown as { destroy?: () => void } | null)?.destroy?.();
};
}, []);
}, [pathname]);

return (
<>
Expand Down
41 changes: 41 additions & 0 deletions app/docs/CopyablePath.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { Check, Copy } from "lucide-react";
import { useState } from "react";

export function CopyablePath({ value }: { value: string }) {
const [copied, setCopied] = useState(false);

async function copyPath() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
} catch {
const input = document.createElement("input");
input.value = value;
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
}
}

const Icon = copied ? Check : Copy;

return (
<button
type="button"
onClick={copyPath}
className="group flex w-full min-w-0 cursor-pointer items-center justify-between gap-3 rounded-[var(--radius-md)] bg-black/25 px-3 py-2 text-left transition-colors hover:bg-[var(--color-bg-subtle)]"
aria-label={`Copy ${value}`}
>
<code className="min-w-0 break-all font-mono text-[12px] text-[var(--color-accent-hi)]">{value}</code>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-[var(--radius-sm)] text-[var(--color-text-mute)] opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<Icon size={14} />
</span>
</button>
);
}
43 changes: 43 additions & 0 deletions app/docs/DocsHeading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import type { MouseEvent, ReactNode } from "react";

export function DocsHeading({ id, children }: { id: string; children: ReactNode }) {
async function copySectionLink(event?: MouseEvent<HTMLElement>) {
event?.stopPropagation();
const url = `${window.location.origin}${window.location.pathname}#${id}`;

try {
await navigator.clipboard.writeText(url);
} catch {
const input = document.createElement("input");
input.value = url;
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}

window.history.replaceState(null, "", `#${id}`);
}

return (
<h2
id={id}
data-docs-heading="true"
data-docs-heading-title={typeof children === "string" ? children : undefined}
onClick={copySectionLink}
className="group flex w-fit max-w-full min-w-0 scroll-mt-36 cursor-pointer items-center gap-2 border-b-[3px] border-[var(--color-accent)] pb-0 pr-0 font-display text-[24px] font-semibold tracking-[-0.02em] text-white sm:text-[28px]"
>
<span className="min-w-0">{children}</span>
<button
type="button"
onClick={copySectionLink}
aria-label={`Copy link to ${children}`}
className="shrink-0 cursor-pointer rounded-[var(--radius-xs)] px-1 font-mono text-[18px] text-[var(--color-accent)] opacity-0 transition-opacity hover:bg-[var(--color-bg-subtle)] group-hover:opacity-100 focus:opacity-100"
>
#
</button>
</h2>
);
}
36 changes: 36 additions & 0 deletions app/docs/DocsMemory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";

const storageKey = "flarial:last-docs-article";
const validSlugs = new Set([
"what-is-flarial",
"usage",
"compatibility",
"module-blocking",
"modules-list",
"extra-features",
]);

export function RememberDocsArticle({ slug }: { slug: string }) {
useEffect(() => {
window.localStorage.setItem(storageKey, slug);
}, [slug]);

return null;
}

export function OpenLastDocsArticle() {
const router = useRouter();

useEffect(() => {
const lastSlug = window.localStorage.getItem(storageKey);

if (lastSlug && validSlugs.has(lastSlug)) {
router.replace(`/docs/${lastSlug}/`);
}
}, [router]);

return null;
}
59 changes: 59 additions & 0 deletions app/docs/DocsToc.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { useEffect, useState } from "react";
import { ListChecks } from "lucide-react";

type HeadingItem = {
href: string;
title: string;
};

function readHeadings() {
const article = document.getElementById("docs-article-content");
if (!article) return [];

return Array.from(article.querySelectorAll<HTMLElement>("[data-docs-heading][id]"))
.map((heading) => ({
href: `#${heading.id}`,
title: heading.dataset.docsHeadingTitle ?? heading.textContent?.replace(/#$/, "").trim() ?? "",
}))
.filter((item) => item.title.length > 0);
}

export function DocsToc() {
const [items, setItems] = useState<HeadingItem[]>([]);

useEffect(() => {
const syncHeadings = () => setItems(readHeadings());
syncHeadings();

const article = document.getElementById("docs-article-content");
if (!article) return;

const observer = new MutationObserver(syncHeadings);
observer.observe(article, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["id", "data-docs-heading", "data-docs-heading-title"],
});

return () => observer.disconnect();
}, []);

return (
<div className="rounded-[var(--radius-2xl)] p-4" style={{ background: "var(--color-bg-nav)", boxShadow: "var(--shadow-rest)" }}>
<div className="flex items-center gap-2 font-display font-semibold text-white">
<ListChecks size={16} className="text-[var(--color-accent)]" />
On this page
</div>
<nav className="mt-4 grid gap-2 text-[13px] text-[var(--color-text-mute)]">
{items.map((item) => (
<a key={item.href} href={item.href} className="rounded-[var(--radius-sm)] px-2 py-1.5 hover:bg-[var(--color-bg-subtle)] hover:text-white">
{item.title}
</a>
))}
</nav>
</div>
);
}
Loading
Loading