diff --git a/src/app/(campanha)/campanha/(app)/layout.tsx b/src/app/(campanha)/campanha/(app)/layout.tsx index 47f59c8f..fee8954f 100644 --- a/src/app/(campanha)/campanha/(app)/layout.tsx +++ b/src/app/(campanha)/campanha/(app)/layout.tsx @@ -1,9 +1,8 @@ -import Link from 'next/link' import { redirect } from 'next/navigation' import React from 'react' -import { logoutCampaign } from '@/app/(campanha)/campanha/actions/auth' -import { Button } from '@/components/ui/button' +import { CampaignSidebar } from '@/components/campanha/campaign-sidebar' +import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' import { getCampaignUser } from '@/utilities/campaignAuth' export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) { @@ -14,23 +13,14 @@ export default async function CampaignAppLayout({ children }: { children: React. } return ( -
-
- -
- {user.name} -
- -
-
-
-
{children}
-
+ + + +
+ +
+
{children}
+
+
) } diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 084c984b..86368e9b 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -211,6 +211,33 @@ body, --field-disabled-background: #f5f5f4; --field-disabled-foreground: rgb(28 25 23 / 55%); --field-icon: rgb(28 25 23 / 55%); + /* Light rail for the campaign app shell (not the public-site dark sidebar). */ + --sidebar: #fafaf9; + --sidebar-foreground: #1c1917; + --sidebar-primary: #c51414; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #f5f5f4; + --sidebar-accent-foreground: #1c1917; + --sidebar-border: rgb(28 25 23 / 12%); + --sidebar-ring: #c51414; +} + +/* + * Cropped full-width Solla mark for the campaign sidebar. + * Source SVG viewBox is 430.9×300.5 but ink sits roughly in y 134–188 — + * size the box to ink height and pin the mask on the ink’s vertical center. + */ +.campaign-logo { + aspect-ratio: 430.9 / 64; + mask-image: url(/LOGO_SOLLA_BRANCO.svg); + mask-size: 100% auto; + mask-repeat: no-repeat; + /* Ink vertical center ≈ (134.4 + 187.8) / 2 / 300.5 */ + mask-position: center 53.6%; + -webkit-mask-image: url(/LOGO_SOLLA_BRANCO.svg); + -webkit-mask-size: 100% auto; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center 53.6%; } .dark { diff --git a/src/components/campanha/campaign-logo.tsx b/src/components/campanha/campaign-logo.tsx new file mode 100644 index 00000000..fe640147 --- /dev/null +++ b/src/components/campanha/campaign-logo.tsx @@ -0,0 +1,20 @@ +import { cn } from '@/lib/utils' + +type CampaignLogoProps = { + className?: string +} + +/** + * Jorge Solla mark for the light campaign shell. + * Recolors the white SVG via CSS mask (see `.campaign-logo` in styles.css) + * so it reads as brand primary on the light sidebar. + */ +export function CampaignLogo({ className }: CampaignLogoProps) { + return ( +
+ ) +} diff --git a/src/components/campanha/campaign-sidebar.tsx b/src/components/campanha/campaign-sidebar.tsx new file mode 100644 index 00000000..23b9ec59 --- /dev/null +++ b/src/components/campanha/campaign-sidebar.tsx @@ -0,0 +1,78 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +import { logoutCampaign } from '@/app/(campanha)/campanha/actions/auth' +import { CampaignLogo } from '@/components/campanha/campaign-logo' +import { campaignNav, isCampaignNavActive } from '@/components/campanha/nav' +import { Button } from '@/components/ui/button' +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/ui/sidebar' + +export type CampaignSidebarUser = { + name: string + email: string +} + +export function CampaignSidebar({ user }: { user: CampaignSidebarUser }) { + const pathname = usePathname() + + return ( + + + + + + + + + + + + {campaignNav.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + + +
+ {user.name} + {user.email} +
+
+ +
+
+
+ ) +} diff --git a/src/components/campanha/nav.ts b/src/components/campanha/nav.ts new file mode 100644 index 00000000..7a93a306 --- /dev/null +++ b/src/components/campanha/nav.ts @@ -0,0 +1,23 @@ +import { HomeIcon, type LucideIcon } from 'lucide-react' + +export type CampaignNavItem = { + title: string + href: string + icon: LucideIcon +} + +export const campaignNav: CampaignNavItem[] = [ + { + title: 'Início', + href: '/campanha', + icon: HomeIcon, + }, +] + +/** Home matches only exactly; other items also match nested paths. */ +export function isCampaignNavActive(pathname: string, href: string): boolean { + if (href === '/campanha') { + return pathname === '/campanha' || pathname === '/campanha/' + } + return pathname === href || pathname.startsWith(`${href}/`) +} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 00000000..370dcf8c --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,147 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..8dcc0b3a --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,707 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { PanelLeftIcon } from "lucide-react" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + +
+ {children} +
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + dir, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + // Mobile sheet must run before collapsible="none" so always-expanded + // desktop rails still open as a sheet on small screens. + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +