diff options
| -rw-r--r-- | apps/web/app/admin/users/page.tsx | 15 | ||||
| -rw-r--r-- | apps/web/app/dashboard/error.tsx | 43 | ||||
| -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 | ||||
| -rw-r--r-- | apps/web/components/dashboard/ErrorFallback.tsx | 43 | ||||
| -rw-r--r-- | apps/web/components/shared/sidebar/SidebarLayout.tsx | 10 |
8 files changed, 297 insertions, 175 deletions
diff --git a/apps/web/app/admin/users/page.tsx b/apps/web/app/admin/users/page.tsx index 5af899a4..3c178e79 100644 --- a/apps/web/app/admin/users/page.tsx +++ b/apps/web/app/admin/users/page.tsx @@ -1,5 +1,9 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; +import InvitesList from "@/components/admin/InvitesList"; +import InvitesListSkeleton from "@/components/admin/InvitesListSkeleton"; import UserList from "@/components/admin/UserList"; +import UserListSkeleton from "@/components/admin/UserListSkeleton"; import { useTranslation } from "@/lib/i18n/server"; export async function generateMetadata(): Promise<Metadata> { @@ -11,5 +15,14 @@ export async function generateMetadata(): Promise<Metadata> { } export default function AdminUsersPage() { - return <UserList />; + return ( + <div className="flex flex-col gap-4"> + <Suspense fallback={<UserListSkeleton />}> + <UserList /> + </Suspense> + <Suspense fallback={<InvitesListSkeleton />}> + <InvitesList /> + </Suspense> + </div> + ); } diff --git a/apps/web/app/dashboard/error.tsx b/apps/web/app/dashboard/error.tsx index 2577d2bf..bf1ae0a0 100644 --- a/apps/web/app/dashboard/error.tsx +++ b/apps/web/app/dashboard/error.tsx @@ -1,46 +1,7 @@ "use client"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { AlertTriangle, Home, RefreshCw } from "lucide-react"; +import ErrorFallback from "@/components/dashboard/ErrorFallback"; export default function Error() { - return ( - <div className="flex flex-1 items-center justify-center rounded-lg bg-slate-50 p-8 shadow-sm dark:bg-slate-700/50 dark:shadow-md"> - <div className="w-full max-w-md space-y-8 text-center"> - {/* Error Icon */} - <div className="flex justify-center"> - <div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted"> - <AlertTriangle className="h-10 w-10 text-muted-foreground" /> - </div> - </div> - - {/* Main Content */} - <div className="space-y-4"> - <h1 className="text-balance text-2xl font-semibold text-foreground"> - Oops! Something went wrong - </h1> - <p className="text-pretty leading-relaxed text-muted-foreground"> - We're sorry, but an unexpected error occurred. Please try again - or contact support if the issue persists. - </p> - </div> - - {/* Action Buttons */} - <div className="space-y-3"> - <Button className="w-full" onClick={() => window.location.reload()}> - <RefreshCw className="mr-2 h-4 w-4" /> - Try Again - </Button> - - <Link href="/" className="block"> - <Button variant="outline" className="w-full"> - <Home className="mr-2 h-4 w-4" /> - Go Home - </Button> - </Link> - </div> - </div> - </div> - ); + return <ErrorFallback />; } 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> + ); +} diff --git a/apps/web/components/dashboard/ErrorFallback.tsx b/apps/web/components/dashboard/ErrorFallback.tsx new file mode 100644 index 00000000..7e4ce0d6 --- /dev/null +++ b/apps/web/components/dashboard/ErrorFallback.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, Home, RefreshCw } from "lucide-react"; + +export default function ErrorFallback() { + return ( + <div className="flex flex-1 items-center justify-center rounded-lg bg-slate-50 p-8 shadow-sm dark:bg-slate-700/50 dark:shadow-md"> + <div className="w-full max-w-md space-y-8 text-center"> + <div className="flex justify-center"> + <div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted"> + <AlertTriangle className="h-10 w-10 text-muted-foreground" /> + </div> + </div> + + <div className="space-y-4"> + <h1 className="text-balance text-2xl font-semibold text-foreground"> + Oops! Something went wrong + </h1> + <p className="text-pretty leading-relaxed text-muted-foreground"> + We're sorry, but an unexpected error occurred. Please try again + or contact support if the issue persists. + </p> + </div> + + <div className="space-y-3"> + <Button className="w-full" onClick={() => window.location.reload()}> + <RefreshCw className="mr-2 h-4 w-4" /> + Try Again + </Button> + + <Link href="/" className="block"> + <Button variant="outline" className="w-full"> + <Home className="mr-2 h-4 w-4" /> + Go Home + </Button> + </Link> + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/shared/sidebar/SidebarLayout.tsx b/apps/web/components/shared/sidebar/SidebarLayout.tsx index 8ea8655e..e1b35634 100644 --- a/apps/web/components/shared/sidebar/SidebarLayout.tsx +++ b/apps/web/components/shared/sidebar/SidebarLayout.tsx @@ -1,7 +1,11 @@ +import { Suspense } from "react"; +import ErrorFallback from "@/components/dashboard/ErrorFallback"; import Header from "@/components/dashboard/header/Header"; import DemoModeBanner from "@/components/DemoModeBanner"; import { Separator } from "@/components/ui/separator"; +import LoadingSpinner from "@/components/ui/spinner"; import ValidAccountCheck from "@/components/utils/ValidAccountCheck"; +import { ErrorBoundary } from "react-error-boundary"; import serverConfig from "@karakeep/shared/config"; @@ -29,7 +33,11 @@ export default function SidebarLayout({ <Separator /> </div> {modal} - <div className="min-h-30 container p-4">{children}</div> + <div className="min-h-30 container p-4"> + <ErrorBoundary fallback={<ErrorFallback />}> + <Suspense fallback={<LoadingSpinner />}>{children}</Suspense> + </ErrorBoundary> + </div> </main> </div> </div> |
