diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-12-29 08:56:45 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-12-29 08:56:45 +0000 |
| commit | 3c3d86855c649c85f49c47f688039057ffec4a10 (patch) | |
| tree | af6490c192e9a92844deb9605c89a3e42b5c71aa /apps/web/components/admin | |
| parent | 6ee48ffb9d628a04c487b73b222be76241ff3ec4 (diff) | |
| download | karakeep-3c3d86855c649c85f49c47f688039057ffec4a10.tar.zst | |
refactor: add suspense boundary in sidebar layout
Diffstat (limited to 'apps/web/components/admin')
| -rw-r--r-- | apps/web/components/admin/InvitesList.tsx | 32 | ||||
| -rw-r--r-- | apps/web/components/admin/InvitesListSkeleton.tsx | 55 | ||||
| -rw-r--r-- | apps/web/components/admin/UserList.tsx | 218 | ||||
| -rw-r--r-- | apps/web/components/admin/UserListSkeleton.tsx | 56 |
4 files changed, 229 insertions, 132 deletions
diff --git a/apps/web/components/admin/InvitesList.tsx b/apps/web/components/admin/InvitesList.tsx index fdc39798..75d29748 100644 --- a/apps/web/components/admin/InvitesList.tsx +++ b/apps/web/components/admin/InvitesList.tsx @@ -3,7 +3,6 @@ import { ActionButton } from "@/components/ui/action-button"; import { ButtonWithTooltip } from "@/components/ui/button"; import { toast } from "@/components/ui/sonner"; -import LoadingSpinner from "@/components/ui/spinner"; import { Table, TableBody, @@ -17,11 +16,12 @@ import { formatDistanceToNow } from "date-fns"; import { Mail, MailX, UserPlus } from "lucide-react"; import ActionConfirmingDialog from "../ui/action-confirming-dialog"; +import { AdminCard } from "./AdminCard"; import CreateInviteDialog from "./CreateInviteDialog"; export default function InvitesList() { const invalidateInvitesList = api.useUtils().invites.list.invalidate; - const { data: invites, isLoading } = api.invites.list.useQuery(); + const [invites] = api.invites.list.useSuspenseQuery(); const { mutateAsync: revokeInvite, isPending: isRevokePending } = api.invites.revoke.useMutation({ @@ -55,10 +55,6 @@ export default function InvitesList() { }, }); - if (isLoading) { - return <LoadingSpinner />; - } - const activeInvites = invites?.invites || []; const InviteTable = ({ @@ -139,17 +135,19 @@ export default function InvitesList() { ); return ( - <div className="flex flex-col gap-4"> - <div className="mb-2 flex items-center justify-between text-xl font-medium"> - <span>User Invitations ({activeInvites.length})</span> - <CreateInviteDialog> - <ButtonWithTooltip tooltip="Send Invite" variant="outline"> - <UserPlus size={16} /> - </ButtonWithTooltip> - </CreateInviteDialog> - </div> + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between text-xl font-medium"> + <span>User Invitations ({activeInvites.length})</span> + <CreateInviteDialog> + <ButtonWithTooltip tooltip="Send Invite" variant="outline"> + <UserPlus size={16} /> + </ButtonWithTooltip> + </CreateInviteDialog> + </div> - <InviteTable invites={activeInvites} title="Invites" /> - </div> + <InviteTable invites={activeInvites} title="Invites" /> + </div> + </AdminCard> ); } diff --git a/apps/web/components/admin/InvitesListSkeleton.tsx b/apps/web/components/admin/InvitesListSkeleton.tsx new file mode 100644 index 00000000..19e8088d --- /dev/null +++ b/apps/web/components/admin/InvitesListSkeleton.tsx @@ -0,0 +1,55 @@ +import { AdminCard } from "@/components/admin/AdminCard"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const headerWidths = ["w-40", "w-28", "w-20", "w-20"]; + +export default function InvitesListSkeleton() { + return ( + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between"> + <Skeleton className="h-6 w-48" /> + <Skeleton className="h-9 w-9" /> + </div> + + <Table> + <TableHeader> + <TableRow> + {headerWidths.map((width, index) => ( + <TableHead key={`invite-list-header-${index}`}> + <Skeleton className={`h-4 ${width}`} /> + </TableHead> + ))} + </TableRow> + </TableHeader> + <TableBody> + {Array.from({ length: 2 }).map((_, rowIndex) => ( + <TableRow key={`invite-list-row-${rowIndex}`}> + {headerWidths.map((width, cellIndex) => ( + <TableCell key={`invite-list-cell-${rowIndex}-${cellIndex}`}> + {cellIndex === headerWidths.length - 1 ? ( + <div className="flex gap-2"> + <Skeleton className="h-6 w-6" /> + <Skeleton className="h-6 w-6" /> + </div> + ) : ( + <Skeleton className={`h-4 ${width}`} /> + )} + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> + ); +} diff --git a/apps/web/components/admin/UserList.tsx b/apps/web/components/admin/UserList.tsx index 3a382a3b..91467f94 100644 --- a/apps/web/components/admin/UserList.tsx +++ b/apps/web/components/admin/UserList.tsx @@ -3,7 +3,6 @@ import { ActionButton } from "@/components/ui/action-button"; import { ButtonWithTooltip } from "@/components/ui/button"; import { toast } from "@/components/ui/sonner"; -import LoadingSpinner from "@/components/ui/spinner"; import { Table, TableBody, @@ -20,7 +19,6 @@ import { useSession } from "next-auth/react"; import ActionConfirmingDialog from "../ui/action-confirming-dialog"; import AddUserDialog from "./AddUserDialog"; import { AdminCard } from "./AdminCard"; -import InvitesList from "./InvitesList"; import ResetPasswordDialog from "./ResetPasswordDialog"; import UpdateUserDialog from "./UpdateUserDialog"; @@ -35,8 +33,8 @@ export default function UsersSection() { const { t } = useTranslation(); const { data: session } = useSession(); const invalidateUserList = api.useUtils().users.list.invalidate; - const { data: users } = api.users.list.useQuery(); - const { data: userStats } = api.admin.userStats.useQuery(); + const [{ users }] = api.users.list.useSuspenseQuery(); + const [userStats] = api.admin.userStats.useSuspenseQuery(); const { mutateAsync: deleteUser, isPending: isDeletionPending } = api.users.delete.useMutation({ onSuccess: () => { @@ -53,120 +51,110 @@ export default function UsersSection() { }, }); - if (!users || !userStats) { - return <LoadingSpinner />; - } - return ( - <div className="flex flex-col gap-4"> - <AdminCard> - <div className="flex flex-col gap-4"> - <div className="mb-2 flex items-center justify-between text-xl font-medium"> - <span>{t("admin.users_list.users_list")}</span> - <AddUserDialog> - <ButtonWithTooltip tooltip="Create User" variant="outline"> - <UserPlus size={16} /> - </ButtonWithTooltip> - </AddUserDialog> - </div> + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between text-xl font-medium"> + <span>{t("admin.users_list.users_list")}</span> + <AddUserDialog> + <ButtonWithTooltip tooltip="Create User" variant="outline"> + <UserPlus size={16} /> + </ButtonWithTooltip> + </AddUserDialog> + </div> - <Table> - <TableHeader className="bg-gray-200"> - <TableRow> - <TableHead>{t("common.name")}</TableHead> - <TableHead>{t("common.email")}</TableHead> - <TableHead>{t("admin.users_list.num_bookmarks")}</TableHead> - <TableHead>{t("admin.users_list.asset_sizes")}</TableHead> - <TableHead>{t("common.role")}</TableHead> - <TableHead>{t("admin.users_list.local_user")}</TableHead> - <TableHead>{t("common.actions")}</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {users.users.map((u) => ( - <TableRow key={u.id}> - <TableCell className="py-1">{u.name}</TableCell> - <TableCell className="py-1">{u.email}</TableCell> - <TableCell className="py-1"> - {userStats[u.id].numBookmarks} /{" "} - {u.bookmarkQuota ?? t("admin.users_list.unlimited")} - </TableCell> - <TableCell className="py-1"> - {toHumanReadableSize(userStats[u.id].assetSizes)} /{" "} - {u.storageQuota - ? toHumanReadableSize(u.storageQuota) - : t("admin.users_list.unlimited")} - </TableCell> - <TableCell className="py-1"> - {u.role && t(`common.roles.${u.role}`)} - </TableCell> - <TableCell className="py-1"> - {u.localUser ? <Check /> : <X />} - </TableCell> - <TableCell className="flex gap-1 py-1"> - <ActionConfirmingDialog - title={t("admin.users_list.delete_user")} - description={t( - "admin.users_list.delete_user_confirm_description", - { - name: u.name ?? "this user", - }, - )} - actionButton={(setDialogOpen) => ( - <ActionButton - variant="destructive" - loading={isDeletionPending} - onClick={async () => { - await deleteUser({ userId: u.id }); - setDialogOpen(false); - }} - > - Delete - </ActionButton> - )} - > - <ButtonWithTooltip - tooltip={t("admin.users_list.delete_user")} - variant="outline" - disabled={session!.user.id == u.id} - > - <Trash size={16} color="red" /> - </ButtonWithTooltip> - </ActionConfirmingDialog> - <ResetPasswordDialog userId={u.id}> - <ButtonWithTooltip - tooltip={t("admin.users_list.reset_password")} - variant="outline" - disabled={session!.user.id == u.id || !u.localUser} + <Table> + <TableHeader className="bg-gray-200"> + <TableRow> + <TableHead>{t("common.name")}</TableHead> + <TableHead>{t("common.email")}</TableHead> + <TableHead>{t("admin.users_list.num_bookmarks")}</TableHead> + <TableHead>{t("admin.users_list.asset_sizes")}</TableHead> + <TableHead>{t("common.role")}</TableHead> + <TableHead>{t("admin.users_list.local_user")}</TableHead> + <TableHead>{t("common.actions")}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {users.map((u) => ( + <TableRow key={u.id}> + <TableCell className="py-1">{u.name}</TableCell> + <TableCell className="py-1">{u.email}</TableCell> + <TableCell className="py-1"> + {userStats[u.id].numBookmarks} /{" "} + {u.bookmarkQuota ?? t("admin.users_list.unlimited")} + </TableCell> + <TableCell className="py-1"> + {toHumanReadableSize(userStats[u.id].assetSizes)} /{" "} + {u.storageQuota + ? toHumanReadableSize(u.storageQuota) + : t("admin.users_list.unlimited")} + </TableCell> + <TableCell className="py-1"> + {u.role && t(`common.roles.${u.role}`)} + </TableCell> + <TableCell className="py-1"> + {u.localUser ? <Check /> : <X />} + </TableCell> + <TableCell className="flex gap-1 py-1"> + <ActionConfirmingDialog + title={t("admin.users_list.delete_user")} + description={t( + "admin.users_list.delete_user_confirm_description", + { + name: u.name ?? "this user", + }, + )} + actionButton={(setDialogOpen) => ( + <ActionButton + variant="destructive" + loading={isDeletionPending} + onClick={async () => { + await deleteUser({ userId: u.id }); + setDialogOpen(false); + }} > - <KeyRound size={16} color="red" /> - </ButtonWithTooltip> - </ResetPasswordDialog> - <UpdateUserDialog - userId={u.id} - currentRole={u.role!} - currentQuota={u.bookmarkQuota} - currentStorageQuota={u.storageQuota} + Delete + </ActionButton> + )} + > + <ButtonWithTooltip + tooltip={t("admin.users_list.delete_user")} + variant="outline" + disabled={session!.user.id == u.id} > - <ButtonWithTooltip - tooltip="Edit User" - variant="outline" - disabled={session!.user.id == u.id} - > - <Pencil size={16} color="red" /> - </ButtonWithTooltip> - </UpdateUserDialog> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - </AdminCard> - - <AdminCard> - <InvitesList /> - </AdminCard> - </div> + <Trash size={16} color="red" /> + </ButtonWithTooltip> + </ActionConfirmingDialog> + <ResetPasswordDialog userId={u.id}> + <ButtonWithTooltip + tooltip={t("admin.users_list.reset_password")} + variant="outline" + disabled={session!.user.id == u.id || !u.localUser} + > + <KeyRound size={16} color="red" /> + </ButtonWithTooltip> + </ResetPasswordDialog> + <UpdateUserDialog + userId={u.id} + currentRole={u.role!} + currentQuota={u.bookmarkQuota} + currentStorageQuota={u.storageQuota} + > + <ButtonWithTooltip + tooltip="Edit User" + variant="outline" + disabled={session!.user.id == u.id} + > + <Pencil size={16} color="red" /> + </ButtonWithTooltip> + </UpdateUserDialog> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> ); } diff --git a/apps/web/components/admin/UserListSkeleton.tsx b/apps/web/components/admin/UserListSkeleton.tsx new file mode 100644 index 00000000..3da80aa1 --- /dev/null +++ b/apps/web/components/admin/UserListSkeleton.tsx @@ -0,0 +1,56 @@ +import { AdminCard } from "@/components/admin/AdminCard"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const headerWidths = ["w-24", "w-32", "w-28", "w-28", "w-20", "w-16", "w-24"]; + +export default function UserListSkeleton() { + return ( + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between"> + <Skeleton className="h-6 w-40" /> + <Skeleton className="h-9 w-9" /> + </div> + + <Table> + <TableHeader> + <TableRow> + {headerWidths.map((width, index) => ( + <TableHead key={`user-list-header-${index}`}> + <Skeleton className={`h-4 ${width}`} /> + </TableHead> + ))} + </TableRow> + </TableHeader> + <TableBody> + {Array.from({ length: 4 }).map((_, rowIndex) => ( + <TableRow key={`user-list-row-${rowIndex}`}> + {headerWidths.map((width, cellIndex) => ( + <TableCell key={`user-list-cell-${rowIndex}-${cellIndex}`}> + {cellIndex === headerWidths.length - 1 ? ( + <div className="flex gap-2"> + <Skeleton className="h-6 w-6" /> + <Skeleton className="h-6 w-6" /> + <Skeleton className="h-6 w-6" /> + </div> + ) : ( + <Skeleton className={`h-4 ${width}`} /> + )} + </TableCell> + ))} + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> + ); +} |
