aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-12-29 08:56:45 +0000
committerMohamed Bassem <me@mbassem.com>2025-12-29 08:56:45 +0000
commit3c3d86855c649c85f49c47f688039057ffec4a10 (patch)
treeaf6490c192e9a92844deb9605c89a3e42b5c71aa
parent6ee48ffb9d628a04c487b73b222be76241ff3ec4 (diff)
downloadkarakeep-3c3d86855c649c85f49c47f688039057ffec4a10.tar.zst
refactor: add suspense boundary in sidebar layout
-rw-r--r--apps/web/app/admin/users/page.tsx15
-rw-r--r--apps/web/app/dashboard/error.tsx43
-rw-r--r--apps/web/components/admin/InvitesList.tsx32
-rw-r--r--apps/web/components/admin/InvitesListSkeleton.tsx55
-rw-r--r--apps/web/components/admin/UserList.tsx218
-rw-r--r--apps/web/components/admin/UserListSkeleton.tsx56
-rw-r--r--apps/web/components/dashboard/ErrorFallback.tsx43
-rw-r--r--apps/web/components/shared/sidebar/SidebarLayout.tsx10
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&apos;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&apos;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>