diff --git a/src/app/dashboard/(active)/account/loading.tsx b/src/app/dashboard/(active)/account/loading.tsx new file mode 100644 index 0000000..f67b7c1 --- /dev/null +++ b/src/app/dashboard/(active)/account/loading.tsx @@ -0,0 +1,34 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { NewPasskeyButton } from "./passkey-button" + +export default function Loading() { + return ( +
+

Account

+
+ + +
+
+ Name: + +
+ +
+ Email: + +
+
+ Telegram: + +
+
+
+
+

Passkeys

+ + +
+
+ ) +} diff --git a/src/app/dashboard/(active)/account/page.tsx b/src/app/dashboard/(active)/account/page.tsx index d48316d..0ac592a 100644 --- a/src/app/dashboard/(active)/account/page.tsx +++ b/src/app/dashboard/(active)/account/page.tsx @@ -21,7 +21,7 @@ export default async function Account() { const { user } = session return ( -
+

Account

diff --git a/src/app/dashboard/(active)/azure/members/loading.tsx b/src/app/dashboard/(active)/azure/members/loading.tsx index ac43fc0..a6bd30c 100644 --- a/src/app/dashboard/(active)/azure/members/loading.tsx +++ b/src/app/dashboard/(active)/azure/members/loading.tsx @@ -2,7 +2,7 @@ import { SkeletonAssocTable } from "./table" export default function Loading() { return ( -
+
) diff --git a/src/app/dashboard/(active)/azure/members/page.tsx b/src/app/dashboard/(active)/azure/members/page.tsx index cefb25c..4a4d975 100644 --- a/src/app/dashboard/(active)/azure/members/page.tsx +++ b/src/app/dashboard/(active)/azure/members/page.tsx @@ -8,7 +8,7 @@ export default async function AssocMembers() { const members = await getAzureMembers() return ( -
+
Something went wrong
}> }> diff --git a/src/app/dashboard/(active)/azure/members/table.tsx b/src/app/dashboard/(active)/azure/members/table.tsx index 18b19fe..2a5bf70 100644 --- a/src/app/dashboard/(active)/azure/members/table.tsx +++ b/src/app/dashboard/(active)/azure/members/table.tsx @@ -33,7 +33,7 @@ export function AssocTable({ members }: { members: AzureMember[] }) { const users = sociFilter ? members.filter((v) => v.isMember) : members return ( -
+

Utenti MS @polinetwork.org

+

Utenti MS @polinetwork.org

diff --git a/src/app/dashboard/(active)/azure/page.tsx b/src/app/dashboard/(active)/azure/page.tsx deleted file mode 100644 index 853533d..0000000 --- a/src/app/dashboard/(active)/azure/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { UsersRound } from "lucide-react" -import Image from "next/image" -import Link from "next/link" -import azureSvg from "@/assets/svg/azure.svg" -import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" - -export default function AssocIndex() { - return ( -
-

- azure logo - Azure -

-
- - - - - - Members - - Manage all @polinetwork.org accounts - - - -
-
- ) -} diff --git a/src/app/dashboard/(active)/breadcrumb.tsx b/src/app/dashboard/(active)/breadcrumb.tsx new file mode 100644 index 0000000..17052fe --- /dev/null +++ b/src/app/dashboard/(active)/breadcrumb.tsx @@ -0,0 +1,74 @@ +"use client" +import { usePathname } from "next/navigation" +import { Fragment, useMemo } from "react" +import { NAV_MAP } from "@/components/dashboard-sidebar/data" +import { + BreadcrumbItem as BreadcrumbItemComponent, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + Breadcrumb as BreadcrumbRoot, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" + +export type BreadcrumbItem = { + title: string + url?: string +} + +export function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { + const pathname = usePathname() + const items = useMemo(() => getBreadcrumbs(NAV_MAP, pathname), [pathname]) + + return ( + + + {items.map((item, i) => ( + + + {i === items.length - 1 ? ( + {item.title} + ) : item.url ? ( + {item.title} + ) : ( + item.title + )} + + {i < items.length - 1 && } + + ))} + + + ) +} + +function getBreadcrumbs(navMap: Map, pathname: string): BreadcrumbItem[] { + const segments = pathname.split("/").filter(Boolean) + const breadcrumbs: BreadcrumbItem[] = [] + + let currentPath = "" + let i = 0 + + for (const segment of segments) { + currentPath += `/${segment}` + let title = navMap.get(currentPath) + if (!title) { + if (isUUIDorId(segment)) { + title = "Details" + } else { + title = segment.charAt(0).toUpperCase() + segment.slice(1) + } + } + + // note: at the moment we do not plan to make category pages. + // If such pages are made in the future, this logic can be removed + breadcrumbs.push({ title, url: i !== 1 ? currentPath : undefined }) + i++ + } + + return breadcrumbs +} + +function isUUIDorId(segment: string) { + return !Number.isNaN(Number(segment)) || segment.length > 20 // Regex custom a seconda dei tuoi ID +} diff --git a/src/app/dashboard/(active)/complete-profile.tsx b/src/app/dashboard/(active)/complete-profile.tsx index 8de2c3f..89fc4ac 100644 --- a/src/app/dashboard/(active)/complete-profile.tsx +++ b/src/app/dashboard/(active)/complete-profile.tsx @@ -1,13 +1,17 @@ "use client" -import type { User } from "better-auth" import { UserRoundPenIcon } from "lucide-react" import Link from "next/link" import { Button } from "@/components/ui/button" +import { useSession } from "@/lib/auth" + +export function CompleteProfile() { + const { data, isPending } = useSession() + + if (!data || isPending) return null -export function CompleteProfile({ user }: { user: User }) { return ( - !user.name && ( + !data.user.name && (

Your profile is incomplete, please enter the missing information.

diff --git a/src/app/dashboard/(active)/layout.tsx b/src/app/dashboard/(active)/layout.tsx new file mode 100644 index 0000000..016881d --- /dev/null +++ b/src/app/dashboard/(active)/layout.tsx @@ -0,0 +1,50 @@ +import { cookies } from "next/headers" +import { DashboardSidebar } from "@/components/dashboard-sidebar" +import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" +import { COOKIES } from "@/constants" +import { Breadcrumb } from "./breadcrumb" + +function parseCookie(cookie: string) { + try { + const parsed = JSON.parse(cookie) + return parsed + } catch (_e) { + return {} + } +} + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const sidebarCookies = await getSidebarCookies() + + return ( + + + +
+ + +
+
+ {children} +
+
+ ) +} + +async function getSidebarCookies() { + const cookieStore = await cookies() + + const sidebarCategoryStateCookie = cookieStore.get(COOKIES.SIDEBAR_CATEGORY_STATE)?.value + const sidebarOpenCookie = cookieStore.get(COOKIES.SIDEBAR_OPEN)?.value + const state: Record = sidebarCategoryStateCookie ? parseCookie(sidebarCategoryStateCookie) : {} + const open: boolean = sidebarOpenCookie !== undefined ? parseCookie(sidebarOpenCookie) : true + + return { state, open } +} diff --git a/src/app/dashboard/(active)/not-found.tsx b/src/app/dashboard/(active)/not-found.tsx new file mode 100644 index 0000000..e962605 --- /dev/null +++ b/src/app/dashboard/(active)/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link" +import { Button } from "@/components/ui/button" + +export default function NotFound() { + return ( +
+
+

404

+

Not found

+

+ The resource you're looking for doesn't exist or has been moved. +

+
+ + + +
+ ) +} diff --git a/src/app/dashboard/(active)/page.tsx b/src/app/dashboard/(active)/page.tsx index db2caba..a0cb34b 100644 --- a/src/app/dashboard/(active)/page.tsx +++ b/src/app/dashboard/(active)/page.tsx @@ -1,45 +1,16 @@ -import Image from "next/image" -import Link from "next/link" -import azureSvg from "@/assets/svg/azure.svg" -import telegramSvg from "@/assets/svg/telegram.svg" -import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { getServerSession } from "@/server/auth" +import { InfoIcon } from "lucide-react" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { CompleteProfile } from "./complete-profile" -export default async function AdminHome() { - const { data: session } = await getServerSession() +export default function AdminHome() { return ( - session && ( -
- -

Home

- -
- - - - - azure logo - Azure - - Manage Azure related things - - - - - - - - - telegram logo - Telegram - - Manage Telegram related things - - - -
-
- ) +
+ + + + Page under construction + Use the sidebar to access the sections + +
) } diff --git a/src/app/dashboard/(active)/telegram/grants/delete-grant.tsx b/src/app/dashboard/(active)/telegram/grants/delete-grant.tsx index 8743e65..c12558b 100644 --- a/src/app/dashboard/(active)/telegram/grants/delete-grant.tsx +++ b/src/app/dashboard/(active)/telegram/grants/delete-grant.tsx @@ -19,7 +19,13 @@ import { import { Button } from "@/components/ui/button" import { interruptGrant } from "@/server/actions/grants" -export function DeleteGrant({ userId, onDelete }: { userId: number; onDelete(): void }) { +export function DeleteGrant({ + userId, + // onDelete +}: { + userId: number + // onDelete(): void +}) { const router = useRouter() const [open, setOpen] = useState(false) @@ -33,7 +39,7 @@ export function DeleteGrant({ userId, onDelete }: { userId: number; onDelete(): else { toast.success("Grant interrupted successfully") router.refresh() - onDelete() + // onDelete() } } catch (err) { toast.error("There was an error") diff --git a/src/app/dashboard/(active)/telegram/grants/grant-list.tsx b/src/app/dashboard/(active)/telegram/grants/grant-list.tsx index 6f7e02c..aeb93a0 100644 --- a/src/app/dashboard/(active)/telegram/grants/grant-list.tsx +++ b/src/app/dashboard/(active)/telegram/grants/grant-list.tsx @@ -19,7 +19,7 @@ export function GrantList({ grants, isScheduled }: { grants: Grants; isScheduled ))} {grants.length === 0 && ( -
+
There are no {isScheduled ? "scheduled" : "ongoing"} grants
)} @@ -36,7 +36,7 @@ function GrantRow({ row: r }: { row: Grants[number] }) {

{format(r.grant.validSince, "yyyy/MM/dd HH:mm")}

{format(r.grant.validUntil, "yyyy/MM/dd HH:mm")}

- null} /> +
) } diff --git a/src/app/dashboard/(active)/telegram/grants/loading.tsx b/src/app/dashboard/(active)/telegram/grants/loading.tsx index f74f889..3d6aca2 100644 --- a/src/app/dashboard/(active)/telegram/grants/loading.tsx +++ b/src/app/dashboard/(active)/telegram/grants/loading.tsx @@ -4,13 +4,13 @@ import { NewGrant } from "./new-grant" export default async function Loading() { return ( -
-
+
+

Telegram Grants

- + All @@ -36,7 +36,7 @@ export default async function Loading() { function Content() { return (
-
+

Telegram ID

Username

Start Date

diff --git a/src/app/dashboard/(active)/telegram/grants/page.tsx b/src/app/dashboard/(active)/telegram/grants/page.tsx index 737f356..0ca8cb4 100644 --- a/src/app/dashboard/(active)/telegram/grants/page.tsx +++ b/src/app/dashboard/(active)/telegram/grants/page.tsx @@ -8,13 +8,13 @@ export default async function GrantsPage() { const { grants: scheduled } = await trpc.tg.grants.getScheduled.query() return ( -
-
+
+

Telegram Grants

- + All diff --git a/src/app/dashboard/(active)/telegram/groups/group-row.tsx b/src/app/dashboard/(active)/telegram/groups/group-row.tsx index f5b6e9a..bfb8006 100644 --- a/src/app/dashboard/(active)/telegram/groups/group-row.tsx +++ b/src/app/dashboard/(active)/telegram/groups/group-row.tsx @@ -57,7 +57,7 @@ export function GroupRow({ row: r }: { row: TgGroup }) {
-
+

{r.hide ? ( diff --git a/src/app/dashboard/(active)/telegram/groups/loading.tsx b/src/app/dashboard/(active)/telegram/groups/loading.tsx index 9bd5207..b66a765 100644 --- a/src/app/dashboard/(active)/telegram/groups/loading.tsx +++ b/src/app/dashboard/(active)/telegram/groups/loading.tsx @@ -1,21 +1,21 @@ +import { SearchInput } from "@/components/search-input" import { Skeleton } from "@/components/ui/skeleton" -import { SearchInput } from "./search-input" export default async function Loading() { return ( -

+
-
+

Count:

-
-
-

telegram ID

+
+
+

Telegram ID

Title

Tag

Invite Link

-

Actions

+

Actions

{new Array(12).fill(0).map((_, i) => ( diff --git a/src/app/dashboard/(active)/telegram/groups/page.tsx b/src/app/dashboard/(active)/telegram/groups/page.tsx index 6753e61..47ba9cd 100644 --- a/src/app/dashboard/(active)/telegram/groups/page.tsx +++ b/src/app/dashboard/(active)/telegram/groups/page.tsx @@ -1,6 +1,6 @@ +import { SearchInput } from "@/components/search-input" import { trpc } from "@/server/trpc" import { GroupRow } from "./group-row" -import { SearchInput } from "./search-input" export default async function TgGroups({ searchParams }: { searchParams: Promise<{ q?: string }> }) { const { q } = await searchParams @@ -12,18 +12,18 @@ export default async function TgGroups({ searchParams }: { searchParams: Promise const sorted = rows.sort((a, b) => a.title.localeCompare(b.title)) return ( -
+
-

+

Count: {rows.length}

-
-
-

telegram ID

+
+
+

Telegram ID

Title

Tag

Invite Link

-

Actions

+

Actions

{sorted.map((r) => ( diff --git a/src/app/dashboard/(active)/telegram/page.tsx b/src/app/dashboard/(active)/telegram/page.tsx deleted file mode 100644 index c9adc44..0000000 --- a/src/app/dashboard/(active)/telegram/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { MessageCircleMore, Sparkle, UserCog, Users } from "lucide-react" -import Image from "next/image" -import Link from "next/link" -import telegramSvg from "@/assets/svg/telegram.svg" -import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" - -export default function TelegramIndex() { - return ( -
-

- telegram logo - Telegram -

-
- - - - - - Groups - - Search groups and get links - - - - - - - - - - User Details - - Manage user roles and group admins - - - - - - - - - - User List - - See list of all our users - - - - - - - - - - Grants - - Manage grants - - - -
-
- ) -} diff --git a/src/app/dashboard/(active)/telegram/user-details/page.tsx b/src/app/dashboard/(active)/telegram/user-details/page.tsx deleted file mode 100644 index 40d1671..0000000 --- a/src/app/dashboard/(active)/telegram/user-details/page.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client" -import { ArrowLeft, RefreshCcw, Search, X } from "lucide-react" -import Link from "next/link" -import { Suspense, useState, useTransition } from "react" -import { Spinner } from "@/components/spinner" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { searchUser } from "@/server/actions/users" -import { AuditLogCard } from "./card-audit-log" -import { GroupAdminCard } from "./card-group-admin" -import { MessageCard } from "./card-message" -import { UserGrantCard } from "./card-user-grant" -import { UserInfoCard } from "./card-user-info" -import { NewGroupAdmin } from "./new-group-admin" - -type Data = Awaited> - -export default function TgUsers() { - const [username, setUsername] = useState("") - const [data, setData] = useState(null) - const [pending, startTransition] = useTransition() - - async function submit() { - startTransition(async () => { - const user = await searchUser(username) - setData(user) - }) - } - - async function reset() { - setUsername("") - setData(null) - } - - return ( -
- - Back - - -
-
- -
- { - setUsername(e.target.value) - }} - value={username} - /> - - {data ? ( - - ) : ( - - )} - {data && ( - - )} -
-
-
- -
- - {data && ( - <> -
- - {data.grant && } -
- -
-

Admin in groups:

- g?.group.id ?? 0) ?? []} - onConfirm={submit} - /> -
-
- {data.groupAdmin - .filter((m) => m !== null && m !== undefined) - .map((m) => ( - - ))} - - {data.groupAdmin.length === 0 && ( -

- This user is not group admin in any group. -

- )} -
- -

Last messages (max 12):

-
- {data.messages?.map((m) => ( - - ))} - - {data.messages?.length === 0 && ( -

- No recent messages sent by this user -

- )} -
- -

Audit log:

-
- {data.audits.map((m) => ( - - - - ))} - {data.audits.length === 0 && ( -

- No audit log found for this user -

- )} -
- - )} -
- ) -} diff --git a/src/app/dashboard/(active)/telegram/user-list/page.tsx b/src/app/dashboard/(active)/telegram/user-list/page.tsx deleted file mode 100644 index 42e0f5e..0000000 --- a/src/app/dashboard/(active)/telegram/user-list/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { trpc } from "@/server/trpc" -import type { ApiOutput } from "@/server/trpc/types" - -type Users = NonNullable - -export default async function TgUsers() { - const data = await trpc.tg.users.getAll.query() - return ( -
-

- Count: {data.users?.length} -

-
-
-

Telegram ID

-

Username

-

Name

-
- {data?.users?.map((r) => ( - - ))} -
-
- ) -} - -function UserRow({ row: r }: { row: Users[number] }) { - return ( -
-

{r.id}

-

{r.username ? `@${r.username}` : ``}

-

- {r.firstName ?? ""} {r.lastName ?? ""} -

-
- ) -} diff --git a/src/app/dashboard/(active)/telegram/user-details/add-role.tsx b/src/app/dashboard/(active)/telegram/users/[id]/add-role.tsx similarity index 94% rename from src/app/dashboard/(active)/telegram/user-details/add-role.tsx rename to src/app/dashboard/(active)/telegram/users/[id]/add-role.tsx index dfd5685..361ddc1 100644 --- a/src/app/dashboard/(active)/telegram/user-details/add-role.tsx +++ b/src/app/dashboard/(active)/telegram/users/[id]/add-role.tsx @@ -1,6 +1,7 @@ "use client" import { USER_ROLE } from "@polinetwork/backend" import { Plus } from "lucide-react" +import { useRouter } from "next/navigation" import { useState } from "react" import { toast } from "sonner" import { Spinner } from "@/components/spinner" @@ -29,7 +30,15 @@ const ARRAY_USER_ROLES = [ USER_ROLE.PRESIDENT, ] as const -export function AddRole({ user, alreadyRoles, onAdd }: { user: TgUser; alreadyRoles: TgUserRole[]; onAdd(): void }) { +export function AddRole({ + user, + alreadyRoles, + // onAdd +}: { + user: TgUser + alreadyRoles: TgUserRole[] + // onAdd(): void +}) { const availableRoles = ARRAY_USER_ROLES.filter((r) => !alreadyRoles.includes(r)).map((g) => ({ value: g, label: `${g.slice(0, 1).toUpperCase()}${g.slice(1)}`, @@ -39,6 +48,8 @@ export function AddRole({ user, alreadyRoles, onAdd }: { user: TgUser; alreadyRo const [pending, setPending] = useState(false) const [selectedRole, setSelectedRole] = useState(null) + const router = useRouter() + async function submit() { if (!selectedRole) return toast.warning("No group selected, cannot proceed") if (!user) return toast.warning("Invalid user, try restarting the dialog") @@ -53,7 +64,8 @@ export function AddRole({ user, alreadyRoles, onAdd }: { user: TgUser; alreadyRo else if (error === "UNAUTHORIZED_SELF_ASSIGN") toast.error("You cannot add roles to yourself") else { toast.success(`Role added!`) - onAdd() + router.refresh() + // onAdd() } handleOpenChange(false) } catch (err) { diff --git a/src/app/dashboard/(active)/telegram/user-details/card-audit-log.tsx b/src/app/dashboard/(active)/telegram/users/[id]/card-audit-log.tsx similarity index 91% rename from src/app/dashboard/(active)/telegram/user-details/card-audit-log.tsx rename to src/app/dashboard/(active)/telegram/users/[id]/card-audit-log.tsx index 681216e..4c412d8 100644 --- a/src/app/dashboard/(active)/telegram/user-details/card-audit-log.tsx +++ b/src/app/dashboard/(active)/telegram/users/[id]/card-audit-log.tsx @@ -1,8 +1,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { fmtUser } from "@/lib/utils/telegram" -import type { searchUser } from "@/server/actions/users" +import type { getUserDetails } from "@/server/actions/users" -type Data = Awaited> +type Data = Awaited> type Log = NonNullable["audits"][number] export function AuditLogCard({ log: m }: { log: Log }) { diff --git a/src/app/dashboard/(active)/telegram/user-details/card-group-admin.tsx b/src/app/dashboard/(active)/telegram/users/[id]/card-group-admin.tsx similarity index 91% rename from src/app/dashboard/(active)/telegram/user-details/card-group-admin.tsx rename to src/app/dashboard/(active)/telegram/users/[id]/card-group-admin.tsx index e098242..ea70a93 100644 --- a/src/app/dashboard/(active)/telegram/user-details/card-group-admin.tsx +++ b/src/app/dashboard/(active)/telegram/users/[id]/card-group-admin.tsx @@ -10,11 +10,11 @@ type GroupAdminSingle = NonNullable groupAdminInfo: GroupAdminSingle - onDelete(): void + // onDelete(): void }) { return ( @@ -32,7 +32,7 @@ export function GroupAdminCard({

- +
) diff --git a/src/app/dashboard/(active)/telegram/user-details/card-message.tsx b/src/app/dashboard/(active)/telegram/users/[id]/card-message.tsx similarity index 100% rename from src/app/dashboard/(active)/telegram/user-details/card-message.tsx rename to src/app/dashboard/(active)/telegram/users/[id]/card-message.tsx diff --git a/src/app/dashboard/(active)/telegram/user-details/card-user-grant.tsx b/src/app/dashboard/(active)/telegram/users/[id]/card-user-grant.tsx similarity index 78% rename from src/app/dashboard/(active)/telegram/user-details/card-user-grant.tsx rename to src/app/dashboard/(active)/telegram/users/[id]/card-user-grant.tsx index 05a80ab..7dbeb37 100644 --- a/src/app/dashboard/(active)/telegram/user-details/card-user-grant.tsx +++ b/src/app/dashboard/(active)/telegram/users/[id]/card-user-grant.tsx @@ -3,9 +3,17 @@ import { format } from "date-fns" import { Sparkle } from "lucide-react" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import type { TgGrant, TgUser } from "@/server/trpc/types" -import { DeleteGrant } from "../grants/delete-grant" +import { DeleteGrant } from "../../grants/delete-grant" -export function UserGrantCard({ user, grant, onDelete }: { user: TgUser; grant: TgGrant; onDelete(): void }) { +export function UserGrantCard({ + user, + grant, + //onDelete +}: { + user: TgUser + grant: TgGrant + //onDelete(): void +}) { return ( @@ -19,7 +27,7 @@ export function UserGrantCard({ user, grant, onDelete }: { user: TgUser; grant:

End: {format(grant.validUntil, "yyyy/MM/dd HH:mm")}

- +
) diff --git a/src/app/dashboard/(active)/telegram/user-details/card-user-info.tsx b/src/app/dashboard/(active)/telegram/users/[id]/card-user-info.tsx similarity index 89% rename from src/app/dashboard/(active)/telegram/user-details/card-user-info.tsx rename to src/app/dashboard/(active)/telegram/users/[id]/card-user-info.tsx index ca2362a..52741df 100644 --- a/src/app/dashboard/(active)/telegram/user-details/card-user-info.tsx +++ b/src/app/dashboard/(active)/telegram/users/[id]/card-user-info.tsx @@ -1,3 +1,4 @@ +"use client" import { Star } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" @@ -12,11 +13,11 @@ type UserRoles = ApiOutput["tg"]["permissions"]["getRoles"]["roles"] export function UserInfoCard({ user, roles, - onUpdate, + // onUpdate, }: { user: NonNullable roles: UserRoles - onUpdate(): void + // onUpdate(): void }) { const sesh = useSession() const seshUserId = sesh.data?.user.telegramId @@ -49,8 +50,8 @@ export function UserInfoCard({
- - + + ) diff --git a/src/app/dashboard/(active)/telegram/user-details/delete-group-admin.tsx b/src/app/dashboard/(active)/telegram/users/[id]/delete-group-admin.tsx similarity index 90% rename from src/app/dashboard/(active)/telegram/user-details/delete-group-admin.tsx rename to src/app/dashboard/(active)/telegram/users/[id]/delete-group-admin.tsx index 3a2cdcc..25b0b36 100644 --- a/src/app/dashboard/(active)/telegram/user-details/delete-group-admin.tsx +++ b/src/app/dashboard/(active)/telegram/users/[id]/delete-group-admin.tsx @@ -1,6 +1,7 @@ "use client" import { Trash2, Trash2Icon } from "lucide-react" +import { useRouter } from "next/navigation" import { useState } from "react" import { toast } from "sonner" import { Spinner } from "@/components/spinner" @@ -19,10 +20,20 @@ import { import { Button } from "@/components/ui/button" import { delGroupAdmin } from "@/server/actions/users" -export function DeleteGroupAdmin({ userId, chatId, onDelete }: { userId: number; chatId: number; onDelete(): void }) { +export function DeleteGroupAdmin({ + userId, + chatId, + // onDelete +}: { + userId: number + chatId: number + // onDelete(): void +}) { const [open, setOpen] = useState(false) const [pending, setPending] = useState(false) + const router = useRouter() + async function deleteGroupAdmin() { setPending(true) @@ -35,7 +46,8 @@ export function DeleteGroupAdmin({ userId, chatId, onDelete }: { userId: number; else if (error === "UNAUTHORIZED_SELF_ASSIGN") toast.error("You cannot delete on yourself") else { toast.success("Group Admin deleted!") - onDelete() + router.refresh() + // onDelete() } } catch (err) { toast.error("There was an error") diff --git a/src/app/dashboard/(active)/telegram/users/[id]/loading.tsx b/src/app/dashboard/(active)/telegram/users/[id]/loading.tsx new file mode 100644 index 0000000..be3d0ae --- /dev/null +++ b/src/app/dashboard/(active)/telegram/users/[id]/loading.tsx @@ -0,0 +1,37 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default async function Loading() { + return ( +
+
+ + +
+ +
+

Admin in groups:

+
+ + +
+
+ +
+

Last messages (max 12):

+
+ + +
+
+ +
+

Audit log:

+
+ {new Array(9).fill(0).map((_, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/dashboard/(active)/telegram/user-details/new-group-admin.tsx b/src/app/dashboard/(active)/telegram/users/[id]/new-group-admin.tsx similarity index 94% rename from src/app/dashboard/(active)/telegram/user-details/new-group-admin.tsx rename to src/app/dashboard/(active)/telegram/users/[id]/new-group-admin.tsx index 58763cc..840e20d 100644 --- a/src/app/dashboard/(active)/telegram/user-details/new-group-admin.tsx +++ b/src/app/dashboard/(active)/telegram/users/[id]/new-group-admin.tsx @@ -1,5 +1,6 @@ "use client" import { Plus, Search, X } from "lucide-react" +import { useRouter } from "next/navigation" import { useState } from "react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -23,12 +24,22 @@ import type { ApiOutput } from "@/server/trpc/types" type Groups = ApiOutput["tg"]["groups"]["search"]["groups"] type User = ApiOutput["tg"]["users"]["getByUsername"]["user"] -export function NewGroupAdmin({ user, alreadyIn, onConfirm }: { user: User; alreadyIn: number[]; onConfirm(): void }) { +export function NewGroupAdmin({ + user, + alreadyIn, + // onConfirm +}: { + user: User + alreadyIn: number[] + // onConfirm(): void +}) { const [open, setOpen] = useState(false) const [groupQuery, setGroupQuery] = useState("") const [groups, setGroups] = useState([]) const [selectedGroup, setSelectedGroup] = useState(null) + const router = useRouter() + async function search() { const { groups } = await searchGroup(groupQuery) setGroups(groups.filter((g) => !alreadyIn.includes(g.telegramId))) @@ -44,7 +55,8 @@ export function NewGroupAdmin({ user, alreadyIn, onConfirm }: { user: User; alre toast.error("You don't have enough permissions") } else { toast.info(`Group admin added`) - onConfirm() + router.refresh() + // onConfirm() } } catch (err) { console.error(err) diff --git a/src/app/dashboard/(active)/telegram/users/[id]/page.tsx b/src/app/dashboard/(active)/telegram/users/[id]/page.tsx new file mode 100644 index 0000000..bc83ede --- /dev/null +++ b/src/app/dashboard/(active)/telegram/users/[id]/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from "next/navigation" +import { Suspense } from "react" +import { getUserDetails } from "@/server/actions/users" +import { AuditLogCard } from "./card-audit-log" +import { GroupAdminCard } from "./card-group-admin" +import { MessageCard } from "./card-message" +import { UserGrantCard } from "./card-user-grant" +import { UserInfoCard } from "./card-user-info" +import { NewGroupAdmin } from "./new-group-admin" + +export default async function TgUserDetails({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const parsedInt = parseInt(id, 10) + if (isNaN(parsedInt)) notFound() + const data = await getUserDetails(parsedInt) + + if (!data) notFound() + + return ( +
+ {data && ( + <> +
+ + {data.grant && } +
+ +
+
+

Admin in groups:

+ g?.group.id ?? 0) ?? []} /> +
+ +
+ {data.groupAdmin + .filter((m) => m !== null && m !== undefined) + .map((m) => ( + + ))} + + {data.groupAdmin.length === 0 && ( +

+ This user is not group admin in any group. +

+ )} +
+
+ +
+

Last messages (max 15):

+
+ {data.messages?.map((m) => ( + + ))} + + {data.messages?.length === 0 && ( +

+ No recent messages sent by this user +

+ )} +
+
+ +
+

Audit log:

+
+ {data.audits.map((m) => ( + + + + ))} + {data.audits.length === 0 && ( +

+ No audit log found for this user +

+ )} +
+
+ + )} +
+ ) +} diff --git a/src/app/dashboard/(active)/telegram/user-details/remove-role.tsx b/src/app/dashboard/(active)/telegram/users/[id]/remove-role.tsx similarity index 96% rename from src/app/dashboard/(active)/telegram/user-details/remove-role.tsx rename to src/app/dashboard/(active)/telegram/users/[id]/remove-role.tsx index fcf7f23..82ddde0 100644 --- a/src/app/dashboard/(active)/telegram/user-details/remove-role.tsx +++ b/src/app/dashboard/(active)/telegram/users/[id]/remove-role.tsx @@ -1,6 +1,7 @@ "use client" import { USER_ROLE } from "@polinetwork/backend" import { Minus } from "lucide-react" +import { useRouter } from "next/navigation" import { useState } from "react" import { toast } from "sonner" import { Spinner } from "@/components/spinner" @@ -33,11 +34,11 @@ const ARRAY_USER_ROLES = [ export function RemoveRole({ user, alreadyRoles, - onDelete, + // onDelete, }: { user: TgUser alreadyRoles: TgUserRole[] - onDelete(): void + // onDelete(): void }) { const sesh = useSession() const removerId = sesh.data?.user.telegramId @@ -51,6 +52,8 @@ export function RemoveRole({ const [pending, setPending] = useState(false) const [selectedRole, setSelectedRole] = useState(null) + const router = useRouter() + async function submit() { if (!removerId) return toast.warning("Invalid session, try reloading the page") if (!selectedRole) return toast.warning("No group selected, cannot proceed") @@ -66,7 +69,8 @@ export function RemoveRole({ else if (error === "UNAUTHORIZED_SELF_ASSIGN") toast.error("You cannot delete on yourself") else { toast.success("Role removed!") - onDelete() + router.refresh() + // onDelete() } } catch (err) { console.error(err) diff --git a/src/app/dashboard/(active)/telegram/user-list/loading.tsx b/src/app/dashboard/(active)/telegram/users/loading.tsx similarity index 66% rename from src/app/dashboard/(active)/telegram/user-list/loading.tsx rename to src/app/dashboard/(active)/telegram/users/loading.tsx index 55f8aa6..e289840 100644 --- a/src/app/dashboard/(active)/telegram/user-list/loading.tsx +++ b/src/app/dashboard/(active)/telegram/users/loading.tsx @@ -1,17 +1,20 @@ +import { SearchInput } from "@/components/search-input" import { Skeleton } from "@/components/ui/skeleton" export default async function Loading() { return ( -
-
+
+ +

Count:

-
-
+
+

Telegram ID

Username

Name

+

Actions

{new Array(12).fill(0).map((_, i) => ( diff --git a/src/app/dashboard/(active)/telegram/users/page.tsx b/src/app/dashboard/(active)/telegram/users/page.tsx new file mode 100644 index 0000000..5a52f6d --- /dev/null +++ b/src/app/dashboard/(active)/telegram/users/page.tsx @@ -0,0 +1,56 @@ +import { Eye } from "lucide-react" +import Link from "next/link" +import { SearchInput } from "@/components/search-input" +import { Button } from "@/components/ui/button" +import { trpc } from "@/server/trpc" +import type { ApiOutput } from "@/server/trpc/types" + +type Users = NonNullable + +export default async function TgUsers({ searchParams }: { searchParams: Promise<{ q?: string }> }) { + const { q } = await searchParams + const { users } = await trpc.tg.users.getAll.query() + + const data = (!q ? users : users?.filter((u) => u.username?.toLowerCase().replace("@", "").startsWith(q))) ?? [] + + return ( +
+ +

+ Count: {users?.length} +

+
+
+

Telegram ID

+

Username

+

Name

+

Actions

+
+ {data.length > 0 ? ( + data.map((r) => ) + ) : ( +
No users found
+ )} +
+
+ ) +} + +function UserRow({ row: r }: { row: Users[number] }) { + return ( +
+

{r.id}

+

{r.username ? `@${r.username}` : ``}

+

+ {r.firstName ?? ""} {r.lastName ?? ""} +

+
+ + + +
+
+ ) +} 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 808a440..0000000 --- a/src/components/admin-header/right-nav.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client" -import { LogOutIcon, Settings2, UserIcon } from "lucide-react" -import Link from "next/link" -import { redirect } from "next/navigation" -import { Avatar, AvatarFallback, AvatarImage } 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) : } - - - - } - /> - - - 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..7401a0c --- /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: telegram logo, + items: [ + { title: "Grants", url: "/dashboard/telegram/grants", icon: }, + { title: "Groups", url: "/dashboard/telegram/groups", icon: }, + { title: "Users", url: "/dashboard/telegram/users", icon: }, + ], + }, + { + title: "Azure", + icon: azure logo, + 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..7b7acf5 --- /dev/null +++ b/src/components/dashboard-sidebar/main-nav.tsx @@ -0,0 +1,103 @@ +"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 { 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={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/app/dashboard/(active)/telegram/groups/search-input.tsx b/src/components/search-input.tsx similarity index 100% rename from src/app/dashboard/(active)/telegram/groups/search-input.tsx rename to src/components/search-input.tsx 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..3205f8a --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,727 @@ +"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" +import { useCookieStorage } from "@/hooks/use-cookie-storage" +import { COOKIES } from "@/constants" + +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 [_, setOpenCookie] = useCookieStorage( + COOKIES.SIDEBAR_OPEN, + false, + { expires: 60 * 60 * 24 * 7 } + ) + 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) + } + + setOpenCookie(openState) + }, + [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 ( +