From 13d4ed2f3cb83f9ab72f6864c9fbc44e15b2bdb8 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Wed, 7 Jan 2026 07:17:35 +0000 Subject: [PATCH 01/19] feat(sidebar): add collapsible sidebar with smooth animation - Add SidebarContext for managing collapsed state - Create MinimalHeader with hamburger toggle aligned to sidebar icons - Implement AppSidebar component with navigation items - Update sidebar-layout with animated width transitions and clipping - Adjust sidebar padding to p-3 and hover containment with max-w-9 - Add pink accent for current page indicator --- components/Header/MinimalHeader.tsx | 208 ++++++++++++++++++++ components/SideBar/AppSidebar.tsx | 178 +++++++++++++++++ components/ui-components/sidebar-layout.tsx | 62 ++++-- components/ui-components/sidebar.tsx | 40 ++-- context/SidebarContext.tsx | 57 ++++++ 5 files changed, 507 insertions(+), 38 deletions(-) create mode 100644 components/Header/MinimalHeader.tsx create mode 100644 components/SideBar/AppSidebar.tsx create mode 100644 context/SidebarContext.tsx diff --git a/components/Header/MinimalHeader.tsx b/components/Header/MinimalHeader.tsx new file mode 100644 index 00000000..be8599e0 --- /dev/null +++ b/components/Header/MinimalHeader.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { api } from "@/server/trpc/react"; +import { usePathname } from "next/navigation"; +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from "@headlessui/react"; +import { BellIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { Bars3Icon } from "@heroicons/react/24/outline"; +import { signIn, signOut } from "next-auth/react"; +import { PromptLink as Link } from "@/components/PromptService/PromptLink"; +import { Fragment } from "react"; +import { type Session } from "next-auth"; +import Image from "next/image"; +import { MobileSearch, Search } from "@/components/ui/Search"; +import { useSidebar } from "@/context/SidebarContext"; + +type AlgoliaConfig = { + ALGOLIA_APP_ID: string; + ALGOLIA_SEARCH_API: string; + ALGOLIA_SOURCE_IDX: string; +}; + +interface MinimalHeaderProps { + session: Session | null; + algoliaSearchConfig: AlgoliaConfig; + username: string | null; +} + +export function MinimalHeader({ + session, + algoliaSearchConfig, + username, +}: MinimalHeaderProps) { + const { data: count } = api.notification.getCount.useQuery(undefined, { + enabled: session ? true : false, + }); + const { isCollapsed, toggleSidebar } = useSidebar(); + + const pathname = usePathname(); + + const userNavigation = [ + { + name: "Your Profile", + href: `/${username || "settings"}`, + }, + { + name: "Saved posts", + href: "/saved", + }, + { name: "Settings", href: "/settings" }, + { name: "Sign out", onClick: () => signOut() }, + ]; + + const hasNotifications = !!count && count > 0; + + const handleSignInPageNavigation = () => { + if (pathname === "/get-started") { + return; + } + signIn(); + }; + + return ( +
+ {/* Desktop: Burger menu and Logo on the left */} +
+ + + Codú + +
+ + {/* Mobile: Logo in center */} +
+ + Codú + +
+ + {/* Desktop: Search bar - centered */} +
+
+ +
+
+ + {/* Mobile: Search icon */} +
+ +
+ +
+ {session ? ( + <> + {/* Create button - desktop only */} + + + Create + + + {/* Notifications */} + + View notifications + {hasNotifications && ( +
+ )} +
+
+ ); +} diff --git a/components/SideBar/AppSidebar.tsx b/components/SideBar/AppSidebar.tsx new file mode 100644 index 00000000..73855c58 --- /dev/null +++ b/components/SideBar/AppSidebar.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import type { Session } from "next-auth"; +import { + HomeIcon, + NewspaperIcon, + CalendarIcon, + UserIcon, + DocumentTextIcon, + BookmarkIcon, + Cog6ToothIcon, +} from "@heroicons/react/24/outline"; +import { + Sidebar, + SidebarBody, + SidebarFooter, + SidebarSection, + SidebarItem, + SidebarLabel, + SidebarDivider, + SidebarHeading, + SidebarSpacer, +} from "@/components/ui-components/sidebar"; +import { + sidebarNavigation, + sidebarUserNavigation, + sidebarFooterNav, + githubUrl, + twitterUrl, + linkedinUrl, +} from "@/config/site_settings"; +import { useSidebar } from "@/context/SidebarContext"; +import Twitter from "@/icons/x.svg"; +import Github from "@/icons/github.svg"; +import Linkedin from "@/icons/linkedin.svg"; + +const iconMap = { + HomeIcon, + NewspaperIcon, + CalendarIcon, + UserIcon, + DocumentTextIcon, + BookmarkIcon, + Cog6ToothIcon, +}; + +const socialLinks = [ + { + name: "Twitter", + href: twitterUrl, + Icon: Twitter, + customStyle: "hover:bg-twitter focus:bg-twitter", + }, + { + name: "GitHub", + href: githubUrl, + Icon: Github, + customStyle: "hover:bg-github focus:bg-github", + }, + { + name: "LinkedIn", + href: linkedinUrl, + Icon: Linkedin, + customStyle: "hover:bg-[#0A66C2] focus:bg-[#0A66C2]", + }, +]; + +interface AppSidebarProps { + session: Session | null; + username: string | null; +} + +export function AppSidebar({ session, username }: AppSidebarProps) { + const pathname = usePathname(); + const { isCollapsed } = useSidebar(); + + const isActive = (href: string) => { + if (href === "/") return pathname === "/"; + return pathname.startsWith(href); + }; + + const getUserNavHref = (href: string, dynamic?: boolean) => { + if (dynamic && username) { + return `/${username}`; + } + return href; + }; + + return ( + + + + {sidebarNavigation.map((item) => { + const Icon = iconMap[item.icon as keyof typeof iconMap]; + return ( + + {Icon && ( + + )} + + {item.name} + + + ); + })} + + + {session && ( + <> + + + Account + {sidebarUserNavigation.map((item) => { + const Icon = iconMap[item.icon as keyof typeof iconMap]; + const href = getUserNavHref( + item.href, + "dynamic" in item ? item.dynamic : false, + ); + return ( + + {Icon && ( + + )} + + {item.name} + + + ); + })} + + + )} + + + + + {sidebarFooterNav.map((item) => ( + + + {item.name} + + + ))} + + + + +
+ {socialLinks.map((item) => ( + + {item.name} + + ))} +
+
+
+ ); +} diff --git a/components/ui-components/sidebar-layout.tsx b/components/ui-components/sidebar-layout.tsx index f3e9f1bc..7c90de05 100644 --- a/components/ui-components/sidebar-layout.tsx +++ b/components/ui-components/sidebar-layout.tsx @@ -4,10 +4,10 @@ import * as Headless from "@headlessui/react"; import React, { useState } from "react"; import { NavbarItem } from "./navbar"; -function OpenMenuIcon() { +function MenuIcon() { return ( -
+
@@ -48,20 +48,44 @@ function MobileSidebar({ ); } +interface SidebarLayoutProps { + navbar: React.ReactNode; + sidebar: React.ReactNode; + children: React.ReactNode; + isCollapsed?: boolean; +} + export function SidebarLayout({ navbar, sidebar, children, -}: React.PropsWithChildren<{ - navbar: React.ReactNode; - sidebar: React.ReactNode; -}>) { + isCollapsed = false, +}: SidebarLayoutProps) { const [showSidebar, setShowSidebar] = useState(false); + const contentPadding = isCollapsed ? "lg:pl-16" : "lg:pl-64"; + return ( -
- {/* Sidebar on desktop */} -
{sidebar}
+
+ {/* Desktop navbar - completely independent, full width, above everything */} +
+ {navbar} +
+ + {/* Sidebar on desktop - starts below navbar */} + {/* Outer frame: animates width, has border */} +
+ {/* Middle: clips content */} +
+ {/* Inner: fixed width, content doesn't move */} +
+ {sidebar} +
+
+
{/* Sidebar on mobile */} setShowSidebar(false)}> @@ -69,23 +93,23 @@ export function SidebarLayout({ {/* Navbar on mobile */} -
+
setShowSidebar(true)} aria-label="Open navigation" > - +
{navbar}
{/* Content */} -
-
-
{children}
-
+
+
{children}
); diff --git a/components/ui-components/sidebar.tsx b/components/ui-components/sidebar.tsx index 953ec1ac..a43946fe 100644 --- a/components/ui-components/sidebar.tsx +++ b/components/ui-components/sidebar.tsx @@ -29,7 +29,7 @@ export function SidebarHeader({ {...props} className={clsx( className, - "flex flex-col border-b border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5", + "flex flex-col p-3 [&>[data-slot=section]+[data-slot=section]]:mt-2.5", )} /> ); @@ -44,7 +44,7 @@ export function SidebarBody({ {...props} className={clsx( className, - "flex flex-1 flex-col overflow-y-auto p-4 [&>[data-slot=section]+[data-slot=section]]:mt-8", + "flex flex-1 flex-col overflow-y-auto p-3 [&>[data-slot=section]+[data-slot=section]]:mt-8", )} /> ); @@ -59,7 +59,7 @@ export function SidebarFooter({ {...props} className={clsx( className, - "flex flex-col border-t border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5", + "flex flex-col p-3 [&>[data-slot=section]+[data-slot=section]]:mt-2.5", )} /> ); @@ -91,7 +91,7 @@ export function SidebarDivider({ {...props} className={clsx( className, - "my-4 border-t border-zinc-950/5 dark:border-white/5 lg:-mx-4", + "my-4 border-t border-neutral-200 dark:border-neutral-800 -mx-3", )} /> ); @@ -119,7 +119,7 @@ export function SidebarHeading({ {...props} className={clsx( className, - "mb-1 px-2 text-xs/6 font-medium text-zinc-500 dark:text-zinc-400", + "mb-1 px-2 text-xs/6 font-medium text-neutral-500 dark:text-neutral-400", )} /> ); @@ -138,25 +138,27 @@ export const SidebarItem = forwardRef(function SidebarItem( ref: React.ForwardedRef, ) { const classes = clsx( - // Base - "flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5", - // Leading icon/icon-only - "data-[slot=icon]:*:size-6 data-[slot=icon]:*:shrink-0 data-[slot=icon]:*:fill-zinc-500 sm:data-[slot=icon]:*:size-5", + // Base - inactive text and icons same grey + "flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-neutral-400 sm:py-2 sm:text-sm/5", + // Collapsed state - animate max-width to contain hover area (matches container 300ms animation) + "max-w-full transition-[max-width] duration-300 ease-in-out [[data-collapsed=true]_&]:max-w-9", + // Leading icon/icon-only - same grey as text + "data-[slot=icon]:*:size-6 data-[slot=icon]:*:shrink-0 data-[slot=icon]:*:text-neutral-400 sm:data-[slot=icon]:*:size-5", // Trailing icon (down chevron or similar) "data-[slot=icon]:last:*:ml-auto data-[slot=icon]:last:*:size-5 sm:data-[slot=icon]:last:*:size-4", // Avatar "data-[slot=avatar]:*:-m-0.5 data-[slot=avatar]:*:size-7 data-[slot=avatar]:*:[--ring-opacity:10%] sm:data-[slot=avatar]:*:size-6", // Hover - "data-[hover]:bg-zinc-950/5 data-[slot=icon]:*:data-[hover]:fill-zinc-950", + "data-[hover]:bg-neutral-200 data-[hover]:text-neutral-800 data-[slot=icon]:*:data-[hover]:text-neutral-800", // Active - "data-[active]:bg-zinc-950/5 data-[slot=icon]:*:data-[active]:fill-zinc-950", - // Current - "data-[slot=icon]:*:data-[current]:fill-zinc-950", - // Dark mode - "dark:text-white dark:data-[slot=icon]:*:fill-zinc-400", - "dark:data-[hover]:bg-white/5 dark:data-[slot=icon]:*:data-[hover]:fill-white", - "dark:data-[active]:bg-white/5 dark:data-[slot=icon]:*:data-[active]:fill-white", - "dark:data-[slot=icon]:*:data-[current]:fill-white", + "data-[active]:bg-neutral-200 data-[active]:text-neutral-800 data-[slot=icon]:*:data-[active]:text-neutral-800", + // Current - text and icons become dark when active + "data-[current]:text-neutral-800 data-[slot=icon]:*:data-[current]:text-neutral-800", + // Dark mode - inactive text and icons same grey + "dark:text-neutral-400 dark:data-[slot=icon]:*:text-neutral-400", + "dark:data-[hover]:bg-neutral-900 dark:data-[hover]:text-white dark:data-[slot=icon]:*:data-[hover]:text-white", + "dark:data-[active]:bg-neutral-900 dark:data-[active]:text-white dark:data-[slot=icon]:*:data-[active]:text-white", + "dark:data-[current]:text-white dark:data-[slot=icon]:*:data-[current]:text-white", ); return ( @@ -164,7 +166,7 @@ export const SidebarItem = forwardRef(function SidebarItem( {current && ( )} {"href" in props ? ( diff --git a/context/SidebarContext.tsx b/context/SidebarContext.tsx new file mode 100644 index 00000000..27d09a51 --- /dev/null +++ b/context/SidebarContext.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { + createContext, + useContext, + useCallback, + type ReactNode, +} from "react"; +import useLocalStorage from "@/hooks/useLocalStorage"; + +interface SidebarContextValue { + isCollapsed: boolean; + toggleSidebar: () => void; + setCollapsed: (collapsed: boolean) => void; +} + +const SidebarContext = createContext( + undefined, +); + +interface SidebarProviderProps { + children: ReactNode; +} + +export function SidebarProvider({ children }: SidebarProviderProps) { + const [isCollapsed, setIsCollapsed] = useLocalStorage( + "sidebar-collapsed", + false, + ); + + const toggleSidebar = useCallback(() => { + setIsCollapsed(!isCollapsed); + }, [isCollapsed, setIsCollapsed]); + + const setCollapsed = useCallback( + (collapsed: boolean) => { + setIsCollapsed(collapsed); + }, + [setIsCollapsed], + ); + + return ( + + {children} + + ); +} + +export function useSidebar(): SidebarContextValue { + const context = useContext(SidebarContext); + if (context === undefined) { + throw new Error("useSidebar must be used within a SidebarProvider"); + } + return context; +} From 568f8bab26e0426e130b1891b7d5c2bda8c5c108 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Wed, 7 Jan 2026 07:18:52 +0000 Subject: [PATCH 02/19] feat(layout): integrate collapsible sidebar into app layout --- app/(app)/layout.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index cdb45497..19bd6261 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,8 +1,6 @@ import { headers } from "next/headers"; import ThemeProvider from "@/components/Theme/ThemeProvider"; import { TRPCReactProvider } from "@/server/trpc/react"; -import Footer from "@/components/Footer/Footer"; -import Nav from "@/components/Nav/Nav"; import { getServerAuthSession } from "@/server/auth"; import AuthProvider from "@/context/AuthProvider"; import ProgressBar from "@/components/ProgressBar/ProgressBar"; @@ -11,6 +9,7 @@ import { PromptProvider } from "@/components/PromptService"; import { db } from "@/server/db"; import { eq } from "drizzle-orm"; import { user } from "@/server/db/schema"; +import { SidebarAppLayout } from "@/components/Layout/SidebarAppLayout"; export const metadata = { title: "Codú - Join Our Web Developer Community", @@ -78,14 +77,12 @@ export default async function RootLayout({ : null; return ( - <> -
); }; diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 7a3f3125..23e92bb5 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -13,7 +13,7 @@ const Home = async () => { const session = await getServerAuthSession(); return ( -
+ <> {!session && (
@@ -70,7 +70,7 @@ const Home = async () => {
- + ); }; diff --git a/app/(editor)/create/[[...paramsArr]]/navigation.tsx b/app/(editor)/create/[[...paramsArr]]/navigation.tsx index d1b5397d..5d01f230 100644 --- a/app/(editor)/create/[[...paramsArr]]/navigation.tsx +++ b/app/(editor)/create/[[...paramsArr]]/navigation.tsx @@ -5,9 +5,9 @@ import { Menu, Transition } from "@headlessui/react"; import { BellIcon } from "@heroicons/react/20/solid"; import { signOut } from "next-auth/react"; import Link from "next/link"; +import Image from "next/image"; import { Fragment } from "react"; import { type Session } from "next-auth"; -import Logo from "@/icons/logo.svg"; import { type PostStatus, status } from "@/utils/post"; type EditorNavProps = { @@ -57,12 +57,21 @@ const EditorNav = ({ const statusText = getStatusText(); return ( -