diff options
Diffstat (limited to 'apps/web/app')
| -rw-r--r-- | apps/web/app/admin/admin_tools/page.tsx | 19 | ||||
| -rw-r--r-- | apps/web/app/admin/layout.tsx | 7 | ||||
| -rw-r--r-- | apps/web/app/admin/users/page.tsx | 15 | ||||
| -rw-r--r-- | apps/web/app/check-email/page.tsx | 32 | ||||
| -rw-r--r-- | apps/web/app/dashboard/error.tsx | 43 | ||||
| -rw-r--r-- | apps/web/app/dashboard/highlights/page.tsx | 16 | ||||
| -rw-r--r-- | apps/web/app/dashboard/layout.tsx | 37 | ||||
| -rw-r--r-- | apps/web/app/dashboard/lists/page.tsx | 15 | ||||
| -rw-r--r-- | apps/web/app/layout.tsx | 2 | ||||
| -rw-r--r-- | apps/web/app/logout/page.tsx | 2 | ||||
| -rw-r--r-- | apps/web/app/reader/[bookmarkId]/page.tsx | 176 | ||||
| -rw-r--r-- | apps/web/app/reader/layout.tsx | 39 | ||||
| -rw-r--r-- | apps/web/app/settings/assets/page.tsx | 22 | ||||
| -rw-r--r-- | apps/web/app/settings/broken-links/page.tsx | 18 | ||||
| -rw-r--r-- | apps/web/app/settings/import/[sessionId]/page.tsx | 20 | ||||
| -rw-r--r-- | apps/web/app/settings/info/page.tsx | 4 | ||||
| -rw-r--r-- | apps/web/app/settings/layout.tsx | 41 | ||||
| -rw-r--r-- | apps/web/app/settings/rules/page.tsx | 25 | ||||
| -rw-r--r-- | apps/web/app/settings/stats/page.tsx | 38 | ||||
| -rw-r--r-- | apps/web/app/signup/page.tsx | 15 | ||||
| -rw-r--r-- | apps/web/app/verify-email/page.tsx | 89 |
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'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> </> )} |
