aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/app
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/app')
-rw-r--r--apps/web/app/admin/admin_tools/page.tsx19
-rw-r--r--apps/web/app/admin/layout.tsx7
-rw-r--r--apps/web/app/admin/users/page.tsx15
-rw-r--r--apps/web/app/check-email/page.tsx32
-rw-r--r--apps/web/app/dashboard/error.tsx43
-rw-r--r--apps/web/app/dashboard/highlights/page.tsx16
-rw-r--r--apps/web/app/dashboard/layout.tsx37
-rw-r--r--apps/web/app/dashboard/lists/page.tsx15
-rw-r--r--apps/web/app/layout.tsx2
-rw-r--r--apps/web/app/logout/page.tsx2
-rw-r--r--apps/web/app/reader/[bookmarkId]/page.tsx176
-rw-r--r--apps/web/app/reader/layout.tsx39
-rw-r--r--apps/web/app/settings/assets/page.tsx22
-rw-r--r--apps/web/app/settings/broken-links/page.tsx18
-rw-r--r--apps/web/app/settings/import/[sessionId]/page.tsx20
-rw-r--r--apps/web/app/settings/info/page.tsx4
-rw-r--r--apps/web/app/settings/layout.tsx41
-rw-r--r--apps/web/app/settings/rules/page.tsx25
-rw-r--r--apps/web/app/settings/stats/page.tsx38
-rw-r--r--apps/web/app/signup/page.tsx15
-rw-r--r--apps/web/app/verify-email/page.tsx89
21 files changed, 351 insertions, 324 deletions
diff --git a/apps/web/app/admin/admin_tools/page.tsx b/apps/web/app/admin/admin_tools/page.tsx
new file mode 100644
index 00000000..e036c755
--- /dev/null
+++ b/apps/web/app/admin/admin_tools/page.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from "next";
+import BookmarkDebugger from "@/components/admin/BookmarkDebugger";
+import { useTranslation } from "@/lib/i18n/server";
+
+export async function generateMetadata(): Promise<Metadata> {
+ // oxlint-disable-next-line rules-of-hooks
+ const { t } = await useTranslation();
+ return {
+ title: `${t("admin.admin_tools.admin_tools")} | Karakeep`,
+ };
+}
+
+export default function AdminToolsPage() {
+ return (
+ <div className="flex flex-col gap-6">
+ <BookmarkDebugger />
+ </div>
+ );
+}
diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx
index 4b589712..03144b78 100644
--- a/apps/web/app/admin/layout.tsx
+++ b/apps/web/app/admin/layout.tsx
@@ -6,7 +6,7 @@ import Sidebar from "@/components/shared/sidebar/Sidebar";
import SidebarLayout from "@/components/shared/sidebar/SidebarLayout";
import { getServerAuthSession } from "@/server/auth";
import { TFunction } from "i18next";
-import { Activity, ArrowLeft, Settings, Users } from "lucide-react";
+import { Activity, ArrowLeft, Settings, Users, Wrench } from "lucide-react";
const adminSidebarItems = (
t: TFunction,
@@ -35,6 +35,11 @@ const adminSidebarItems = (
icon: <Settings size={18} />,
path: "/admin/background_jobs",
},
+ {
+ name: t("admin.admin_tools.admin_tools"),
+ icon: <Wrench size={18} />,
+ path: "/admin/admin_tools",
+ },
];
export default async function AdminLayout({
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/check-email/page.tsx b/apps/web/app/check-email/page.tsx
index 227e116c..50eed4bd 100644
--- a/apps/web/app/check-email/page.tsx
+++ b/apps/web/app/check-email/page.tsx
@@ -11,30 +11,38 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
-import { api } from "@/lib/trpc";
+import { useMutation } from "@tanstack/react-query";
import { Loader2, Mail } from "lucide-react";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+import { validateRedirectUrl } from "@karakeep/shared/utils/redirectUrl";
+
export default function CheckEmailPage() {
+ const api = useTRPC();
const searchParams = useSearchParams();
const router = useRouter();
const [message, setMessage] = useState("");
const email = searchParams.get("email");
+ const redirectUrl =
+ validateRedirectUrl(searchParams.get("redirectUrl")) ?? "/";
- const resendEmailMutation = api.users.resendVerificationEmail.useMutation({
- onSuccess: () => {
- setMessage(
- "A new verification email has been sent to your email address.",
- );
- },
- onError: (error) => {
- setMessage(error.message || "Failed to resend verification email.");
- },
- });
+ const resendEmailMutation = useMutation(
+ api.users.resendVerificationEmail.mutationOptions({
+ onSuccess: () => {
+ setMessage(
+ "A new verification email has been sent to your email address.",
+ );
+ },
+ onError: (error) => {
+ setMessage(error.message || "Failed to resend verification email.");
+ },
+ }),
+ );
const handleResendEmail = () => {
if (email) {
- resendEmailMutation.mutate({ email });
+ resendEmailMutation.mutate({ email, redirectUrl });
}
};
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/app/dashboard/highlights/page.tsx b/apps/web/app/dashboard/highlights/page.tsx
index 5945de00..ed0b16c0 100644
--- a/apps/web/app/dashboard/highlights/page.tsx
+++ b/apps/web/app/dashboard/highlights/page.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import AllHighlights from "@/components/dashboard/highlights/AllHighlights";
-import { Separator } from "@/components/ui/separator";
import { useTranslation } from "@/lib/i18n/server";
import { api } from "@/server/api/client";
import { Highlighter } from "lucide-react";
@@ -18,13 +17,14 @@ export default async function HighlightsPage() {
const { t } = await useTranslation();
const highlights = await api.highlights.getAll({});
return (
- <div className="flex flex-col gap-8 rounded-md border bg-background p-4">
- <span className="flex items-center gap-1 text-2xl">
- <Highlighter className="size-6" />
- {t("common.highlights")}
- </span>
- <Separator />
- <AllHighlights highlights={highlights} />
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center">
+ <Highlighter className="mr-2" />
+ <p className="text-2xl">{t("common.highlights")}</p>
+ </div>
+ <div className="flex flex-col gap-8 rounded-md border bg-background p-4">
+ <AllHighlights highlights={highlights} />
+ </div>
</div>
);
}
diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx
index 911d542c..be65e66a 100644
--- a/apps/web/app/dashboard/layout.tsx
+++ b/apps/web/app/dashboard/layout.tsx
@@ -4,6 +4,7 @@ import MobileSidebar from "@/components/shared/sidebar/MobileSidebar";
import Sidebar from "@/components/shared/sidebar/Sidebar";
import SidebarLayout from "@/components/shared/sidebar/SidebarLayout";
import { Separator } from "@/components/ui/separator";
+import { ReaderSettingsProvider } from "@/lib/readerSettings";
import { UserSettingsContextProvider } from "@/lib/userSettings";
import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth";
@@ -98,23 +99,25 @@ export default async function Dashboard({
return (
<UserSettingsContextProvider userSettings={userSettings.data}>
- <SidebarLayout
- sidebar={
- <Sidebar
- items={items}
- extraSections={
- <>
- <Separator />
- <AllLists initialData={lists.data} />
- </>
- }
- />
- }
- mobileSidebar={<MobileSidebar items={mobileSidebar} />}
- modal={modal}
- >
- {children}
- </SidebarLayout>
+ <ReaderSettingsProvider>
+ <SidebarLayout
+ sidebar={
+ <Sidebar
+ items={items}
+ extraSections={
+ <>
+ <Separator />
+ <AllLists initialData={lists.data} />
+ </>
+ }
+ />
+ }
+ mobileSidebar={<MobileSidebar items={mobileSidebar} />}
+ modal={modal}
+ >
+ {children}
+ </SidebarLayout>
+ </ReaderSettingsProvider>
</UserSettingsContextProvider>
);
}
diff --git a/apps/web/app/dashboard/lists/page.tsx b/apps/web/app/dashboard/lists/page.tsx
index 7950cd76..2f9e54c6 100644
--- a/apps/web/app/dashboard/lists/page.tsx
+++ b/apps/web/app/dashboard/lists/page.tsx
@@ -1,8 +1,10 @@
import AllListsView from "@/components/dashboard/lists/AllListsView";
+import { EditListModal } from "@/components/dashboard/lists/EditListModal";
import { PendingInvitationsCard } from "@/components/dashboard/lists/PendingInvitationsCard";
-import { Separator } from "@/components/ui/separator";
+import { Button } from "@/components/ui/button";
import { useTranslation } from "@/lib/i18n/server";
import { api } from "@/server/api/client";
+import { Plus } from "lucide-react";
export default async function ListsPage() {
// oxlint-disable-next-line rules-of-hooks
@@ -11,10 +13,17 @@ export default async function ListsPage() {
return (
<div className="flex flex-col gap-4">
+ <div className="flex items-center justify-between">
+ <p className="text-2xl">📋 {t("lists.all_lists")}</p>
+ <EditListModal>
+ <Button className="flex items-center">
+ <Plus className="mr-2 size-4" />
+ <span>{t("lists.new_list")}</span>
+ </Button>
+ </EditListModal>
+ </div>
<PendingInvitationsCard />
<div className="flex flex-col gap-3 rounded-md border bg-background p-4">
- <p className="text-2xl">📋 {t("lists.all_lists")}</p>
- <Separator />
<AllListsView initialData={lists.lists} />
</div>
</div>
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 8514b8ad..ba09a973 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -8,11 +8,11 @@ import "@karakeep/tailwind-config/globals.css";
import type { Viewport } from "next";
import React from "react";
-import { Toaster } from "@/components/ui/toaster";
import Providers from "@/lib/providers";
import { getUserLocalSettings } from "@/lib/userLocalSettings/userLocalSettings";
import { getServerAuthSession } from "@/server/auth";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { Toaster } from "sonner";
import { clientConfig } from "@karakeep/shared/config";
diff --git a/apps/web/app/logout/page.tsx b/apps/web/app/logout/page.tsx
index 91ad684d..1e43622e 100644
--- a/apps/web/app/logout/page.tsx
+++ b/apps/web/app/logout/page.tsx
@@ -2,7 +2,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
-import { signOut } from "next-auth/react";
+import { signOut } from "@/lib/auth/client";
import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history";
diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx
index e32811a9..0ba72016 100644
--- a/apps/web/app/reader/[bookmarkId]/page.tsx
+++ b/apps/web/app/reader/[bookmarkId]/page.tsx
@@ -3,63 +3,42 @@
import { Suspense, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import HighlightCard from "@/components/dashboard/highlights/HighlightCard";
+import ReaderSettingsPopover from "@/components/dashboard/preview/ReaderSettingsPopover";
import ReaderView from "@/components/dashboard/preview/ReaderView";
import { Button } from "@/components/ui/button";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
-import { Slider } from "@/components/ui/slider";
-import {
- HighlighterIcon as Highlight,
- Minus,
- Plus,
- Printer,
- Settings,
- Type,
- X,
-} from "lucide-react";
-import { useSession } from "next-auth/react";
+import { useSession } from "@/lib/auth/client";
+import { useReaderSettings } from "@/lib/readerSettings";
+import { useQuery } from "@tanstack/react-query";
+import { HighlighterIcon as Highlight, Printer, X } from "lucide-react";
-import { api } from "@karakeep/shared-react/trpc";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers";
import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
export default function ReaderViewPage() {
+ const api = useTRPC();
const params = useParams<{ bookmarkId: string }>();
const bookmarkId = params.bookmarkId;
- const { data: highlights } = api.highlights.getForBookmark.useQuery({
- bookmarkId,
- });
- const { data: bookmark } = api.bookmarks.getBookmark.useQuery({
- bookmarkId,
- });
+ const { data: highlights } = useQuery(
+ api.highlights.getForBookmark.queryOptions({
+ bookmarkId,
+ }),
+ );
+ const { data: bookmark } = useQuery(
+ api.bookmarks.getBookmark.queryOptions({
+ bookmarkId,
+ }),
+ );
const { data: session } = useSession();
const router = useRouter();
- const [fontSize, setFontSize] = useState([18]);
- const [lineHeight, setLineHeight] = useState([1.6]);
- const [fontFamily, setFontFamily] = useState("serif");
+ const { settings } = useReaderSettings();
const [showHighlights, setShowHighlights] = useState(false);
- const [showSettings, setShowSettings] = useState(false);
const isOwner = session?.user?.id === bookmark?.userId;
- const fontFamilies = {
- serif: "ui-serif, Georgia, Cambria, serif",
- sans: "ui-sans-serif, system-ui, sans-serif",
- mono: "ui-monospace, Menlo, Monaco, monospace",
- };
-
const onClose = () => {
if (window.history.length > 1) {
router.back();
@@ -89,94 +68,7 @@ export default function ReaderViewPage() {
<Printer className="h-4 w-4" />
</Button>
- <Popover open={showSettings} onOpenChange={setShowSettings}>
- <PopoverTrigger asChild>
- <Button variant="ghost" size="icon">
- <Settings className="h-4 w-4" />
- </Button>
- </PopoverTrigger>
- <PopoverContent side="bottom" align="end" className="w-80">
- <div className="space-y-4">
- <div className="flex items-center gap-2 pb-2">
- <Type className="h-4 w-4" />
- <h3 className="font-semibold">Reading Settings</h3>
- </div>
-
- <div className="space-y-4">
- <div className="space-y-2">
- <label className="text-sm font-medium">Font Family</label>
- <Select value={fontFamily} onValueChange={setFontFamily}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="serif">Serif</SelectItem>
- <SelectItem value="sans">Sans Serif</SelectItem>
- <SelectItem value="mono">Monospace</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <label className="text-sm font-medium">Font Size</label>
- <span className="text-sm text-muted-foreground">
- {fontSize[0]}px
- </span>
- </div>
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="icon"
- className="h-7 w-7 bg-transparent"
- onClick={() =>
- setFontSize([Math.max(12, fontSize[0] - 1)])
- }
- >
- <Minus className="h-3 w-3" />
- </Button>
- <Slider
- value={fontSize}
- onValueChange={setFontSize}
- max={24}
- min={12}
- step={1}
- className="flex-1"
- />
- <Button
- variant="outline"
- size="icon"
- className="h-7 w-7 bg-transparent"
- onClick={() =>
- setFontSize([Math.min(24, fontSize[0] + 1)])
- }
- >
- <Plus className="h-3 w-3" />
- </Button>
- </div>
- </div>
-
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <label className="text-sm font-medium">
- Line Height
- </label>
- <span className="text-sm text-muted-foreground">
- {lineHeight[0]}
- </span>
- </div>
- <Slider
- value={lineHeight}
- onValueChange={setLineHeight}
- max={2.5}
- min={1.2}
- step={0.1}
- />
- </div>
- </div>
- </div>
- </PopoverContent>
- </Popover>
+ <ReaderSettingsPopover variant="ghost" />
<Button
variant={showHighlights ? "default" : "ghost"}
@@ -216,10 +108,9 @@ export default function ReaderViewPage() {
<h1
className="font-bold leading-tight"
style={{
- fontFamily:
- fontFamilies[fontFamily as keyof typeof fontFamilies],
- fontSize: `${fontSize[0] * 1.8}px`,
- lineHeight: lineHeight[0] * 0.9,
+ fontFamily: READER_FONT_FAMILIES[settings.fontFamily],
+ fontSize: `${settings.fontSize * 1.8}px`,
+ lineHeight: settings.lineHeight * 0.9,
}}
>
{getBookmarkTitle(bookmark)}
@@ -239,10 +130,9 @@ export default function ReaderViewPage() {
<ReaderView
className="prose prose-neutral max-w-none break-words dark:prose-invert [&_code]:break-all [&_img]:h-auto [&_img]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto"
style={{
- fontFamily:
- fontFamilies[fontFamily as keyof typeof fontFamilies],
- fontSize: `${fontSize[0]}px`,
- lineHeight: lineHeight[0],
+ fontFamily: READER_FONT_FAMILIES[settings.fontFamily],
+ fontSize: `${settings.fontSize}px`,
+ lineHeight: settings.lineHeight,
}}
bookmarkId={bookmarkId}
readOnly={!isOwner}
@@ -256,20 +146,6 @@ export default function ReaderViewPage() {
</article>
</main>
- {/* Mobile backdrop */}
- {showHighlights && (
- <button
- className="fixed inset-0 top-14 z-40 bg-black/50 lg:hidden"
- onClick={() => setShowHighlights(false)}
- onKeyDown={(e) => {
- if (e.key === "Escape") {
- setShowHighlights(false);
- }
- }}
- aria-label="Close highlights sidebar"
- />
- )}
-
{/* Highlights Sidebar */}
{showHighlights && highlights && (
<aside className="fixed right-0 top-14 z-50 h-[calc(100vh-3.5rem)] w-full border-l bg-background sm:w-80 lg:z-auto lg:bg-background/95 lg:backdrop-blur lg:supports-[backdrop-filter]:bg-background/60 print:hidden">
diff --git a/apps/web/app/reader/layout.tsx b/apps/web/app/reader/layout.tsx
new file mode 100644
index 00000000..b0c27c84
--- /dev/null
+++ b/apps/web/app/reader/layout.tsx
@@ -0,0 +1,39 @@
+import { redirect } from "next/navigation";
+import { ReaderSettingsProvider } from "@/lib/readerSettings";
+import { UserSettingsContextProvider } from "@/lib/userSettings";
+import { api } from "@/server/api/client";
+import { getServerAuthSession } from "@/server/auth";
+import { TRPCError } from "@trpc/server";
+
+import { tryCatch } from "@karakeep/shared/tryCatch";
+
+export default async function ReaderLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/");
+ }
+
+ const userSettings = await tryCatch(api.users.settings());
+
+ if (userSettings.error) {
+ if (userSettings.error instanceof TRPCError) {
+ if (
+ userSettings.error.code === "NOT_FOUND" ||
+ userSettings.error.code === "UNAUTHORIZED"
+ ) {
+ redirect("/logout");
+ }
+ }
+ throw userSettings.error;
+ }
+
+ return (
+ <UserSettingsContextProvider userSettings={userSettings.data}>
+ <ReaderSettingsProvider>{children}</ReaderSettingsProvider>
+ </UserSettingsContextProvider>
+ );
+}
diff --git a/apps/web/app/settings/assets/page.tsx b/apps/web/app/settings/assets/page.tsx
index 14144455..77b3d159 100644
--- a/apps/web/app/settings/assets/page.tsx
+++ b/apps/web/app/settings/assets/page.tsx
@@ -5,6 +5,7 @@ import { ActionButton } from "@/components/ui/action-button";
import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
import { Button } from "@/components/ui/button";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
+import { toast } from "@/components/ui/sonner";
import {
Table,
TableBody,
@@ -13,14 +14,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import { toast } from "@/components/ui/use-toast";
import { ASSET_TYPE_TO_ICON } from "@/lib/attachments";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
import { formatBytes } from "@/lib/utils";
+import { useInfiniteQuery } from "@tanstack/react-query";
import { ExternalLink, Trash2 } from "lucide-react";
import { useDetachBookmarkAsset } from "@karakeep/shared-react/hooks/assets";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
import {
humanFriendlyNameForAssertType,
@@ -28,6 +29,7 @@ import {
} from "@karakeep/trpc/lib/attachments";
export default function AssetsSettingsPage() {
+ const api = useTRPC();
const { t } = useTranslation();
const { mutate: detachAsset, isPending: isDetaching } =
useDetachBookmarkAsset({
@@ -49,13 +51,15 @@ export default function AssetsSettingsPage() {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
- } = api.assets.list.useInfiniteQuery(
- {
- limit: 20,
- },
- {
- getNextPageParam: (lastPage) => lastPage.nextCursor,
- },
+ } = useInfiniteQuery(
+ api.assets.list.infiniteQueryOptions(
+ {
+ limit: 20,
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ ),
);
const assets = data?.pages.flatMap((page) => page.assets) ?? [];
diff --git a/apps/web/app/settings/broken-links/page.tsx b/apps/web/app/settings/broken-links/page.tsx
index e2b42d07..4197d62e 100644
--- a/apps/web/app/settings/broken-links/page.tsx
+++ b/apps/web/app/settings/broken-links/page.tsx
@@ -2,6 +2,7 @@
import { ActionButton } from "@/components/ui/action-button";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
+import { toast } from "@/components/ui/sonner";
import {
Table,
TableBody,
@@ -10,7 +11,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import { toast } from "@/components/ui/use-toast";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -18,20 +19,23 @@ import {
useDeleteBookmark,
useRecrawlBookmark,
} from "@karakeep/shared-react/hooks/bookmarks";
-import { api } from "@karakeep/shared-react/trpc";
+import { useTRPC } from "@karakeep/shared-react/trpc";
export default function BrokenLinksPage() {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
- const { data, isPending } = api.bookmarks.getBrokenLinks.useQuery();
+ const queryClient = useQueryClient();
+ const { data, isPending } = useQuery(
+ api.bookmarks.getBrokenLinks.queryOptions(),
+ );
const { mutate: deleteBookmark, isPending: isDeleting } = useDeleteBookmark({
onSuccess: () => {
toast({
description: t("toasts.bookmarks.deleted"),
});
- apiUtils.bookmarks.getBrokenLinks.invalidate();
+ queryClient.invalidateQueries(api.bookmarks.getBrokenLinks.pathFilter());
},
onError: () => {
toast({
@@ -47,7 +51,9 @@ export default function BrokenLinksPage() {
toast({
description: t("toasts.bookmarks.refetch"),
});
- apiUtils.bookmarks.getBrokenLinks.invalidate();
+ queryClient.invalidateQueries(
+ api.bookmarks.getBrokenLinks.pathFilter(),
+ );
},
onError: () => {
toast({
diff --git a/apps/web/app/settings/import/[sessionId]/page.tsx b/apps/web/app/settings/import/[sessionId]/page.tsx
new file mode 100644
index 00000000..968de13a
--- /dev/null
+++ b/apps/web/app/settings/import/[sessionId]/page.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from "next";
+import ImportSessionDetail from "@/components/settings/ImportSessionDetail";
+import { useTranslation } from "@/lib/i18n/server";
+
+export async function generateMetadata(): Promise<Metadata> {
+ // oxlint-disable-next-line rules-of-hooks
+ const { t } = await useTranslation();
+ return {
+ title: `${t("settings.import_sessions.detail.page_title")} | Karakeep`,
+ };
+}
+
+export default async function ImportSessionDetailPage({
+ params,
+}: {
+ params: Promise<{ sessionId: string }>;
+}) {
+ const { sessionId } = await params;
+ return <ImportSessionDetail sessionId={sessionId} />;
+}
diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx
index 1807b538..da9b2e51 100644
--- a/apps/web/app/settings/info/page.tsx
+++ b/apps/web/app/settings/info/page.tsx
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { ChangePassword } from "@/components/settings/ChangePassword";
import { DeleteAccount } from "@/components/settings/DeleteAccount";
+import ReaderSettings from "@/components/settings/ReaderSettings";
+import UserAvatar from "@/components/settings/UserAvatar";
import UserDetails from "@/components/settings/UserDetails";
import UserOptions from "@/components/settings/UserOptions";
import { useTranslation } from "@/lib/i18n/server";
@@ -16,9 +18,11 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function InfoPage() {
return (
<div className="flex flex-col gap-4">
+ <UserAvatar />
<UserDetails />
<ChangePassword />
<UserOptions />
+ <ReaderSettings />
<DeleteAccount />
</div>
);
diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx
index 1c7d25ac..8d211e53 100644
--- a/apps/web/app/settings/layout.tsx
+++ b/apps/web/app/settings/layout.tsx
@@ -1,8 +1,12 @@
+import { redirect } from "next/navigation";
import MobileSidebar from "@/components/shared/sidebar/MobileSidebar";
import Sidebar from "@/components/shared/sidebar/Sidebar";
import SidebarLayout from "@/components/shared/sidebar/SidebarLayout";
+import { ReaderSettingsProvider } from "@/lib/readerSettings";
import { UserSettingsContextProvider } from "@/lib/userSettings";
import { api } from "@/server/api/client";
+import { getServerAuthSession } from "@/server/auth";
+import { TRPCError } from "@trpc/server";
import { TFunction } from "i18next";
import {
ArrowLeft,
@@ -21,6 +25,7 @@ import {
} from "lucide-react";
import serverConfig from "@karakeep/shared/config";
+import { tryCatch } from "@karakeep/shared/tryCatch";
const settingsSidebarItems = (
t: TFunction,
@@ -111,15 +116,35 @@ export default async function SettingsLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
- const userSettings = await api.users.settings();
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/");
+ }
+
+ const userSettings = await tryCatch(api.users.settings());
+
+ if (userSettings.error) {
+ if (userSettings.error instanceof TRPCError) {
+ if (
+ userSettings.error.code === "NOT_FOUND" ||
+ userSettings.error.code === "UNAUTHORIZED"
+ ) {
+ redirect("/logout");
+ }
+ }
+ throw userSettings.error;
+ }
+
return (
- <UserSettingsContextProvider userSettings={userSettings}>
- <SidebarLayout
- sidebar={<Sidebar items={settingsSidebarItems} />}
- mobileSidebar={<MobileSidebar items={settingsSidebarItems} />}
- >
- {children}
- </SidebarLayout>
+ <UserSettingsContextProvider userSettings={userSettings.data}>
+ <ReaderSettingsProvider>
+ <SidebarLayout
+ sidebar={<Sidebar items={settingsSidebarItems} />}
+ mobileSidebar={<MobileSidebar items={settingsSidebarItems} />}
+ >
+ {children}
+ </SidebarLayout>
+ </ReaderSettingsProvider>
</UserSettingsContextProvider>
);
}
diff --git a/apps/web/app/settings/rules/page.tsx b/apps/web/app/settings/rules/page.tsx
index 98a30bcc..2e739343 100644
--- a/apps/web/app/settings/rules/page.tsx
+++ b/apps/web/app/settings/rules/page.tsx
@@ -6,22 +6,25 @@ import RuleList from "@/components/dashboard/rules/RuleEngineRuleList";
import { Button } from "@/components/ui/button";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
-import { Tooltip, TooltipContent, TooltipTrigger } from "components/ui/tooltip";
-import { FlaskConical, PlusCircle } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { PlusCircle } from "lucide-react";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { RuleEngineRule } from "@karakeep/shared/types/rules";
export default function RulesSettingsPage() {
+ const api = useTRPC();
const { t } = useTranslation();
const [editingRule, setEditingRule] = useState<
(Omit<RuleEngineRule, "id"> & { id: string | null }) | null
>(null);
- const { data: rules, isLoading } = api.rules.list.useQuery(undefined, {
- refetchOnWindowFocus: true,
- refetchOnMount: true,
- });
+ const { data: rules, isLoading } = useQuery(
+ api.rules.list.queryOptions(undefined, {
+ refetchOnWindowFocus: true,
+ refetchOnMount: true,
+ }),
+ );
const handleCreateRule = () => {
const newRule = {
@@ -49,14 +52,6 @@ export default function RulesSettingsPage() {
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-lg font-medium">
{t("settings.rules.rules")}
- <Tooltip>
- <TooltipTrigger className="text-muted-foreground">
- <FlaskConical size={15} />
- </TooltipTrigger>
- <TooltipContent side="bottom">
- {t("common.experimental")}
- </TooltipContent>
- </Tooltip>
</span>
<Button onClick={handleCreateRule} variant="default">
<PlusCircle className="mr-2 h-4 w-4" />
diff --git a/apps/web/app/settings/stats/page.tsx b/apps/web/app/settings/stats/page.tsx
index 944d1c59..a8896a03 100644
--- a/apps/web/app/settings/stats/page.tsx
+++ b/apps/web/app/settings/stats/page.tsx
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
+import { useQuery } from "@tanstack/react-query";
import {
Archive,
BarChart3,
@@ -32,6 +32,7 @@ import {
} from "lucide-react";
import { z } from "zod";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks";
type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>;
@@ -159,9 +160,10 @@ function StatCard({
}
export default function StatsPage() {
+ const api = useTRPC();
const { t } = useTranslation();
- const { data: stats, isLoading } = api.users.stats.useQuery();
- const { data: userSettings } = api.users.settings.useQuery();
+ const { data: stats, isLoading } = useQuery(api.users.stats.queryOptions());
+ const { data: userSettings } = useQuery(api.users.settings.queryOptions());
const maxHourlyActivity = useMemo(() => {
if (!stats) return 0;
@@ -222,20 +224,21 @@ export default function StatsPage() {
return (
<div className="space-y-6">
- <div>
- <h1 className="text-3xl font-bold">
- {t("settings.stats.usage_statistics")}
- </h1>
- <p className="text-muted-foreground">
- Insights into your bookmarking habits and collection
- {userSettings?.timezone && userSettings.timezone !== "UTC" && (
- <span className="block text-sm">
- Times shown in {userSettings.timezone} timezone
- </span>
- )}
- </p>
+ <div className="flex items-start justify-between">
+ <div>
+ <h1 className="text-3xl font-bold">
+ {t("settings.stats.usage_statistics")}
+ </h1>
+ <p className="text-muted-foreground">
+ Insights into your bookmarking habits and collection
+ {userSettings?.timezone && userSettings.timezone !== "UTC" && (
+ <span className="block text-sm">
+ Times shown in {userSettings.timezone} timezone
+ </span>
+ )}
+ </p>
+ </div>
</div>
-
{/* Overview Stats */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
@@ -287,7 +290,6 @@ export default function StatsPage() {
description={t("settings.stats.overview.bookmarks_added")}
/>
</div>
-
<div className="grid gap-6 md:grid-cols-2">
{/* Bookmark Types */}
<Card>
@@ -530,7 +532,6 @@ export default function StatsPage() {
</CardContent>
</Card>
</div>
-
{/* Activity Patterns */}
<div className="grid gap-6 md:grid-cols-2">
{/* Hourly Activity */}
@@ -581,7 +582,6 @@ export default function StatsPage() {
</CardContent>
</Card>
</div>
-
{/* Asset Storage */}
{stats.assetsByType.length > 0 && (
<Card>
diff --git a/apps/web/app/signup/page.tsx b/apps/web/app/signup/page.tsx
index ee77f65e..5c8b943e 100644
--- a/apps/web/app/signup/page.tsx
+++ b/apps/web/app/signup/page.tsx
@@ -3,10 +3,19 @@ import KarakeepLogo from "@/components/KarakeepIcon";
import SignUpForm from "@/components/signup/SignUpForm";
import { getServerAuthSession } from "@/server/auth";
-export default async function SignUpPage() {
+import { validateRedirectUrl } from "@karakeep/shared/utils/redirectUrl";
+
+export default async function SignUpPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ redirectUrl?: string }>;
+}) {
const session = await getServerAuthSession();
+ const { redirectUrl: rawRedirectUrl } = await searchParams;
+ const redirectUrl = validateRedirectUrl(rawRedirectUrl) ?? "/";
+
if (session) {
- redirect("/");
+ redirect(redirectUrl);
}
return (
@@ -15,7 +24,7 @@ export default async function SignUpPage() {
<div className="flex items-center justify-center">
<KarakeepLogo height={80} />
</div>
- <SignUpForm />
+ <SignUpForm redirectUrl={redirectUrl} />
</div>
</div>
);
diff --git a/apps/web/app/verify-email/page.tsx b/apps/web/app/verify-email/page.tsx
index da9b8b6b..5044c63e 100644
--- a/apps/web/app/verify-email/page.tsx
+++ b/apps/web/app/verify-email/page.tsx
@@ -11,10 +11,17 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
-import { api } from "@/lib/trpc";
+import { useMutation } from "@tanstack/react-query";
import { CheckCircle, Loader2, XCircle } from "lucide-react";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+import {
+ isMobileAppRedirect,
+ validateRedirectUrl,
+} from "@karakeep/shared/utils/redirectUrl";
+
export default function VerifyEmailPage() {
+ const api = useTRPC();
const searchParams = useSearchParams();
const router = useRouter();
const [status, setStatus] = useState<"loading" | "success" | "error">(
@@ -24,33 +31,51 @@ export default function VerifyEmailPage() {
const token = searchParams.get("token");
const email = searchParams.get("email");
+ const redirectUrl =
+ validateRedirectUrl(searchParams.get("redirectUrl")) ?? "/";
- const verifyEmailMutation = api.users.verifyEmail.useMutation({
- onSuccess: () => {
- setStatus("success");
- setMessage(
- "Your email has been successfully verified! You can now sign in.",
- );
- },
- onError: (error) => {
- setStatus("error");
- setMessage(
- error.message ||
- "Failed to verify email. The link may be invalid or expired.",
- );
- },
- });
+ const verifyEmailMutation = useMutation(
+ api.users.verifyEmail.mutationOptions({
+ onSuccess: () => {
+ setStatus("success");
+ if (isMobileAppRedirect(redirectUrl)) {
+ setMessage(
+ "Your email has been successfully verified! Redirecting to the app...",
+ );
+ // Redirect to mobile app after a brief delay
+ setTimeout(() => {
+ window.location.href = redirectUrl;
+ }, 1500);
+ } else {
+ setMessage(
+ "Your email has been successfully verified! You can now sign in.",
+ );
+ }
+ },
+ onError: (error) => {
+ setStatus("error");
+ setMessage(
+ error.message ||
+ "Failed to verify email. The link may be invalid or expired.",
+ );
+ },
+ }),
+ );
- const resendEmailMutation = api.users.resendVerificationEmail.useMutation({
- onSuccess: () => {
- setMessage(
- "A new verification email has been sent to your email address.",
- );
- },
- onError: (error) => {
- setMessage(error.message || "Failed to resend verification email.");
- },
- });
+ const resendEmailMutation = useMutation(
+ api.users.resendVerificationEmail.mutationOptions({
+ onSuccess: () => {
+ setMessage(
+ "A new verification email has been sent to your email address.",
+ );
+ },
+ onError: (error) => {
+ setMessage(error.message || "Failed to resend verification email.");
+ },
+ }),
+ );
+
+ const isMobileRedirect = isMobileAppRedirect(redirectUrl);
useEffect(() => {
if (token && email) {
@@ -63,12 +88,18 @@ export default function VerifyEmailPage() {
const handleResendEmail = () => {
if (email) {
- resendEmailMutation.mutate({ email });
+ resendEmailMutation.mutate({ email, redirectUrl });
}
};
const handleSignIn = () => {
- router.push("/signin");
+ if (isMobileRedirect) {
+ window.location.href = redirectUrl;
+ } else if (redirectUrl !== "/") {
+ router.push(`/signin?redirectUrl=${encodeURIComponent(redirectUrl)}`);
+ } else {
+ router.push("/signin");
+ }
};
return (
@@ -102,7 +133,7 @@ export default function VerifyEmailPage() {
</AlertDescription>
</Alert>
<Button onClick={handleSignIn} className="w-full">
- Sign In
+ {isMobileRedirect ? "Open App" : "Sign In"}
</Button>
</>
)}