diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index d55b79f..54aee61 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation"
-import { AdminHeader } from "@/components/admin-header"
import { getServerSession } from "@/server/auth"
import { trpc } from "@/server/trpc"
@@ -21,10 +20,5 @@ export default async function AdminLayout({ children }: { children: React.ReactN
if (!roles.includes("owner") && !roles.includes("direttivo") && !roles.includes("president"))
redirect("/onboarding/unauthorized")
- return (
- <>
-
- {children}
- >
- )
+ return children
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index b503bb8..02c7f72 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -43,9 +43,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
disableTransitionOnChange
>
-
- {children}
-
+ {children}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index abdba51..a658b62 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -9,12 +9,12 @@ export default async function IndexPage() {
if (session.data?.user) redirect("/dashboard")
return (
- <>
+
- >
+
)
}
diff --git a/src/components/admin-header/index.tsx b/src/components/admin-header/index.tsx
deleted file mode 100644
index 135217e..0000000
--- a/src/components/admin-header/index.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import Link from "next/link"
-import { Logo } from "../logo"
-import { RightNav } from "./right-nav"
-
-export function AdminHeader() {
- return (
-
-
-
-
-
-
-
- )
-}
diff --git a/src/components/admin-header/right-nav.tsx b/src/components/admin-header/right-nav.tsx
deleted file mode 100644
index 633341b..0000000
--- a/src/components/admin-header/right-nav.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client"
-import { LogOutIcon, Settings2 } from "lucide-react"
-import Link from "next/link"
-import { redirect } from "next/navigation"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { signOut, useSession } from "@/lib/auth"
-import { getInitials } from "@/lib/utils"
-
-export function RightNav() {
- const { data } = useSession()
-
- return data ? (
-
-
-
-
- {data.user.name ? getInitials(data.user.name) : data.user.email.slice(0, 2)}
-
-
-
- }
- />
-
-
- Account
-
-
-
- Settings
-
-
- {
- await signOut()
- redirect("/login")
- }}
- variant="destructive"
- >
- Logout
-
-
-
-
- ) : (
-
- )
-}
diff --git a/src/components/dashboard-sidebar/data.tsx b/src/components/dashboard-sidebar/data.tsx
new file mode 100644
index 0000000..b946fbe
--- /dev/null
+++ b/src/components/dashboard-sidebar/data.tsx
@@ -0,0 +1,41 @@
+import { MessageCircleMoreIcon, Sparkle, Users } from "lucide-react"
+import Image from "next/image"
+import azureSvg from "@/assets/svg/azure.svg"
+import telegramSvg from "@/assets/svg/telegram.svg"
+
+export const DSData = {
+ mainNav: [
+ {
+ title: "Telegram",
+ icon:
,
+ items: [
+ { title: "Grants", url: "/dashboard/telegram/grants", icon:
},
+ { title: "Groups", url: "/dashboard/telegram/groups", icon:
},
+ { title: "Users", url: "/dashboard/telegram/user-list", icon:
},
+ ],
+ },
+ {
+ title: "Azure",
+ icon:
,
+ items: [{ title: "Members", url: "/dashboard/azure/members", icon:
}],
+ },
+ ],
+}
+
+const flattenNavigation = (): Map
=> {
+ const map = new Map()
+ const traverse = (list: { title: string; url?: string }[]) => {
+ for (const item of list) {
+ if (!item.url) continue
+ map.set(item.url, item.title)
+ }
+ }
+ Object.entries(DSData).forEach(([_k, nav]) => {
+ nav.forEach((category) => {
+ traverse(category.items)
+ })
+ })
+ return map
+}
+
+export const NAV_MAP = flattenNavigation()
diff --git a/src/components/dashboard-sidebar/index.tsx b/src/components/dashboard-sidebar/index.tsx
new file mode 100644
index 0000000..4fb0080
--- /dev/null
+++ b/src/components/dashboard-sidebar/index.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import Link from "next/link"
+import type * as React from "react"
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar"
+import { Logo } from "../logo"
+import { DSMainNav } from "./main-nav"
+import { DSUserNav } from "./user-nav"
+
+export function DashboardSidebar({
+ categoryState,
+ ...props
+}: React.ComponentProps & { categoryState: Record }) {
+ return (
+
+
+
+
+ }>
+
+
+ PoliNetwork APS
+ Admin Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/dashboard-sidebar/main-nav.tsx b/src/components/dashboard-sidebar/main-nav.tsx
new file mode 100644
index 0000000..556d708
--- /dev/null
+++ b/src/components/dashboard-sidebar/main-nav.tsx
@@ -0,0 +1,106 @@
+"use client"
+import { ChevronRight } from "lucide-react"
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+import { useState } from "react"
+import { COOKIES } from "@/constants"
+import { useCookieStorage } from "@/hooks/use-cookie-storage"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"
+import {
+ SidebarGroup,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+} from "../ui/sidebar"
+import { Skeleton } from "../ui/skeleton"
+import { DSData } from "./data"
+
+export function DSMainNav({ categoryState }: { categoryState: Record }) {
+ const [_, setCategoryState] = useCookieStorage>(
+ COOKIES.SIDEBAR_CATEGORY_STATE,
+ {},
+ { expires: 60 * 60 * 24 * 7 }
+ )
+
+ return (
+
+
+ {DSData.mainNav.map((category) => (
+ {
+ setCategoryState((state) => ({ ...state, [category.title]: open }))
+ }}
+ />
+ ))}
+
+
+ )
+}
+
+function DSMenuCategory({
+ category,
+ initialOpen,
+ onPersistOpen,
+}: {
+ category: (typeof DSData)["mainNav"][0]
+ initialOpen?: boolean
+ onPersistOpen: (open: boolean) => void
+}) {
+ const pathname = usePathname()
+ const categoryUrl = category.items[0]?.url.split("/").slice(0, 3).join("/")
+ const [open, setOpen] = useState(initialOpen ?? (categoryUrl ? pathname.startsWith(categoryUrl) : false))
+
+ function handleOpenChange(open: boolean) {
+ setOpen(open)
+ onPersistOpen(open)
+ }
+
+ return open !== undefined ? (
+ } open={open} onOpenChange={handleOpenChange} className="group/collapsible">
+
+ {category.icon}
+ {category.title}
+
+
+ }
+ />
+
+ {category.items?.length ? (
+
+ {category.items.map((item) => (
+
+ ))}
+
+ ) : null}
+
+
+ ) : (
+
+ )
+}
+
+function DSMenuItem({ item }: { item: (typeof DSData)["mainNav"][0]["items"][0] }) {
+ const path = usePathname()
+
+ // NOTE: as of now, we have only 1 level depth of submenu, so using startsWith to
+ // match also subroutes is ok.
+ // If we go with multiple levels of depth it should be changed accordingly.
+ const isActive = path.startsWith(item.url)
+
+ return (
+
+ }>
+ {item.icon}
+ {item.title}
+
+
+ )
+}
diff --git a/src/components/dashboard-sidebar/user-nav.tsx b/src/components/dashboard-sidebar/user-nav.tsx
new file mode 100644
index 0000000..580c2cc
--- /dev/null
+++ b/src/components/dashboard-sidebar/user-nav.tsx
@@ -0,0 +1,110 @@
+"use client"
+
+import { Bell, ChevronsUpDown, LogOut, UserIcon } from "lucide-react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"
+import { signOut, useSession } from "@/lib/auth"
+import { getInitials } from "@/lib/utils"
+import { Skeleton } from "../ui/skeleton"
+
+export function DSUserNav() {
+ const { data } = useSession()
+ const { isMobile } = useSidebar()
+ const router = useRouter()
+
+ const user = data?.user
+
+ if (!user) return
+
+ return (
+
+
+
+
+
+
+
+ {user.name ? getInitials(user.name) : }
+
+
+
+ {user.name}
+ {user.email}
+
+
+
+ }
+ />
+
+
+
+
+
+
+ {user.name ? getInitials(user.name) : }
+
+
+ {user.name}
+ {user.email}
+
+
+
+
+
+
+
+
+
+ Account
+
+
+
+
+ Notifications
+
+
+
+
+ signOut({
+ fetchOptions: {
+ onSuccess: () => {
+ toast.success("Logged out!")
+ router.refresh()
+ },
+ },
+ })
+ }
+ >
+
+ Log out
+
+
+
+
+
+ )
+}
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
index e130133..52a36bc 100644
--- a/src/components/ui/alert.tsx
+++ b/src/components/ui/alert.tsx
@@ -9,6 +9,7 @@ const alertVariants = cva(
variants: {
variant: {
default: "bg-card text-card-foreground",
+ info: "bg-primary/20 text-primary-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 598ffa0..e696922 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -25,12 +25,12 @@ const buttonVariants = cva(
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- icon: "size-8",
+ icon: "size-8 [&_svg:not([class*='size-'])]:size-4.5",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
- "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
- "icon-lg": "size-9",
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-4",
+ "icon-lg": "size-9 [&_svg:not([class*='size-'])]:size-5",
},
},
defaultVariants: {
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx
new file mode 100644
index 0000000..41b2135
--- /dev/null
+++ b/src/components/ui/sidebar.tsx
@@ -0,0 +1,723 @@
+"use client"
+
+import * as React from "react"
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { cn } from "@/lib/utils/shadcn"
+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()
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ )
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {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 (
+
+ )
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ )
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroupLabel({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
+ return useRender({
+ defaultTagName: "div",
+ props: mergeProps<"div">(
+ {
+ className: cn(
+ "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-group-label",
+ sidebar: "group-label",
+ },
+ })
+}
+
+function SidebarGroupAction({
+ className,
+ render,
+ ...props
+}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-group-action",
+ sidebar: "group-action",
+ },
+ })
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function SidebarMenuButton({
+ render,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ isActive?: boolean
+ tooltip?: string | React.ComponentProps
+ } & VariantProps) {
+ const { isMobile, state } = useSidebar()
+ const comp = useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(sidebarMenuButtonVariants({ variant, size }), className),
+ },
+ props
+ ),
+ render: !tooltip ? render : ,
+ state: {
+ slot: "sidebar-menu-button",
+ sidebar: "menu-button",
+ size,
+ active: isActive,
+ },
+ })
+
+ if (!tooltip) {
+ return comp
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ }
+ }
+
+ return (
+
+ {comp}
+
+
+ )
+}
+
+function SidebarMenuAction({
+ className,
+ render,
+ showOnHover = false,
+ ...props
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ showOnHover?: boolean
+ }) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(
+ "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
+ showOnHover &&
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-menu-action",
+ sidebar: "menu-action",
+ },
+ })
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean
+}) {
+ // Random width between 50 to 90%.
+ const [width] = React.useState(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ })
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ )
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubButton({
+ render,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: useRender.ComponentProps<"a"> &
+ React.ComponentProps<"a"> & {
+ size?: "sm" | "md"
+ isActive?: boolean
+ }) {
+ return useRender({
+ defaultTagName: "a",
+ props: mergeProps<"a">(
+ {
+ className: cn(
+ "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
+ className
+ ),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "sidebar-menu-sub-button",
+ sidebar: "menu-sub-button",
+ size,
+ active: isActive,
+ },
+ })
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+}
diff --git a/src/constants.ts b/src/constants.ts
index 80aa13a..74f37c5 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,39 +1,3 @@
-export const USER_ROLE = {
- ADMIN_ORG: "owner",
- MEMBER: "member",
- INACTIVE: "inactive",
- DISABLED: "disabled",
+export const COOKIES = {
+ SIDEBAR_CATEGORY_STATE: "sidebar_category_state",
} as const
-export type TUserRole = (typeof USER_ROLE)[keyof typeof USER_ROLE]
-
-export const DEPARTMENT_ID = {
- IT: "it",
- SOCIAL: "social",
- HR: "hr",
- INST_RELATIONS: "institutional_relations",
- EVENTS: "events",
-} as const
-export type TDepartmentId = (typeof DEPARTMENT_ID)[keyof typeof DEPARTMENT_ID]
-
-// department roles
-export const DEP_ROLE = {
- HEAD: "head",
- DEPUTY_HEAD: "deputy_head",
- MEMBER: "member",
-} as const
-export type TDepRole = (typeof DEP_ROLE)[keyof typeof DEP_ROLE]
-
-export const BOARD_ROLE = {
- PRESIDENT: "president",
- VICE_PRESIDENT: "vice_president",
- SECRETARY: "secretary",
- TREASURER: "treasurer",
- MEMBER: "member",
-} as const
-export type TBoardRole = (typeof BOARD_ROLE)[keyof typeof BOARD_ROLE]
-
-export const INCOMPATIBLE_BOARD_ROLES: TBoardRole[] = [
- BOARD_ROLE.PRESIDENT,
- BOARD_ROLE.VICE_PRESIDENT,
- BOARD_ROLE.SECRETARY,
-] as const
diff --git a/src/hooks/use-cookie-storage.tsx b/src/hooks/use-cookie-storage.tsx
new file mode 100644
index 0000000..a6339cc
--- /dev/null
+++ b/src/hooks/use-cookie-storage.tsx
@@ -0,0 +1,49 @@
+import { type Dispatch, type SetStateAction, useCallback, useMemo, useState } from "react"
+import { type CookieOptions, deleteCookie, getCookie, getDefaultCookieOptions, setCookie } from "@/utils/cookies"
+
+export function useCookieStorage(
+ key: string,
+ initialValue: T,
+ options: CookieOptions = {}
+): [T, Dispatch>] {
+ const envOptions = useMemo(() => getDefaultCookieOptions(), [])
+ const mergedOptions = useMemo(() => ({ ...envOptions, ...options }), [options, envOptions])
+
+ const readValue = useCallback((): T => {
+ if (typeof document === "undefined") {
+ return initialValue
+ }
+
+ try {
+ const item = getCookie(key)
+ return item ? (JSON.parse(item) as T) : initialValue
+ } catch (error) {
+ console.warn(`Error reading cookie "${key}":`, error)
+ return initialValue
+ }
+ }, [key, initialValue])
+
+ const [storedValue, setStoredValue] = useState(readValue)
+
+ const setValue: Dispatch> = useCallback(
+ (value) => {
+ try {
+ const valueToStore = value instanceof Function ? value(storedValue) : value
+ setStoredValue(valueToStore)
+
+ if (typeof document !== "undefined") {
+ if (valueToStore === null || valueToStore === undefined) {
+ deleteCookie(key, { path: mergedOptions.path, domain: mergedOptions.domain })
+ } else {
+ setCookie(key, JSON.stringify(valueToStore), mergedOptions)
+ }
+ }
+ } catch (error) {
+ console.warn(`Error setting cookie "${key}":`, error)
+ }
+ },
+ [key, storedValue, mergedOptions]
+ )
+
+ return [storedValue, setValue]
+}
diff --git a/src/hooks/use-session-storage.tsx b/src/hooks/use-session-storage.tsx
new file mode 100644
index 0000000..b1de618
--- /dev/null
+++ b/src/hooks/use-session-storage.tsx
@@ -0,0 +1,39 @@
+import { type Dispatch, type SetStateAction, useCallback, useState } from "react"
+
+export function useSessionStorage(key: string, initialValue: T): [T, Dispatch>] {
+ // Read from sessionStorage on initialization
+ const readValue = useCallback((): T => {
+ if (typeof window === "undefined") {
+ return initialValue
+ }
+
+ try {
+ const item = window.sessionStorage.getItem(key)
+ return item ? (JSON.parse(item) as T) : initialValue
+ } catch (error) {
+ console.warn(`Error reading sessionStorage key "${key}":`, error)
+ return initialValue
+ }
+ }, [key, initialValue])
+
+ const [storedValue, setStoredValue] = useState(readValue)
+
+ // Return a memoized setter function
+ const setValue: Dispatch> = useCallback(
+ (value) => {
+ try {
+ const valueToStore = value instanceof Function ? value(storedValue) : value
+ setStoredValue(valueToStore)
+
+ if (typeof window !== "undefined") {
+ window.sessionStorage.setItem(key, JSON.stringify(valueToStore))
+ }
+ } catch (error) {
+ console.warn(`Error setting sessionStorage key "${key}":`, error)
+ }
+ },
+ [key, storedValue]
+ )
+
+ return [storedValue, setValue]
+}
diff --git a/src/index.css b/src/index.css
index 41a1b5f..d2dd450 100644
--- a/src/index.css
+++ b/src/index.css
@@ -82,7 +82,7 @@
--placeholder: oklch(0.5623 0.04 280.47);
--accent: oklch(0.35 0.04 254.63);
--accent-foreground: oklch(0.96 0.01 292.8);
- --destructive: oklch(0.51 0.19 27.08);
+ --destructive: oklch(0.61 0.19 27.08);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.35 0.04 254.63);
--input: oklch(0.3961 0.03 259.05);
@@ -92,13 +92,13 @@
--chart-3: oklch(0.72 0.15 58.79);
--chart-4: oklch(0.66 0.2 335.4);
--chart-5: oklch(0.76 0.12 214.15);
- --sidebar: oklch(0.19 0.03 256.45);
+ --sidebar: oklch(0.24 0.03 259.05);
--sidebar-foreground: oklch(0.82 0.02 256.74);
- --sidebar-primary: oklch(0.49 0.22 264.38);
- --sidebar-primary-foreground: oklch(1 0 0);
- --sidebar-accent: oklch(0.27 0.03 254.37);
- --sidebar-accent-foreground: oklch(0.97 0 286.38);
- --sidebar-border: oklch(0 0 0);
+ --sidebar-primary: oklch(0.47 0.16 257.42);
+ --sidebar-primary-foreground: oklch(0.96 0.01 278.64);
+ --sidebar-accent: oklch(0.3 0.03 254.37);
+ --sidebar-accent-foreground: oklch(0.96 0.01 292.8);
+ --sidebar-border: oklch(0.35 0.04 254.63);
--sidebar-ring: oklch(0.47 0.16 257.42);
--font-sans: Poppins, sans-serif;
--font-serif: Libre Baskerville, serif;
@@ -174,3 +174,7 @@
@theme {
--breakpoint-xs: 30rem;
}
+
+@utility container {
+ margin-inline: auto;
+}
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
deleted file mode 100644
index f2b1b92..0000000
--- a/src/lib/i18n.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-// THIS FILE IS TEMPORARY UNTIL i18n IS PROPER IMPLEMENTED
-// HERE ARE IMPLEMENTED SOME FUNCTION THAT ARE USEFUL IN THIS EARLY STAGE
-// FOR REPLACING IDS with WORDS
-
-import { BOARD_ROLE, DEP_ROLE, type TBoardRole, type TDepRole } from "@/constants"
-
-const BOARD_ROLE_STRING_EN: Record = {
- [BOARD_ROLE.PRESIDENT]: "President",
- [BOARD_ROLE.VICE_PRESIDENT]: "Vice President",
- [BOARD_ROLE.SECRETARY]: "Secretary",
- [BOARD_ROLE.TREASURER]: "Treasurer",
- [BOARD_ROLE.MEMBER]: "",
-}
-
-export function getBoardRoleString(role: TBoardRole): string {
- return BOARD_ROLE_STRING_EN[role]
-}
-
-const DEP_ROLE_STRING_EN: Record = {
- [DEP_ROLE.HEAD]: "Head",
- [DEP_ROLE.DEPUTY_HEAD]: "Deputy Head",
- [DEP_ROLE.MEMBER]: "",
-}
-
-export function getDepartmentRoleString(role: TDepRole): string {
- return DEP_ROLE_STRING_EN[role]
-}
diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts
new file mode 100644
index 0000000..e424ead
--- /dev/null
+++ b/src/utils/cookies.ts
@@ -0,0 +1,56 @@
+export type CookieOptions = {
+ expires?: Date | number // Date or seconds from now
+ path?: string
+ domain?: string
+ secure?: boolean
+ sameSite?: "strict" | "lax" | "none"
+}
+
+export function setCookie(name: string, value: string, options: CookieOptions = {}): void {
+ if (typeof document === "undefined") return
+
+ const { expires, path, domain, secure, sameSite } = options
+
+ let cookieString = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
+
+ if (expires !== undefined) {
+ const date = expires instanceof Date ? expires : new Date(Date.now() + expires * 1000)
+ cookieString += `; expires=${date.toUTCString()}`
+ }
+
+ if (path) cookieString += `; path=${path}`
+ if (domain) cookieString += `; domain=${domain}`
+ if (secure) cookieString += "; secure"
+ if (sameSite) cookieString += `; samesite=${sameSite}`
+
+ // biome-ignore lint/suspicious/noDocumentCookie: same as @/ui/sidebar strategy
+ document.cookie = cookieString
+}
+
+export function getCookie(name: string): string | null {
+ if (typeof document === "undefined") return null
+
+ const match = document.cookie.match(
+ new RegExp(`(?:^|; )${encodeURIComponent(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`)
+ )
+
+ return match?.[1] ? decodeURIComponent(match[1]) : null
+}
+
+export function deleteCookie(name: string, options: Pick = {}): void {
+ setCookie(name, "", { ...options, expires: new Date(0) })
+}
+
+export function getDefaultCookieOptions(): CookieOptions {
+ return process.env.NODE_ENV === "development"
+ ? {
+ path: "/",
+ sameSite: "lax",
+ }
+ : {
+ path: "/",
+ domain: ".polinetwork.org",
+ secure: true,
+ sameSite: "strict",
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 3f6bceb..b9dad8c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -29,6 +29,15 @@
"@/*": ["./src/*"]
}
},
- "include": [".eslintrc.cjs", "next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.js", ".next/types/**/*.ts"],
+ "include": [
+ ".eslintrc.cjs",
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ "**/*.cjs",
+ "**/*.js",
+ ".next/types/**/*.ts",
+ "src/components/admin-sidebar"
+ ],
"exclude": ["node_modules"]
}