feat: add user centric features
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SiteSidebar — desktop-only thin rail that expands on hover.
|
||||
*
|
||||
* Layout (collapsed, w-16):
|
||||
*
|
||||
* ┌──┐
|
||||
* │🏠│ ← top group (Home / Knowledgebase / Collections /
|
||||
* │📚│ Bookmarks / Mentions / Search)
|
||||
* │📁│
|
||||
* │🔖│
|
||||
* │@ │
|
||||
* │🔍│
|
||||
* │ │
|
||||
* │⋮ │ ← bottom group (Reading settings, Profile)
|
||||
* │👤│
|
||||
* └──┘
|
||||
*
|
||||
* Hovering anywhere on the rail expands it to w-56 and reveals labels.
|
||||
* The rail floats above content (fixed position, the main area is
|
||||
* padded by w-16) so the expansion never causes layout shift.
|
||||
*
|
||||
* Hidden below md — mobile uses SiteBottomNav.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
AtSign,
|
||||
Bookmark,
|
||||
FolderClosed,
|
||||
Home,
|
||||
LogIn,
|
||||
Search,
|
||||
Settings2,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
/** Optional: routes to mark active when the path starts with these.
|
||||
* Used for /books variants (lectures, letters, etc.) all flagging the
|
||||
* Knowledgebase entry. */
|
||||
matchPrefixes?: string[];
|
||||
};
|
||||
|
||||
const TOP_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "Home", icon: <Home className="size-5" /> },
|
||||
{
|
||||
href: "/search",
|
||||
label: "Search",
|
||||
icon: <Search className="size-5" />,
|
||||
// Search doubles as the knowledgebase browse surface — light it up
|
||||
// any time the user is deep inside the library so they have a clear
|
||||
// path back.
|
||||
matchPrefixes: [
|
||||
"/search",
|
||||
"/books",
|
||||
"/letters",
|
||||
"/transcriptions",
|
||||
"/essays",
|
||||
"/glossary",
|
||||
"/journals",
|
||||
"/magazines",
|
||||
"/other-documents",
|
||||
"/disciples",
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/u/me/collections",
|
||||
label: "Collections",
|
||||
icon: <FolderClosed className="size-5" />,
|
||||
},
|
||||
{
|
||||
href: "/u/me/bookmarks",
|
||||
label: "Bookmarks",
|
||||
icon: <Bookmark className="size-5" />,
|
||||
},
|
||||
{
|
||||
href: "/u/me/mentions",
|
||||
label: "Mentions",
|
||||
icon: <AtSign className="size-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function SiteSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
// While the reading-settings popover is open we pin the rail to its
|
||||
// expanded width so the popover (which anchors to the trigger button)
|
||||
// doesn't visually drift when the user's mouse leaves the rail.
|
||||
const [readingOpen, setReadingOpen] = useState(false);
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
if (item.matchPrefixes) {
|
||||
return item.matchPrefixes.some((p) =>
|
||||
p === "/" ? pathname === "/" : pathname.startsWith(p),
|
||||
);
|
||||
}
|
||||
if (item.href === "/") return pathname === "/";
|
||||
return pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Primary navigation"
|
||||
data-expanded={readingOpen ? "true" : undefined}
|
||||
className={cn(
|
||||
"hidden md:flex group fixed inset-y-0 left-0 z-40",
|
||||
"transition-[width] duration-200 ease-out",
|
||||
readingOpen ? "w-56" : "w-16 hover:w-56",
|
||||
"bg-background/95 backdrop-blur border-r border-border",
|
||||
"flex-col",
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5"
|
||||
aria-label="Primary"
|
||||
>
|
||||
{TOP_ITEMS.map((item) => (
|
||||
<SidebarLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
active={isActive(item)}
|
||||
expandedOverride={readingOpen}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border py-4 flex flex-col gap-0.5">
|
||||
<SidebarReadingSettings
|
||||
expanded={readingOpen}
|
||||
onOpenChange={setReadingOpen}
|
||||
/>
|
||||
<SidebarProfile
|
||||
authed={!!user}
|
||||
handle={user?.handle ?? null}
|
||||
name={user?.name ?? null}
|
||||
active={pathname.startsWith("/u/me")}
|
||||
expandedOverride={readingOpen}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
item,
|
||||
active,
|
||||
expandedOverride,
|
||||
}: {
|
||||
item: NavItem;
|
||||
active: boolean;
|
||||
/** Force labels visible when the rail is pinned open (e.g. reading
|
||||
* settings popover is up) and the mouse isn't actually hovering. */
|
||||
expandedOverride?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">{item.icon}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity duration-150",
|
||||
expandedOverride
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarReadingSettings({
|
||||
expanded,
|
||||
onOpenChange,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-2">
|
||||
<ReadingSettings
|
||||
placement="above-left"
|
||||
onOpenChange={onOpenChange}
|
||||
renderTrigger={({ open, toggle, buttonRef }) => (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label="Reading settings"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
"h-11 w-full flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
||||
)}
|
||||
>
|
||||
<Settings2 className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity duration-150",
|
||||
expanded
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
Reading
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarProfile({
|
||||
authed,
|
||||
handle,
|
||||
name,
|
||||
active,
|
||||
expandedOverride,
|
||||
}: {
|
||||
authed: boolean;
|
||||
handle: string | null;
|
||||
name: string | null;
|
||||
active: boolean;
|
||||
expandedOverride?: boolean;
|
||||
}) {
|
||||
if (!authed) {
|
||||
return (
|
||||
<Link
|
||||
href="/login"
|
||||
className={cn(
|
||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
|
||||
)}
|
||||
aria-label="Sign in"
|
||||
title="Sign in"
|
||||
>
|
||||
<LogIn className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"transition-opacity duration-150",
|
||||
expandedOverride
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
Sign in
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href="/u/me"
|
||||
aria-label="Profile"
|
||||
title={handle ? `@${handle}` : "Profile"}
|
||||
className={cn(
|
||||
"mx-2 h-11 flex items-center gap-3 px-3 rounded-lg overflow-hidden whitespace-nowrap text-sm font-medium",
|
||||
"transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<UserIcon className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate transition-opacity duration-150",
|
||||
expandedOverride
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 delay-0 group-hover:delay-100",
|
||||
)}
|
||||
>
|
||||
{name || (handle ? `@${handle}` : "Profile")}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user