diff options
Diffstat (limited to 'apps/web')
190 files changed, 10144 insertions, 2474 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> </> )} diff --git a/apps/web/components/admin/AddUserDialog.tsx b/apps/web/components/admin/AddUserDialog.tsx index 67c38501..b5843eab 100644 --- a/apps/web/components/admin/AddUserDialog.tsx +++ b/apps/web/components/admin/AddUserDialog.tsx @@ -1,213 +1,217 @@ -import { useEffect, useState } from "react";
-import { ActionButton } from "@/components/ui/action-button";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { toast } from "@/components/ui/use-toast";
-import { api } from "@/lib/trpc";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { TRPCClientError } from "@trpc/client";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { zAdminCreateUserSchema } from "@karakeep/shared/types/admin";
-
-type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>;
-
-export default function AddUserDialog({
- children,
-}: {
- children?: React.ReactNode;
-}) {
- const apiUtils = api.useUtils();
- const [isOpen, onOpenChange] = useState(false);
- const form = useForm<AdminCreateUserSchema>({
- resolver: zodResolver(zAdminCreateUserSchema),
- defaultValues: {
- name: "",
- email: "",
- password: "",
- confirmPassword: "",
- role: "user",
- },
- });
- const { mutate, isPending } = api.admin.createUser.useMutation({
- onSuccess: () => {
- toast({
- description: "User created successfully",
- });
- onOpenChange(false);
- apiUtils.users.list.invalidate();
- apiUtils.admin.userStats.invalidate();
- },
- onError: (error) => {
- if (error instanceof TRPCClientError) {
- toast({
- variant: "destructive",
- description: error.message,
- });
- } else {
- toast({
- variant: "destructive",
- description: "Failed to create user",
- });
- }
- },
- });
-
- useEffect(() => {
- if (!isOpen) {
- form.reset();
- }
- }, [isOpen, form]);
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogTrigger asChild>{children}</DialogTrigger>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Add User</DialogTitle>
- </DialogHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit((val) => mutate(val))}>
- <div className="flex w-full flex-col space-y-2">
- <FormField
- control={form.control}
- name="name"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Name</FormLabel>
- <FormControl>
- <Input
- type="text"
- placeholder="Name"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Email</FormLabel>
- <FormControl>
- <Input
- type="email"
- placeholder="Email"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="password"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="confirmPassword"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Confirm Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Confirm Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="role"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Role</FormLabel>
- <FormControl>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <SelectTrigger className="w-full">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="user">User</SelectItem>
- <SelectItem value="admin">Admin</SelectItem>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <DialogFooter className="sm:justify-end">
- <DialogClose asChild>
- <Button type="button" variant="secondary">
- Close
- </Button>
- </DialogClose>
- <ActionButton
- type="submit"
- loading={isPending}
- disabled={isPending}
- >
- Create
- </ActionButton>
- </DialogFooter>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-}
+import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/sonner"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { zAdminCreateUserSchema } from "@karakeep/shared/types/admin"; + +type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>; + +export default function AddUserDialog({ + children, +}: { + children?: React.ReactNode; +}) { + const api = useTRPC(); + const queryClient = useQueryClient(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm<AdminCreateUserSchema>({ + resolver: zodResolver(zAdminCreateUserSchema), + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + role: "user", + }, + }); + const { mutate, isPending } = useMutation( + api.admin.createUser.mutationOptions({ + onSuccess: () => { + toast({ + description: "User created successfully", + }); + onOpenChange(false); + queryClient.invalidateQueries(api.users.list.pathFilter()); + queryClient.invalidateQueries(api.admin.userStats.pathFilter()); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to create user", + }); + } + }, + }), + ); + + useEffect(() => { + if (!isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Add User</DialogTitle> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit((val) => mutate(val))}> + <div className="flex w-full flex-col space-y-2"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input + type="text" + placeholder="Name" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input + type="email" + placeholder="Email" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="confirmPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="role" + render={({ field }) => ( + <FormItem> + <FormLabel>Role</FormLabel> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="user">User</SelectItem> + <SelectItem value="admin">Admin</SelectItem> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={isPending} + disabled={isPending} + > + Create + </ActionButton> + </DialogFooter> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/admin/AdminNotices.tsx b/apps/web/components/admin/AdminNotices.tsx index 77b1b481..76c3df04 100644 --- a/apps/web/components/admin/AdminNotices.tsx +++ b/apps/web/components/admin/AdminNotices.tsx @@ -2,9 +2,11 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { AdminCard } from "./AdminCard"; interface AdminNotice { @@ -14,7 +16,8 @@ interface AdminNotice { } function useAdminNotices() { - const { data } = api.admin.getAdminNoticies.useQuery(); + const api = useTRPC(); + const { data } = useQuery(api.admin.getAdminNoticies.queryOptions()); if (!data) { return []; } diff --git a/apps/web/components/admin/BackgroundJobs.tsx b/apps/web/components/admin/BackgroundJobs.tsx index ba73db2e..0df34cc4 100644 --- a/apps/web/components/admin/BackgroundJobs.tsx +++ b/apps/web/components/admin/BackgroundJobs.tsx @@ -11,10 +11,9 @@ import { CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { Activity, AlertTriangle, @@ -31,6 +30,8 @@ import { Webhook, } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { Button } from "../ui/button"; import { AdminCard } from "./AdminCard"; @@ -254,13 +255,51 @@ function JobCard({ } function useJobActions() { + const api = useTRPC(); const { t } = useTranslation(); const { mutateAsync: recrawlLinks, isPending: isRecrawlPending } = - api.admin.recrawlLinks.useMutation({ + useMutation( + api.admin.recrawlLinks.mutationOptions({ + onSuccess: () => { + toast({ + description: "Recrawl enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }), + ); + + const { mutateAsync: reindexBookmarks, isPending: isReindexPending } = + useMutation( + api.admin.reindexAllBookmarks.mutationOptions({ + onSuccess: () => { + toast({ + description: "Reindex enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }), + ); + + const { + mutateAsync: reprocessAssetsFixMode, + isPending: isReprocessingPending, + } = useMutation( + api.admin.reprocessAssetsFixMode.mutationOptions({ onSuccess: () => { toast({ - description: "Recrawl enqueued", + description: "Reprocessing enqueued", }); }, onError: (e) => { @@ -269,13 +308,17 @@ function useJobActions() { description: e.message, }); }, - }); + }), + ); - const { mutateAsync: reindexBookmarks, isPending: isReindexPending } = - api.admin.reindexAllBookmarks.useMutation({ + const { + mutateAsync: reRunInferenceOnAllBookmarks, + isPending: isInferencePending, + } = useMutation( + api.admin.reRunInferenceOnAllBookmarks.mutationOptions({ onSuccess: () => { toast({ - description: "Reindex enqueued", + description: "Inference jobs enqueued", }); }, onError: (e) => { @@ -284,62 +327,38 @@ function useJobActions() { description: e.message, }); }, - }); - - const { - mutateAsync: reprocessAssetsFixMode, - isPending: isReprocessingPending, - } = api.admin.reprocessAssetsFixMode.useMutation({ - onSuccess: () => { - toast({ - description: "Reprocessing enqueued", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); - - const { - mutateAsync: reRunInferenceOnAllBookmarks, - isPending: isInferencePending, - } = api.admin.reRunInferenceOnAllBookmarks.useMutation({ - onSuccess: () => { - toast({ - description: "Inference jobs enqueued", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); + }), + ); const { mutateAsync: runAdminMaintenanceTask, isPending: isAdminMaintenancePending, - } = api.admin.runAdminMaintenanceTask.useMutation({ - onSuccess: () => { - toast({ - description: "Admin maintenance request has been enqueued!", - }); - }, - onError: (e) => { - toast({ - variant: "destructive", - description: e.message, - }); - }, - }); + } = useMutation( + api.admin.runAdminMaintenanceTask.mutationOptions({ + onSuccess: () => { + toast({ + description: "Admin maintenance request has been enqueued!", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }), + ); return { crawlActions: [ { + label: t("admin.background_jobs.actions.recrawl_pending_links_only"), + onClick: () => + recrawlLinks({ crawlStatus: "pending", runInference: true }), + variant: "secondary" as const, + loading: isRecrawlPending, + }, + { label: t("admin.background_jobs.actions.recrawl_failed_links_only"), onClick: () => recrawlLinks({ crawlStatus: "failure", runInference: true }), @@ -361,6 +380,15 @@ function useJobActions() { inferenceActions: [ { label: t( + "admin.background_jobs.actions.regenerate_ai_tags_for_pending_bookmarks_only", + ), + onClick: () => + reRunInferenceOnAllBookmarks({ type: "tag", status: "pending" }), + variant: "secondary" as const, + loading: isInferencePending, + }, + { + label: t( "admin.background_jobs.actions.regenerate_ai_tags_for_failed_bookmarks_only", ), onClick: () => @@ -378,6 +406,18 @@ function useJobActions() { }, { label: t( + "admin.background_jobs.actions.regenerate_ai_summaries_for_pending_bookmarks_only", + ), + onClick: () => + reRunInferenceOnAllBookmarks({ + type: "summarize", + status: "pending", + }), + variant: "secondary" as const, + loading: isInferencePending, + }, + { + label: t( "admin.background_jobs.actions.regenerate_ai_summaries_for_failed_bookmarks_only", ), onClick: () => @@ -438,13 +478,13 @@ function useJobActions() { } export default function BackgroundJobs() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: serverStats } = api.admin.backgroundJobsStats.useQuery( - undefined, - { + const { data: serverStats } = useQuery( + api.admin.backgroundJobsStats.queryOptions(undefined, { refetchInterval: 1000, placeholderData: keepPreviousData, - }, + }), ); const actions = useJobActions(); diff --git a/apps/web/components/admin/BasicStats.tsx b/apps/web/components/admin/BasicStats.tsx index 67352f66..ec2b73a9 100644 --- a/apps/web/components/admin/BasicStats.tsx +++ b/apps/web/components/admin/BasicStats.tsx @@ -3,9 +3,10 @@ import { AdminCard } from "@/components/admin/AdminCard"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const REPO_LATEST_RELEASE_API = "https://api.github.com/repos/karakeep-app/karakeep/releases/latest"; const REPO_RELEASE_PAGE = "https://github.com/karakeep-app/karakeep/releases"; @@ -42,7 +43,7 @@ function ReleaseInfo() { rel="noreferrer" title="Update available" > - ({latestRelease} ⬆️) + ({latestRelease}⬆️) </a> ); } @@ -71,10 +72,13 @@ function StatsSkeleton() { } export default function BasicStats() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: serverStats } = api.admin.stats.useQuery(undefined, { - refetchInterval: 5000, - }); + const { data: serverStats } = useQuery( + api.admin.stats.queryOptions(undefined, { + refetchInterval: 5000, + }), + ); if (!serverStats) { return <StatsSkeleton />; diff --git a/apps/web/components/admin/BookmarkDebugger.tsx b/apps/web/components/admin/BookmarkDebugger.tsx new file mode 100644 index 00000000..7e15262f --- /dev/null +++ b/apps/web/components/admin/BookmarkDebugger.tsx @@ -0,0 +1,661 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AdminCard } from "@/components/admin/AdminCard"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import InfoTooltip from "@/components/ui/info-tooltip"; +import { Input } from "@/components/ui/input"; +import { useTranslation } from "@/lib/i18n/client"; +import { formatBytes } from "@/lib/utils"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Database, + ExternalLink, + FileText, + FileType, + Image as ImageIcon, + Link as LinkIcon, + Loader2, + RefreshCw, + Search, + Sparkles, + Tag, + User, + XCircle, +} from "lucide-react"; +import { parseAsString, useQueryState } from "nuqs"; +import { toast } from "sonner"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +export default function BookmarkDebugger() { + const api = useTRPC(); + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(""); + const [bookmarkId, setBookmarkId] = useQueryState( + "bookmarkId", + parseAsString.withDefault(""), + ); + const [showHtmlPreview, setShowHtmlPreview] = useState(false); + + // Sync input value with URL on mount/change + useEffect(() => { + if (bookmarkId) { + setInputValue(bookmarkId); + } + }, [bookmarkId]); + + const { + data: debugInfo, + isLoading, + error, + } = useQuery( + api.admin.getBookmarkDebugInfo.queryOptions( + { bookmarkId: bookmarkId }, + { enabled: !!bookmarkId && bookmarkId.length > 0 }, + ), + ); + + const handleLookup = () => { + if (inputValue.trim()) { + setBookmarkId(inputValue.trim()); + } + }; + + const recrawlMutation = useMutation( + api.admin.adminRecrawlBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.recrawl_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const reindexMutation = useMutation( + api.admin.adminReindexBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.reindex_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const retagMutation = useMutation( + api.admin.adminRetagBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.retag_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const resummarizeMutation = useMutation( + api.admin.adminResummarizeBookmark.mutationOptions({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.resummarize_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }), + ); + + const handleRecrawl = () => { + if (bookmarkId) { + recrawlMutation.mutate({ bookmarkId }); + } + }; + + const handleReindex = () => { + if (bookmarkId) { + reindexMutation.mutate({ bookmarkId }); + } + }; + + const handleRetag = () => { + if (bookmarkId) { + retagMutation.mutate({ bookmarkId }); + } + }; + + const handleResummarize = () => { + if (bookmarkId) { + resummarizeMutation.mutate({ bookmarkId }); + } + }; + + const getStatusBadge = (status: "pending" | "failure" | "success" | null) => { + if (!status) return null; + + const config = { + success: { + variant: "default" as const, + icon: CheckCircle2, + }, + failure: { + variant: "destructive" as const, + icon: XCircle, + }, + pending: { + variant: "secondary" as const, + icon: AlertCircle, + }, + }; + + const { variant, icon: Icon } = config[status]; + + return ( + <Badge variant={variant}> + <Icon className="mr-1 h-3 w-3" /> + {status} + </Badge> + ); + }; + + return ( + <div className="flex flex-col gap-4"> + {/* Input Section */} + <AdminCard> + <div className="mb-3 flex items-center gap-2"> + <Search className="h-5 w-5 text-muted-foreground" /> + <h2 className="text-lg font-semibold"> + {t("admin.admin_tools.bookmark_debugger")} + </h2> + <InfoTooltip className="text-muted-foreground" size={16}> + Some data will be redacted for privacy. + </InfoTooltip> + </div> + <div className="flex gap-2"> + <div className="relative max-w-md flex-1"> + <Database className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + <Input + placeholder={t("admin.admin_tools.bookmark_id_placeholder")} + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleLookup(); + } + }} + className="pl-9" + /> + </div> + <Button onClick={handleLookup} disabled={!inputValue.trim()}> + <Search className="mr-2 h-4 w-4" /> + {t("admin.admin_tools.lookup")} + </Button> + </div> + </AdminCard> + + {/* Loading State */} + {isLoading && ( + <AdminCard> + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-gray-400" /> + </div> + </AdminCard> + )} + + {/* Error State */} + {!isLoading && error && ( + <AdminCard> + <div className="flex items-center gap-3 rounded-lg border border-destructive/50 bg-destructive/10 p-4"> + <XCircle className="h-5 w-5 flex-shrink-0 text-destructive" /> + <div className="flex-1"> + <h3 className="text-sm font-semibold text-destructive"> + {t("admin.admin_tools.fetch_error")} + </h3> + <p className="mt-1 text-sm text-muted-foreground"> + {error.message} + </p> + </div> + </div> + </AdminCard> + )} + + {/* Debug Info Display */} + {!isLoading && !error && debugInfo && ( + <AdminCard> + <div className="space-y-4"> + {/* Basic Info & Status */} + <div className="grid gap-4 md:grid-cols-2"> + {/* Basic Info */} + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <Database className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.basic_info")} + </h3> + </div> + <div className="space-y-2.5 text-sm"> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Database className="h-3.5 w-3.5" /> + {t("common.id")} + </span> + <span className="font-mono text-xs">{debugInfo.id}</span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <FileType className="h-3.5 w-3.5" /> + {t("common.type")} + </span> + <Badge variant="secondary">{debugInfo.type}</Badge> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("common.source")} + </span> + <span>{debugInfo.source || "N/A"}</span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <User className="h-3.5 w-3.5" /> + {t("admin.admin_tools.owner_user_id")} + </span> + <span className="font-mono text-xs"> + {debugInfo.userId} + </span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("common.created_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow(new Date(debugInfo.createdAt), { + addSuffix: true, + })} + </span> + </div> + {debugInfo.modifiedAt && ( + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("common.updated_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow(new Date(debugInfo.modifiedAt), { + addSuffix: true, + })} + </span> + </div> + )} + </div> + </div> + + {/* Status */} + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.status")} + </h3> + </div> + <div className="space-y-2.5 text-sm"> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Tag className="h-3.5 w-3.5" /> + {t("admin.admin_tools.tagging_status")} + </span> + {getStatusBadge(debugInfo.taggingStatus)} + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Sparkles className="h-3.5 w-3.5" /> + {t("admin.admin_tools.summarization_status")} + </span> + {getStatusBadge(debugInfo.summarizationStatus)} + </div> + {debugInfo.linkInfo && ( + <> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <RefreshCw className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawl_status")} + </span> + {getStatusBadge(debugInfo.linkInfo.crawlStatus)} + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawl_status_code")} + </span> + <Badge + variant={ + debugInfo.linkInfo.crawlStatusCode === null || + (debugInfo.linkInfo.crawlStatusCode >= 200 && + debugInfo.linkInfo.crawlStatusCode < 300) + ? "default" + : "destructive" + } + > + {debugInfo.linkInfo.crawlStatusCode} + </Badge> + </div> + {debugInfo.linkInfo.crawledAt && ( + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawled_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow( + new Date(debugInfo.linkInfo.crawledAt), + { + addSuffix: true, + }, + )} + </span> + </div> + )} + </> + )} + </div> + </div> + </div> + + {/* Content */} + {(debugInfo.title || + debugInfo.summary || + debugInfo.linkInfo || + debugInfo.textInfo?.sourceUrl || + debugInfo.assetInfo) && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.content")} + </h3> + </div> + <div className="space-y-3 text-sm"> + {debugInfo.title && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <FileText className="h-3.5 w-3.5" /> + {t("common.title")} + </div> + <div className="rounded border bg-background px-3 py-2 font-medium"> + {debugInfo.title} + </div> + </div> + )} + {debugInfo.summary && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <Sparkles className="h-3.5 w-3.5" /> + {t("admin.admin_tools.summary")} + </div> + <div className="rounded border bg-background px-3 py-2"> + {debugInfo.summary} + </div> + </div> + )} + {debugInfo.linkInfo && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.url")} + </div> + <Link + prefetch={false} + href={debugInfo.linkInfo.url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline" + > + <span className="break-all"> + {debugInfo.linkInfo.url} + </span> + <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" /> + </Link> + </div> + )} + {debugInfo.textInfo?.sourceUrl && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.source_url")} + </div> + <Link + prefetch={false} + href={debugInfo.textInfo.sourceUrl} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline" + > + <span className="break-all"> + {debugInfo.textInfo.sourceUrl} + </span> + <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" /> + </Link> + </div> + )} + {debugInfo.assetInfo && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <ImageIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.asset_type")} + </div> + <div className="rounded border bg-background px-3 py-2"> + <Badge variant="secondary" className="mb-1"> + {debugInfo.assetInfo.assetType} + </Badge> + {debugInfo.assetInfo.fileName && ( + <div className="mt-1 font-mono text-xs text-muted-foreground"> + {debugInfo.assetInfo.fileName} + </div> + )} + </div> + </div> + )} + </div> + </div> + )} + + {/* HTML Preview */} + {debugInfo.linkInfo && debugInfo.linkInfo.htmlContentPreview && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <button + onClick={() => setShowHtmlPreview(!showHtmlPreview)} + className="flex w-full items-center gap-2 text-sm font-semibold hover:opacity-70" + > + {showHtmlPreview ? ( + <ChevronDown className="h-4 w-4 text-muted-foreground" /> + ) : ( + <ChevronRight className="h-4 w-4 text-muted-foreground" /> + )} + <FileText className="h-4 w-4 text-muted-foreground" /> + {t("admin.admin_tools.html_preview")} + </button> + {showHtmlPreview && ( + <pre className="mt-3 max-h-60 overflow-auto rounded-md border bg-muted p-3 text-xs"> + {debugInfo.linkInfo.htmlContentPreview} + </pre> + )} + </div> + )} + + {/* Tags */} + {debugInfo.tags.length > 0 && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <Tag className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("common.tags")}{" "} + <span className="text-muted-foreground"> + ({debugInfo.tags.length}) + </span> + </h3> + </div> + <div className="flex flex-wrap gap-2"> + {debugInfo.tags.map((tag) => ( + <Badge + key={tag.id} + variant={ + tag.attachedBy === "ai" ? "default" : "secondary" + } + className="gap-1.5" + > + {tag.attachedBy === "ai" && ( + <Sparkles className="h-3 w-3" /> + )} + <span>{tag.name}</span> + </Badge> + ))} + </div> + </div> + )} + + {/* Assets */} + {debugInfo.assets.length > 0 && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <ImageIcon className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("common.attachments")}{" "} + <span className="text-muted-foreground"> + ({debugInfo.assets.length}) + </span> + </h3> + </div> + <div className="space-y-2 text-sm"> + {debugInfo.assets.map((asset) => ( + <div + key={asset.id} + className="flex items-center justify-between rounded-md border bg-background p-3" + > + <div className="flex items-center gap-3"> + <ImageIcon className="h-4 w-4 text-muted-foreground" /> + <div> + <Badge variant="secondary" className="text-xs"> + {asset.assetType} + </Badge> + <div className="mt-1 text-xs text-muted-foreground"> + {formatBytes(asset.size)} + </div> + </div> + </div> + {asset.url && ( + <Link + prefetch={false} + href={asset.url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 text-primary hover:underline" + > + {t("admin.admin_tools.view")} + <ExternalLink className="h-3.5 w-3.5" /> + </Link> + )} + </div> + ))} + </div> + </div> + )} + + {/* Actions */} + <div className="rounded-lg border border-dashed bg-muted/20 p-4"> + <div className="mb-3 flex items-center gap-2"> + <RefreshCw className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold">{t("common.actions")}</h3> + </div> + <div className="flex flex-wrap gap-2"> + <Button + onClick={handleRecrawl} + disabled={ + debugInfo.type !== BookmarkTypes.LINK || + recrawlMutation.isPending + } + size="sm" + variant="outline" + > + {recrawlMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <RefreshCw className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.recrawl")} + </Button> + <Button + onClick={handleReindex} + disabled={reindexMutation.isPending} + size="sm" + variant="outline" + > + {reindexMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Search className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.reindex")} + </Button> + <Button + onClick={handleRetag} + disabled={retagMutation.isPending} + size="sm" + variant="outline" + > + {retagMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Tag className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.retag")} + </Button> + <Button + onClick={handleResummarize} + disabled={ + debugInfo.type !== BookmarkTypes.LINK || + resummarizeMutation.isPending + } + size="sm" + variant="outline" + > + {resummarizeMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Sparkles className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.resummarize")} + </Button> + </div> + </div> + </div> + </AdminCard> + )} + </div> + ); +} diff --git a/apps/web/components/admin/CreateInviteDialog.tsx b/apps/web/components/admin/CreateInviteDialog.tsx index 84f5c60f..e9930b1e 100644 --- a/apps/web/components/admin/CreateInviteDialog.tsx +++ b/apps/web/components/admin/CreateInviteDialog.tsx @@ -19,13 +19,15 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const createInviteSchema = z.object({ email: z.string().email("Please enter a valid email address"), }); @@ -37,6 +39,8 @@ interface CreateInviteDialogProps { export default function CreateInviteDialog({ children, }: CreateInviteDialogProps) { + const api = useTRPC(); + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(""); @@ -47,25 +51,26 @@ export default function CreateInviteDialog({ }, }); - const invalidateInvitesList = api.useUtils().invites.list.invalidate; - const createInviteMutation = api.invites.create.useMutation({ - onSuccess: () => { - toast({ - description: "Invite sent successfully", - }); - invalidateInvitesList(); - setOpen(false); - form.reset(); - setErrorMessage(""); - }, - onError: (e) => { - if (e instanceof TRPCClientError) { - setErrorMessage(e.message); - } else { - setErrorMessage("Failed to send invite"); - } - }, - }); + const createInviteMutation = useMutation( + api.invites.create.mutationOptions({ + onSuccess: () => { + toast({ + description: "Invite sent successfully", + }); + queryClient.invalidateQueries(api.invites.list.pathFilter()); + setOpen(false); + form.reset(); + setErrorMessage(""); + }, + onError: (e) => { + if (e instanceof TRPCClientError) { + setErrorMessage(e.message); + } else { + setErrorMessage("Failed to send invite"); + } + }, + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> diff --git a/apps/web/components/admin/InvitesList.tsx b/apps/web/components/admin/InvitesList.tsx index 1418c9bb..d4dc1793 100644 --- a/apps/web/components/admin/InvitesList.tsx +++ b/apps/web/components/admin/InvitesList.tsx @@ -2,7 +2,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { ButtonWithTooltip } from "@/components/ui/button"; -import LoadingSpinner from "@/components/ui/spinner"; +import { toast } from "@/components/ui/sonner"; import { Table, TableBody, @@ -11,25 +11,32 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; import { formatDistanceToNow } from "date-fns"; import { Mail, MailX, UserPlus } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + 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 api = useTRPC(); + const queryClient = useQueryClient(); + const { data: invites } = useSuspenseQuery(api.invites.list.queryOptions()); - const { mutateAsync: revokeInvite, isPending: isRevokePending } = - api.invites.revoke.useMutation({ + const { mutateAsync: revokeInvite, isPending: isRevokePending } = useMutation( + api.invites.revoke.mutationOptions({ onSuccess: () => { toast({ description: "Invite revoked successfully", }); - invalidateInvitesList(); + queryClient.invalidateQueries(api.invites.list.pathFilter()); }, onError: (e) => { toast({ @@ -37,15 +44,16 @@ export default function InvitesList() { description: `Failed to revoke invite: ${e.message}`, }); }, - }); + }), + ); - const { mutateAsync: resendInvite, isPending: isResendPending } = - api.invites.resend.useMutation({ + const { mutateAsync: resendInvite, isPending: isResendPending } = useMutation( + api.invites.resend.mutationOptions({ onSuccess: () => { toast({ description: "Invite resent successfully", }); - invalidateInvitesList(); + queryClient.invalidateQueries(api.invites.list.pathFilter()); }, onError: (e) => { toast({ @@ -53,11 +61,8 @@ export default function InvitesList() { description: `Failed to resend invite: ${e.message}`, }); }, - }); - - if (isLoading) { - return <LoadingSpinner />; - } + }), + ); const activeInvites = invites?.invites || []; @@ -139,17 +144,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/ResetPasswordDialog.tsx b/apps/web/components/admin/ResetPasswordDialog.tsx index cc2a95f5..f195395a 100644 --- a/apps/web/components/admin/ResetPasswordDialog.tsx +++ b/apps/web/components/admin/ResetPasswordDialog.tsx @@ -1,145 +1,150 @@ -import { useEffect, useState } from "react";
-import { ActionButton } from "@/components/ui/action-button";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
-import { Input } from "@/components/ui/input";
-import { toast } from "@/components/ui/use-toast";
-import { api } from "@/lib/trpc"; // Adjust the import path as needed
-import { zodResolver } from "@hookform/resolvers/zod";
-import { TRPCClientError } from "@trpc/client";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { resetPasswordSchema } from "@karakeep/shared/types/admin";
-
-interface ResetPasswordDialogProps {
- userId: string;
- children?: React.ReactNode;
-}
-
-type ResetPasswordSchema = z.infer<typeof resetPasswordSchema>;
-
-export default function ResetPasswordDialog({
- children,
- userId,
-}: ResetPasswordDialogProps) {
- const [isOpen, onOpenChange] = useState(false);
- const form = useForm<ResetPasswordSchema>({
- resolver: zodResolver(resetPasswordSchema),
- defaultValues: {
- userId,
- newPassword: "",
- newPasswordConfirm: "",
- },
- });
- const { mutate, isPending } = api.admin.resetPassword.useMutation({
- onSuccess: () => {
- toast({
- description: "Password reset successfully",
- });
- onOpenChange(false);
- },
- onError: (error) => {
- if (error instanceof TRPCClientError) {
- toast({
- variant: "destructive",
- description: error.message,
- });
- } else {
- toast({
- variant: "destructive",
- description: "Failed to reset password",
- });
- }
- },
- });
-
- useEffect(() => {
- if (isOpen) {
- form.reset();
- }
- }, [isOpen, form]);
-
- return (
- <Dialog open={isOpen} onOpenChange={onOpenChange}>
- <DialogTrigger asChild>{children}</DialogTrigger>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Reset Password</DialogTitle>
- </DialogHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit((val) => mutate(val))}>
- <div className="flex w-full flex-col space-y-2">
- <FormField
- control={form.control}
- name="newPassword"
- render={({ field }) => (
- <FormItem>
- <FormLabel>New Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="New Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name="newPasswordConfirm"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Confirm New Password</FormLabel>
- <FormControl>
- <Input
- type="password"
- placeholder="Confirm New Password"
- {...field}
- className="w-full rounded border p-2"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <DialogFooter className="sm:justify-end">
- <DialogClose asChild>
- <Button type="button" variant="secondary">
- Close
- </Button>
- </DialogClose>
- <ActionButton
- type="submit"
- loading={isPending}
- disabled={isPending}
- >
- Reset
- </ActionButton>
- </DialogFooter>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- );
-}
+import { useEffect, useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; // Adjust the import path as needed + +import { resetPasswordSchema } from "@karakeep/shared/types/admin"; + +interface ResetPasswordDialogProps { + userId: string; + children?: React.ReactNode; +} + +type ResetPasswordSchema = z.infer<typeof resetPasswordSchema>; + +export default function ResetPasswordDialog({ + children, + userId, +}: ResetPasswordDialogProps) { + const api = useTRPC(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm<ResetPasswordSchema>({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { + userId, + newPassword: "", + newPasswordConfirm: "", + }, + }); + const { mutate, isPending } = useMutation( + api.admin.resetPassword.mutationOptions({ + onSuccess: () => { + toast({ + description: "Password reset successfully", + }); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to reset password", + }); + } + }, + }), + ); + + useEffect(() => { + if (isOpen) { + form.reset(); + } + }, [isOpen, form]); + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>Reset Password</DialogTitle> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit((val) => mutate(val))}> + <div className="flex w-full flex-col space-y-2"> + <FormField + control={form.control} + name="newPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>New Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="New Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="newPasswordConfirm" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm New Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm New Password" + {...field} + className="w-full rounded border p-2" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <DialogFooter className="sm:justify-end"> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + type="submit" + loading={isPending} + disabled={isPending} + > + Reset + </ActionButton> + </DialogFooter> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/admin/ServiceConnections.tsx b/apps/web/components/admin/ServiceConnections.tsx index 8d79d8bb..5cdab46a 100644 --- a/apps/web/components/admin/ServiceConnections.tsx +++ b/apps/web/components/admin/ServiceConnections.tsx @@ -2,7 +2,9 @@ import { AdminCard } from "@/components/admin/AdminCard"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; function ConnectionStatus({ label, @@ -105,10 +107,13 @@ function ConnectionsSkeleton() { } export default function ServiceConnections() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: connections } = api.admin.checkConnections.useQuery(undefined, { - refetchInterval: 10000, - }); + const { data: connections } = useQuery( + api.admin.checkConnections.queryOptions(undefined, { + refetchInterval: 10000, + }), + ); if (!connections) { return <ConnectionsSkeleton />; diff --git a/apps/web/components/admin/UpdateUserDialog.tsx b/apps/web/components/admin/UpdateUserDialog.tsx index 7093ccda..95ccb6fd 100644 --- a/apps/web/components/admin/UpdateUserDialog.tsx +++ b/apps/web/components/admin/UpdateUserDialog.tsx @@ -26,13 +26,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { updateUserSchema } from "@karakeep/shared/types/admin"; type UpdateUserSchema = z.infer<typeof updateUserSchema>; @@ -51,7 +52,8 @@ export default function UpdateUserDialog({ currentStorageQuota, children, }: UpdateUserDialogProps) { - const apiUtils = api.useUtils(); + const api = useTRPC(); + const queryClient = useQueryClient(); const [isOpen, onOpenChange] = useState(false); const defaultValues = { userId, @@ -63,28 +65,30 @@ export default function UpdateUserDialog({ resolver: zodResolver(updateUserSchema), defaultValues, }); - const { mutate, isPending } = api.admin.updateUser.useMutation({ - onSuccess: () => { - toast({ - description: "User updated successfully", - }); - apiUtils.users.list.invalidate(); - onOpenChange(false); - }, - onError: (error) => { - if (error instanceof TRPCClientError) { + const { mutate, isPending } = useMutation( + api.admin.updateUser.mutationOptions({ + onSuccess: () => { toast({ - variant: "destructive", - description: error.message, + description: "User updated successfully", }); - } else { - toast({ - variant: "destructive", - description: "Failed to update user", - }); - } - }, - }); + queryClient.invalidateQueries(api.users.list.pathFilter()); + onOpenChange(false); + }, + onError: (error) => { + if (error instanceof TRPCClientError) { + toast({ + variant: "destructive", + description: error.message, + }); + } else { + toast({ + variant: "destructive", + description: "Failed to update user", + }); + } + }, + }), + ); useEffect(() => { if (isOpen) { diff --git a/apps/web/components/admin/UserList.tsx b/apps/web/components/admin/UserList.tsx index f386a8cd..6789f66a 100644 --- a/apps/web/components/admin/UserList.tsx +++ b/apps/web/components/admin/UserList.tsx @@ -2,7 +2,7 @@ import { ActionButton } from "@/components/ui/action-button"; import { ButtonWithTooltip } from "@/components/ui/button"; -import LoadingSpinner from "@/components/ui/spinner"; +import { toast } from "@/components/ui/sonner"; import { Table, TableBody, @@ -11,16 +11,20 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; +import { useSession } from "@/lib/auth/client"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; import { Check, KeyRound, Pencil, Trash, UserPlus, X } from "lucide-react"; -import { useSession } from "next-auth/react"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; 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"; @@ -32,18 +36,23 @@ function toHumanReadableSize(size: number) { } export default function UsersSection() { + const api = useTRPC(); + const queryClient = useQueryClient(); 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 { mutateAsync: deleteUser, isPending: isDeletionPending } = - api.users.delete.useMutation({ + const { + data: { users }, + } = useSuspenseQuery(api.users.list.queryOptions()); + const { data: userStats } = useSuspenseQuery( + api.admin.userStats.queryOptions(), + ); + const { mutateAsync: deleteUser, isPending: isDeletionPending } = useMutation( + api.users.delete.mutationOptions({ onSuccess: () => { toast({ description: "User deleted", }); - invalidateUserList(); + queryClient.invalidateQueries(api.users.list.pathFilter()); }, onError: (e) => { toast({ @@ -51,122 +60,113 @@ export default function UsersSection() { description: `Something went wrong: ${e.message}`, }); }, - }); - - 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} + <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); + }} > - <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} + 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/BulkBookmarksAction.tsx b/apps/web/components/dashboard/BulkBookmarksAction.tsx index 817521ff..0e74b985 100644 --- a/apps/web/components/dashboard/BulkBookmarksAction.tsx +++ b/apps/web/components/dashboard/BulkBookmarksAction.tsx @@ -7,7 +7,7 @@ import { ActionButtonWithTooltip, } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { useToast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import useBulkActionsStore from "@/lib/bulkActions"; import { useTranslation } from "@/lib/i18n/client"; import { @@ -16,6 +16,7 @@ import { Hash, Link, List, + ListMinus, Pencil, RotateCw, Trash2, @@ -27,6 +28,7 @@ import { useRecrawlBookmark, useUpdateBookmark, } from "@karakeep/shared-react/hooks/bookmarks"; +import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists"; import { limitConcurrency } from "@karakeep/shared/concurrency"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; @@ -38,7 +40,11 @@ const MAX_CONCURRENT_BULK_ACTIONS = 50; export default function BulkBookmarksAction() { const { t } = useTranslation(); - const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); + const { + selectedBookmarks, + isBulkEditEnabled, + listContext: withinListContext, + } = useBulkActionsStore(); const setIsBulkEditEnabled = useBulkActionsStore( (state) => state.setIsBulkEditEnabled, ); @@ -49,8 +55,9 @@ export default function BulkBookmarksAction() { const isEverythingSelected = useBulkActionsStore( (state) => state.isEverythingSelected, ); - const { toast } = useToast(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRemoveFromListDialogOpen, setIsRemoveFromListDialogOpen] = + useState(false); const [manageListsModal, setManageListsModalOpen] = useState(false); const [bulkTagModal, setBulkTagModalOpen] = useState(false); const pathname = usePathname(); @@ -93,6 +100,13 @@ export default function BulkBookmarksAction() { onError, }); + const removeBookmarkFromListMutator = useRemoveBookmarkFromList({ + onSuccess: () => { + setIsBulkEditEnabled(false); + }, + onError, + }); + interface UpdateBookmarkProps { favourited?: boolean; archived?: boolean; @@ -185,6 +199,31 @@ export default function BulkBookmarksAction() { setIsDeleteDialogOpen(false); }; + const removeBookmarksFromList = async () => { + if (!withinListContext) return; + + const results = await Promise.allSettled( + limitConcurrency( + selectedBookmarks.map( + (item) => () => + removeBookmarkFromListMutator.mutateAsync({ + bookmarkId: item.id, + listId: withinListContext.id, + }), + ), + MAX_CONCURRENT_BULK_ACTIONS, + ), + ); + + const successes = results.filter((r) => r.status === "fulfilled").length; + if (successes > 0) { + toast({ + description: `${successes} bookmarks have been removed from the list!`, + }); + } + setIsRemoveFromListDialogOpen(false); + }; + const alreadyFavourited = selectedBookmarks.length && selectedBookmarks.every((item) => item.favourited === true); @@ -204,6 +243,18 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { + name: t("actions.remove_from_list"), + icon: <ListMinus size={18} />, + action: () => setIsRemoveFromListDialogOpen(true), + isPending: removeBookmarkFromListMutator.isPending, + hidden: + !isBulkEditEnabled || + !withinListContext || + withinListContext.type !== "manual" || + (withinListContext.userRole !== "editor" && + withinListContext.userRole !== "owner"), + }, + { name: t("actions.add_to_list"), icon: <List size={18} />, action: () => setManageListsModalOpen(true), @@ -232,7 +283,7 @@ export default function BulkBookmarksAction() { hidden: !isBulkEditEnabled, }, { - name: t("actions.download_full_page_archive"), + name: t("actions.preserve_offline_archive"), icon: <FileDown size={18} />, action: () => recrawlBookmarks(true), isPending: recrawlBookmarkMutator.isPending, @@ -299,6 +350,27 @@ export default function BulkBookmarksAction() { </ActionButton> )} /> + <ActionConfirmingDialog + open={isRemoveFromListDialogOpen} + setOpen={setIsRemoveFromListDialogOpen} + title={"Remove Bookmarks from List"} + description={ + <p> + Are you sure you want to remove {selectedBookmarks.length} bookmarks + from this list? + </p> + } + actionButton={() => ( + <ActionButton + type="button" + variant="destructive" + loading={removeBookmarkFromListMutator.isPending} + onClick={() => removeBookmarksFromList()} + > + {t("actions.remove")} + </ActionButton> + )} + /> <BulkManageListsModal bookmarkIds={selectedBookmarks.map((b) => b.id)} open={manageListsModal} diff --git a/apps/web/components/dashboard/ErrorFallback.tsx b/apps/web/components/dashboard/ErrorFallback.tsx new file mode 100644 index 00000000..7e4ce0d6 --- /dev/null +++ b/apps/web/components/dashboard/ErrorFallback.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { AlertTriangle, Home, RefreshCw } from "lucide-react"; + +export default function ErrorFallback() { + return ( + <div className="flex flex-1 items-center justify-center rounded-lg bg-slate-50 p-8 shadow-sm dark:bg-slate-700/50 dark:shadow-md"> + <div className="w-full max-w-md space-y-8 text-center"> + <div className="flex justify-center"> + <div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted"> + <AlertTriangle className="h-10 w-10 text-muted-foreground" /> + </div> + </div> + + <div className="space-y-4"> + <h1 className="text-balance text-2xl font-semibold text-foreground"> + Oops! Something went wrong + </h1> + <p className="text-pretty leading-relaxed text-muted-foreground"> + We're sorry, but an unexpected error occurred. Please try again + or contact support if the issue persists. + </p> + </div> + + <div className="space-y-3"> + <Button className="w-full" onClick={() => window.location.reload()}> + <RefreshCw className="mr-2 h-4 w-4" /> + Try Again + </Button> + + <Link href="/" className="block"> + <Button variant="outline" className="w-full"> + <Home className="mr-2 h-4 w-4" /> + Go Home + </Button> + </Link> + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx index 8d119467..c76da523 100644 --- a/apps/web/components/dashboard/UploadDropzone.tsx +++ b/apps/web/components/dashboard/UploadDropzone.tsx @@ -1,6 +1,8 @@ "use client"; import React, { useCallback, useState } from "react"; +import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useUpload from "@/lib/hooks/upload-file"; import { cn } from "@/lib/utils"; import { TRPCClientError } from "@trpc/client"; @@ -10,7 +12,6 @@ import { useCreateBookmarkWithPostHook } from "@karakeep/shared-react/hooks/book import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import LoadingSpinner from "../ui/spinner"; -import { toast } from "../ui/use-toast"; import BookmarkAlreadyExistsToast from "../utils/BookmarkAlreadyExistsToast"; export function useUploadAsset() { @@ -136,7 +137,12 @@ export default function UploadDropzone({ <DropZone noClick onDrop={onDrop} - onDragEnter={() => setDragging(true)} + onDragEnter={(e) => { + // Don't show overlay for internal bookmark card drags + if (!e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + setDragging(true); + } + }} onDragLeave={() => setDragging(false)} > {({ getRootProps, getInputProps }) => ( diff --git a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx index 595a9e00..b120e0b1 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx @@ -1,5 +1,6 @@ -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { getBookmarkRefreshInterval } from "@karakeep/shared/utils/bookmarkUtils"; @@ -15,20 +16,23 @@ export default function BookmarkCard({ bookmark: ZBookmark; className?: string; }) { - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId: initialData.id, - }, - { - initialData, - refetchInterval: (query) => { - const data = query.state.data; - if (!data) { - return false; - } - return getBookmarkRefreshInterval(data); + const api = useTRPC(); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId: initialData.id, }, - }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + return getBookmarkRefreshInterval(data); + }, + }, + ), ); switch (bookmark.content.type) { diff --git a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx index a3e5d3b3..7c254336 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx @@ -1,8 +1,8 @@ -import dayjs from "dayjs"; +import { format, isAfter, subYears } from "date-fns"; export default function BookmarkFormattedCreatedAt(prop: { createdAt: Date }) { - const createdAt = dayjs(prop.createdAt); - const oneYearAgo = dayjs().subtract(1, "year"); - const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY"; - return createdAt.format(formatString); + const createdAt = prop.createdAt; + const oneYearAgo = subYears(new Date(), 1); + const formatString = isAfter(createdAt, oneYearAgo) ? "MMM d" : "MMM d, yyyy"; + return format(createdAt, formatString); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index e8520b1a..f164b275 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -2,9 +2,11 @@ import type { BookmarksLayoutTypes } from "@/lib/userLocalSettings/types"; import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import Image from "next/image"; import Link from "next/link"; +import { useSession } from "@/lib/auth/client"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import useBulkActionsStore from "@/lib/bulkActions"; import { bookmarkLayoutSwitch, @@ -12,17 +14,28 @@ import { useBookmarkLayout, } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; -import { Check, Image as ImageIcon, NotebookPen } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useQuery } from "@tanstack/react-query"; +import { + Check, + GripVertical, + Image as ImageIcon, + NotebookPen, +} from "lucide-react"; import { useTheme } from "next-themes"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; -import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils"; +import { + getBookmarkTitle, + isBookmarkStillTagging, +} from "@karakeep/shared/utils/bookmarkUtils"; import { switchCase } from "@karakeep/shared/utils/switch"; import BookmarkActionBar from "./BookmarkActionBar"; import BookmarkFormattedCreatedAt from "./BookmarkFormattedCreatedAt"; +import BookmarkOwnerIcon from "./BookmarkOwnerIcon"; import { NotePreview } from "./NotePreview"; import TagList from "./TagList"; @@ -60,6 +73,43 @@ function BottomRow({ ); } +function OwnerIndicator({ bookmark }: { bookmark: ZBookmark }) { + const api = useTRPC(); + const listContext = useBookmarkListContext(); + const collaborators = useQuery( + api.lists.getCollaborators.queryOptions( + { + listId: listContext?.id ?? "", + }, + { + refetchOnWindowFocus: false, + enabled: !!listContext?.hasCollaborators, + }, + ), + ); + + if (!listContext || listContext.userRole === "owner" || !collaborators.data) { + return null; + } + + let owner = undefined; + if (bookmark.userId === collaborators.data.owner?.id) { + owner = collaborators.data.owner; + } else { + owner = collaborators.data.collaborators.find( + (c) => c.userId === bookmark.userId, + )?.user; + } + + if (!owner) return null; + + return ( + <div className="absolute right-2 top-2 z-40 opacity-0 transition-opacity duration-200 group-hover:opacity-100"> + <BookmarkOwnerIcon ownerName={owner.name} ownerAvatar={owner.image} /> + </div> + ); +} + function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore(); const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark); @@ -114,6 +164,65 @@ function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) { ); } +function DragHandle({ + bookmark, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { isBulkEditEnabled } = useBulkActionsStore(); + const handleDragStart = useCallback( + (e: React.DragEvent) => { + e.stopPropagation(); + e.dataTransfer.setData(BOOKMARK_DRAG_MIME, bookmark.id); + e.dataTransfer.effectAllowed = "copy"; + + // Create a small pill element as the drag preview + const pill = document.createElement("div"); + const title = getBookmarkTitle(bookmark) ?? "Untitled"; + pill.textContent = + title.length > 40 ? title.substring(0, 40) + "\u2026" : title; + Object.assign(pill.style, { + position: "fixed", + left: "-9999px", + top: "-9999px", + padding: "6px 12px", + borderRadius: "8px", + backgroundColor: "hsl(var(--card))", + border: "1px solid hsl(var(--border))", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + fontSize: "13px", + fontFamily: "inherit", + color: "hsl(var(--foreground))", + maxWidth: "240px", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }); + document.body.appendChild(pill); + e.dataTransfer.setDragImage(pill, 0, 0); + requestAnimationFrame(() => pill.remove()); + }, + [bookmark], + ); + + if (isBulkEditEnabled) return null; + + return ( + <div + draggable + onDragStart={handleDragStart} + className={cn( + "absolute z-40 cursor-grab rounded bg-background/70 p-0.5 opacity-0 shadow-sm transition-opacity duration-200 group-hover:opacity-100", + className, + )} + > + <GripVertical className="size-4 text-muted-foreground" /> + </div> + ); +} + function ListView({ bookmark, image, @@ -133,11 +242,16 @@ function ListView({ return ( <div className={cn( - "relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2", + "group relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2", className, )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-1 top-1/2 -translate-y-1/2" + /> <div className="flex size-32 items-center justify-center overflow-hidden"> {image("list", cn("size-32 rounded-lg", imgFitClass))} </div> @@ -191,12 +305,14 @@ function GridView({ return ( <div className={cn( - "relative flex flex-col overflow-hidden rounded-lg", + "group relative flex flex-col overflow-hidden rounded-lg", className, fitHeight && layout != "grid" ? "max-h-96" : "h-96", )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle bookmark={bookmark} className="left-2 top-2" /> {img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>} <div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2"> <div className="grow-1 flex flex-col gap-2 overflow-hidden"> @@ -228,12 +344,17 @@ function CompactView({ bookmark, title, footer, className }: Props) { return ( <div className={cn( - "relative flex flex-col overflow-hidden rounded-lg", + "group relative flex flex-col overflow-hidden rounded-lg", className, "max-h-96", )} > <MultiBookmarkSelector bookmark={bookmark} /> + <OwnerIndicator bookmark={bookmark} /> + <DragHandle + bookmark={bookmark} + className="left-0.5 top-1/2 -translate-y-1/2" + /> <div className="flex h-full justify-between gap-2 overflow-hidden p-2"> <div className="flex items-center gap-2"> {bookmark.content.type === BookmarkTypes.LINK && diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx index e7fea2c3..a1eab830 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx @@ -1,6 +1,6 @@ import MarkdownEditor from "@/components/ui/markdown/markdown-editor";
import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly";
-import { toast } from "@/components/ui/use-toast";
+import { toast } from "@/components/ui/sonner";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 66de6156..c161853d 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -1,18 +1,26 @@ "use client"; -import { useEffect, useState } from "react"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useToast } from "@/components/ui/use-toast"; +import { useSession } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; +import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; import { + Archive, + Download, FileDown, + FileText, + ImagePlus, Link, List, ListX, @@ -22,20 +30,25 @@ import { SquarePen, Trash2, } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { toast } from "sonner"; import type { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; import { - useRecrawlBookmark, - useUpdateBookmark, -} from "@karakeep/shared-react/hooks//bookmarks"; -import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks//lists"; + useAttachBookmarkAsset, + useReplaceBookmarkAsset, +} from "@karakeep/shared-react/hooks/assets"; import { useBookmarkGridContext } from "@karakeep/shared-react/hooks/bookmark-grid-context"; import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; +import { + useRecrawlBookmark, + useUpdateBookmark, +} from "@karakeep/shared-react/hooks/bookmarks"; +import { useRemoveBookmarkFromList } from "@karakeep/shared-react/hooks/lists"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; import DeleteBookmarkConfirmationDialog from "./DeleteBookmarkConfirmationDialog"; @@ -43,9 +56,35 @@ import { EditBookmarkDialog } from "./EditBookmarkDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "./icons"; import { useManageListsModal } from "./ManageListsModal"; +interface ActionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + disabled: boolean; + className?: string; + onClick: () => void; +} + +interface SubsectionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + items: ActionItem[]; +} + +const getBannerSonnerId = (bookmarkId: string) => + `replace-banner-${bookmarkId}`; + +type ActionItemType = ActionItem | SubsectionItem; + +function isSubsectionItem(item: ActionItemType): item is SubsectionItem { + return "items" in item; +} + export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); - const { toast } = useToast(); const linkId = bookmark.id; const { data: session } = useSession(); @@ -73,54 +112,122 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const [isTextEditorOpen, setTextEditorOpen] = useState(false); const [isEditBookmarkDialogOpen, setEditBookmarkDialogOpen] = useState(false); + const bannerFileInputRef = useRef<HTMLInputElement>(null); + + const { mutate: uploadBannerAsset } = useUpload({ + onError: (e) => { + toast.error(e.error, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + + const { mutate: attachAsset, isPending: isAttaching } = + useAttachBookmarkAsset({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.update_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + }, + onError: (e) => { + toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + + const { mutate: replaceAsset, isPending: isReplacing } = + useReplaceBookmarkAsset({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.update_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + }, + onError: (e) => { + toast.error(e.message, { id: getBannerSonnerId(bookmark.id) }); + }, + }); + const { listId } = useBookmarkGridContext() ?? {}; const withinListContext = useBookmarkListContext(); const onError = () => { - toast({ - variant: "destructive", - title: t("common.something_went_wrong"), - }); + toast.error(t("common.something_went_wrong")); }; const updateBookmarkMutator = useUpdateBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.updated"), - }); + toast.success(t("toasts.bookmarks.updated")); }, onError, }); const crawlBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.refetch"), - }); + toast.success(t("toasts.bookmarks.refetch")); }, onError, }); const fullPageArchiveBookmarkMutator = useRecrawlBookmark({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.full_page_archive"), - }); + toast.success(t("toasts.bookmarks.full_page_archive")); + }, + onError, + }); + + const preservePdfMutator = useRecrawlBookmark({ + onSuccess: () => { + toast.success(t("toasts.bookmarks.preserve_pdf")); }, onError, }); const removeFromListMutator = useRemoveBookmarkFromList({ onSuccess: () => { - toast({ - description: t("toasts.bookmarks.delete_from_list"), - }); + toast.success(t("toasts.bookmarks.delete_from_list")); }, onError, }); + const handleBannerFileChange = (event: ChangeEvent<HTMLInputElement>) => { + const files = event.target.files; + if (files && files.length > 0) { + const file = files[0]; + const existingBanner = bookmark.assets.find( + (asset) => asset.assetType === "bannerImage", + ); + + if (existingBanner) { + toast.loading(t("toasts.bookmarks.uploading_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + uploadBannerAsset(file, { + onSuccess: (resp) => { + replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: existingBanner.id, + newAssetId: resp.assetId, + }); + }, + }); + } else { + toast.loading(t("toasts.bookmarks.uploading_banner"), { + id: getBannerSonnerId(bookmark.id), + }); + uploadBannerAsset(file, { + onSuccess: (resp) => { + attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: resp.assetId, + assetType: "bannerImage", + }, + }); + }, + }); + } + } + }; + // Define action items array - const actionItems = [ + const actionItems: ActionItemType[] = [ { id: "edit", title: t("actions.edit"), @@ -174,19 +281,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }), }, { - id: "download-full-page", - title: t("actions.download_full_page_archive"), - icon: <FileDown className="mr-2 size-4" />, - visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: false, - onClick: () => { - fullPageArchiveBookmarkMutator.mutate({ - bookmarkId: bookmark.id, - archiveFullPage: true, - }); - }, - }, - { id: "copy-link", title: t("actions.copy_link"), icon: <Link className="mr-2 size-4" />, @@ -196,9 +290,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { navigator.clipboard.writeText( (bookmark.content as ZBookmarkedLink).url, ); - toast({ - description: t("toasts.bookmarks.clipboard_copied"), - }); + toast.success(t("toasts.bookmarks.clipboard_copied")); }, }, { @@ -213,14 +305,15 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { id: "remove-from-list", title: t("actions.remove_from_list"), icon: <ListX className="mr-2 size-4" />, - visible: + visible: Boolean( (isOwner || (withinListContext && (withinListContext.userRole === "editor" || withinListContext.userRole === "owner"))) && - !!listId && - !!withinListContext && - withinListContext.type === "manual", + !!listId && + !!withinListContext && + withinListContext.type === "manual", + ), disabled: demoMode, onClick: () => removeFromListMutator.mutate({ @@ -229,12 +322,98 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }), }, { - id: "refresh", - title: t("actions.refresh"), - icon: <RotateCw className="mr-2 size-4" />, + id: "offline-copies", + title: t("actions.offline_copies"), + icon: <Archive className="mr-2 size-4" />, visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: demoMode, - onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + items: [ + { + id: "download-full-page", + title: t("actions.preserve_offline_archive"), + icon: <FileDown className="mr-2 size-4" />, + visible: true, + disabled: demoMode, + onClick: () => { + fullPageArchiveBookmarkMutator.mutate({ + bookmarkId: bookmark.id, + archiveFullPage: true, + }); + }, + }, + { + id: "preserve-pdf", + title: t("actions.preserve_as_pdf"), + icon: <FileText className="mr-2 size-4" />, + visible: true, + disabled: demoMode, + onClick: () => { + preservePdfMutator.mutate({ + bookmarkId: bookmark.id, + storePdf: true, + }); + }, + }, + { + id: "download-full-page-archive", + title: t("actions.download_full_page_archive_file"), + icon: <Download className="mr-2 size-4" />, + visible: + bookmark.content.type === BookmarkTypes.LINK && + !!( + bookmark.content.fullPageArchiveAssetId || + bookmark.content.precrawledArchiveAssetId + ), + disabled: false, + onClick: () => { + const link = bookmark.content as ZBookmarkedLink; + const archiveAssetId = + link.fullPageArchiveAssetId ?? link.precrawledArchiveAssetId; + if (archiveAssetId) { + window.open(getAssetUrl(archiveAssetId), "_blank"); + } + }, + }, + { + id: "download-pdf", + title: t("actions.download_pdf_file"), + icon: <Download className="mr-2 size-4" />, + visible: !!(bookmark.content as ZBookmarkedLink).pdfAssetId, + disabled: false, + onClick: () => { + const link = bookmark.content as ZBookmarkedLink; + if (link.pdfAssetId) { + window.open(getAssetUrl(link.pdfAssetId), "_blank"); + } + }, + }, + ], + }, + { + id: "more", + title: t("actions.more"), + icon: <MoreHorizontal className="mr-2 size-4" />, + visible: isOwner, + items: [ + { + id: "refresh", + title: t("actions.refresh"), + icon: <RotateCw className="mr-2 size-4" />, + visible: bookmark.content.type === BookmarkTypes.LINK, + disabled: demoMode, + onClick: () => + crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), + }, + { + id: "replace-banner", + title: bookmark.assets.find((a) => a.assetType === "bannerImage") + ? t("actions.replace_banner") + : t("actions.add_banner"), + icon: <ImagePlus className="mr-2 size-4" />, + visible: true, + disabled: demoMode || isAttaching || isReplacing, + onClick: () => bannerFileInputRef.current?.click(), + }, + ], }, { id: "delete", @@ -248,7 +427,12 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { ]; // Filter visible items - const visibleItems = actionItems.filter((item) => item.visible); + const visibleItems: ActionItemType[] = actionItems.filter((item) => { + if (isSubsectionItem(item)) { + return item.visible && item.items.some((subItem) => subItem.visible); + } + return item.visible; + }); // If no items are visible, don't render the dropdown if (visibleItems.length === 0) { @@ -283,19 +467,56 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> - {visibleItems.map((item) => ( - <DropdownMenuItem - key={item.id} - disabled={item.disabled} - className={item.className} - onClick={item.onClick} - > - {item.icon} - <span>{item.title}</span> - </DropdownMenuItem> - ))} + {visibleItems.map((item) => { + if (isSubsectionItem(item)) { + const visibleSubItems = item.items.filter( + (subItem) => subItem.visible, + ); + if (visibleSubItems.length === 0) { + return null; + } + return ( + <DropdownMenuSub key={item.id}> + <DropdownMenuSubTrigger> + {item.icon} + <span>{item.title}</span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent> + {visibleSubItems.map((subItem) => ( + <DropdownMenuItem + key={subItem.id} + disabled={subItem.disabled} + onClick={subItem.onClick} + > + {subItem.icon} + <span>{subItem.title}</span> + </DropdownMenuItem> + ))} + </DropdownMenuSubContent> + </DropdownMenuSub> + ); + } + return ( + <DropdownMenuItem + key={item.id} + disabled={item.disabled} + className={item.className} + onClick={item.onClick} + > + {item.icon} + <span>{item.title}</span> + </DropdownMenuItem> + ); + })} </DropdownMenuContent> </DropdownMenu> + <input + type="file" + ref={bannerFileInputRef} + onChange={handleBannerFileChange} + className="hidden" + accept=".jpg,.jpeg,.png,.webp" + /> </> ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx new file mode 100644 index 00000000..57770547 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx @@ -0,0 +1,31 @@ +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { UserAvatar } from "@/components/ui/user-avatar"; + +interface BookmarkOwnerIconProps { + ownerName: string; + ownerAvatar: string | null; +} + +export default function BookmarkOwnerIcon({ + ownerName, + ownerAvatar, +}: BookmarkOwnerIconProps) { + return ( + <Tooltip> + <TooltipTrigger> + <UserAvatar + name={ownerName} + image={ownerAvatar} + className="size-5 shrink-0 rounded-full ring-1 ring-border" + /> + </TooltipTrigger> + <TooltipContent className="font-sm"> + <p className="font-medium">{ownerName}</p> + </TooltipContent> + </Tooltip> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx index 22b5408e..09843bce 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx @@ -1,4 +1,4 @@ -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index f726c703..b3a1881a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -16,6 +16,7 @@ import Masonry from "react-masonry-css"; import resolveConfig from "tailwindcss/resolveConfig"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { useBookmarkListContext } from "@karakeep/shared-react/hooks/bookmark-list-context"; import BookmarkCard from "./BookmarkCard"; import EditorCard from "./EditorCard"; @@ -64,6 +65,7 @@ export default function BookmarksGrid({ const gridColumns = useGridColumns(); const bulkActionsStore = useBulkActionsStore(); const inBookmarkGrid = useInBookmarkGridStore(); + const withinListContext = useBookmarkListContext(); const breakpointConfig = useMemo( () => getBreakpointConfig(gridColumns), [gridColumns], @@ -72,10 +74,13 @@ export default function BookmarksGrid({ useEffect(() => { bulkActionsStore.setVisibleBookmarks(bookmarks); + bulkActionsStore.setListContext(withinListContext); + return () => { bulkActionsStore.setVisibleBookmarks([]); + bulkActionsStore.setListContext(undefined); }; - }, [bookmarks]); + }, [bookmarks, withinListContext?.id]); useEffect(() => { inBookmarkGrid.setInBookmarkGrid(true); @@ -112,12 +117,20 @@ export default function BookmarksGrid({ <> {bookmarkLayoutSwitch(layout, { masonry: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), grid: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx index b592919b..9adc7b7a 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx @@ -69,12 +69,20 @@ export default function BookmarksGridSkeleton({ return bookmarkLayoutSwitch(layout, { masonry: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), grid: ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {children} </Masonry> ), diff --git a/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx b/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx index 23afa7d2..1d4f5814 100644 --- a/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx +++ b/apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx @@ -15,7 +15,7 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx index 431f0fcd..c790a5fe 100644 --- a/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx +++ b/apps/web/components/dashboard/bookmarks/BulkTagModal.tsx @@ -7,10 +7,11 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { useQueries } from "@tanstack/react-query"; import { useUpdateBookmarkTags } from "@karakeep/shared-react/hooks/bookmarks"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { limitConcurrency } from "@karakeep/shared/concurrency"; import { ZBookmark } from "@karakeep/shared/types/bookmarks"; @@ -25,9 +26,12 @@ export default function BulkTagModal({ open: boolean; setOpen: (open: boolean) => void; }) { - const results = api.useQueries((t) => - bookmarkIds.map((id) => t.bookmarks.getBookmark({ bookmarkId: id })), - ); + const api = useTRPC(); + const results = useQueries({ + queries: bookmarkIds.map((id) => + api.bookmarks.getBookmark.queryOptions({ bookmarkId: id }), + ), + }); const bookmarks = results .map((r) => r.data) diff --git a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx index 7e680706..8e7a4d34 100644 --- a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx +++ b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx @@ -1,7 +1,7 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import { useDeleteBookmark } from "@karakeep/shared-react/hooks//bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx index 76208158..8b77365c 100644 --- a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx +++ b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx @@ -25,18 +25,19 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { useDialogFormReset } from "@/lib/hooks/useDialogFormReset"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark, @@ -60,10 +61,11 @@ export function EditBookmarkDialog({ open: boolean; setOpen: (v: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); - const { data: assetContent, isLoading: isAssetContentLoading } = - api.bookmarks.getBookmark.useQuery( + const { data: assetContent, isLoading: isAssetContentLoading } = useQuery( + api.bookmarks.getBookmark.queryOptions( { bookmarkId: bookmark.id, includeContent: true, @@ -73,11 +75,13 @@ export function EditBookmarkDialog({ select: (b) => b.content.type == BookmarkTypes.ASSET ? b.content.content : null, }, - ); + ), + ); const bookmarkToDefault = (bookmark: ZBookmark) => ({ bookmarkId: bookmark.id, summary: bookmark.summary, + note: bookmark.note === null ? undefined : bookmark.note, title: getBookmarkTitle(bookmark), createdAt: bookmark.createdAt ?? new Date(), // Link specific defaults (only if bookmark is a link) @@ -196,6 +200,26 @@ export function EditBookmarkDialog({ /> )} + { + <FormField + control={form.control} + name="note" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("common.note")}</FormLabel> + <FormControl> + <Textarea + placeholder="Bookmark notes" + {...field} + value={field.value ?? ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + } + {isLink && ( <FormField control={form.control} diff --git a/apps/web/components/dashboard/bookmarks/EditorCard.tsx b/apps/web/components/dashboard/bookmarks/EditorCard.tsx index fa752c5f..4636bcb9 100644 --- a/apps/web/components/dashboard/bookmarks/EditorCard.tsx +++ b/apps/web/components/dashboard/bookmarks/EditorCard.tsx @@ -5,8 +5,8 @@ import { Form, FormControl, FormItem } from "@/components/ui/form"; import { Kbd } from "@/components/ui/kbd"; import MultipleChoiceDialog from "@/components/ui/multiple-choice-dialog"; import { Separator } from "@/components/ui/separator"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import BookmarkAlreadyExistsToast from "@/components/utils/BookmarkAlreadyExistsToast"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; diff --git a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx index 7c3827ab..1fee0505 100644 --- a/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx +++ b/apps/web/components/dashboard/bookmarks/ManageListsModal.tsx @@ -16,11 +16,11 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { Archive, X } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -30,6 +30,7 @@ import { useBookmarkLists, useRemoveBookmarkFromList, } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkListSelector } from "../lists/BookmarkListSelector"; import ArchiveBookmarkButton from "./action-buttons/ArchiveBookmarkButton"; @@ -43,6 +44,7 @@ export default function ManageListsModal({ open: boolean; setOpen: (open: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); const formSchema = z.object({ listId: z.string({ @@ -61,13 +63,14 @@ export default function ManageListsModal({ { enabled: open }, ); - const { data: alreadyInList, isPending: isAlreadyInListPending } = - api.lists.getListsOfBookmark.useQuery( + const { data: alreadyInList, isPending: isAlreadyInListPending } = useQuery( + api.lists.getListsOfBookmark.queryOptions( { bookmarkId, }, { enabled: open }, - ); + ), + ); const isLoading = isAllListsPending || isAlreadyInListPending; diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx index b2cf118e..5f107663 100644 --- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx +++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx @@ -1,8 +1,8 @@ import React from "react"; import { ActionButton } from "@/components/ui/action-button"; import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; diff --git a/apps/web/components/dashboard/bookmarks/TagList.tsx b/apps/web/components/dashboard/bookmarks/TagList.tsx index f1c319ea..88611c52 100644 --- a/apps/web/components/dashboard/bookmarks/TagList.tsx +++ b/apps/web/components/dashboard/bookmarks/TagList.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import { badgeVariants } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { useSession } from "@/lib/auth/client"; import { cn } from "@/lib/utils"; -import { useSession } from "next-auth/react"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; diff --git a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx index bc06c647..ec4a9d8a 100644 --- a/apps/web/components/dashboard/bookmarks/TagsEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/TagsEditor.tsx @@ -13,25 +13,32 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; +import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Command as CommandPrimitive } from "cmdk"; import { Check, Loader2, Plus, Sparkles, X } from "lucide-react"; import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export function TagsEditor({ tags: _tags, onAttach, onDetach, disabled, + allowCreation = true, + placeholder, }: { tags: ZBookmarkTags[]; onAttach: (tag: { tagName: string; tagId?: string }) => void; onDetach: (tag: { tagName: string; tagId: string }) => void; disabled?: boolean; + allowCreation?: boolean; + placeholder?: string; }) { + const api = useTRPC(); + const { t } = useTranslation(); const demoMode = !!useClientConfig().demoMode; const isDisabled = demoMode || disabled; const inputRef = React.useRef<HTMLInputElement>(null); @@ -40,6 +47,7 @@ export function TagsEditor({ const [inputValue, setInputValue] = React.useState(""); const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_tags); const tempIdCounter = React.useRef(0); + const hasInitializedRef = React.useRef(_tags.length > 0); const generateTempId = React.useCallback(() => { tempIdCounter.current += 1; @@ -54,25 +62,42 @@ export function TagsEditor({ }, []); React.useEffect(() => { + // When allowCreation is false, only sync on initial load + // After that, rely on optimistic updates to avoid re-ordering + if (!allowCreation) { + if (!hasInitializedRef.current && _tags.length > 0) { + hasInitializedRef.current = true; + setOptimisticTags(_tags); + } + return; + } + + // For allowCreation mode, sync server state with optimistic state setOptimisticTags((prev) => { - let results = prev; + // Start with a copy to avoid mutating the previous state + const results = [...prev]; + let changed = false; + for (const tag of _tags) { const idx = results.findIndex((t) => t.name === tag.name); if (idx == -1) { results.push(tag); + changed = true; continue; } if (results[idx].id.startsWith("temp-")) { results[idx] = tag; + changed = true; continue; } } - return results; + + return changed ? results : prev; }); - }, [_tags]); + }, [_tags, allowCreation]); - const { data: filteredOptions, isLoading: isExistingTagsLoading } = - api.tags.list.useQuery( + const { data: filteredOptions, isLoading: isExistingTagsLoading } = useQuery( + api.tags.list.queryOptions( { nameContains: inputValue, limit: 50, @@ -91,7 +116,8 @@ export function TagsEditor({ placeholderData: keepPreviousData, gcTime: inputValue.length > 0 ? 60_000 : 3_600_000, }, - ); + ), + ); const selectedValues = optimisticTags.map((tag) => tag.id); @@ -122,7 +148,7 @@ export function TagsEditor({ (opt) => opt.name.toLowerCase() === trimmedInputValue.toLowerCase(), ); - if (!exactMatch) { + if (!exactMatch && allowCreation) { return [ { id: "create-new", @@ -136,7 +162,7 @@ export function TagsEditor({ } return baseOptions; - }, [filteredOptions, trimmedInputValue]); + }, [filteredOptions, trimmedInputValue, allowCreation]); const onChange = ( actionMeta: @@ -256,6 +282,24 @@ export function TagsEditor({ } }; + const inputPlaceholder = + placeholder ?? + (allowCreation + ? t("tags.search_or_create_placeholder", { + defaultValue: "Search or create tags...", + }) + : t("tags.search_placeholder", { + defaultValue: "Search tags...", + })); + const visiblePlaceholder = + optimisticTags.length === 0 ? inputPlaceholder : undefined; + const inputWidth = Math.max( + inputValue.length > 0 + ? inputValue.length + : Math.min(visiblePlaceholder?.length ?? 1, 24), + 1, + ); + return ( <div ref={containerRef} className="w-full"> <Popover open={open && !isDisabled} onOpenChange={handleOpenChange}> @@ -311,8 +355,9 @@ export function TagsEditor({ value={inputValue} onKeyDown={handleKeyDown} onValueChange={(v) => setInputValue(v)} + placeholder={visiblePlaceholder} className="bg-transparent outline-none placeholder:text-muted-foreground" - style={{ width: `${Math.max(inputValue.length, 1)}ch` }} + style={{ width: `${inputWidth}ch` }} disabled={isDisabled} /> {isExistingTagsLoading && ( @@ -329,7 +374,7 @@ export function TagsEditor({ <CommandList className="max-h-64"> {displayedOptions.length === 0 ? ( <CommandEmpty> - {trimmedInputValue ? ( + {trimmedInputValue && allowCreation ? ( <div className="flex items-center justify-between px-2 py-1.5"> <span>Create "{trimmedInputValue}"</span> <Button diff --git a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx index 968d0326..e9bee653 100644 --- a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx @@ -3,13 +3,14 @@ import { useEffect } from "react"; import UploadDropzone from "@/components/dashboard/UploadDropzone"; import { useSortOrderStore } from "@/lib/store/useSortOrderStore"; -import { api } from "@/lib/trpc"; +import { useInfiniteQuery } from "@tanstack/react-query"; import type { ZGetBookmarksRequest, ZGetBookmarksResponse, } from "@karakeep/shared/types/bookmarks"; import { BookmarkGridContextProvider } from "@karakeep/shared-react/hooks/bookmark-grid-context"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import BookmarksGrid from "./BookmarksGrid"; @@ -23,6 +24,7 @@ export default function UpdatableBookmarksGrid({ showEditorCard?: boolean; itemsPerPage?: number; }) { + const api = useTRPC(); let sortOrder = useSortOrderStore((state) => state.sortOrder); if (sortOrder === "relevance") { // Relevance is not supported in the `getBookmarks` endpoint. @@ -32,17 +34,19 @@ export default function UpdatableBookmarksGrid({ const finalQuery = { ...query, sortOrder, includeContent: false }; const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = - api.bookmarks.getBookmarks.useInfiniteQuery( - { ...finalQuery, useCursorV2: true }, - { - initialData: () => ({ - pages: [initialBookmarks], - pageParams: [query.cursor], - }), - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - refetchOnMount: true, - }, + useInfiniteQuery( + api.bookmarks.getBookmarks.infiniteQueryOptions( + { ...finalQuery, useCursorV2: true }, + { + initialData: () => ({ + pages: [initialBookmarks], + pageParams: [query.cursor ?? null], + }), + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + refetchOnMount: true, + }, + ), ); useEffect(() => { diff --git a/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx b/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx index d45cfc82..48d3c7ac 100644 --- a/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx +++ b/apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx @@ -1,9 +1,10 @@ import React from "react"; import { ActionButton, ActionButtonProps } from "@/components/ui/action-button"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; +import { useQuery } from "@tanstack/react-query"; import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface ArchiveBookmarkButtonProps extends Omit<ActionButtonProps, "loading" | "disabled"> { @@ -15,13 +16,16 @@ const ArchiveBookmarkButton = React.forwardRef< HTMLButtonElement, ArchiveBookmarkButtonProps >(({ bookmarkId, onDone, ...props }, ref) => { - const { data } = api.bookmarks.getBookmark.useQuery( - { bookmarkId }, - { - select: (data) => ({ - archived: data.archived, - }), - }, + const api = useTRPC(); + const { data } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { bookmarkId }, + { + select: (data) => ({ + archived: data.archived, + }), + }, + ), ); const { mutate: updateBookmark, isPending: isArchivingBookmark } = diff --git a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx index 52a9ab0c..b1870644 100644 --- a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx +++ b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx @@ -11,6 +11,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/sonner"; import LoadingSpinner from "@/components/ui/spinner"; import { Table, @@ -20,14 +21,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import { distance } from "fastest-levenshtein"; import { Check, Combine, X } from "lucide-react"; import { useMergeTag } from "@karakeep/shared-react/hooks/tags"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface Suggestion { mergeIntoId: string; @@ -199,12 +200,15 @@ function SuggestionRow({ } export function TagDuplicationDetection() { + const api = useTRPC(); const [expanded, setExpanded] = useState(false); - let { data: allTags } = api.tags.list.useQuery( - {}, - { - refetchOnWindowFocus: false, - }, + let { data: allTags } = useQuery( + api.tags.list.queryOptions( + {}, + { + refetchOnWindowFocus: false, + }, + ), ); const { suggestions, updateMergeInto, setSuggestions, deleteSuggestion } = diff --git a/apps/web/components/dashboard/feeds/FeedSelector.tsx b/apps/web/components/dashboard/feeds/FeedSelector.tsx index db95a042..58fae503 100644 --- a/apps/web/components/dashboard/feeds/FeedSelector.tsx +++ b/apps/web/components/dashboard/feeds/FeedSelector.tsx @@ -7,8 +7,10 @@ import { SelectValue, } from "@/components/ui/select"; import LoadingSpinner from "@/components/ui/spinner"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export function FeedSelector({ value, @@ -21,9 +23,12 @@ export function FeedSelector({ onChange: (value: string) => void; placeholder?: string; }) { - const { data, isPending } = api.feeds.list.useQuery(undefined, { - select: (data) => data.feeds, - }); + const api = useTRPC(); + const { data, isPending } = useQuery( + api.feeds.list.queryOptions(undefined, { + select: (data) => data.feeds, + }), + ); if (isPending) { return <LoadingSpinner />; diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx index 7ccc0078..8a2b0165 100644 --- a/apps/web/components/dashboard/header/ProfileOptions.tsx +++ b/apps/web/components/dashboard/header/ProfileOptions.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import Link from "next/link"; import { redirect, useRouter } from "next/navigation"; import { useToggleTheme } from "@/components/theme-provider"; @@ -11,11 +12,24 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; +import { UserAvatar } from "@/components/ui/user-avatar"; +import { useSession } from "@/lib/auth/client"; import { useTranslation } from "@/lib/i18n/client"; -import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { + BookOpen, + LogOut, + Moon, + Paintbrush, + Puzzle, + Settings, + Shield, + Sun, + Twitter, +} from "lucide-react"; import { useTheme } from "next-themes"; +import { useWhoAmI } from "@karakeep/shared-react/hooks/users"; + import { AdminNoticeBadge } from "../../admin/AdminNotices"; function DarkModeToggle() { @@ -43,7 +57,12 @@ export default function SidebarProfileOptions() { const { t } = useTranslation(); const toggleTheme = useToggleTheme(); const { data: session } = useSession(); + const { data: whoami } = useWhoAmI(); const router = useRouter(); + + const avatarImage = whoami?.image ?? null; + const avatarUrl = useMemo(() => avatarImage ?? null, [avatarImage]); + if (!session) return redirect("/"); return ( @@ -53,13 +72,21 @@ export default function SidebarProfileOptions() { className="border-new-gray-200 aspect-square rounded-full border-4 bg-black p-0 text-white" variant="ghost" > - {session.user.name?.charAt(0) ?? "U"} + <UserAvatar + image={avatarUrl} + name={session.user.name} + className="h-full w-full rounded-full" + /> </Button> </DropdownMenuTrigger> <DropdownMenuContent className="mr-2 min-w-64 p-2"> <div className="flex gap-2"> - <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center rounded-full border-4 bg-black p-0 text-white"> - {session.user.name?.charAt(0) ?? "U"} + <div className="border-new-gray-200 flex aspect-square size-11 items-center justify-center overflow-hidden rounded-full border-4 bg-black p-0 text-white"> + <UserAvatar + image={avatarUrl} + name={session.user.name} + className="h-full w-full" + /> </div> <div className="flex flex-col"> <p>{session.user.name}</p> @@ -95,6 +122,25 @@ export default function SidebarProfileOptions() { <DarkModeToggle /> </DropdownMenuItem> <Separator className="my-2" /> + <DropdownMenuItem asChild> + <a href="https://karakeep.app/apps" target="_blank" rel="noreferrer"> + <Puzzle className="mr-2 size-4" /> + {t("options.apps_extensions")} + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href="https://docs.karakeep.app" target="_blank" rel="noreferrer"> + <BookOpen className="mr-2 size-4" /> + {t("options.documentation")} + </a> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <a href="https://x.com/karakeep_app" target="_blank" rel="noreferrer"> + <Twitter className="mr-2 size-4" /> + {t("options.follow_us_on_x")} + </a> + </DropdownMenuItem> + <Separator className="my-2" /> <DropdownMenuItem onClick={() => router.push("/logout")}> <LogOut className="mr-2 size-4" /> <span>{t("actions.sign_out")}</span> diff --git a/apps/web/components/dashboard/highlights/AllHighlights.tsx b/apps/web/components/dashboard/highlights/AllHighlights.tsx index 928f4e05..c7e809ec 100644 --- a/apps/web/components/dashboard/highlights/AllHighlights.tsx +++ b/apps/web/components/dashboard/highlights/AllHighlights.tsx @@ -5,15 +5,14 @@ import Link from "next/link"; import { ActionButton } from "@/components/ui/action-button"; import { Input } from "@/components/ui/input"; import useRelativeTime from "@/lib/hooks/relative-time"; -import { api } from "@/lib/trpc"; import { Separator } from "@radix-ui/react-dropdown-menu"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { Dot, LinkIcon, Search, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useInView } from "react-intersection-observer"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZGetAllHighlightsResponse, ZHighlight, @@ -21,8 +20,6 @@ import { import HighlightCard from "./HighlightCard"; -dayjs.extend(relativeTime); - function Highlight({ highlight }: { highlight: ZHighlight }) { const { fromNow, localCreatedAt } = useRelativeTime(highlight.createdAt); const { t } = useTranslation(); @@ -49,6 +46,7 @@ export default function AllHighlights({ }: { highlights: ZGetAllHighlightsResponse; }) { + const api = useTRPC(); const { t } = useTranslation(); const [searchInput, setSearchInput] = useState(""); const debouncedSearch = useDebounce(searchInput, 300); @@ -56,28 +54,32 @@ export default function AllHighlights({ // Use search endpoint if searchQuery is provided, otherwise use getAll const useSearchQuery = debouncedSearch.trim().length > 0; - const getAllQuery = api.highlights.getAll.useInfiniteQuery( - {}, - { - enabled: !useSearchQuery, - initialData: !useSearchQuery - ? () => ({ - pages: [initialHighlights], - pageParams: [null], - }) - : undefined, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + const getAllQuery = useInfiniteQuery( + api.highlights.getAll.infiniteQueryOptions( + {}, + { + enabled: !useSearchQuery, + initialData: !useSearchQuery + ? () => ({ + pages: [initialHighlights], + pageParams: [null], + }) + : undefined, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); - const searchQueryResult = api.highlights.search.useInfiniteQuery( - { text: debouncedSearch }, - { - enabled: useSearchQuery, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + const searchQueryResult = useInfiniteQuery( + api.highlights.search.infiniteQueryOptions( + { text: debouncedSearch }, + { + enabled: useSearchQuery, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = diff --git a/apps/web/components/dashboard/highlights/HighlightCard.tsx b/apps/web/components/dashboard/highlights/HighlightCard.tsx index 51421e0f..e7e7c519 100644 --- a/apps/web/components/dashboard/highlights/HighlightCard.tsx +++ b/apps/web/components/dashboard/highlights/HighlightCard.tsx @@ -1,5 +1,5 @@ import { ActionButton } from "@/components/ui/action-button"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { Trash2 } from "lucide-react"; diff --git a/apps/web/components/dashboard/lists/AllListsView.tsx b/apps/web/components/dashboard/lists/AllListsView.tsx index 7a7c9504..52d65756 100644 --- a/apps/web/components/dashboard/lists/AllListsView.tsx +++ b/apps/web/components/dashboard/lists/AllListsView.tsx @@ -2,7 +2,6 @@ import { useMemo, useState } from "react"; import Link from "next/link"; -import { EditListModal } from "@/components/dashboard/lists/EditListModal"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -10,7 +9,7 @@ import { CollapsibleTriggerChevron, } from "@/components/ui/collapsible"; import { useTranslation } from "@/lib/i18n/client"; -import { MoreHorizontal, Plus } from "lucide-react"; +import { MoreHorizontal } from "lucide-react"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { @@ -89,12 +88,6 @@ export default function AllListsView({ return ( <ul> - <EditListModal> - <Button className="mb-2 flex h-full w-full items-center"> - <Plus /> - <span>{t("lists.new_list")}</span> - </Button> - </EditListModal> <ListItem collapsible={false} name={t("lists.favourites")} diff --git a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx index 2bb5f41b..0070b827 100644 --- a/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx +++ b/apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { api } from "@/lib/trpc"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; @@ -101,6 +101,7 @@ export function CollapsibleBookmarkLists({ filter?: (node: ZBookmarkListTreeNode) => boolean; indentOffset?: number; }) { + const api = useTRPC(); // If listsData is provided, use it directly. Otherwise, fetch it. let { data: fetchedData } = useBookmarkLists(undefined, { initialData: initialData ? { lists: initialData } : undefined, @@ -108,9 +109,11 @@ export function CollapsibleBookmarkLists({ }); const data = listsData || fetchedData; - const { data: listStats } = api.lists.stats.useQuery(undefined, { - placeholderData: keepPreviousData, - }); + const { data: listStats } = useQuery( + api.lists.stats.queryOptions(undefined, { + placeholderData: keepPreviousData, + }), + ); if (!data) { return <FullPageSpinner />; diff --git a/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx index 4996ddf1..6c091d7a 100644 --- a/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx +++ b/apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx @@ -3,8 +3,8 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx index 5febf88c..21a61d65 100644 --- a/apps/web/components/dashboard/lists/EditListModal.tsx +++ b/apps/web/components/dashboard/lists/EditListModal.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -34,7 +36,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import data from "@emoji-mart/data"; import Picker from "@emoji-mart/react"; diff --git a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx index 62dbbcef..859f4c83 100644 --- a/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx +++ b/apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx @@ -2,11 +2,12 @@ import React from "react"; import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function LeaveListConfirmationDialog({ list, @@ -19,34 +20,37 @@ export default function LeaveListConfirmationDialog({ open: boolean; setOpen: (v: boolean) => void; }) { + const api = useTRPC(); const { t } = useTranslation(); const currentPath = usePathname(); const router = useRouter(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: leaveList, isPending } = api.lists.leaveList.useMutation({ - onSuccess: () => { - toast({ - description: t("lists.leave_list.success", { - icon: list.icon, - name: list.name, - }), - }); - setOpen(false); - // Invalidate the lists cache - utils.lists.list.invalidate(); - // If currently viewing this list, redirect to lists page - if (currentPath.includes(list.id)) { - router.push("/dashboard/lists"); - } - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("common.something_went_wrong"), - }); - }, - }); + const { mutate: leaveList, isPending } = useMutation( + api.lists.leaveList.mutationOptions({ + onSuccess: () => { + toast({ + description: t("lists.leave_list.success", { + icon: list.icon, + name: list.name, + }), + }); + setOpen(false); + // Invalidate the lists cache + queryClient.invalidateQueries(api.lists.list.pathFilter()); + // If currently viewing this list, redirect to lists page + if (currentPath.includes(list.id)) { + router.push("/dashboard/lists"); + } + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("common.something_went_wrong"), + }); + }, + }), + ); return ( <ActionConfirmingDialog diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx index 8e014e2a..4176a80e 100644 --- a/apps/web/components/dashboard/lists/ListHeader.tsx +++ b/apps/web/components/dashboard/lists/ListHeader.tsx @@ -6,13 +6,14 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; -import { MoreHorizontal, SearchIcon, Users } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { MoreHorizontal, SearchIcon } from "lucide-react"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; @@ -24,15 +25,30 @@ export default function ListHeader({ }: { initialData: ZBookmarkList; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); - const { data: list, error } = api.lists.get.useQuery( - { - listId: initialData.id, - }, - { - initialData, - }, + const { data: list, error } = useQuery( + api.lists.get.queryOptions( + { + listId: initialData.id, + }, + { + initialData, + }, + ), + ); + + const { data: collaboratorsData } = useQuery( + api.lists.getCollaborators.queryOptions( + { + listId: initialData.id, + }, + { + refetchOnWindowFocus: false, + enabled: list.hasCollaborators, + }, + ), ); const parsedQuery = useMemo(() => { @@ -55,22 +71,44 @@ export default function ListHeader({ <span className="text-2xl"> {list.icon} {list.name} </span> - {list.hasCollaborators && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Users className="size-5 text-primary" /> - </TooltipTrigger> - <TooltipContent> - <p>{t("lists.shared")}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> + {list.hasCollaborators && collaboratorsData && ( + <div className="group flex"> + {collaboratorsData.owner && ( + <Tooltip> + <TooltipTrigger> + <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1"> + <UserAvatar + name={collaboratorsData.owner.name} + image={collaboratorsData.owner.image} + className="size-5 shrink-0 rounded-full ring-2 ring-background" + /> + </div> + </TooltipTrigger> + <TooltipContent> + <p>{collaboratorsData.owner.name}</p> + </TooltipContent> + </Tooltip> + )} + {collaboratorsData.collaborators.map((collab) => ( + <Tooltip key={collab.userId}> + <TooltipTrigger> + <div className="-mr-2 transition-all duration-300 ease-out group-hover:mr-1"> + <UserAvatar + name={collab.user.name} + image={collab.user.image} + className="size-5 shrink-0 rounded-full ring-2 ring-background" + /> + </div> + </TooltipTrigger> + <TooltipContent> + <p>{collab.user.name}</p> + </TooltipContent> + </Tooltip> + ))} + </div> )} {list.description && ( - <span className="text-lg text-gray-400"> - {`(${list.description})`} - </span> + <span className="text-lg text-gray-400">{`(${list.description})`}</span> )} </div> <div className="flex items-center"> diff --git a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx index 0a55c5fe..518e6440 100644 --- a/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx +++ b/apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx @@ -22,11 +22,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2, UserPlus, Users } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZBookmarkList } from "@karakeep/shared/types/lists"; export function ManageCollaboratorsModal({ @@ -42,6 +44,7 @@ export function ManageCollaboratorsModal({ children?: React.ReactNode; readOnly?: boolean; }) { + const api = useTRPC(); if ( (userOpen !== undefined && !userSetOpen) || (userOpen === undefined && userSetOpen) @@ -60,82 +63,102 @@ export function ManageCollaboratorsModal({ >("viewer"); const { t } = useTranslation(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); const invalidateListCaches = () => Promise.all([ - utils.lists.getCollaborators.invalidate({ listId: list.id }), - utils.lists.get.invalidate({ listId: list.id }), - utils.lists.list.invalidate(), - utils.bookmarks.getBookmarks.invalidate({ listId: list.id }), + queryClient.invalidateQueries( + api.lists.getCollaborators.queryFilter({ listId: list.id }), + ), + queryClient.invalidateQueries( + api.lists.get.queryFilter({ listId: list.id }), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + queryClient.invalidateQueries( + api.bookmarks.getBookmarks.queryFilter({ listId: list.id }), + ), ]); // Fetch collaborators - const { data: collaboratorsData, isLoading } = - api.lists.getCollaborators.useQuery({ listId: list.id }, { enabled: open }); + const { data: collaboratorsData, isLoading } = useQuery( + api.lists.getCollaborators.queryOptions( + { listId: list.id }, + { enabled: open }, + ), + ); // Mutations - const addCollaborator = api.lists.addCollaborator.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.invitation_sent"), - }); - setNewCollaboratorEmail(""); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_add"), - }); - }, - }); + const addCollaborator = useMutation( + api.lists.addCollaborator.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.invitation_sent"), + }); + setNewCollaboratorEmail(""); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("lists.collaborators.failed_to_add"), + }); + }, + }), + ); - const removeCollaborator = api.lists.removeCollaborator.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.removed"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_remove"), - }); - }, - }); + const removeCollaborator = useMutation( + api.lists.removeCollaborator.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.removed"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_remove"), + }); + }, + }), + ); - const updateCollaboratorRole = api.lists.updateCollaboratorRole.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.role_updated"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: - error.message || t("lists.collaborators.failed_to_update_role"), - }); - }, - }); + const updateCollaboratorRole = useMutation( + api.lists.updateCollaboratorRole.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.role_updated"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_update_role"), + }); + }, + }), + ); - const revokeInvitation = api.lists.revokeInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.collaborators.invitation_revoked"), - }); - await invalidateListCaches(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.collaborators.failed_to_revoke"), - }); - }, - }); + const revokeInvitation = useMutation( + api.lists.revokeInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.collaborators.invitation_revoked"), + }); + await invalidateListCaches(); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.collaborators.failed_to_revoke"), + }); + }, + }), + ); const handleAddCollaborator = () => { if (!newCollaboratorEmail.trim()) { @@ -256,15 +279,22 @@ export function ManageCollaboratorsModal({ key={`owner-${collaboratorsData.owner.id}`} className="flex items-center justify-between rounded-lg border p-3" > - <div className="flex-1"> - <div className="font-medium"> - {collaboratorsData.owner.name} - </div> - {collaboratorsData.owner.email && ( - <div className="text-sm text-muted-foreground"> - {collaboratorsData.owner.email} + <div className="flex flex-1 items-center gap-3"> + <UserAvatar + name={collaboratorsData.owner.name} + image={collaboratorsData.owner.image} + className="size-10 ring-1 ring-border" + /> + <div className="flex-1"> + <div className="font-medium"> + {collaboratorsData.owner.name} </div> - )} + {collaboratorsData.owner.email && ( + <div className="text-sm text-muted-foreground"> + {collaboratorsData.owner.email} + </div> + )} + </div> </div> <div className="text-sm capitalize text-muted-foreground"> {t("lists.collaborators.owner")} @@ -278,27 +308,34 @@ export function ManageCollaboratorsModal({ key={collaborator.id} className="flex items-center justify-between rounded-lg border p-3" > - <div className="flex-1"> - <div className="flex items-center gap-2"> - <div className="font-medium"> - {collaborator.user.name} + <div className="flex flex-1 items-center gap-3"> + <UserAvatar + name={collaborator.user.name} + image={collaborator.user.image} + className="size-10 ring-1 ring-border" + /> + <div className="flex-1"> + <div className="flex items-center gap-2"> + <div className="font-medium"> + {collaborator.user.name} + </div> + {collaborator.status === "pending" && ( + <Badge variant="outline" className="text-xs"> + {t("lists.collaborators.pending")} + </Badge> + )} + {collaborator.status === "declined" && ( + <Badge variant="destructive" className="text-xs"> + {t("lists.collaborators.declined")} + </Badge> + )} </div> - {collaborator.status === "pending" && ( - <Badge variant="outline" className="text-xs"> - {t("lists.collaborators.pending")} - </Badge> - )} - {collaborator.status === "declined" && ( - <Badge variant="destructive" className="text-xs"> - {t("lists.collaborators.declined")} - </Badge> + {collaborator.user.email && ( + <div className="text-sm text-muted-foreground"> + {collaborator.user.email} + </div> )} </div> - {collaborator.user.email && ( - <div className="text-sm text-muted-foreground"> - {collaborator.user.email} - </div> - )} </div> {readOnly ? ( <div className="text-sm capitalize text-muted-foreground"> diff --git a/apps/web/components/dashboard/lists/MergeListModal.tsx b/apps/web/components/dashboard/lists/MergeListModal.tsx index 0b7d362a..b22cd1a2 100644 --- a/apps/web/components/dashboard/lists/MergeListModal.tsx +++ b/apps/web/components/dashboard/lists/MergeListModal.tsx @@ -19,8 +19,8 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { X } from "lucide-react"; diff --git a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx index c453a91f..7c13dbeb 100644 --- a/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx +++ b/apps/web/components/dashboard/lists/PendingInvitationsCard.tsx @@ -8,11 +8,13 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, Mail, X } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + interface Invitation { id: string; role: string; @@ -27,41 +29,51 @@ interface Invitation { } function InvitationRow({ invitation }: { invitation: Invitation }) { + const api = useTRPC(); const { t } = useTranslation(); - const utils = api.useUtils(); + const queryClient = useQueryClient(); - const acceptInvitation = api.lists.acceptInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.invitations.accepted"), - }); - await Promise.all([ - utils.lists.getPendingInvitations.invalidate(), - utils.lists.list.invalidate(), - ]); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.invitations.failed_to_accept"), - }); - }, - }); + const acceptInvitation = useMutation( + api.lists.acceptInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.invitations.accepted"), + }); + await Promise.all([ + queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ), + queryClient.invalidateQueries(api.lists.list.pathFilter()), + ]); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: error.message || t("lists.invitations.failed_to_accept"), + }); + }, + }), + ); - const declineInvitation = api.lists.declineInvitation.useMutation({ - onSuccess: async () => { - toast({ - description: t("lists.invitations.declined"), - }); - await utils.lists.getPendingInvitations.invalidate(); - }, - onError: (error) => { - toast({ - variant: "destructive", - description: error.message || t("lists.invitations.failed_to_decline"), - }); - }, - }); + const declineInvitation = useMutation( + api.lists.declineInvitation.mutationOptions({ + onSuccess: async () => { + toast({ + description: t("lists.invitations.declined"), + }); + await queryClient.invalidateQueries( + api.lists.getPendingInvitations.pathFilter(), + ); + }, + onError: (error) => { + toast({ + variant: "destructive", + description: + error.message || t("lists.invitations.failed_to_decline"), + }); + }, + }), + ); return ( <div className="flex items-center justify-between rounded-lg border p-4"> @@ -126,10 +138,12 @@ function InvitationRow({ invitation }: { invitation: Invitation }) { } export function PendingInvitationsCard() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: invitations, isLoading } = - api.lists.getPendingInvitations.useQuery(); + const { data: invitations, isLoading } = useQuery( + api.lists.getPendingInvitations.queryOptions(), + ); if (isLoading) { return null; @@ -142,9 +156,13 @@ export function PendingInvitationsCard() { return ( <Card> <CardHeader> - <CardTitle className="flex items-center gap-2"> + <CardTitle className="flex items-center gap-2 font-normal"> <Mail className="h-5 w-5" /> - {t("lists.invitations.pending")} ({invitations.length}) + {t("lists.invitations.pending")} + + <span className="rounded bg-secondary p-1 text-sm text-secondary-foreground"> + {invitations.length} + </span> </CardTitle> <CardDescription>{t("lists.invitations.description")}</CardDescription> </CardHeader> diff --git a/apps/web/components/dashboard/lists/RssLink.tsx b/apps/web/components/dashboard/lists/RssLink.tsx index 1be48681..2ac53c93 100644 --- a/apps/web/components/dashboard/lists/RssLink.tsx +++ b/apps/web/components/dashboard/lists/RssLink.tsx @@ -7,29 +7,39 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, RotateCcw } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + export default function RssLink({ listId }: { listId: string }) { + const api = useTRPC(); const { t } = useTranslation(); const clientConfig = useClientConfig(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: regenRssToken, isPending: isRegenPending } = - api.lists.regenRssToken.useMutation({ + const { mutate: regenRssToken, isPending: isRegenPending } = useMutation( + api.lists.regenRssToken.mutationOptions({ onSuccess: () => { - apiUtils.lists.getRssToken.invalidate({ listId }); + queryClient.invalidateQueries( + api.lists.getRssToken.queryFilter({ listId }), + ); }, - }); - const { mutate: clearRssToken, isPending: isClearPending } = - api.lists.clearRssToken.useMutation({ + }), + ); + const { mutate: clearRssToken, isPending: isClearPending } = useMutation( + api.lists.clearRssToken.mutationOptions({ onSuccess: () => { - apiUtils.lists.getRssToken.invalidate({ listId }); + queryClient.invalidateQueries( + api.lists.getRssToken.queryFilter({ listId }), + ); }, - }); - const { data: rssToken, isLoading: isTokenLoading } = - api.lists.getRssToken.useQuery({ listId }); + }), + ); + const { data: rssToken, isLoading: isTokenLoading } = useQuery( + api.lists.getRssToken.queryOptions({ listId }), + ); const rssUrl = useMemo(() => { if (!rssToken || !rssToken.token) { diff --git a/apps/web/components/dashboard/preview/ActionBar.tsx b/apps/web/components/dashboard/preview/ActionBar.tsx index 6e4cd5a2..9603465e 100644 --- a/apps/web/components/dashboard/preview/ActionBar.tsx +++ b/apps/web/components/dashboard/preview/ActionBar.tsx @@ -1,12 +1,12 @@ import { useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { Pencil, Trash2 } from "lucide-react"; diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx index 73eea640..654f3211 100644 --- a/apps/web/components/dashboard/preview/AttachmentBox.tsx +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -8,7 +8,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import FilePickerButton from "@/components/ui/file-picker-button"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { ASSET_TYPE_TO_ICON } from "@/lib/attachments"; import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index 7e6bf814..719cdff8 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -13,12 +13,13 @@ import { TooltipPortal, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useSession } from "@/lib/auth/client"; import useRelativeTime from "@/lib/hooks/relative-time"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { Building, CalendarDays, ExternalLink, User } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; import { getBookmarkRefreshInterval, @@ -116,24 +117,27 @@ export default function BookmarkPreview({ bookmarkId: string; initialData?: ZBookmark; }) { + const api = useTRPC(); const { t } = useTranslation(); const [activeTab, setActiveTab] = useState<string>("content"); const { data: session } = useSession(); - const { data: bookmark } = api.bookmarks.getBookmark.useQuery( - { - bookmarkId, - }, - { - initialData, - refetchInterval: (query) => { - const data = query.state.data; - if (!data) { - return false; - } - return getBookmarkRefreshInterval(data); + const { data: bookmark } = useQuery( + api.bookmarks.getBookmark.queryOptions( + { + bookmarkId, }, - }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + return getBookmarkRefreshInterval(data); + }, + }, + ), ); if (!bookmark) { diff --git a/apps/web/components/dashboard/preview/HighlightsBox.tsx b/apps/web/components/dashboard/preview/HighlightsBox.tsx index 41ab7d74..e8503fd9 100644 --- a/apps/web/components/dashboard/preview/HighlightsBox.tsx +++ b/apps/web/components/dashboard/preview/HighlightsBox.tsx @@ -5,10 +5,12 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { Separator } from "@radix-ui/react-dropdown-menu"; +import { useQuery } from "@tanstack/react-query"; import { ChevronsDownUp } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import HighlightCard from "../highlights/HighlightCard"; export default function HighlightsBox({ @@ -18,10 +20,12 @@ export default function HighlightsBox({ bookmarkId: string; readOnly: boolean; }) { + const api = useTRPC(); const { t } = useTranslation(); - const { data: highlights, isPending: isLoading } = - api.highlights.getForBookmark.useQuery({ bookmarkId }); + const { data: highlights, isPending: isLoading } = useQuery( + api.highlights.getForBookmark.queryOptions({ bookmarkId }), + ); if (isLoading || !highlights || highlights?.highlights.length === 0) { return null; diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index 64b62df6..f4e344ac 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -16,16 +16,19 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useTranslation } from "@/lib/i18n/client"; +import { useSession } from "@/lib/auth/client"; +import { Trans, useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; import { AlertTriangle, Archive, BookOpen, Camera, ExpandIcon, + FileText, + Info, Video, } from "lucide-react"; -import { useSession } from "next-auth/react"; import { useQueryState } from "nuqs"; import { ErrorBoundary } from "react-error-boundary"; @@ -34,8 +37,10 @@ import { ZBookmark, ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; +import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers"; import { contentRendererRegistry } from "./content-renderers"; +import ReaderSettingsPopover from "./ReaderSettingsPopover"; import ReaderView from "./ReaderView"; function CustomRendererErrorFallback({ error }: { error: Error }) { @@ -100,12 +105,23 @@ function VideoSection({ link }: { link: ZBookmarkedLink }) { ); } +function PDFSection({ link }: { link: ZBookmarkedLink }) { + return ( + <iframe + title="PDF Viewer" + src={`/api/assets/${link.pdfAssetId}`} + className="relative h-full min-w-full" + /> + ); +} + export default function LinkContentSection({ bookmark, }: { bookmark: ZBookmark; }) { const { t } = useTranslation(); + const { settings } = useReaderSettings(); const availableRenderers = contentRendererRegistry.getRenderers(bookmark); const defaultSection = availableRenderers.length > 0 ? availableRenderers[0].id : "cached"; @@ -135,6 +151,11 @@ export default function LinkContentSection({ <ScrollArea className="h-full"> <ReaderView className="prose mx-auto dark:prose-invert" + style={{ + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${settings.fontSize}px`, + lineHeight: settings.lineHeight, + }} bookmarkId={bookmark.id} readOnly={!isOwner} /> @@ -144,6 +165,8 @@ export default function LinkContentSection({ content = <FullPageArchiveSection link={bookmark.content} />; } else if (section === "video") { content = <VideoSection link={bookmark.content} />; + } else if (section === "pdf") { + content = <PDFSection link={bookmark.content} />; } else { content = <ScreenshotSection link={bookmark.content} />; } @@ -188,6 +211,12 @@ export default function LinkContentSection({ {t("common.screenshot")} </div> </SelectItem> + <SelectItem value="pdf" disabled={!bookmark.content.pdfAssetId}> + <div className="flex items-center"> + <FileText className="mr-2 h-4 w-4" /> + {t("common.pdf")} + </div> + </SelectItem> <SelectItem value="archive" disabled={ @@ -213,16 +242,47 @@ export default function LinkContentSection({ </SelectContent> </Select> {section === "cached" && ( + <> + <ReaderSettingsPopover /> + <Tooltip> + <TooltipTrigger> + <Link + href={`/reader/${bookmark.id}`} + className={buttonVariants({ variant: "outline" })} + > + <ExpandIcon className="h-4 w-4" /> + </Link> + </TooltipTrigger> + <TooltipContent side="bottom">FullScreen</TooltipContent> + </Tooltip> + </> + )} + {section === "archive" && ( <Tooltip> - <TooltipTrigger> - <Link - href={`/reader/${bookmark.id}`} - className={buttonVariants({ variant: "outline" })} - > - <ExpandIcon className="h-4 w-4" /> - </Link> + <TooltipTrigger asChild> + <div className="flex h-10 items-center gap-1 rounded-md border border-blue-500/50 bg-blue-50 px-3 text-blue-700 dark:bg-blue-950 dark:text-blue-300"> + <Info className="h-4 w-4" /> + </div> </TooltipTrigger> - <TooltipContent side="bottom">FullScreen</TooltipContent> + <TooltipContent side="bottom" className="max-w-sm"> + <p className="text-sm"> + <Trans + i18nKey="preview.archive_info" + components={{ + 1: ( + <Link + prefetch={false} + href={`/api/assets/${bookmark.content.fullPageArchiveAssetId ?? bookmark.content.precrawledArchiveAssetId}`} + download + className="font-medium underline" + > + link + </Link> + ), + }} + /> + </p> + </TooltipContent> </Tooltip> )} </div> diff --git a/apps/web/components/dashboard/preview/NoteEditor.tsx b/apps/web/components/dashboard/preview/NoteEditor.tsx index 538aff2e..86807569 100644 --- a/apps/web/components/dashboard/preview/NoteEditor.tsx +++ b/apps/web/components/dashboard/preview/NoteEditor.tsx @@ -1,5 +1,5 @@ +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; diff --git a/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx new file mode 100644 index 00000000..f37b8263 --- /dev/null +++ b/apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +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 { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { + Globe, + Laptop, + Minus, + Plus, + RotateCcw, + Settings, + Type, + X, +} from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +interface ReaderSettingsPopoverProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + variant?: "outline" | "ghost"; +} + +export default function ReaderSettingsPopover({ + open, + onOpenChange, + variant = "outline", +}: ReaderSettingsPopoverProps) { + const { t } = useTranslation(); + const { + settings, + serverSettings, + localOverrides, + sessionOverrides, + hasSessionChanges, + hasLocalOverrides, + isSaving, + updateSession, + clearSession, + saveToDevice, + clearLocalOverride, + saveToServer, + } = useReaderSettings(); + + // Helper to get the effective server value (server setting or default) + const getServerValue = <K extends keyof typeof serverSettings>(key: K) => { + return serverSettings[key] ?? READER_DEFAULTS[key]; + }; + + // Helper to check if a setting has a local override + const hasLocalOverride = (key: keyof typeof localOverrides) => { + return localOverrides[key] !== undefined; + }; + + // Build tooltip message for the settings button + const getSettingsTooltip = () => { + if (hasSessionChanges && hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_preview_and_local"); + } + if (hasSessionChanges) { + return t("settings.info.reader_settings.tooltip_preview"); + } + if (hasLocalOverrides) { + return t("settings.info.reader_settings.tooltip_local"); + } + return t("settings.info.reader_settings.tooltip_default"); + }; + + return ( + <Popover open={open} onOpenChange={onOpenChange}> + <Tooltip> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <Button variant={variant} size="icon" className="relative"> + <Settings className="h-4 w-4" /> + {(hasSessionChanges || hasLocalOverrides) && ( + <span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" /> + )} + </Button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent side="bottom"> + <p>{getSettingsTooltip()}</p> + </TooltipContent> + </Tooltip> + <PopoverContent + side="bottom" + align="center" + collisionPadding={32} + className="flex w-80 flex-col overflow-hidden p-0" + style={{ + maxHeight: "var(--radix-popover-content-available-height)", + }} + > + <div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4"> + <div className="flex items-center justify-between pb-2"> + <div className="flex items-center gap-2"> + <Type className="h-4 w-4" /> + <h3 className="font-semibold"> + {t("settings.info.reader_settings.title")} + </h3> + </div> + {hasSessionChanges && ( + <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"> + {t("settings.info.reader_settings.preview")} + </span> + )} + </div> + + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_family")} + </label> + <div className="flex items-center gap-1"> + {sessionOverrides.fontFamily !== undefined && ( + <span className="text-xs text-muted-foreground"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + {hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontFamily")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: t( + `settings.info.reader_settings.${getServerValue("fontFamily")}` as const, + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <Select + value={settings.fontFamily} + onValueChange={(value) => + updateSession({ + fontFamily: value as "serif" | "sans" | "mono", + }) + } + > + <SelectTrigger + className={ + hasLocalOverride("fontFamily") && + sessionOverrides.fontFamily === undefined + ? "border-primary/50" + : "" + } + > + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="serif"> + {t("settings.info.reader_settings.serif")} + </SelectItem> + <SelectItem value="sans"> + {t("settings.info.reader_settings.sans")} + </SelectItem> + <SelectItem value="mono"> + {t("settings.info.reader_settings.mono")} + </SelectItem> + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_size")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatFontSize(settings.fontSize)} + {sessionOverrides.fontSize !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("fontSize")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatFontSize( + getServerValue("fontSize"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.max( + READER_SETTING_CONSTRAINTS.fontSize.min, + settings.fontSize - + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.fontSize]} + onValueChange={([value]) => + updateSession({ fontSize: value }) + } + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + className={`flex-1 ${ + hasLocalOverride("fontSize") && + sessionOverrides.fontSize === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + fontSize: Math.min( + READER_SETTING_CONSTRAINTS.fontSize.max, + settings.fontSize + + READER_SETTING_CONSTRAINTS.fontSize.step, + ), + }) + } + > + <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"> + {t("settings.info.reader_settings.line_height")} + </label> + <div className="flex items-center gap-1"> + <span className="text-sm text-muted-foreground"> + {formatLineHeight(settings.lineHeight)} + {sessionOverrides.lineHeight !== undefined && ( + <span className="ml-1 text-xs"> + {t("settings.info.reader_settings.preview_inline")} + </span> + )} + </span> + {hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined && ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="h-5 w-5 text-muted-foreground hover:text-foreground" + onClick={() => clearLocalOverride("lineHeight")} + > + <X className="h-3 w-3" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p> + {t( + "settings.info.reader_settings.clear_override_hint", + { + value: formatLineHeight( + getServerValue("lineHeight"), + ), + }, + )} + </p> + </TooltipContent> + </Tooltip> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.max( + READER_SETTING_CONSTRAINTS.lineHeight.min, + Math.round( + (settings.lineHeight - + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Minus className="h-3 w-3" /> + </Button> + <Slider + value={[settings.lineHeight]} + onValueChange={([value]) => + updateSession({ lineHeight: value }) + } + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + className={`flex-1 ${ + hasLocalOverride("lineHeight") && + sessionOverrides.lineHeight === undefined + ? "[&_[role=slider]]:border-primary/50" + : "" + }`} + /> + <Button + variant="outline" + size="icon" + className="h-7 w-7 bg-transparent" + onClick={() => + updateSession({ + lineHeight: Math.min( + READER_SETTING_CONSTRAINTS.lineHeight.max, + Math.round( + (settings.lineHeight + + READER_SETTING_CONSTRAINTS.lineHeight.step) * + 10, + ) / 10, + ), + }) + } + > + <Plus className="h-3 w-3" /> + </Button> + </div> + </div> + + {hasSessionChanges && ( + <> + <Separator /> + + <div className="space-y-2"> + <Button + variant="outline" + size="sm" + className="w-full" + onClick={() => clearSession()} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.reset_preview")} + </Button> + + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToDevice()} + > + <Laptop className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_device")} + </Button> + <Button + variant="default" + size="sm" + className="flex-1" + disabled={isSaving} + onClick={() => saveToServer()} + > + <Globe className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.save_to_all_devices")} + </Button> + </div> + + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.save_hint")} + </p> + </div> + </> + )} + + {!hasSessionChanges && ( + <p className="text-center text-xs text-muted-foreground"> + {t("settings.info.reader_settings.adjust_hint")} + </p> + )} + </div> + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/web/components/dashboard/preview/ReaderView.tsx b/apps/web/components/dashboard/preview/ReaderView.tsx index f2f843ee..76070534 100644 --- a/apps/web/components/dashboard/preview/ReaderView.tsx +++ b/apps/web/components/dashboard/preview/ReaderView.tsx @@ -1,12 +1,15 @@ import { FullPageSpinner } from "@/components/ui/full-page-spinner"; -import { toast } from "@/components/ui/use-toast"; -import { api } from "@/lib/trpc"; +import { toast } from "@/components/ui/sonner"; +import { useTranslation } from "@/lib/i18n/client"; +import { useQuery } from "@tanstack/react-query"; +import { FileX } from "lucide-react"; import { useCreateHighlight, useDeleteHighlight, useUpdateHighlight, } from "@karakeep/shared-react/hooks/highlights"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import BookmarkHTMLHighlighter from "./BookmarkHtmlHighlighter"; @@ -22,11 +25,15 @@ export default function ReaderView({ style?: React.CSSProperties; readOnly: boolean; }) { - const { data: highlights } = api.highlights.getForBookmark.useQuery({ - bookmarkId, - }); - const { data: cachedContent, isPending: isCachedContentLoading } = - api.bookmarks.getBookmark.useQuery( + const { t } = useTranslation(); + const api = useTRPC(); + const { data: highlights } = useQuery( + api.highlights.getForBookmark.queryOptions({ + bookmarkId, + }), + ); + const { data: cachedContent, isPending: isCachedContentLoading } = useQuery( + api.bookmarks.getBookmark.queryOptions( { bookmarkId, includeContent: true, @@ -37,7 +44,8 @@ export default function ReaderView({ ? data.content.htmlContent : null, }, - ); + ), + ); const { mutate: createHighlight } = useCreateHighlight({ onSuccess: () => { @@ -86,7 +94,23 @@ export default function ReaderView({ content = <FullPageSpinner />; } else if (!cachedContent) { content = ( - <div className="text-destructive">Failed to fetch link content ...</div> + <div className="flex h-full w-full items-center justify-center p-4"> + <div className="max-w-sm space-y-4 text-center"> + <div className="flex justify-center"> + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"> + <FileX className="h-8 w-8 text-muted-foreground" /> + </div> + </div> + <div className="space-y-2"> + <h3 className="text-lg font-medium text-foreground"> + {t("preview.fetch_error_title")} + </h3> + <p className="text-sm leading-relaxed text-muted-foreground"> + {t("preview.fetch_error_description")} + </p> + </div> + </div> + </div> ); } else { content = ( diff --git a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx index 8faca013..28bf690d 100644 --- a/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx @@ -19,6 +19,7 @@ import { ChevronDown, ChevronRight, FileType, + Heading, Link, PlusCircle, Rss, @@ -28,7 +29,10 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { RuleEngineCondition } from "@karakeep/shared/types/rules"; +import type { + RuleEngineCondition, + RuleEngineEvent, +} from "@karakeep/shared/types/rules"; import { FeedSelector } from "../feeds/FeedSelector"; import { TagAutocomplete } from "../tags/TagAutocomplete"; @@ -36,6 +40,7 @@ import { TagAutocomplete } from "../tags/TagAutocomplete"; interface ConditionBuilderProps { value: RuleEngineCondition; onChange: (condition: RuleEngineCondition) => void; + eventType: RuleEngineEvent["type"]; level?: number; onRemove?: () => void; } @@ -43,6 +48,7 @@ interface ConditionBuilderProps { export function ConditionBuilder({ value, onChange, + eventType, level = 0, onRemove, }: ConditionBuilderProps) { @@ -54,6 +60,15 @@ export function ConditionBuilder({ case "urlContains": onChange({ type: "urlContains", str: "" }); break; + case "urlDoesNotContain": + onChange({ type: "urlDoesNotContain", str: "" }); + break; + case "titleContains": + onChange({ type: "titleContains", str: "" }); + break; + case "titleDoesNotContain": + onChange({ type: "titleDoesNotContain", str: "" }); + break; case "importedFromFeed": onChange({ type: "importedFromFeed", feedId: "" }); break; @@ -88,7 +103,11 @@ export function ConditionBuilder({ const renderConditionIcon = (type: RuleEngineCondition["type"]) => { switch (type) { case "urlContains": + case "urlDoesNotContain": return <Link className="h-4 w-4" />; + case "titleContains": + case "titleDoesNotContain": + return <Heading className="h-4 w-4" />; case "importedFromFeed": return <Rss className="h-4 w-4" />; case "bookmarkTypeIs": @@ -118,6 +137,42 @@ export function ConditionBuilder({ </div> ); + case "urlDoesNotContain": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="URL does not contain..." + className="w-full" + /> + </div> + ); + + case "titleContains": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="Title contains..." + className="w-full" + /> + </div> + ); + + case "titleDoesNotContain": + return ( + <div className="mt-2"> + <Input + value={value.str} + onChange={(e) => onChange({ ...value, str: e.target.value })} + placeholder="Title does not contain..." + className="w-full" + /> + </div> + ); + case "importedFromFeed": return ( <div className="mt-2"> @@ -182,6 +237,7 @@ export function ConditionBuilder({ newConditions[index] = newCondition; onChange({ ...value, conditions: newConditions }); }} + eventType={eventType} level={level + 1} onRemove={() => { const newConditions = [...value.conditions]; @@ -217,6 +273,10 @@ export function ConditionBuilder({ } }; + // Title conditions are hidden for "bookmarkAdded" event because + // titles are not available at bookmark creation time (they're fetched during crawling) + const showTitleConditions = eventType !== "bookmarkAdded"; + const ConditionSelector = () => ( <Select value={value.type} onValueChange={handleTypeChange}> <SelectTrigger className="ml-2 h-8 border-none bg-transparent px-2"> @@ -235,6 +295,19 @@ export function ConditionBuilder({ <SelectItem value="urlContains"> {t("settings.rules.conditions_types.url_contains")} </SelectItem> + <SelectItem value="urlDoesNotContain"> + {t("settings.rules.conditions_types.url_does_not_contain")} + </SelectItem> + {showTitleConditions && ( + <SelectItem value="titleContains"> + {t("settings.rules.conditions_types.title_contains")} + </SelectItem> + )} + {showTitleConditions && ( + <SelectItem value="titleDoesNotContain"> + {t("settings.rules.conditions_types.title_does_not_contain")} + </SelectItem> + )} <SelectItem value="importedFromFeed"> {t("settings.rules.conditions_types.imported_from_feed")} </SelectItem> diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx index da10317a..e4859b4a 100644 --- a/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx @@ -8,8 +8,8 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/use-toast"; import { Save, X } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -175,6 +175,7 @@ export function RuleEditor({ rule, onCancel }: RuleEditorProps) { <ConditionBuilder value={editedRule.condition} onChange={handleConditionChange} + eventType={editedRule.event.type} /> </div> diff --git a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx index 206a3550..32262b31 100644 --- a/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx +++ b/apps/web/components/dashboard/rules/RuleEngineRuleList.tsx @@ -2,8 +2,8 @@ import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import { Edit, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index 15facb2d..4d3a690b 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -208,6 +208,17 @@ export default function QueryExplainerTooltip({ </TableCell> </TableRow> ); + case "source": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.is_not_from_source") + : t("search.is_from_source")} + </TableCell> + <TableCell>{matcher.source}</TableCell> + </TableRow> + ); default: { const _exhaustiveCheck: never = matcher; return null; diff --git a/apps/web/components/dashboard/search/useSearchAutocomplete.ts b/apps/web/components/dashboard/search/useSearchAutocomplete.ts index ba55d51f..c72f4fc5 100644 --- a/apps/web/components/dashboard/search/useSearchAutocomplete.ts +++ b/apps/web/components/dashboard/search/useSearchAutocomplete.ts @@ -2,8 +2,9 @@ import type translation from "@/lib/i18n/locales/en/translation.json"; import type { TFunction } from "i18next"; import type { LucideIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { + Globe, History, ListTree, RssIcon, @@ -14,6 +15,8 @@ import { import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists"; import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useTRPC } from "@karakeep/shared-react/trpc"; +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; const MAX_DISPLAY_SUGGESTIONS = 5; @@ -97,10 +100,14 @@ const QUALIFIER_DEFINITIONS = [ value: "age:", descriptionKey: "search.created_within", }, + { + value: "source:", + descriptionKey: "search.is_from_source", + }, ] satisfies ReadonlyArray<QualifierDefinition>; export interface AutocompleteSuggestionItem { - type: "token" | "tag" | "list" | "feed"; + type: "token" | "tag" | "list" | "feed" | "source"; id: string; label: string; insertText: string; @@ -263,6 +270,7 @@ const useTagSuggestions = ( const { data: tagResults } = useTagAutocomplete({ nameContains: debouncedTagSearchTerm, select: (data) => data.tags, + enabled: parsed.activeToken.length > 0, }); const tagSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { @@ -292,6 +300,7 @@ const useTagSuggestions = ( const useFeedSuggestions = ( parsed: ParsedSearchState, ): AutocompleteSuggestionItem[] => { + const api = useTRPC(); const shouldSuggestFeeds = parsed.normalizedTokenWithoutMinus.startsWith("feed:"); const feedSearchTermRaw = shouldSuggestFeeds @@ -299,7 +308,11 @@ const useFeedSuggestions = ( : ""; const feedSearchTerm = stripSurroundingQuotes(feedSearchTermRaw); const normalizedFeedSearchTerm = feedSearchTerm.toLowerCase(); - const { data: feedResults } = api.feeds.list.useQuery(); + const { data: feedResults } = useQuery( + api.feeds.list.queryOptions(undefined, { + enabled: parsed.activeToken.length > 0, + }), + ); const feedSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { if (!shouldSuggestFeeds) { @@ -349,7 +362,9 @@ const useListSuggestions = ( : ""; const listSearchTerm = stripSurroundingQuotes(listSearchTermRaw); const normalizedListSearchTerm = listSearchTerm.toLowerCase(); - const { data: listResults } = useBookmarkLists(); + const { data: listResults } = useBookmarkLists(undefined, { + enabled: parsed.activeToken.length > 0, + }); const listSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { if (!shouldSuggestLists) { @@ -357,6 +372,7 @@ const useListSuggestions = ( } const lists = listResults?.data ?? []; + const seenListNames = new Set<string>(); return lists .filter((list) => { @@ -365,6 +381,15 @@ const useListSuggestions = ( } return list.name.toLowerCase().includes(normalizedListSearchTerm); }) + .filter((list) => { + const normalizedListName = list.name.trim().toLowerCase(); + if (seenListNames.has(normalizedListName)) { + return false; + } + + seenListNames.add(normalizedListName); + return true; + }) .slice(0, MAX_DISPLAY_SUGGESTIONS) .map((list) => { const formattedName = formatSearchValue(list.name); @@ -389,12 +414,53 @@ const useListSuggestions = ( return listSuggestions; }; +const SOURCE_VALUES = zBookmarkSourceSchema.options; + +const useSourceSuggestions = ( + parsed: ParsedSearchState, +): AutocompleteSuggestionItem[] => { + const shouldSuggestSources = + parsed.normalizedTokenWithoutMinus.startsWith("source:"); + const sourceSearchTerm = shouldSuggestSources + ? parsed.normalizedTokenWithoutMinus.slice("source:".length) + : ""; + + const sourceSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => { + if (!shouldSuggestSources) { + return []; + } + + return SOURCE_VALUES.filter((source) => { + if (sourceSearchTerm.length === 0) { + return true; + } + return source.startsWith(sourceSearchTerm); + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map((source) => { + const insertText = `${parsed.isTokenNegative ? "-" : ""}source:${source}`; + return { + type: "source" as const, + id: `source-${source}`, + label: insertText, + insertText, + appendSpace: true, + description: undefined, + Icon: Globe, + } satisfies AutocompleteSuggestionItem; + }); + }, [shouldSuggestSources, sourceSearchTerm, parsed.isTokenNegative]); + + return sourceSuggestions; +}; + const useHistorySuggestions = ( value: string, history: string[], ): HistorySuggestionItem[] => { const historyItems = useMemo<HistorySuggestionItem[]>(() => { const trimmedValue = value.trim(); + const seenTerms = new Set<string>(); const results = trimmedValue.length === 0 ? history @@ -402,16 +468,27 @@ const useHistorySuggestions = ( item.toLowerCase().includes(trimmedValue.toLowerCase()), ); - return results.slice(0, MAX_DISPLAY_SUGGESTIONS).map( - (term) => - ({ - type: "history" as const, - id: `history-${term}`, - term, - label: term, - Icon: History, - }) satisfies HistorySuggestionItem, - ); + return results + .filter((term) => { + const normalizedTerm = term.trim().toLowerCase(); + if (seenTerms.has(normalizedTerm)) { + return false; + } + + seenTerms.add(normalizedTerm); + return true; + }) + .slice(0, MAX_DISPLAY_SUGGESTIONS) + .map( + (term) => + ({ + type: "history" as const, + id: `history-${term}`, + term, + label: term, + Icon: History, + }) satisfies HistorySuggestionItem, + ); }, [history, value]); return historyItems; @@ -431,6 +508,7 @@ export const useSearchAutocomplete = ({ const tagSuggestions = useTagSuggestions(parsedState); const listSuggestions = useListSuggestions(parsedState); const feedSuggestions = useFeedSuggestions(parsedState); + const sourceSuggestions = useSourceSuggestions(parsedState); const historyItems = useHistorySuggestions(value, history); const { activeToken, getActiveToken } = parsedState; @@ -461,6 +539,14 @@ export const useSearchAutocomplete = ({ }); } + if (sourceSuggestions.length > 0) { + groups.push({ + id: "sources", + label: t("search.is_from_source"), + items: sourceSuggestions, + }); + } + // Only suggest qualifiers if no other suggestions are available if (groups.length === 0 && qualifierSuggestions.length > 0) { groups.push({ @@ -484,6 +570,7 @@ export const useSearchAutocomplete = ({ tagSuggestions, listSuggestions, feedSuggestions, + sourceSuggestions, historyItems, t, ]); diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx index 306bf4b4..d1099231 100644 --- a/apps/web/components/dashboard/sidebar/AllLists.tsx +++ b/apps/web/components/dashboard/sidebar/AllLists.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import SidebarItem from "@/components/shared/sidebar/SidebarItem"; @@ -10,6 +10,8 @@ import { CollapsibleContent, CollapsibleTriggerTriangle, } from "@/components/ui/collapsible"; +import { toast } from "@/components/ui/sonner"; +import { BOOKMARK_DRAG_MIME } from "@/lib/bookmark-drag"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; import { MoreHorizontal, Plus } from "lucide-react"; @@ -17,6 +19,7 @@ import { MoreHorizontal, Plus } from "lucide-react"; import type { ZBookmarkList } from "@karakeep/shared/types/lists"; import { augmentBookmarkListsWithInitialData, + useAddBookmarkToList, useBookmarkLists, } from "@karakeep/shared-react/hooks/lists"; import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils"; @@ -26,6 +29,146 @@ import { EditListModal } from "../lists/EditListModal"; import { ListOptions } from "../lists/ListOptions"; import { InvitationNotificationBadge } from "./InvitationNotificationBadge"; +function useDropTarget(listId: string, listName: string) { + const { mutateAsync: addToList } = useAddBookmarkToList(); + const [dropHighlight, setDropHighlight] = useState(false); + const dragCounterRef = useRef(0); + const { t } = useTranslation(); + + const onDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + }, []); + + const onDragEnter = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(BOOKMARK_DRAG_MIME)) { + e.preventDefault(); + dragCounterRef.current++; + setDropHighlight(true); + } + }, []); + + const onDragLeave = useCallback(() => { + dragCounterRef.current--; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setDropHighlight(false); + } + }, []); + + const onDrop = useCallback( + async (e: React.DragEvent) => { + dragCounterRef.current = 0; + setDropHighlight(false); + const bookmarkId = e.dataTransfer.getData(BOOKMARK_DRAG_MIME); + if (!bookmarkId) return; + e.preventDefault(); + try { + await addToList({ bookmarkId, listId }); + toast({ + description: t("lists.add_to_list_success", { + list: listName, + defaultValue: `Added to "${listName}"`, + }), + }); + } catch { + toast({ + description: t("common.something_went_wrong", { + defaultValue: "Something went wrong", + }), + variant: "destructive", + }); + } + }, + [addToList, listId, listName, t], + ); + + return { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop }; +} + +function DroppableListSidebarItem({ + node, + level, + open, + numBookmarks, + selectedListId, + setSelectedListId, +}: { + node: ZBookmarkListTreeNode; + level: number; + open: boolean; + numBookmarks?: number; + selectedListId: string | null; + setSelectedListId: (id: string | null) => void; +}) { + const canDrop = + node.item.type === "manual" && + (node.item.userRole === "owner" || node.item.userRole === "editor"); + const { dropHighlight, onDragOver, onDragEnter, onDragLeave, onDrop } = + useDropTarget(node.item.id, node.item.name); + + return ( + <SidebarItem + collapseButton={ + node.children.length > 0 && ( + <CollapsibleTriggerTriangle + className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" + open={open} + /> + ) + } + logo={ + <span className="flex"> + <span className="text-lg"> {node.item.icon}</span> + </span> + } + name={node.item.name} + path={`/dashboard/lists/${node.item.id}`} + className="group px-0.5" + right={ + <ListOptions + onOpenChange={(isOpen) => { + if (isOpen) { + setSelectedListId(node.item.id); + } else { + setSelectedListId(null); + } + }} + list={node.item} + > + <Button size="none" variant="ghost" className="relative"> + <MoreHorizontal + className={cn( + "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", + selectedListId == node.item.id ? "opacity-100" : "opacity-0", + )} + /> + <span + className={cn( + "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", + selectedListId == node.item.id || numBookmarks === undefined + ? "opacity-0" + : "opacity-100", + )} + > + {numBookmarks} + </span> + </Button> + </ListOptions> + } + linkClassName="py-0.5" + style={{ marginLeft: `${level * 1}rem` }} + dropHighlight={canDrop && dropHighlight} + onDragOver={canDrop ? onDragOver : undefined} + onDragEnter={canDrop ? onDragEnter : undefined} + onDragLeave={canDrop ? onDragLeave : undefined} + onDrop={canDrop ? onDrop : undefined} + /> + ); +} + export default function AllLists({ initialData, }: { @@ -71,7 +214,7 @@ export default function AllLists({ }, [isViewingSharedList, sharedListsOpen]); return ( - <ul className="max-h-full gap-y-2 overflow-auto text-sm"> + <ul className="sidebar-scrollbar max-h-full gap-y-2 overflow-auto text-sm"> <li className="flex justify-between pb-3"> <p className="text-xs uppercase tracking-wider text-muted-foreground"> Lists @@ -107,59 +250,13 @@ export default function AllLists({ filter={(node) => node.item.userRole === "owner"} isOpenFunc={isNodeOpen} render={({ node, level, open, numBookmarks }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> @@ -187,59 +284,13 @@ export default function AllLists({ isOpenFunc={isNodeOpen} indentOffset={1} render={({ node, level, open, numBookmarks }) => ( - <SidebarItem - collapseButton={ - node.children.length > 0 && ( - <CollapsibleTriggerTriangle - className="absolute left-0.5 top-1/2 size-2 -translate-y-1/2" - open={open} - /> - ) - } - logo={ - <span className="flex"> - <span className="text-lg"> {node.item.icon}</span> - </span> - } - name={node.item.name} - path={`/dashboard/lists/${node.item.id}`} - className="group px-0.5" - right={ - <ListOptions - onOpenChange={(isOpen) => { - if (isOpen) { - setSelectedListId(node.item.id); - } else { - setSelectedListId(null); - } - }} - list={node.item} - > - <Button size="none" variant="ghost" className="relative"> - <MoreHorizontal - className={cn( - "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100", - selectedListId == node.item.id - ? "opacity-100" - : "opacity-0", - )} - /> - <span - className={cn( - "px-2.5 text-xs font-light text-muted-foreground opacity-100 transition-opacity duration-100 group-hover:opacity-0", - selectedListId == node.item.id || - numBookmarks === undefined - ? "opacity-0" - : "opacity-100", - )} - > - {numBookmarks} - </span> - </Button> - </ListOptions> - } - linkClassName="py-0.5" - style={{ marginLeft: `${level * 1}rem` }} + <DroppableListSidebarItem + node={node} + level={level} + open={open} + numBookmarks={numBookmarks} + selectedListId={selectedListId} + setSelectedListId={setSelectedListId} /> )} /> diff --git a/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx b/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx index e4d7b39f..e3c65be9 100644 --- a/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx +++ b/apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx @@ -1,13 +1,15 @@ "use client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export function InvitationNotificationBadge() { - const { data: pendingInvitations } = api.lists.getPendingInvitations.useQuery( - undefined, - { + const api = useTRPC(); + const { data: pendingInvitations } = useQuery( + api.lists.getPendingInvitations.queryOptions(undefined, { refetchInterval: 1000 * 60 * 5, - }, + }), ); const pendingInvitationsCount = pendingInvitations?.length ?? 0; diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx index c21f9aac..9708c37f 100644 --- a/apps/web/components/dashboard/tags/AllTagsView.tsx +++ b/apps/web/components/dashboard/tags/AllTagsView.tsx @@ -22,9 +22,9 @@ import { import InfoTooltip from "@/components/ui/info-tooltip"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; +import { toast } from "@/components/ui/sonner"; import Spinner from "@/components/ui/spinner"; import { Toggle } from "@/components/ui/toggle"; -import { toast } from "@/components/ui/use-toast"; import useBulkTagActionsStore from "@/lib/bulkTagActions"; import { useTranslation } from "@/lib/i18n/client"; import { ArrowDownAZ, ChevronDown, Combine, Search, Tag } from "lucide-react"; diff --git a/apps/web/components/dashboard/tags/BulkTagAction.tsx b/apps/web/components/dashboard/tags/BulkTagAction.tsx index fbd044e0..c8061a1f 100644 --- a/apps/web/components/dashboard/tags/BulkTagAction.tsx +++ b/apps/web/components/dashboard/tags/BulkTagAction.tsx @@ -4,8 +4,8 @@ import { useEffect, useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { ButtonWithTooltip } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; import { Toggle } from "@/components/ui/toggle"; -import { useToast } from "@/components/ui/use-toast"; import useBulkTagActionsStore from "@/lib/bulkTagActions"; import { useTranslation } from "@/lib/i18n/client"; import { CheckCheck, Pencil, Trash2, X } from "lucide-react"; @@ -17,7 +17,6 @@ const MAX_CONCURRENT_BULK_ACTIONS = 50; export default function BulkTagAction() { const { t } = useTranslation(); - const { toast } = useToast(); const { selectedTagIds, diff --git a/apps/web/components/dashboard/tags/CreateTagModal.tsx b/apps/web/components/dashboard/tags/CreateTagModal.tsx index 3a4c4995..e5cf4a45 100644 --- a/apps/web/components/dashboard/tags/CreateTagModal.tsx +++ b/apps/web/components/dashboard/tags/CreateTagModal.tsx @@ -22,7 +22,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { Plus } from "lucide-react"; diff --git a/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx index 0a589ee6..7df04e20 100644 --- a/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx +++ b/apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx @@ -1,7 +1,7 @@ import { usePathname, useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useDeleteTag } from "@karakeep/shared-react/hooks/tags"; diff --git a/apps/web/components/dashboard/tags/EditableTagName.tsx b/apps/web/components/dashboard/tags/EditableTagName.tsx index 7854be32..e6df5086 100644 --- a/apps/web/components/dashboard/tags/EditableTagName.tsx +++ b/apps/web/components/dashboard/tags/EditableTagName.tsx @@ -1,7 +1,7 @@ "use client"; import { usePathname, useRouter } from "next/navigation"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { useUpdateTag } from "@karakeep/shared-react/hooks/tags"; diff --git a/apps/web/components/dashboard/tags/MergeTagModal.tsx b/apps/web/components/dashboard/tags/MergeTagModal.tsx index 84dcd478..22b07c98 100644 --- a/apps/web/components/dashboard/tags/MergeTagModal.tsx +++ b/apps/web/components/dashboard/tags/MergeTagModal.tsx @@ -18,7 +18,7 @@ import { FormItem, FormMessage, } from "@/components/ui/form"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/dashboard/tags/TagAutocomplete.tsx b/apps/web/components/dashboard/tags/TagAutocomplete.tsx index 8164dc81..656d4c5a 100644 --- a/apps/web/components/dashboard/tags/TagAutocomplete.tsx +++ b/apps/web/components/dashboard/tags/TagAutocomplete.tsx @@ -15,11 +15,12 @@ import { } from "@/components/ui/popover"; import LoadingSpinner from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import { Check, ChevronsUpDown, X } from "lucide-react"; import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags"; import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; interface TagAutocompleteProps { tagId: string; @@ -32,6 +33,7 @@ export function TagAutocomplete({ onChange, className, }: TagAutocompleteProps) { + const api = useTRPC(); const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const searchQueryDebounced = useDebounce(searchQuery, 500); @@ -41,8 +43,8 @@ export function TagAutocomplete({ select: (data) => data.tags, }); - const { data: selectedTag, isLoading: isSelectedTagLoading } = - api.tags.get.useQuery( + const { data: selectedTag, isLoading: isSelectedTagLoading } = useQuery( + api.tags.get.queryOptions( { tagId, }, @@ -53,7 +55,8 @@ export function TagAutocomplete({ }), enabled: !!tagId, }, - ); + ), + ); const handleSelect = (currentValue: string) => { setOpen(false); diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx index 65a42e08..09310f9f 100644 --- a/apps/web/components/dashboard/tags/TagPill.tsx +++ b/apps/web/components/dashboard/tags/TagPill.tsx @@ -2,7 +2,7 @@ import React, { useRef, useState } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useDragAndDrop } from "@/lib/drag-and-drop"; import { X } from "lucide-react"; import Draggable from "react-draggable"; diff --git a/apps/web/components/invite/InviteAcceptForm.tsx b/apps/web/components/invite/InviteAcceptForm.tsx index 95a0e1eb..eb1fa5c9 100644 --- a/apps/web/components/invite/InviteAcceptForm.tsx +++ b/apps/web/components/invite/InviteAcceptForm.tsx @@ -21,14 +21,16 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { api } from "@/lib/trpc"; +import { signIn } from "@/lib/auth/client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, Clock, Loader2, Mail, UserPlus } from "lucide-react"; -import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const inviteAcceptSchema = z .object({ name: z.string().min(1, "Name is required"), @@ -47,6 +49,7 @@ interface InviteAcceptFormProps { } export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { + const api = useTRPC(); const router = useRouter(); const form = useForm<z.infer<typeof inviteAcceptSchema>>({ @@ -59,7 +62,7 @@ export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { isPending: loading, data: inviteData, error, - } = api.invites.get.useQuery({ token }); + } = useQuery(api.invites.get.queryOptions({ token })); useEffect(() => { if (error) { @@ -67,7 +70,9 @@ export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { } }, [error]); - const acceptInviteMutation = api.invites.accept.useMutation(); + const acceptInviteMutation = useMutation( + api.invites.accept.mutationOptions(), + ); const handleBackToSignIn = () => { router.push("/signin"); diff --git a/apps/web/components/public/lists/PublicBookmarkGrid.tsx b/apps/web/components/public/lists/PublicBookmarkGrid.tsx index d6aa9875..742d7e6e 100644 --- a/apps/web/components/public/lists/PublicBookmarkGrid.tsx +++ b/apps/web/components/public/lists/PublicBookmarkGrid.tsx @@ -9,14 +9,15 @@ import { ActionButton } from "@/components/ui/action-button"; import { badgeVariants } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import tailwindConfig from "@/tailwind.config"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { Expand, FileIcon, ImageIcon } from "lucide-react"; import { useInView } from "react-intersection-observer"; import Masonry from "react-masonry-css"; import resolveConfig from "tailwindcss/resolveConfig"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { BookmarkTypes, ZPublicBookmark, @@ -199,19 +200,22 @@ export default function PublicBookmarkGrid({ bookmarks: ZPublicBookmark[]; nextCursor: ZCursor | null; }) { + const api = useTRPC(); const { ref: loadMoreRef, inView: loadMoreButtonInView } = useInView(); const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = - api.publicBookmarks.getPublicBookmarksInList.useInfiniteQuery( - { listId: list.id }, - { - initialData: () => ({ - pages: [{ bookmarks: initialBookmarks, nextCursor, list }], - pageParams: [null], - }), - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - refetchOnMount: true, - }, + useInfiniteQuery( + api.publicBookmarks.getPublicBookmarksInList.infiniteQueryOptions( + { listId: list.id }, + { + initialData: () => ({ + pages: [{ bookmarks: initialBookmarks, nextCursor, list }], + pageParams: [null], + }), + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + refetchOnMount: true, + }, + ), ); useEffect(() => { @@ -227,7 +231,11 @@ export default function PublicBookmarkGrid({ }, [data]); return ( <> - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + <Masonry + className="-ml-4 flex w-auto" + columnClassName="pl-4" + breakpointCols={breakpointConfig} + > {bookmarks.map((bookmark) => ( <BookmarkCard key={bookmark.id} bookmark={bookmark} /> ))} diff --git a/apps/web/components/settings/AISettings.tsx b/apps/web/components/settings/AISettings.tsx index beaa93dc..6d28f4f8 100644 --- a/apps/web/components/settings/AISettings.tsx +++ b/apps/web/components/settings/AISettings.tsx @@ -1,6 +1,25 @@ "use client"; +import React from "react"; +import { TagsEditor } from "@/components/dashboard/bookmarks/TagsEditor"; import { ActionButton } from "@/components/ui/action-button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldTitle, +} from "@/components/ui/field"; import { Form, FormControl, @@ -10,6 +29,7 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, @@ -18,15 +38,22 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { Switch } from "@/components/ui/switch"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useUserSettings } from "@/lib/userSettings"; +import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Plus, Save, Trash2 } from "lucide-react"; -import { useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Info, Plus, Save, Trash2 } from "lucide-react"; +import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; +import type { ZBookmarkTags } from "@karakeep/shared/types/tags"; +import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce"; +import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { buildImagePrompt, buildSummaryPromptUntruncated, @@ -37,10 +64,426 @@ import { ZPrompt, zUpdatePromptSchema, } from "@karakeep/shared/types/prompts"; +import { zUpdateUserSettingsSchema } from "@karakeep/shared/types/users"; + +function SettingsSection({ + title, + description, + children, +}: { + title?: string; + description?: string; + children: React.ReactNode; + className?: string; +}) { + return ( + <Card> + <CardHeader> + {title && <CardTitle>{title}</CardTitle>} + {description && <CardDescription>{description}</CardDescription>} + </CardHeader> + <CardContent>{children}</CardContent> + </Card> + ); +} + +export function AIPreferences() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const settings = useUserSettings(); + + const { mutate: updateSettings, isPending } = useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: "Settings updated successfully!", + }); + }, + onError: () => { + toast({ + description: "Failed to update settings", + variant: "destructive", + }); + }, + }); + + const form = useForm<z.infer<typeof zUpdateUserSettingsSchema>>({ + resolver: zodResolver(zUpdateUserSettingsSchema), + values: settings + ? { + inferredTagLang: settings.inferredTagLang ?? "", + autoTaggingEnabled: settings.autoTaggingEnabled, + autoSummarizationEnabled: settings.autoSummarizationEnabled, + } + : undefined, + }); + + const showAutoTagging = clientConfig.inference.enableAutoTagging; + const showAutoSummarization = clientConfig.inference.enableAutoSummarization; + + const onSubmit = (data: z.infer<typeof zUpdateUserSettingsSchema>) => { + updateSettings(data); + }; + + return ( + <SettingsSection title="AI preferences"> + <form onSubmit={form.handleSubmit(onSubmit)}> + <FieldGroup className="gap-3"> + <Controller + name="inferredTagLang" + control={form.control} + render={({ field, fieldState }) => ( + <Field + className="rounded-lg border p-3" + data-invalid={fieldState.invalid} + > + <FieldContent> + <FieldLabel htmlFor="inferredTagLang"> + {t("settings.ai.inference_language")} + </FieldLabel> + <FieldDescription> + {t("settings.ai.inference_language_description")} + </FieldDescription> + </FieldContent> + <Input + {...field} + id="inferredTagLang" + value={field.value ?? ""} + onChange={(e) => + field.onChange( + e.target.value.length > 0 ? e.target.value : null, + ) + } + aria-invalid={fieldState.invalid} + placeholder={`Default (${clientConfig.inference.inferredTagLang})`} + type="text" + /> + {fieldState.invalid && ( + <FieldError errors={[fieldState.error]} /> + )} + </Field> + )} + /> + + {showAutoTagging && ( + <Controller + name="autoTaggingEnabled" + control={form.control} + render={({ field, fieldState }) => ( + <Field + orientation="horizontal" + className="rounded-lg border p-3" + data-invalid={fieldState.invalid} + > + <FieldContent> + <FieldLabel htmlFor="autoTaggingEnabled"> + {t("settings.ai.auto_tagging")} + </FieldLabel> + <FieldDescription> + {t("settings.ai.auto_tagging_description")} + </FieldDescription> + </FieldContent> + <Switch + id="autoTaggingEnabled" + name={field.name} + checked={field.value ?? true} + onCheckedChange={field.onChange} + aria-invalid={fieldState.invalid} + /> + {fieldState.invalid && ( + <FieldError errors={[fieldState.error]} /> + )} + </Field> + )} + /> + )} + + {showAutoSummarization && ( + <Controller + name="autoSummarizationEnabled" + control={form.control} + render={({ field, fieldState }) => ( + <Field + orientation="horizontal" + className="rounded-lg border p-3" + data-invalid={fieldState.invalid} + > + <FieldContent> + <FieldLabel htmlFor="autoSummarizationEnabled"> + {t("settings.ai.auto_summarization")} + </FieldLabel> + <FieldDescription> + {t("settings.ai.auto_summarization_description")} + </FieldDescription> + </FieldContent> + <Switch + id="autoSummarizationEnabled" + name={field.name} + checked={field.value ?? true} + onCheckedChange={field.onChange} + aria-invalid={fieldState.invalid} + /> + {fieldState.invalid && ( + <FieldError errors={[fieldState.error]} /> + )} + </Field> + )} + /> + )} + + <div className="flex justify-end pt-4"> + <ActionButton type="submit" loading={isPending} variant="default"> + <Save className="mr-2 size-4" /> + {t("actions.save")} + </ActionButton> + </div> + </FieldGroup> + </form> + </SettingsSection> + ); +} + +export function TagStyleSelector() { + const { t } = useTranslation(); + const settings = useUserSettings(); + + const { mutate: updateSettings, isPending: isUpdating } = + useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: "Tag style updated successfully!", + }); + }, + onError: () => { + toast({ + description: "Failed to update tag style", + variant: "destructive", + }); + }, + }); + + const tagStyleOptions = [ + { + value: "lowercase-hyphens", + label: t("settings.ai.lowercase_hyphens"), + examples: ["machine-learning", "web-development"], + }, + { + value: "lowercase-spaces", + label: t("settings.ai.lowercase_spaces"), + examples: ["machine learning", "web development"], + }, + { + value: "lowercase-underscores", + label: t("settings.ai.lowercase_underscores"), + examples: ["machine_learning", "web_development"], + }, + { + value: "titlecase-spaces", + label: t("settings.ai.titlecase_spaces"), + examples: ["Machine Learning", "Web Development"], + }, + { + value: "titlecase-hyphens", + label: t("settings.ai.titlecase_hyphens"), + examples: ["Machine-Learning", "Web-Development"], + }, + { + value: "camelCase", + label: t("settings.ai.camelCase"), + examples: ["machineLearning", "webDevelopment"], + }, + { + value: "as-generated", + label: t("settings.ai.no_preference"), + examples: ["Machine Learning", "web development", "AI_generated"], + }, + ] as const; + + const selectedStyle = settings?.tagStyle ?? "as-generated"; + + return ( + <SettingsSection + title={t("settings.ai.tag_style")} + description={t("settings.ai.tag_style_description")} + > + <RadioGroup + value={selectedStyle} + onValueChange={(value) => { + updateSettings({ tagStyle: value as typeof selectedStyle }); + }} + disabled={isUpdating} + className="grid gap-3 sm:grid-cols-2" + > + {tagStyleOptions.map((option) => ( + <FieldLabel + key={option.value} + htmlFor={option.value} + className={cn(selectedStyle === option.value && "ring-1")} + > + <Field orientation="horizontal"> + <FieldContent> + <FieldTitle>{option.label}</FieldTitle> + <div className="flex flex-wrap gap-1"> + {option.examples.map((example) => ( + <Badge + key={example} + variant="secondary" + className="text-xs font-light" + > + {example} + </Badge> + ))} + </div> + </FieldContent> + <RadioGroupItem value={option.value} id={option.value} /> + </Field> + </FieldLabel> + ))} + </RadioGroup> + </SettingsSection> + ); +} + +export function CuratedTagsSelector() { + const api = useTRPC(); + const { t } = useTranslation(); + const settings = useUserSettings(); + + const { mutate: updateSettings, isPending: isUpdatingCuratedTags } = + useUpdateUserSettings({ + onSuccess: () => { + toast({ + description: t("settings.ai.curated_tags_updated"), + }); + }, + onError: () => { + toast({ + description: t("settings.ai.curated_tags_update_failed"), + variant: "destructive", + }); + }, + }); + + const areTagIdsEqual = React.useCallback((a: string[], b: string[]) => { + return a.length === b.length && a.every((id, index) => id === b[index]); + }, []); + + const curatedTagIds = React.useMemo( + () => settings?.curatedTagIds ?? [], + [settings?.curatedTagIds], + ); + const [localCuratedTagIds, setLocalCuratedTagIds] = + React.useState<string[]>(curatedTagIds); + const debouncedCuratedTagIds = useDebounce(localCuratedTagIds, 300); + const lastServerCuratedTagIdsRef = React.useRef(curatedTagIds); + const lastSubmittedCuratedTagIdsRef = React.useRef<string[] | null>(null); + + React.useEffect(() => { + const hadUnsyncedLocalChanges = !areTagIdsEqual( + localCuratedTagIds, + lastServerCuratedTagIdsRef.current, + ); + + if ( + !hadUnsyncedLocalChanges && + !areTagIdsEqual(localCuratedTagIds, curatedTagIds) + ) { + setLocalCuratedTagIds(curatedTagIds); + } + + lastServerCuratedTagIdsRef.current = curatedTagIds; + }, [areTagIdsEqual, curatedTagIds, localCuratedTagIds]); + + React.useEffect(() => { + if (isUpdatingCuratedTags) { + return; + } + + if (areTagIdsEqual(debouncedCuratedTagIds, curatedTagIds)) { + lastSubmittedCuratedTagIdsRef.current = null; + return; + } + + if ( + lastSubmittedCuratedTagIdsRef.current && + areTagIdsEqual( + lastSubmittedCuratedTagIdsRef.current, + debouncedCuratedTagIds, + ) + ) { + return; + } + + lastSubmittedCuratedTagIdsRef.current = debouncedCuratedTagIds; + updateSettings({ + curatedTagIds: + debouncedCuratedTagIds.length > 0 ? debouncedCuratedTagIds : null, + }); + }, [ + areTagIdsEqual, + curatedTagIds, + debouncedCuratedTagIds, + isUpdatingCuratedTags, + updateSettings, + ]); + + // Fetch selected tags to display their names + const { data: selectedTagsData } = useQuery( + api.tags.list.queryOptions( + { ids: localCuratedTagIds }, + { enabled: localCuratedTagIds.length > 0 }, + ), + ); + + const selectedTags: ZBookmarkTags[] = React.useMemo(() => { + const tagsMap = new Map( + (selectedTagsData?.tags ?? []).map((tag) => [tag.id, tag]), + ); + // Preserve the order from curatedTagIds instead of server sort order + return localCuratedTagIds + .map((id) => tagsMap.get(id)) + .filter((tag): tag is NonNullable<typeof tag> => tag != null) + .map((tag) => ({ + id: tag.id, + name: tag.name, + attachedBy: "human" as const, + })); + }, [selectedTagsData?.tags, localCuratedTagIds]); + + return ( + <SettingsSection + title={t("settings.ai.curated_tags")} + description={t("settings.ai.curated_tags_description")} + > + <TagsEditor + tags={selectedTags} + placeholder="Select curated tags..." + onAttach={(tag) => { + const tagId = tag.tagId; + if (tagId) { + setLocalCuratedTagIds((prev) => { + if (prev.includes(tagId)) { + return prev; + } + return [...prev, tagId]; + }); + } + }} + onDetach={(tag) => { + setLocalCuratedTagIds((prev) => { + return prev.filter((id) => id !== tag.tagId); + }); + }} + allowCreation={false} + /> + </SettingsSection> + ); +} export function PromptEditor() { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const form = useForm<z.infer<typeof zNewPromptSchema>>({ resolver: zodResolver(zNewPromptSchema), @@ -50,15 +493,16 @@ export function PromptEditor() { }, }); - const { mutateAsync: createPrompt, isPending: isCreating } = - api.prompts.create.useMutation({ + const { mutateAsync: createPrompt, isPending: isCreating } = useMutation( + api.prompts.create.mutationOptions({ onSuccess: () => { toast({ description: "Prompt has been created!", }); - apiUtils.prompts.list.invalidate(); + queryClient.invalidateQueries(api.prompts.list.pathFilter()); }, - }); + }), + ); return ( <Form {...form}> @@ -140,26 +584,29 @@ export function PromptEditor() { } export function PromptRow({ prompt }: { prompt: ZPrompt }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { mutateAsync: updatePrompt, isPending: isUpdating } = - api.prompts.update.useMutation({ + const queryClient = useQueryClient(); + const { mutateAsync: updatePrompt, isPending: isUpdating } = useMutation( + api.prompts.update.mutationOptions({ onSuccess: () => { toast({ description: "Prompt has been updated!", }); - apiUtils.prompts.list.invalidate(); + queryClient.invalidateQueries(api.prompts.list.pathFilter()); }, - }); - const { mutate: deletePrompt, isPending: isDeleting } = - api.prompts.delete.useMutation({ + }), + ); + const { mutate: deletePrompt, isPending: isDeleting } = useMutation( + api.prompts.delete.mutationOptions({ onSuccess: () => { toast({ description: "Prompt has been deleted!", }); - apiUtils.prompts.list.invalidate(); + queryClient.invalidateQueries(api.prompts.list.pathFilter()); }, - }); + }), + ); const form = useForm<z.infer<typeof zUpdatePromptSchema>>({ resolver: zodResolver(zUpdatePromptSchema), @@ -273,92 +720,144 @@ export function PromptRow({ prompt }: { prompt: ZPrompt }) { } export function TaggingRules() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: prompts, isLoading } = api.prompts.list.useQuery(); + const { data: prompts, isLoading } = useQuery( + api.prompts.list.queryOptions(), + ); return ( - <div className="mt-2 flex flex-col gap-2"> - <div className="w-full text-xl font-medium sm:w-1/3"> - {t("settings.ai.tagging_rules")} - </div> - <p className="mb-1 text-xs italic text-muted-foreground"> - {t("settings.ai.tagging_rule_description")} - </p> - {isLoading && <FullPageSpinner />} + <SettingsSection + title={t("settings.ai.tagging_rules")} + description={t("settings.ai.tagging_rule_description")} + > {prompts && prompts.length == 0 && ( - <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground"> - You don't have any custom prompts yet. - </p> + <div className="flex items-start gap-2 rounded-md bg-muted p-4 text-sm text-muted-foreground"> + <Info className="size-4 flex-shrink-0" /> + <p>You don't have any custom prompts yet.</p> + </div> )} - {prompts && - prompts.map((prompt) => <PromptRow key={prompt.id} prompt={prompt} />)} - <PromptEditor /> - </div> + <div className="flex flex-col gap-2"> + {isLoading && <FullPageSpinner />} + {prompts && + prompts.map((prompt) => ( + <PromptRow key={prompt.id} prompt={prompt} /> + ))} + <PromptEditor /> + </div> + </SettingsSection> ); } export function PromptDemo() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: prompts } = api.prompts.list.useQuery(); + const { data: prompts } = useQuery(api.prompts.list.queryOptions()); + const settings = useUserSettings(); const clientConfig = useClientConfig(); + const tagStyle = settings?.tagStyle ?? "as-generated"; + const curatedTagIds = settings?.curatedTagIds ?? []; + const { data: tagsData } = useQuery( + api.tags.list.queryOptions( + { ids: curatedTagIds }, + { enabled: curatedTagIds.length > 0 }, + ), + ); + const inferredTagLang = + settings?.inferredTagLang ?? clientConfig.inference.inferredTagLang; + + // Resolve curated tag names for preview + const curatedTagNames = + curatedTagIds.length > 0 && tagsData?.tags + ? curatedTagIds + .map((id) => tagsData.tags.find((tag) => tag.id === id)?.name) + .filter((name): name is string => Boolean(name)) + : undefined; + return ( - <div className="flex flex-col gap-2"> - <div className="mb-4 w-full text-xl font-medium sm:w-1/3"> - {t("settings.ai.prompt_preview")} + <SettingsSection + title={t("settings.ai.prompt_preview")} + description="Preview the actual prompts sent to AI based on your settings" + > + <div className="space-y-4"> + <div> + <p className="mb-2 text-sm font-medium"> + {t("settings.ai.text_prompt")} + </p> + <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildTextPromptUntruncated( + inferredTagLang, + (prompts ?? []) + .filter( + (p) => p.appliesTo == "text" || p.appliesTo == "all_tagging", + ) + .map((p) => p.text), + "\n<CONTENT_HERE>\n", + tagStyle, + curatedTagNames, + ).trim()} + </code> + </div> + <div> + <p className="mb-2 text-sm font-medium"> + {t("settings.ai.images_prompt")} + </p> + <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildImagePrompt( + inferredTagLang, + (prompts ?? []) + .filter( + (p) => + p.appliesTo == "images" || p.appliesTo == "all_tagging", + ) + .map((p) => p.text), + tagStyle, + curatedTagNames, + ).trim()} + </code> + </div> + <div> + <p className="mb-2 text-sm font-medium"> + {t("settings.ai.summarization_prompt")} + </p> + <code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildSummaryPromptUntruncated( + inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "summary") + .map((p) => p.text), + "\n<CONTENT_HERE>\n", + ).trim()} + </code> + </div> </div> - <p>{t("settings.ai.text_prompt")}</p> - <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> - {buildTextPromptUntruncated( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter( - (p) => p.appliesTo == "text" || p.appliesTo == "all_tagging", - ) - .map((p) => p.text), - "\n<CONTENT_HERE>\n", - ).trim()} - </code> - <p>{t("settings.ai.images_prompt")}</p> - <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> - {buildImagePrompt( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter( - (p) => p.appliesTo == "images" || p.appliesTo == "all_tagging", - ) - .map((p) => p.text), - ).trim()} - </code> - <p>{t("settings.ai.summarization_prompt")}</p> - <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> - {buildSummaryPromptUntruncated( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter((p) => p.appliesTo == "summary") - .map((p) => p.text), - "\n<CONTENT_HERE>\n", - ).trim()} - </code> - </div> + </SettingsSection> ); } export default function AISettings() { const { t } = useTranslation(); return ( - <> - <div className="rounded-md border bg-background p-4"> - <div className="mb-2 flex flex-col gap-3"> - <div className="w-full text-2xl font-medium sm:w-1/3"> - {t("settings.ai.ai_settings")} - </div> - <TaggingRules /> - </div> - </div> - <div className="mt-4 rounded-md border bg-background p-4"> - <PromptDemo /> - </div> - </> + <div className="space-y-6"> + <h2 className="text-3xl font-bold tracking-tight"> + {t("settings.ai.ai_settings")} + </h2> + + {/* AI Preferences */} + <AIPreferences /> + + {/* Tag Style */} + <TagStyleSelector /> + + {/* Curated Tags */} + <CuratedTagsSelector /> + + {/* Tagging Rules */} + <TaggingRules /> + + {/* Prompt Preview */} + <PromptDemo /> + </div> ); } diff --git a/apps/web/components/settings/AddApiKey.tsx b/apps/web/components/settings/AddApiKey.tsx index c8baa626..b6612a51 100644 --- a/apps/web/components/settings/AddApiKey.tsx +++ b/apps/web/components/settings/AddApiKey.tsx @@ -24,34 +24,39 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { PlusCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import ApiKeySuccess from "./ApiKeySuccess"; function AddApiKeyForm({ onSuccess }: { onSuccess: (key: string) => void }) { + const api = useTRPC(); const { t } = useTranslation(); const formSchema = z.object({ name: z.string(), }); const router = useRouter(); - const mutator = api.apiKeys.create.useMutation({ - onSuccess: (resp) => { - onSuccess(resp.key); - router.refresh(); - }, - onError: () => { - toast({ - description: t("common.something_went_wrong"), - variant: "destructive", - }); - }, - }); + const mutator = useMutation( + api.apiKeys.create.mutationOptions({ + onSuccess: (resp) => { + onSuccess(resp.key); + router.refresh(); + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }), + ); const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), diff --git a/apps/web/components/settings/ApiKeySettings.tsx b/apps/web/components/settings/ApiKeySettings.tsx index bc4b71c5..fa8b4927 100644 --- a/apps/web/components/settings/ApiKeySettings.tsx +++ b/apps/web/components/settings/ApiKeySettings.tsx @@ -8,6 +8,7 @@ import { } from "@/components/ui/table"; import { useTranslation } from "@/lib/i18n/server"; import { api } from "@/server/api/client"; +import { formatDistanceToNow } from "date-fns"; import AddApiKey from "./AddApiKey"; import DeleteApiKey from "./DeleteApiKey"; @@ -32,23 +33,33 @@ export default async function ApiKeys() { <TableHead>{t("common.name")}</TableHead> <TableHead>{t("common.key")}</TableHead> <TableHead>{t("common.created_at")}</TableHead> + <TableHead>{t("common.last_used")}</TableHead> <TableHead>{t("common.action")}</TableHead> </TableRow> </TableHeader> <TableBody> - {keys.keys.map((k) => ( - <TableRow key={k.id}> - <TableCell>{k.name}</TableCell> - <TableCell>**_{k.keyId}_**</TableCell> - <TableCell>{k.createdAt.toLocaleString()}</TableCell> - <TableCell> - <div className="flex items-center gap-2"> - <RegenerateApiKey name={k.name} id={k.id} /> - <DeleteApiKey name={k.name} id={k.id} /> - </div> - </TableCell> - </TableRow> - ))} + {keys.keys.map((key) => { + return ( + <TableRow key={key.id}> + <TableCell>{key.name}</TableCell> + <TableCell>**_{key.keyId}_**</TableCell> + <TableCell> + {formatDistanceToNow(key.createdAt, { addSuffix: true })} + </TableCell> + <TableCell> + {key.lastUsedAt + ? formatDistanceToNow(key.lastUsedAt, { addSuffix: true }) + : "—"} + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <RegenerateApiKey name={key.name} id={key.id} /> + <DeleteApiKey name={key.name} id={key.id} /> + </div> + </TableCell> + </TableRow> + ); + })} <TableRow></TableRow> </TableBody> </Table> diff --git a/apps/web/components/settings/BackupSettings.tsx b/apps/web/components/settings/BackupSettings.tsx index 18a80993..57672fb0 100644 --- a/apps/web/components/settings/BackupSettings.tsx +++ b/apps/web/components/settings/BackupSettings.tsx @@ -21,12 +21,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { useUserSettings } from "@/lib/userSettings"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle, Download, @@ -39,6 +39,7 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zBackupSchema } from "@karakeep/shared/types/backups"; import { zUpdateBackupSettingsSchema } from "@karakeep/shared/types/users"; import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; @@ -207,16 +208,17 @@ function BackupConfigurationForm() { } function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); - const { mutate: deleteBackup, isPending: isDeleting } = - api.backups.delete.useMutation({ + const { mutate: deleteBackup, isPending: isDeleting } = useMutation( + api.backups.delete.mutationOptions({ onSuccess: () => { toast({ description: t("settings.backups.toasts.backup_deleted"), }); - apiUtils.backups.list.invalidate(); + queryClient.invalidateQueries(api.backups.list.pathFilter()); }, onError: (error) => { toast({ @@ -224,7 +226,8 @@ function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) { variant: "destructive", }); }, - }); + }), + ); const formatSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; @@ -330,25 +333,28 @@ function BackupRow({ backup }: { backup: z.infer<typeof zBackupSchema> }) { } function BackupsList() { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { data: backups, isLoading } = api.backups.list.useQuery(undefined, { - refetchInterval: (query) => { - const data = query.state.data; - // Poll every 3 seconds if there's a pending backup, otherwise don't poll - return data?.backups.some((backup) => backup.status === "pending") - ? 3000 - : false; - }, - }); + const queryClient = useQueryClient(); + const { data: backups, isLoading } = useQuery( + api.backups.list.queryOptions(undefined, { + refetchInterval: (query) => { + const data = query.state.data; + // Poll every 3 seconds if there's a pending backup, otherwise don't poll + return data?.backups.some((backup) => backup.status === "pending") + ? 3000 + : false; + }, + }), + ); - const { mutate: triggerBackup, isPending: isTriggering } = - api.backups.triggerBackup.useMutation({ + const { mutate: triggerBackup, isPending: isTriggering } = useMutation( + api.backups.triggerBackup.mutationOptions({ onSuccess: () => { toast({ description: t("settings.backups.toasts.backup_queued"), }); - apiUtils.backups.list.invalidate(); + queryClient.invalidateQueries(api.backups.list.pathFilter()); }, onError: (error) => { toast({ @@ -356,7 +362,8 @@ function BackupsList() { variant: "destructive", }); }, - }); + }), + ); return ( <div className="rounded-md border bg-background p-4"> diff --git a/apps/web/components/settings/ChangePassword.tsx b/apps/web/components/settings/ChangePassword.tsx index a27741d9..481d4b95 100644 --- a/apps/web/components/settings/ChangePassword.tsx +++ b/apps/web/components/settings/ChangePassword.tsx @@ -12,19 +12,21 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { Eye, EyeOff, Lock } from "lucide-react"; import { useForm } from "react-hook-form"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zChangePasswordSchema } from "@karakeep/shared/types/users"; import { Button } from "../ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; export function ChangePassword() { + const api = useTRPC(); const { t } = useTranslation(); const [showCurrentPassword, setShowCurrentPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); @@ -38,22 +40,27 @@ export function ChangePassword() { }, }); - const mutator = api.users.changePassword.useMutation({ - onSuccess: () => { - toast({ description: "Password changed successfully" }); - form.reset(); - }, - onError: (e) => { - if (e.data?.code == "UNAUTHORIZED") { - toast({ - description: "Your current password is incorrect", - variant: "destructive", - }); - } else { - toast({ description: "Something went wrong", variant: "destructive" }); - } - }, - }); + const mutator = useMutation( + api.users.changePassword.mutationOptions({ + onSuccess: () => { + toast({ description: "Password changed successfully" }); + form.reset(); + }, + onError: (e) => { + if (e.data?.code == "UNAUTHORIZED") { + toast({ + description: "Your current password is incorrect", + variant: "destructive", + }); + } else { + toast({ + description: "Something went wrong", + variant: "destructive", + }); + } + }, + }), + ); async function onSubmit(value: z.infer<typeof zChangePasswordSchema>) { mutator.mutate({ diff --git a/apps/web/components/settings/DeleteAccount.tsx b/apps/web/components/settings/DeleteAccount.tsx index 6ebafff9..5ccbfaf7 100644 --- a/apps/web/components/settings/DeleteAccount.tsx +++ b/apps/web/components/settings/DeleteAccount.tsx @@ -13,7 +13,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { zodResolver } from "@hookform/resolvers/zod"; import { AlertTriangle, Eye, EyeOff, Trash2 } from "lucide-react"; import { useForm } from "react-hook-form"; diff --git a/apps/web/components/settings/DeleteApiKey.tsx b/apps/web/components/settings/DeleteApiKey.tsx index 4efb7ea8..b4cf7eea 100644 --- a/apps/web/components/settings/DeleteApiKey.tsx +++ b/apps/web/components/settings/DeleteApiKey.tsx @@ -4,10 +4,12 @@ import { useRouter } from "next/navigation"; import { ActionButton } from "@/components/ui/action-button"; import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; import { Button } from "@/components/ui/button"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; import { Trash } from "lucide-react"; +import { toast } from "sonner"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; export default function DeleteApiKey({ name, @@ -16,16 +18,17 @@ export default function DeleteApiKey({ name: string; id: string; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); - const mutator = api.apiKeys.revoke.useMutation({ - onSuccess: () => { - toast({ - description: "Key was successfully deleted", - }); - router.refresh(); - }, - }); + const mutator = useMutation( + api.apiKeys.revoke.mutationOptions({ + onSuccess: () => { + toast.success("Key was successfully deleted"); + router.refresh(); + }, + }), + ); return ( <ActionConfirmingDialog @@ -49,8 +52,8 @@ export default function DeleteApiKey({ </ActionButton> )} > - <Button variant="outline"> - <Trash size={18} color="red" /> + <Button variant="ghost" title={t("actions.delete")}> + <Trash size={18} /> </Button> </ActionConfirmingDialog> ); diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx index 23b639e4..ba1568a7 100644 --- a/apps/web/components/settings/FeedSettings.tsx +++ b/apps/web/components/settings/FeedSettings.tsx @@ -13,12 +13,12 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; -import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDownToLine, CheckCircle, @@ -33,6 +33,7 @@ import { import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZFeed, zNewFeedSchema, @@ -61,9 +62,10 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; export function FeedsEditorDialog() { + const api = useTRPC(); const { t } = useTranslation(); const [open, setOpen] = React.useState(false); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const form = useForm<z.infer<typeof zNewFeedSchema>>({ resolver: zodResolver(zNewFeedSchema), @@ -81,16 +83,17 @@ export function FeedsEditorDialog() { } }, [open]); - const { mutateAsync: createFeed, isPending: isCreating } = - api.feeds.create.useMutation({ + const { mutateAsync: createFeed, isPending: isCreating } = useMutation( + api.feeds.create.mutationOptions({ onSuccess: () => { toast({ description: "Feed has been created!", }); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); setOpen(false); }, - }); + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> @@ -191,8 +194,9 @@ export function FeedsEditorDialog() { } export function EditFeedDialog({ feed }: { feed: ZFeed }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (open) { @@ -204,16 +208,17 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { }); } }, [open]); - const { mutateAsync: updateFeed, isPending: isUpdating } = - api.feeds.update.useMutation({ + const { mutateAsync: updateFeed, isPending: isUpdating } = useMutation( + api.feeds.update.mutationOptions({ onSuccess: () => { toast({ description: "Feed has been updated!", }); setOpen(false); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); }, - }); + }), + ); const form = useForm<z.infer<typeof zUpdateFeedSchema>>({ resolver: zodResolver(zUpdateFeedSchema), defaultValues: { @@ -339,44 +344,49 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) { } export function FeedRow({ feed }: { feed: ZFeed }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { mutate: deleteFeed, isPending: isDeleting } = - api.feeds.delete.useMutation({ + const queryClient = useQueryClient(); + const { mutate: deleteFeed, isPending: isDeleting } = useMutation( + api.feeds.delete.mutationOptions({ onSuccess: () => { toast({ description: "Feed has been deleted!", }); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); }, - }); + }), + ); - const { mutate: fetchNow, isPending: isFetching } = - api.feeds.fetchNow.useMutation({ + const { mutate: fetchNow, isPending: isFetching } = useMutation( + api.feeds.fetchNow.mutationOptions({ onSuccess: () => { toast({ description: "Feed fetch has been enqueued!", }); - apiUtils.feeds.list.invalidate(); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); }, - }); + }), + ); - const { mutate: updateFeedEnabled } = api.feeds.update.useMutation({ - onSuccess: () => { - toast({ - description: feed.enabled - ? t("settings.feeds.feed_disabled") - : t("settings.feeds.feed_enabled"), - }); - apiUtils.feeds.list.invalidate(); - }, - onError: (error) => { - toast({ - description: `Error: ${error.message}`, - variant: "destructive", - }); - }, - }); + const { mutate: updateFeedEnabled } = useMutation( + api.feeds.update.mutationOptions({ + onSuccess: () => { + toast({ + description: feed.enabled + ? t("settings.feeds.feed_disabled") + : t("settings.feeds.feed_enabled"), + }); + queryClient.invalidateQueries(api.feeds.list.pathFilter()); + }, + onError: (error) => { + toast({ + description: `Error: ${error.message}`, + variant: "destructive", + }); + }, + }), + ); const handleToggle = (checked: boolean) => { updateFeedEnabled({ feedId: feed.id, enabled: checked }); @@ -456,8 +466,9 @@ export function FeedRow({ feed }: { feed: ZFeed }) { } export default function FeedSettings() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: feeds, isLoading } = api.feeds.list.useQuery(); + const { data: feeds, isLoading } = useQuery(api.feeds.list.queryOptions()); return ( <> <div className="rounded-md border bg-background p-4"> diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx index b6e4da9a..e02297c9 100644 --- a/apps/web/components/settings/ImportExport.tsx +++ b/apps/web/components/settings/ImportExport.tsx @@ -12,6 +12,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { toast } from "@/components/ui/sonner"; import { useBookmarkImport } from "@/lib/hooks/useBookmarkImport"; import { useTranslation } from "@/lib/i18n/client"; import { cn } from "@/lib/utils"; @@ -19,7 +20,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, Download, Loader2, Upload } from "lucide-react"; import { Card, CardContent } from "../ui/card"; -import { toast } from "../ui/use-toast"; import { ImportSessionsSection } from "./ImportSessionsSection"; function ImportCard({ @@ -180,6 +180,23 @@ export function ImportExportRow() { </FilePickerButton> </ImportCard> <ImportCard + text="Matter" + description={t("settings.import.import_bookmarks_from_matter_export")} + > + <FilePickerButton + size={"sm"} + loading={false} + accept=".csv" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "matter" }) + } + > + <p>Import</p> + </FilePickerButton> + </ImportCard> + <ImportCard text="Omnivore" description={t( "settings.import.import_bookmarks_from_omnivore_export", @@ -254,6 +271,25 @@ export function ImportExportRow() { </FilePickerButton> </ImportCard> <ImportCard + text="Instapaper" + description={t( + "settings.import.import_bookmarks_from_instapaper_export", + )} + > + <FilePickerButton + size={"sm"} + loading={false} + accept=".csv" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "instapaper" }) + } + > + <p>Import</p> + </FilePickerButton> + </ImportCard> + <ImportCard text="Karakeep" description={t( "settings.import.import_bookmarks_from_karakeep_export", diff --git a/apps/web/components/settings/ImportSessionCard.tsx b/apps/web/components/settings/ImportSessionCard.tsx index 690caaa5..f62a00dd 100644 --- a/apps/web/components/settings/ImportSessionCard.tsx +++ b/apps/web/components/settings/ImportSessionCard.tsx @@ -9,6 +9,8 @@ import { Progress } from "@/components/ui/progress"; import { useDeleteImportSession, useImportSessionStats, + usePauseImportSession, + useResumeImportSession, } from "@/lib/hooks/useImportSessions"; import { useTranslation } from "@/lib/i18n/client"; import { formatDistanceToNow } from "date-fns"; @@ -19,10 +21,17 @@ import { Clock, ExternalLink, Loader2, + Pause, + Play, Trash2, + Upload, } from "lucide-react"; -import type { ZImportSessionWithStats } from "@karakeep/shared/types/importSessions"; +import type { + ZImportSessionStatus, + ZImportSessionWithStats, +} from "@karakeep/shared/types/importSessions"; +import { switchCase } from "@karakeep/shared/utils/switch"; interface ImportSessionCardProps { session: ZImportSessionWithStats; @@ -30,10 +39,14 @@ interface ImportSessionCardProps { function getStatusColor(status: string) { switch (status) { + case "staging": + return "bg-purple-500/10 text-purple-700 dark:text-purple-400"; case "pending": return "bg-muted text-muted-foreground"; - case "in_progress": + case "running": return "bg-blue-500/10 text-blue-700 dark:text-blue-400"; + case "paused": + return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400"; case "completed": return "bg-green-500/10 text-green-700 dark:text-green-400"; case "failed": @@ -45,10 +58,14 @@ function getStatusColor(status: string) { function getStatusIcon(status: string) { switch (status) { + case "staging": + return <Upload className="h-4 w-4" />; case "pending": return <Clock className="h-4 w-4" />; - case "in_progress": + case "running": return <Loader2 className="h-4 w-4 animate-spin" />; + case "paused": + return <Pause className="h-4 w-4" />; case "completed": return <CheckCircle2 className="h-4 w-4" />; case "failed": @@ -62,13 +79,18 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { const { t } = useTranslation(); const { data: liveStats } = useImportSessionStats(session.id); const deleteSession = useDeleteImportSession(); + const pauseSession = usePauseImportSession(); + const resumeSession = useResumeImportSession(); - const statusLabels: Record<string, string> = { - pending: t("settings.import_sessions.status.pending"), - in_progress: t("settings.import_sessions.status.in_progress"), - completed: t("settings.import_sessions.status.completed"), - failed: t("settings.import_sessions.status.failed"), - }; + const statusLabels = (s: ZImportSessionStatus) => + switchCase(s, { + staging: t("settings.import_sessions.status.staging"), + pending: t("settings.import_sessions.status.pending"), + running: t("settings.import_sessions.status.running"), + paused: t("settings.import_sessions.status.paused"), + completed: t("settings.import_sessions.status.completed"), + failed: t("settings.import_sessions.status.failed"), + }); // Use live stats if available, otherwise fallback to session stats const stats = liveStats || session; @@ -79,7 +101,14 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { 100 : 0; - const canDelete = stats.status !== "in_progress"; + const canDelete = + stats.status === "completed" || + stats.status === "failed" || + stats.status === "paused"; + + const canPause = stats.status === "pending" || stats.status === "running"; + + const canResume = stats.status === "paused"; return ( <Card className="transition-all hover:shadow-md"> @@ -101,7 +130,7 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { > {getStatusIcon(stats.status)} <span className="ml-1 capitalize"> - {statusLabels[stats.status] ?? stats.status.replace("_", " ")} + {statusLabels(stats.status)} </span> </Badge> </div> @@ -213,6 +242,38 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) { {/* Actions */} <div className="flex items-center justify-end pt-2"> <div className="flex items-center gap-2"> + <Button variant="outline" size="sm" asChild> + <Link href={`/settings/import/${session.id}`}> + <ExternalLink className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.view_details")} + </Link> + </Button> + {canPause && ( + <Button + variant="outline" + size="sm" + onClick={() => + pauseSession.mutate({ importSessionId: session.id }) + } + disabled={pauseSession.isPending} + > + <Pause className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.pause_session")} + </Button> + )} + {canResume && ( + <Button + variant="outline" + size="sm" + onClick={() => + resumeSession.mutate({ importSessionId: session.id }) + } + disabled={resumeSession.isPending} + > + <Play className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.resume_session")} + </Button> + )} {canDelete && ( <ActionConfirmingDialog title={t("settings.import_sessions.delete_dialog_title")} diff --git a/apps/web/components/settings/ImportSessionDetail.tsx b/apps/web/components/settings/ImportSessionDetail.tsx new file mode 100644 index 00000000..4b356eda --- /dev/null +++ b/apps/web/components/settings/ImportSessionDetail.tsx @@ -0,0 +1,596 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { Progress } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + useDeleteImportSession, + useImportSessionResults, + useImportSessionStats, + usePauseImportSession, + useResumeImportSession, +} from "@/lib/hooks/useImportSessions"; +import { useTranslation } from "@/lib/i18n/client"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertCircle, + ArrowLeft, + CheckCircle2, + Clock, + ExternalLink, + FileText, + Globe, + Loader2, + Paperclip, + Pause, + Play, + Trash2, + Upload, +} from "lucide-react"; +import { useInView } from "react-intersection-observer"; + +import type { ZImportSessionStatus } from "@karakeep/shared/types/importSessions"; +import { switchCase } from "@karakeep/shared/utils/switch"; + +type FilterType = + | "all" + | "accepted" + | "rejected" + | "skipped_duplicate" + | "pending"; + +type SimpleTFunction = ( + key: string, + options?: Record<string, unknown>, +) => string; + +interface ImportSessionResultItem { + id: string; + title: string | null; + url: string | null; + content: string | null; + type: string; + status: string; + result: string | null; + resultReason: string | null; + resultBookmarkId: string | null; +} + +function getStatusColor(status: string) { + switch (status) { + case "staging": + return "bg-purple-500/10 text-purple-700 dark:text-purple-400"; + case "pending": + return "bg-muted text-muted-foreground"; + case "running": + return "bg-blue-500/10 text-blue-700 dark:text-blue-400"; + case "paused": + return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400"; + case "completed": + return "bg-green-500/10 text-green-700 dark:text-green-400"; + case "failed": + return "bg-destructive/10 text-destructive"; + default: + return "bg-muted text-muted-foreground"; + } +} + +function getStatusIcon(status: string) { + switch (status) { + case "staging": + return <Upload className="h-4 w-4" />; + case "pending": + return <Clock className="h-4 w-4" />; + case "running": + return <Loader2 className="h-4 w-4 animate-spin" />; + case "paused": + return <Pause className="h-4 w-4" />; + case "completed": + return <CheckCircle2 className="h-4 w-4" />; + case "failed": + return <AlertCircle className="h-4 w-4" />; + default: + return <Clock className="h-4 w-4" />; + } +} + +function getResultBadge( + status: string, + result: string | null, + t: (key: string) => string, +) { + if (status === "pending") { + return ( + <Badge + variant="secondary" + className="bg-muted text-muted-foreground hover:bg-muted" + > + <Clock className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_pending")} + </Badge> + ); + } + if (status === "processing") { + return ( + <Badge + variant="secondary" + className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400" + > + <Loader2 className="mr-1 h-3 w-3 animate-spin" /> + {t("settings.import_sessions.detail.result_processing")} + </Badge> + ); + } + switch (result) { + case "accepted": + return ( + <Badge + variant="secondary" + className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400" + > + <CheckCircle2 className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_accepted")} + </Badge> + ); + case "rejected": + return ( + <Badge + variant="secondary" + className="bg-destructive/10 text-destructive hover:bg-destructive/10" + > + <AlertCircle className="mr-1 h-3 w-3" /> + {t("settings.import_sessions.detail.result_rejected")} + </Badge> + ); + case "skipped_duplicate": + return ( + <Badge + variant="secondary" + className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400" + > + {t("settings.import_sessions.detail.result_skipped_duplicate")} + </Badge> + ); + default: + return ( + <Badge variant="secondary" className="bg-muted hover:bg-muted"> + — + </Badge> + ); + } +} + +function getTypeIcon(type: string) { + switch (type) { + case "link": + return <Globe className="h-3 w-3" />; + case "text": + return <FileText className="h-3 w-3" />; + case "asset": + return <Paperclip className="h-3 w-3" />; + default: + return null; + } +} + +function getTypeLabel(type: string, t: SimpleTFunction) { + switch (type) { + case "link": + return t("common.bookmark_types.link"); + case "text": + return t("common.bookmark_types.text"); + case "asset": + return t("common.bookmark_types.media"); + default: + return type; + } +} + +function getTitleDisplay( + item: { + title: string | null; + url: string | null; + content: string | null; + type: string; + }, + noTitleLabel: string, +) { + if (item.title) { + return item.title; + } + if (item.type === "text" && item.content) { + return item.content.length > 80 + ? item.content.substring(0, 80) + "…" + : item.content; + } + if (item.url) { + try { + const url = new URL(item.url); + const display = url.hostname + url.pathname; + return display.length > 60 ? display.substring(0, 60) + "…" : display; + } catch { + return item.url.length > 60 ? item.url.substring(0, 60) + "…" : item.url; + } + } + return noTitleLabel; +} + +export default function ImportSessionDetail({ + sessionId, +}: { + sessionId: string; +}) { + const { t: tRaw } = useTranslation(); + const t = tRaw as SimpleTFunction; + const router = useRouter(); + const [filter, setFilter] = useState<FilterType>("all"); + + const { data: stats, isLoading: isStatsLoading } = + useImportSessionStats(sessionId); + const { + data: resultsData, + isLoading: isResultsLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useImportSessionResults(sessionId, filter); + + const deleteSession = useDeleteImportSession(); + const pauseSession = usePauseImportSession(); + const resumeSession = useResumeImportSession(); + + const { ref: loadMoreRef, inView: loadMoreInView } = useInView(); + + useEffect(() => { + if (loadMoreInView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, loadMoreInView]); + + if (isStatsLoading) { + return <FullPageSpinner />; + } + + if (!stats) { + return null; + } + + const items: ImportSessionResultItem[] = + resultsData?.pages.flatMap((page) => page.items) ?? []; + + const progress = + stats.totalBookmarks > 0 + ? ((stats.completedBookmarks + stats.failedBookmarks) / + stats.totalBookmarks) * + 100 + : 0; + + const canDelete = + stats.status === "completed" || + stats.status === "failed" || + stats.status === "paused"; + const canPause = stats.status === "pending" || stats.status === "running"; + const canResume = stats.status === "paused"; + + const statusLabels = (s: ZImportSessionStatus) => + switchCase(s, { + staging: t("settings.import_sessions.status.staging"), + pending: t("settings.import_sessions.status.pending"), + running: t("settings.import_sessions.status.running"), + paused: t("settings.import_sessions.status.paused"), + completed: t("settings.import_sessions.status.completed"), + failed: t("settings.import_sessions.status.failed"), + }); + + const handleDelete = () => { + deleteSession.mutateAsync({ importSessionId: sessionId }).then(() => { + router.push("/settings/import"); + }); + }; + + return ( + <div className="flex flex-col gap-6"> + {/* Back link */} + <Link + href="/settings/import" + className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" + > + <ArrowLeft className="h-4 w-4" /> + {t("settings.import_sessions.detail.back_to_import")} + </Link> + + {/* Header */} + <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-4"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <h2 className="text-lg font-medium">{stats.name}</h2> + <p className="mt-1 text-sm text-muted-foreground"> + {t("settings.import_sessions.created_at", { + time: formatDistanceToNow(stats.createdAt, { + addSuffix: true, + }), + })} + </p> + </div> + <Badge + className={`${getStatusColor(stats.status)} hover:bg-inherit`} + > + {getStatusIcon(stats.status)} + <span className="ml-1 capitalize"> + {statusLabels(stats.status)} + </span> + </Badge> + </div> + + {/* Progress bar + stats */} + {stats.totalBookmarks > 0 && ( + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <h4 className="text-sm font-medium text-muted-foreground"> + {t("settings.import_sessions.progress")} + </h4> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium"> + {stats.completedBookmarks + stats.failedBookmarks} /{" "} + {stats.totalBookmarks} + </span> + <Badge variant="outline" className="text-xs"> + {Math.round(progress)}% + </Badge> + </div> + </div> + <Progress value={progress} className="h-3" /> + <div className="flex flex-wrap gap-2"> + {stats.completedBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400" + > + <CheckCircle2 className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.completed", { + count: stats.completedBookmarks, + })} + </Badge> + )} + {stats.failedBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-destructive/10 text-destructive hover:bg-destructive/10" + > + <AlertCircle className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.failed", { + count: stats.failedBookmarks, + })} + </Badge> + )} + {stats.pendingBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400" + > + <Clock className="mr-1.5 h-3 w-3" /> + {t("settings.import_sessions.badges.pending", { + count: stats.pendingBookmarks, + })} + </Badge> + )} + {stats.processingBookmarks > 0 && ( + <Badge + variant="secondary" + className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400" + > + <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> + {t("settings.import_sessions.badges.processing", { + count: stats.processingBookmarks, + })} + </Badge> + )} + </div> + </div> + )} + + {/* Message */} + {stats.message && ( + <div className="rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground dark:bg-muted/20"> + {stats.message} + </div> + )} + + {/* Action buttons */} + <div className="flex items-center justify-end"> + <div className="flex items-center gap-2"> + {canPause && ( + <Button + variant="outline" + size="sm" + onClick={() => + pauseSession.mutate({ importSessionId: sessionId }) + } + disabled={pauseSession.isPending} + > + <Pause className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.pause_session")} + </Button> + )} + {canResume && ( + <Button + variant="outline" + size="sm" + onClick={() => + resumeSession.mutate({ importSessionId: sessionId }) + } + disabled={resumeSession.isPending} + > + <Play className="mr-1 h-4 w-4" /> + {t("settings.import_sessions.resume_session")} + </Button> + )} + {canDelete && ( + <ActionConfirmingDialog + title={t("settings.import_sessions.delete_dialog_title")} + description={ + <div> + {t("settings.import_sessions.delete_dialog_description", { + name: stats.name, + })} + </div> + } + actionButton={(setDialogOpen) => ( + <Button + variant="destructive" + onClick={() => { + handleDelete(); + setDialogOpen(false); + }} + disabled={deleteSession.isPending} + > + {t("settings.import_sessions.delete_session")} + </Button> + )} + > + <Button + variant="destructive" + size="sm" + disabled={deleteSession.isPending} + > + <Trash2 className="mr-1 h-4 w-4" /> + {t("actions.delete")} + </Button> + </ActionConfirmingDialog> + )} + </div> + </div> + </div> + </div> + + {/* Filter tabs + Results table */} + <div className="rounded-md border bg-background p-4"> + <Tabs + value={filter} + onValueChange={(v) => setFilter(v as FilterType)} + className="w-full" + > + <TabsList className="mb-4 flex w-full flex-wrap"> + <TabsTrigger value="all"> + {t("settings.import_sessions.detail.filter_all")} + </TabsTrigger> + <TabsTrigger value="accepted"> + {t("settings.import_sessions.detail.filter_accepted")} + </TabsTrigger> + <TabsTrigger value="rejected"> + {t("settings.import_sessions.detail.filter_rejected")} + </TabsTrigger> + <TabsTrigger value="skipped_duplicate"> + {t("settings.import_sessions.detail.filter_duplicates")} + </TabsTrigger> + <TabsTrigger value="pending"> + {t("settings.import_sessions.detail.filter_pending")} + </TabsTrigger> + </TabsList> + </Tabs> + + {isResultsLoading ? ( + <FullPageSpinner /> + ) : items.length === 0 ? ( + <p className="rounded-md bg-muted p-4 text-center text-sm text-muted-foreground"> + {t("settings.import_sessions.detail.no_results")} + </p> + ) : ( + <div className="flex flex-col gap-2"> + <Table> + <TableHeader> + <TableRow> + <TableHead> + {t("settings.import_sessions.detail.table_title")} + </TableHead> + <TableHead className="w-[80px]"> + {t("settings.import_sessions.detail.table_type")} + </TableHead> + <TableHead className="w-[120px]"> + {t("settings.import_sessions.detail.table_result")} + </TableHead> + <TableHead> + {t("settings.import_sessions.detail.table_reason")} + </TableHead> + <TableHead className="w-[100px]"> + {t("settings.import_sessions.detail.table_bookmark")} + </TableHead> + </TableRow> + </TableHeader> + <TableBody> + {items.map((item) => ( + <TableRow key={item.id}> + <TableCell className="max-w-[300px] truncate font-medium"> + {getTitleDisplay( + item, + t("settings.import_sessions.detail.no_title"), + )} + </TableCell> + <TableCell> + <Badge + variant="outline" + className="flex w-fit items-center gap-1 text-xs" + > + {getTypeIcon(item.type)} + {getTypeLabel(item.type, t)} + </Badge> + </TableCell> + <TableCell> + {getResultBadge(item.status, item.result, t)} + </TableCell> + <TableCell className="max-w-[200px] truncate text-sm text-muted-foreground"> + {item.resultReason || "—"} + </TableCell> + <TableCell> + {item.resultBookmarkId ? ( + <Link + href={`/dashboard/preview/${item.resultBookmarkId}`} + className="flex items-center gap-1 text-sm text-primary hover:text-primary/80" + prefetch={false} + > + <ExternalLink className="h-3 w-3" /> + {t("settings.import_sessions.detail.view_bookmark")} + </Link> + ) : ( + <span className="text-sm text-muted-foreground">—</span> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + {hasNextPage && ( + <div className="flex justify-center"> + <ActionButton + ref={loadMoreRef} + ignoreDemoMode={true} + loading={isFetchingNextPage} + onClick={() => fetchNextPage()} + variant="ghost" + > + {t("settings.import_sessions.detail.load_more")} + </ActionButton> + </div> + )} + </div> + )} + </div> + </div> + ); +} diff --git a/apps/web/components/settings/ReaderSettings.tsx b/apps/web/components/settings/ReaderSettings.tsx new file mode 100644 index 00000000..d694bf02 --- /dev/null +++ b/apps/web/components/settings/ReaderSettings.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "@/components/ui/sonner"; +import { useClientConfig } from "@/lib/clientConfig"; +import { useTranslation } from "@/lib/i18n/client"; +import { useReaderSettings } from "@/lib/readerSettings"; +import { + AlertTriangle, + BookOpen, + ChevronDown, + Laptop, + RotateCcw, +} from "lucide-react"; + +import { + formatFontSize, + formatLineHeight, + READER_DEFAULTS, + READER_FONT_FAMILIES, + READER_SETTING_CONSTRAINTS, +} from "@karakeep/shared/types/readers"; + +import { Alert, AlertDescription } from "../ui/alert"; +import { Button } from "../ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; +import { Label } from "../ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Slider } from "../ui/slider"; + +export default function ReaderSettings() { + const { t } = useTranslation(); + const clientConfig = useClientConfig(); + const { + settings, + serverSettings, + localOverrides, + hasLocalOverrides, + clearServerDefaults, + clearLocalOverrides, + updateServerSetting, + } = useReaderSettings(); + + // Local state for collapsible + const [isOpen, setIsOpen] = useState(false); + + // Local state for slider dragging (null = not dragging, use server value) + const [draggingFontSize, setDraggingFontSize] = useState<number | null>(null); + const [draggingLineHeight, setDraggingLineHeight] = useState<number | null>( + null, + ); + + const hasServerSettings = + serverSettings.fontSize !== null || + serverSettings.lineHeight !== null || + serverSettings.fontFamily !== null; + + const handleClearDefaults = () => { + clearServerDefaults(); + toast({ description: t("settings.info.reader_settings.defaults_cleared") }); + }; + + const handleClearLocalOverrides = () => { + clearLocalOverrides(); + toast({ + description: t("settings.info.reader_settings.local_overrides_cleared"), + }); + }; + + // Format local override for display + const formatLocalOverride = ( + key: "fontSize" | "lineHeight" | "fontFamily", + ) => { + const value = localOverrides[key]; + if (value === undefined) return null; + if (key === "fontSize") return formatFontSize(value as number); + if (key === "lineHeight") return formatLineHeight(value as number); + if (key === "fontFamily") { + switch (value) { + case "serif": + return t("settings.info.reader_settings.serif"); + case "sans": + return t("settings.info.reader_settings.sans"); + case "mono": + return t("settings.info.reader_settings.mono"); + } + } + return String(value); + }; + + return ( + <Collapsible open={isOpen} onOpenChange={setIsOpen}> + <Card> + <CardHeader> + <CollapsibleTrigger className="flex w-full items-center justify-between [&[data-state=open]>svg]:rotate-180"> + <div className="flex flex-col items-start gap-1 text-left"> + <CardTitle className="flex items-center gap-2 text-xl"> + <BookOpen className="h-5 w-5" /> + {t("settings.info.reader_settings.title")} + </CardTitle> + <CardDescription> + {t("settings.info.reader_settings.description")} + </CardDescription> + </div> + <ChevronDown className="h-5 w-5 shrink-0 transition-transform duration-200" /> + </CollapsibleTrigger> + </CardHeader> + <CollapsibleContent> + <CardContent className="space-y-6"> + {/* Local Overrides Warning */} + {hasLocalOverrides && ( + <Alert> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="flex flex-col gap-3"> + <div> + <p className="font-medium"> + {t("settings.info.reader_settings.local_overrides_title")} + </p> + <p className="mt-1 text-sm text-muted-foreground"> + {t( + "settings.info.reader_settings.local_overrides_description", + )} + </p> + <ul className="mt-2 text-sm text-muted-foreground"> + {localOverrides.fontFamily !== undefined && ( + <li> + {t("settings.info.reader_settings.font_family")}:{" "} + {formatLocalOverride("fontFamily")} + </li> + )} + {localOverrides.fontSize !== undefined && ( + <li> + {t("settings.info.reader_settings.font_size")}:{" "} + {formatLocalOverride("fontSize")} + </li> + )} + {localOverrides.lineHeight !== undefined && ( + <li> + {t("settings.info.reader_settings.line_height")}:{" "} + {formatLocalOverride("lineHeight")} + </li> + )} + </ul> + </div> + <Button + variant="outline" + size="sm" + onClick={handleClearLocalOverrides} + className="w-fit" + > + <Laptop className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.clear_local_overrides")} + </Button> + </AlertDescription> + </Alert> + )} + + {/* Font Family */} + <div className="space-y-2"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_family")} + </Label> + <Select + disabled={!!clientConfig.demoMode} + value={serverSettings.fontFamily ?? "not-set"} + onValueChange={(value) => { + if (value !== "not-set") { + updateServerSetting({ + fontFamily: value as "serif" | "sans" | "mono", + }); + } + }} + > + <SelectTrigger className="h-11"> + <SelectValue + placeholder={t("settings.info.reader_settings.not_set")} + /> + </SelectTrigger> + <SelectContent> + <SelectItem value="not-set" disabled> + {t("settings.info.reader_settings.not_set")} ( + {t("common.default")}: {READER_DEFAULTS.fontFamily}) + </SelectItem> + <SelectItem value="serif"> + {t("settings.info.reader_settings.serif")} + </SelectItem> + <SelectItem value="sans"> + {t("settings.info.reader_settings.sans")} + </SelectItem> + <SelectItem value="mono"> + {t("settings.info.reader_settings.mono")} + </SelectItem> + </SelectContent> + </Select> + {serverSettings.fontFamily === null && ( + <p className="text-xs text-muted-foreground"> + {t("settings.info.reader_settings.using_default")}:{" "} + {READER_DEFAULTS.fontFamily} + </p> + )} + </div> + + {/* Font Size */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.font_size")} + </Label> + <span className="text-sm text-muted-foreground"> + {formatFontSize(draggingFontSize ?? settings.fontSize)} + {serverSettings.fontSize === null && + draggingFontSize === null && + ` (${t("common.default").toLowerCase()})`} + </span> + </div> + <Slider + disabled={!!clientConfig.demoMode} + value={[draggingFontSize ?? settings.fontSize]} + onValueChange={([value]) => setDraggingFontSize(value)} + onValueCommit={([value]) => { + updateServerSetting({ fontSize: value }); + setDraggingFontSize(null); + }} + max={READER_SETTING_CONSTRAINTS.fontSize.max} + min={READER_SETTING_CONSTRAINTS.fontSize.min} + step={READER_SETTING_CONSTRAINTS.fontSize.step} + /> + </div> + + {/* Line Height */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-medium"> + {t("settings.info.reader_settings.line_height")} + </Label> + <span className="text-sm text-muted-foreground"> + {formatLineHeight(draggingLineHeight ?? settings.lineHeight)} + {serverSettings.lineHeight === null && + draggingLineHeight === null && + ` (${t("common.default").toLowerCase()})`} + </span> + </div> + <Slider + disabled={!!clientConfig.demoMode} + value={[draggingLineHeight ?? settings.lineHeight]} + onValueChange={([value]) => setDraggingLineHeight(value)} + onValueCommit={([value]) => { + updateServerSetting({ lineHeight: value }); + setDraggingLineHeight(null); + }} + max={READER_SETTING_CONSTRAINTS.lineHeight.max} + min={READER_SETTING_CONSTRAINTS.lineHeight.min} + step={READER_SETTING_CONSTRAINTS.lineHeight.step} + /> + </div> + + {/* Clear Defaults Button */} + {hasServerSettings && ( + <Button + variant="outline" + onClick={handleClearDefaults} + className="w-full" + disabled={!!clientConfig.demoMode} + > + <RotateCcw className="mr-2 h-4 w-4" /> + {t("settings.info.reader_settings.clear_defaults")} + </Button> + )} + + {/* Preview */} + <div className="rounded-lg border p-4"> + <p className="mb-2 text-sm font-medium text-muted-foreground"> + {t("settings.info.reader_settings.preview")} + </p> + <p + style={{ + fontFamily: READER_FONT_FAMILIES[settings.fontFamily], + fontSize: `${draggingFontSize ?? settings.fontSize}px`, + lineHeight: draggingLineHeight ?? settings.lineHeight, + }} + > + {t("settings.info.reader_settings.preview_text")} + <br /> + {t("settings.info.reader_settings.preview_text")} + <br /> + {t("settings.info.reader_settings.preview_text")} + </p> + </div> + </CardContent> + </CollapsibleContent> + </Card> + </Collapsible> + ); +} diff --git a/apps/web/components/settings/RegenerateApiKey.tsx b/apps/web/components/settings/RegenerateApiKey.tsx index 1c034026..943d21ef 100644 --- a/apps/web/components/settings/RegenerateApiKey.tsx +++ b/apps/web/components/settings/RegenerateApiKey.tsx @@ -14,11 +14,13 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; import { RefreshCcw } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import ApiKeySuccess from "./ApiKeySuccess"; export default function RegenerateApiKey({ @@ -28,25 +30,28 @@ export default function RegenerateApiKey({ id: string; name: string; }) { + const api = useTRPC(); const { t } = useTranslation(); const router = useRouter(); const [key, setKey] = useState<string | undefined>(undefined); const [dialogOpen, setDialogOpen] = useState<boolean>(false); - const mutator = api.apiKeys.regenerate.useMutation({ - onSuccess: (resp) => { - setKey(resp.key); - router.refresh(); - }, - onError: () => { - toast({ - description: t("common.something_went_wrong"), - variant: "destructive", - }); - setDialogOpen(false); - }, - }); + const mutator = useMutation( + api.apiKeys.regenerate.mutationOptions({ + onSuccess: (resp) => { + setKey(resp.key); + router.refresh(); + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + setDialogOpen(false); + }, + }), + ); const handleRegenerate = () => { mutator.mutate({ id }); diff --git a/apps/web/components/settings/SubscriptionSettings.tsx b/apps/web/components/settings/SubscriptionSettings.tsx index 53f1caf4..48ab1258 100644 --- a/apps/web/components/settings/SubscriptionSettings.tsx +++ b/apps/web/components/settings/SubscriptionSettings.tsx @@ -1,10 +1,13 @@ "use client"; import { useEffect } from "react"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, Loader2 } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { Alert, AlertDescription } from "../ui/alert"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -16,27 +19,29 @@ import { CardTitle, } from "../ui/card"; import { Skeleton } from "../ui/skeleton"; -import { toast } from "../ui/use-toast"; export default function SubscriptionSettings() { + const api = useTRPC(); const { t } = useTranslation(); const { data: subscriptionStatus, refetch, isLoading: isQueryLoading, - } = api.subscriptions.getSubscriptionStatus.useQuery(); + } = useQuery(api.subscriptions.getSubscriptionStatus.queryOptions()); - const { data: subscriptionPrice } = - api.subscriptions.getSubscriptionPrice.useQuery(); + const { data: subscriptionPrice } = useQuery( + api.subscriptions.getSubscriptionPrice.queryOptions(), + ); - const { mutate: syncStripeState } = - api.subscriptions.syncWithStripe.useMutation({ + const { mutate: syncStripeState } = useMutation( + api.subscriptions.syncWithStripe.mutationOptions({ onSuccess: () => { refetch(); }, - }); - const createCheckoutSession = - api.subscriptions.createCheckoutSession.useMutation({ + }), + ); + const createCheckoutSession = useMutation( + api.subscriptions.createCheckoutSession.mutationOptions({ onSuccess: (resp) => { if (resp.url) { window.location.href = resp.url; @@ -48,9 +53,10 @@ export default function SubscriptionSettings() { variant: "destructive", }); }, - }); - const createPortalSession = api.subscriptions.createPortalSession.useMutation( - { + }), + ); + const createPortalSession = useMutation( + api.subscriptions.createPortalSession.mutationOptions({ onSuccess: (resp) => { if (resp.url) { window.location.href = resp.url; @@ -62,7 +68,7 @@ export default function SubscriptionSettings() { variant: "destructive", }); }, - }, + }), ); const isLoading = diff --git a/apps/web/components/settings/UserAvatar.tsx b/apps/web/components/settings/UserAvatar.tsx new file mode 100644 index 00000000..6baff7c2 --- /dev/null +++ b/apps/web/components/settings/UserAvatar.tsx @@ -0,0 +1,149 @@ +"use client"; + +import type { ChangeEvent } from "react"; +import { useRef } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { toast } from "@/components/ui/sonner"; +import { UserAvatar as UserAvatarImage } from "@/components/ui/user-avatar"; +import useUpload from "@/lib/hooks/upload-file"; +import { useTranslation } from "@/lib/i18n/client"; +import { Image as ImageIcon, Upload, User, X } from "lucide-react"; + +import { + useUpdateUserAvatar, + useWhoAmI, +} from "@karakeep/shared-react/hooks/users"; + +import { Button } from "../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; + +export default function UserAvatar() { + const { t } = useTranslation(); + const fileInputRef = useRef<HTMLInputElement>(null); + const whoami = useWhoAmI(); + const image = whoami.data?.image ?? null; + + const updateAvatar = useUpdateUserAvatar({ + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }); + + const upload = useUpload({ + onSuccess: async (resp) => { + try { + await updateAvatar.mutateAsync({ assetId: resp.assetId }); + toast({ + description: t("settings.info.avatar.updated"), + }); + } catch { + // handled in onError + } + }, + onError: (err) => { + toast({ + description: err.error, + variant: "destructive", + }); + }, + }); + + const isBusy = upload.isPending || updateAvatar.isPending; + + const handleSelectFile = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + upload.mutate(file); + event.target.value = ""; + }; + + return ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-xl"> + <ImageIcon className="h-5 w-5" /> + {t("settings.info.avatar.title")} + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <p className="text-sm text-muted-foreground"> + {t("settings.info.avatar.description")} + </p> + <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-center gap-4"> + <div className="flex size-16 items-center justify-center overflow-hidden rounded-full border bg-muted"> + <UserAvatarImage + image={image} + name={t("settings.info.avatar.title")} + fallback={<User className="h-7 w-7 text-muted-foreground" />} + className="h-full w-full" + /> + </div> + <input + ref={fileInputRef} + type="file" + accept="image/*" + className="hidden" + onChange={handleFileChange} + /> + <ActionButton + type="button" + variant="secondary" + onClick={handleSelectFile} + loading={upload.isPending} + disabled={isBusy} + > + <Upload className="mr-2 h-4 w-4" /> + {image + ? t("settings.info.avatar.change") + : t("settings.info.avatar.upload")} + </ActionButton> + </div> + <ActionConfirmingDialog + title={t("settings.info.avatar.remove_confirm_title")} + description={ + <p>{t("settings.info.avatar.remove_confirm_description")}</p> + } + actionButton={(setDialogOpen) => ( + <ActionButton + type="button" + variant="destructive" + loading={updateAvatar.isPending} + onClick={() => + updateAvatar.mutate( + { assetId: null }, + { + onSuccess: () => { + toast({ + description: t("settings.info.avatar.removed"), + }); + setDialogOpen(false); + }, + }, + ) + } + > + {t("settings.info.avatar.remove")} + </ActionButton> + )} + > + <Button type="button" variant="outline" disabled={!image || isBusy}> + <X className="mr-2 h-4 w-4" /> + {t("settings.info.avatar.remove")} + </Button> + </ActionConfirmingDialog> + </div> + </CardContent> + </Card> + ); +} diff --git a/apps/web/components/settings/UserOptions.tsx b/apps/web/components/settings/UserOptions.tsx index 0df1085e..763695c5 100644 --- a/apps/web/components/settings/UserOptions.tsx +++ b/apps/web/components/settings/UserOptions.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { toast } from "@/components/ui/sonner"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { useInterfaceLang } from "@/lib/userLocalSettings/bookmarksLayout"; @@ -28,7 +29,6 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { toast } from "../ui/use-toast"; const LanguageSelect = () => { const lang = useInterfaceLang(); diff --git a/apps/web/components/settings/WebhookSettings.tsx b/apps/web/components/settings/WebhookSettings.tsx index 8efd3ba6..7a05b9e6 100644 --- a/apps/web/components/settings/WebhookSettings.tsx +++ b/apps/web/components/settings/WebhookSettings.tsx @@ -12,10 +12,10 @@ import { } from "@/components/ui/form"; import { FullPageSpinner } from "@/components/ui/full-page-spinner"; import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Edit, KeyRound, @@ -28,6 +28,7 @@ import { import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zNewWebhookSchema, zUpdateWebhookSchema, @@ -56,9 +57,10 @@ import { import { WebhookEventSelector } from "./WebhookEventSelector"; export function WebhooksEditorDialog() { + const api = useTRPC(); const { t } = useTranslation(); const [open, setOpen] = React.useState(false); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const form = useForm<z.infer<typeof zNewWebhookSchema>>({ resolver: zodResolver(zNewWebhookSchema), @@ -75,16 +77,17 @@ export function WebhooksEditorDialog() { } }, [open]); - const { mutateAsync: createWebhook, isPending: isCreating } = - api.webhooks.create.useMutation({ + const { mutateAsync: createWebhook, isPending: isCreating } = useMutation( + api.webhooks.create.mutationOptions({ onSuccess: () => { toast({ description: "Webhook has been created!", }); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); setOpen(false); }, - }); + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> @@ -179,8 +182,9 @@ export function WebhooksEditorDialog() { } export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (open) { @@ -191,16 +195,17 @@ export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { }); } }, [open]); - const { mutateAsync: updateWebhook, isPending: isUpdating } = - api.webhooks.update.useMutation({ + const { mutateAsync: updateWebhook, isPending: isUpdating } = useMutation( + api.webhooks.update.mutationOptions({ onSuccess: () => { toast({ description: "Webhook has been updated!", }); setOpen(false); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); }, - }); + }), + ); const updateSchema = zUpdateWebhookSchema.required({ events: true, url: true, @@ -302,8 +307,9 @@ export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { } export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const [open, setOpen] = React.useState(false); React.useEffect(() => { if (open) { @@ -331,16 +337,17 @@ export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { }, }); - const { mutateAsync: updateWebhook, isPending: isUpdating } = - api.webhooks.update.useMutation({ + const { mutateAsync: updateWebhook, isPending: isUpdating } = useMutation( + api.webhooks.update.mutationOptions({ onSuccess: () => { toast({ description: "Webhook token has been updated!", }); setOpen(false); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); }, - }); + }), + ); return ( <Dialog open={open} onOpenChange={setOpen}> @@ -432,17 +439,19 @@ export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { } export function WebhookRow({ webhook }: { webhook: ZWebhook }) { + const api = useTRPC(); const { t } = useTranslation(); - const apiUtils = api.useUtils(); - const { mutate: deleteWebhook, isPending: isDeleting } = - api.webhooks.delete.useMutation({ + const queryClient = useQueryClient(); + const { mutate: deleteWebhook, isPending: isDeleting } = useMutation( + api.webhooks.delete.mutationOptions({ onSuccess: () => { toast({ description: "Webhook has been deleted!", }); - apiUtils.webhooks.list.invalidate(); + queryClient.invalidateQueries(api.webhooks.list.pathFilter()); }, - }); + }), + ); return ( <TableRow> @@ -479,8 +488,11 @@ export function WebhookRow({ webhook }: { webhook: ZWebhook }) { } export default function WebhookSettings() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: webhooks, isLoading } = api.webhooks.list.useQuery(); + const { data: webhooks, isLoading } = useQuery( + api.webhooks.list.queryOptions(), + ); return ( <div className="rounded-md border bg-background p-4"> <div className="flex flex-col gap-2"> diff --git a/apps/web/components/shared/sidebar/Sidebar.tsx b/apps/web/components/shared/sidebar/Sidebar.tsx index bf5a626b..3f4780e7 100644 --- a/apps/web/components/shared/sidebar/Sidebar.tsx +++ b/apps/web/components/shared/sidebar/Sidebar.tsx @@ -32,7 +32,10 @@ export default async function Sidebar({ </ul> </div> {extraSections} - <SidebarVersion serverVersion={serverConfig.serverVersion} /> + <SidebarVersion + serverVersion={serverConfig.serverVersion} + changeLogVersion={serverConfig.changelogVersion} + /> </aside> ); } diff --git a/apps/web/components/shared/sidebar/SidebarItem.tsx b/apps/web/components/shared/sidebar/SidebarItem.tsx index e602a435..eb61d48b 100644 --- a/apps/web/components/shared/sidebar/SidebarItem.tsx +++ b/apps/web/components/shared/sidebar/SidebarItem.tsx @@ -14,6 +14,11 @@ export default function SidebarItem({ style, collapseButton, right = null, + dropHighlight = false, + onDrop, + onDragOver, + onDragEnter, + onDragLeave, }: { name: string; logo: React.ReactNode; @@ -23,6 +28,11 @@ export default function SidebarItem({ linkClassName?: string; right?: React.ReactNode; collapseButton?: React.ReactNode; + dropHighlight?: boolean; + onDrop?: React.DragEventHandler; + onDragOver?: React.DragEventHandler; + onDragEnter?: React.DragEventHandler; + onDragLeave?: React.DragEventHandler; }) { const currentPath = usePathname(); return ( @@ -32,9 +42,14 @@ export default function SidebarItem({ path == currentPath ? "bg-accent/50 text-foreground" : "text-muted-foreground", + dropHighlight && "bg-accent ring-2 ring-primary", className, )} style={style} + onDrop={onDrop} + onDragOver={onDragOver} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} > <div className="flex-1"> {collapseButton} 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> diff --git a/apps/web/components/shared/sidebar/SidebarVersion.tsx b/apps/web/components/shared/sidebar/SidebarVersion.tsx index fc2ec5a3..2d6d3380 100644 --- a/apps/web/components/shared/sidebar/SidebarVersion.tsx +++ b/apps/web/components/shared/sidebar/SidebarVersion.tsx @@ -46,36 +46,50 @@ function isStableRelease(version?: string) { } interface SidebarVersionProps { + // The actual version of the server serverVersion?: string; + // The version that should be displayed in the changelog + changeLogVersion?: string; } -export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { +export default function SidebarVersion({ + serverVersion, + changeLogVersion, +}: SidebarVersionProps) { const { disableNewReleaseCheck } = useClientConfig(); const { t } = useTranslation(); - const stableRelease = isStableRelease(serverVersion); + const effectiveChangelogVersion = changeLogVersion ?? serverVersion; + const stableRelease = isStableRelease(effectiveChangelogVersion); const displayVersion = serverVersion ?? "unknown"; + const changelogDisplayVersion = effectiveChangelogVersion ?? displayVersion; const versionLabel = `Karakeep v${displayVersion}`; const releasePageUrl = useMemo(() => { - if (!serverVersion || !isStableRelease(serverVersion)) { + if ( + !effectiveChangelogVersion || + !isStableRelease(effectiveChangelogVersion) + ) { return GITHUB_REPO_URL; } - return `${GITHUB_RELEASE_URL}v${serverVersion}`; - }, [serverVersion]); + return `${GITHUB_RELEASE_URL}v${effectiveChangelogVersion}`; + }, [effectiveChangelogVersion]); const [open, setOpen] = useState(false); const [shouldNotify, setShouldNotify] = useState(false); const releaseNotesQuery = useQuery<string>({ - queryKey: ["sidebar-release-notes", serverVersion], + queryKey: ["sidebar-release-notes", effectiveChangelogVersion], queryFn: async ({ signal }) => { - if (!serverVersion) { + if (!effectiveChangelogVersion) { return ""; } - const response = await fetch(`${RELEASE_API_URL}v${serverVersion}`, { - signal, - }); + const response = await fetch( + `${RELEASE_API_URL}v${effectiveChangelogVersion}`, + { + signal, + }, + ); if (!response.ok) { throw new Error("Failed to load release notes"); @@ -89,7 +103,7 @@ export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { open && stableRelease && !disableNewReleaseCheck && - Boolean(serverVersion), + Boolean(effectiveChangelogVersion), staleTime: RELEASE_NOTES_STALE_TIME, retry: 1, refetchOnWindowFocus: false, @@ -123,30 +137,34 @@ export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { }, [releaseNotesQuery.error, t]); useEffect(() => { - if (!stableRelease || !serverVersion || disableNewReleaseCheck) { + if ( + !stableRelease || + !effectiveChangelogVersion || + disableNewReleaseCheck + ) { setShouldNotify(false); return; } try { const seenVersion = window.localStorage.getItem(LOCAL_STORAGE_KEY); - setShouldNotify(seenVersion !== serverVersion); + setShouldNotify(seenVersion !== effectiveChangelogVersion); } catch (error) { console.warn("Failed to read localStorage:", error); setShouldNotify(true); } - }, [serverVersion, stableRelease, disableNewReleaseCheck]); + }, [effectiveChangelogVersion, stableRelease, disableNewReleaseCheck]); const markReleaseAsSeen = useCallback(() => { - if (!serverVersion) return; + if (!effectiveChangelogVersion) return; try { - window.localStorage.setItem(LOCAL_STORAGE_KEY, serverVersion); + window.localStorage.setItem(LOCAL_STORAGE_KEY, effectiveChangelogVersion); } catch (error) { console.warn("Failed to write to localStorage:", error); // Ignore failures, we still clear the notification for the session } setShouldNotify(false); - }, [serverVersion]); + }, [effectiveChangelogVersion]); const handleOpenChange = useCallback( (nextOpen: boolean) => { @@ -202,7 +220,9 @@ export default function SidebarVersion({ serverVersion }: SidebarVersionProps) { <DialogContent className="max-w-3xl"> <DialogHeader> <DialogTitle> - {t("version.whats_new_title", { version: displayVersion })} + {t("version.whats_new_title", { + version: changelogDisplayVersion, + })} </DialogTitle> <DialogDescription> {t("version.release_notes_description")} diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx index 4a4a0533..0ff5b1d0 100644 --- a/apps/web/components/signin/CredentialsForm.tsx +++ b/apps/web/components/signin/CredentialsForm.tsx @@ -14,10 +14,10 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { signIn } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; import { zodResolver } from "@hookform/resolvers/zod"; import { AlertCircle, Lock } from "lucide-react"; -import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; import { z } from "zod"; diff --git a/apps/web/components/signin/ForgotPasswordForm.tsx b/apps/web/components/signin/ForgotPasswordForm.tsx index 29d55f2b..7ba37553 100644 --- a/apps/web/components/signin/ForgotPasswordForm.tsx +++ b/apps/web/components/signin/ForgotPasswordForm.tsx @@ -20,18 +20,21 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, CheckCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + const forgotPasswordSchema = z.object({ email: z.string().email("Please enter a valid email address"), }); export default function ForgotPasswordForm() { + const api = useTRPC(); const [isSubmitted, setIsSubmitted] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const router = useRouter(); @@ -40,7 +43,9 @@ export default function ForgotPasswordForm() { resolver: zodResolver(forgotPasswordSchema), }); - const forgotPasswordMutation = api.users.forgotPassword.useMutation(); + const forgotPasswordMutation = useMutation( + api.users.forgotPassword.mutationOptions(), + ); const onSubmit = async (values: z.infer<typeof forgotPasswordSchema>) => { try { diff --git a/apps/web/components/signin/ResetPasswordForm.tsx b/apps/web/components/signin/ResetPasswordForm.tsx index d4d8a285..571a09ae 100644 --- a/apps/web/components/signin/ResetPasswordForm.tsx +++ b/apps/web/components/signin/ResetPasswordForm.tsx @@ -20,13 +20,14 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, CheckCircle } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zResetPasswordSchema } from "@karakeep/shared/types/users"; const resetPasswordSchema = z @@ -44,6 +45,7 @@ interface ResetPasswordFormProps { } export default function ResetPasswordForm({ token }: ResetPasswordFormProps) { + const api = useTRPC(); const [isSuccess, setIsSuccess] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const router = useRouter(); @@ -52,7 +54,9 @@ export default function ResetPasswordForm({ token }: ResetPasswordFormProps) { resolver: zodResolver(resetPasswordSchema), }); - const resetPasswordMutation = api.users.resetPassword.useMutation(); + const resetPasswordMutation = useMutation( + api.users.resetPassword.mutationOptions(), + ); const onSubmit = async (values: z.infer<typeof resetPasswordSchema>) => { try { diff --git a/apps/web/components/signin/SignInProviderButton.tsx b/apps/web/components/signin/SignInProviderButton.tsx index edb411e6..4b218e2a 100644 --- a/apps/web/components/signin/SignInProviderButton.tsx +++ b/apps/web/components/signin/SignInProviderButton.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { signIn } from "next-auth/react"; +import { signIn } from "@/lib/auth/client"; export default function SignInProviderButton({ provider, diff --git a/apps/web/components/signup/SignUpForm.tsx b/apps/web/components/signup/SignUpForm.tsx index 340b461a..15b64fab 100644 --- a/apps/web/components/signup/SignUpForm.tsx +++ b/apps/web/components/signup/SignUpForm.tsx @@ -23,21 +23,28 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { signIn } from "@/lib/auth/client"; import { useClientConfig } from "@/lib/clientConfig"; -import { api } from "@/lib/trpc"; import { zodResolver } from "@hookform/resolvers/zod"; import { Turnstile } from "@marsidev/react-turnstile"; +import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { AlertCircle, UserX } from "lucide-react"; -import { signIn } from "next-auth/react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { zSignUpSchema } from "@karakeep/shared/types/users"; +import { isMobileAppRedirect } from "@karakeep/shared/utils/redirectUrl"; const VERIFY_EMAIL_ERROR = "Please verify your email address before signing in"; -export default function SignUpForm() { +interface SignUpFormProps { + redirectUrl: string; +} + +export default function SignUpForm({ redirectUrl }: SignUpFormProps) { + const api = useTRPC(); const form = useForm<z.infer<typeof zSignUpSchema>>({ resolver: zodResolver(zSignUpSchema), defaultValues: { @@ -54,7 +61,7 @@ export default function SignUpForm() { const turnstileSiteKey = clientConfig.turnstile?.siteKey; const turnstileRef = useRef<TurnstileInstance>(null); - const createUserMutation = api.users.create.useMutation(); + const createUserMutation = useMutation(api.users.create.mutationOptions()); if ( clientConfig.auth.disableSignups || @@ -111,7 +118,10 @@ export default function SignUpForm() { } form.clearErrors("turnstileToken"); try { - await createUserMutation.mutateAsync(value); + await createUserMutation.mutateAsync({ + ...value, + redirectUrl, + }); } catch (e) { if (e instanceof TRPCClientError) { setErrorMessage(e.message); @@ -131,7 +141,7 @@ export default function SignUpForm() { if (!resp || !resp.ok || resp.error) { if (resp?.error === VERIFY_EMAIL_ERROR) { router.replace( - `/check-email?email=${encodeURIComponent(value.email.trim())}`, + `/check-email?email=${encodeURIComponent(value.email.trim())}&redirectUrl=${encodeURIComponent(redirectUrl)}`, ); } else { setErrorMessage( @@ -145,7 +155,11 @@ export default function SignUpForm() { } return; } - router.replace("/"); + if (isMobileAppRedirect(redirectUrl)) { + window.location.href = redirectUrl; + } else { + router.replace(redirectUrl); + } })} className="space-y-4" > diff --git a/apps/web/components/subscription/QuotaProgress.tsx b/apps/web/components/subscription/QuotaProgress.tsx index 525eae8f..29bb7fc9 100644 --- a/apps/web/components/subscription/QuotaProgress.tsx +++ b/apps/web/components/subscription/QuotaProgress.tsx @@ -1,9 +1,11 @@ "use client"; import { useTranslation } from "@/lib/i18n/client"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; import { Database, HardDrive } from "lucide-react"; +import { useTRPC } from "@karakeep/shared-react/trpc"; + import { Card, CardContent, @@ -110,9 +112,11 @@ function QuotaProgressItem({ } export function QuotaProgress() { + const api = useTRPC(); const { t } = useTranslation(); - const { data: quotaUsage, isLoading } = - api.subscriptions.getQuotaUsage.useQuery(); + const { data: quotaUsage, isLoading } = useQuery( + api.subscriptions.getQuotaUsage.queryOptions(), + ); if (isLoading) { return ( diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx index 1ab9a49d..1179bdfe 100644 --- a/apps/web/components/theme-provider.tsx +++ b/apps/web/components/theme-provider.tsx @@ -5,7 +5,11 @@ import * as React from "react"; import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return <NextThemesProvider {...props}>{children}</NextThemesProvider>; + return ( + <NextThemesProvider scriptProps={{ "data-cfasync": "false" }} {...props}> + {children} + </NextThemesProvider> + ); } export function useToggleTheme() { diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx new file mode 100644 index 00000000..48ec676b --- /dev/null +++ b/apps/web/components/ui/avatar.tsx @@ -0,0 +1,49 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +const Avatar = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Root + ref={ref} + className={cn( + "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", + className, + )} + {...props} + /> +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Image>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Image + ref={ref} + className={cn("aspect-square h-full w-full", className)} + {...props} + /> +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Fallback>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Fallback + ref={ref} + className={cn( + "flex h-full w-full items-center justify-center rounded-full bg-black text-white", + className, + )} + {...props} + /> +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/web/components/ui/copy-button.tsx b/apps/web/components/ui/copy-button.tsx index 8d8699f8..fb1f943f 100644 --- a/apps/web/components/ui/copy-button.tsx +++ b/apps/web/components/ui/copy-button.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react";
+import { toast } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import { Check, Copy } from "lucide-react";
import { Button } from "./button";
-import { toast } from "./use-toast";
export default function CopyBtn({
className,
diff --git a/apps/web/components/ui/field.tsx b/apps/web/components/ui/field.tsx new file mode 100644 index 00000000..a52897f5 --- /dev/null +++ b/apps/web/components/ui/field.tsx @@ -0,0 +1,244 @@ +"use client"; + +import type { VariantProps } from "class-variance-authority"; +import { useMemo } from "react"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { cva } from "class-variance-authority"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( + <fieldset + data-slot="field-set" + className={cn( + "flex flex-col gap-6", + "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + <legend + data-slot="field-legend" + data-variant={variant} + className={cn( + "mb-3 font-medium", + "data-[variant=legend]:text-base", + "data-[variant=label]:text-sm", + className, + )} + {...props} + /> + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-group" + className={cn( + "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", + className, + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start", + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { + return ( + <div + role="group" + data-slot="field" + data-orientation={orientation} + className={cn(fieldVariants({ orientation }), className)} + {...props} + /> + ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-content" + className={cn( + "group/field-content flex flex-1 flex-col gap-1.5 leading-snug", + className, + )} + {...props} + /> + ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps<typeof Label>) { + return ( + <Label + data-slot="field-label" + className={cn( + "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", + "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4", + "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", + className, + )} + {...props} + /> + ); +} + +function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="field-label" + className={cn( + "flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50", + className, + )} + {...props} + /> + ); +} + +function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( + <p + data-slot="field-description" + className={cn( + "text-sm font-normal leading-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance", + "nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5", + "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className, + )} + {...props} + /> + ); +} + +function FieldSeparator({ + children, + className, + ...props +}: React.ComponentProps<"div"> & { + children?: React.ReactNode; +}) { + return ( + <div + data-slot="field-separator" + data-content={!!children} + className={cn( + "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", + className, + )} + {...props} + > + <Separator className="absolute inset-0 top-1/2" /> + {children && ( + <span + className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground" + data-slot="field-separator-content" + > + {children} + </span> + )} + </div> + ); +} + +function FieldError({ + className, + children, + errors, + ...props +}: React.ComponentProps<"div"> & { + errors?: ({ message?: string } | undefined)[]; +}) { + const content = useMemo(() => { + if (children) { + return children; + } + + if (!errors) { + return null; + } + + if (errors?.length === 1 && errors[0]?.message) { + return errors[0].message; + } + + return ( + <ul className="ml-4 flex list-disc flex-col gap-1"> + {errors.map( + (error, index) => + error?.message && <li key={index}>{error.message}</li>, + )} + </ul> + ); + }, [children, errors]); + + if (!content) { + return null; + } + + return ( + <div + role="alert" + data-slot="field-error" + className={cn("text-sm font-normal text-destructive", className)} + {...props} + > + {content} + </div> + ); +} + +export { + Field, + FieldLabel, + FieldDescription, + FieldError, + FieldGroup, + FieldLegend, + FieldSeparator, + FieldSet, + FieldContent, + FieldTitle, +}; diff --git a/apps/web/components/ui/info-tooltip.tsx b/apps/web/components/ui/info-tooltip.tsx index 4dd97199..9d525983 100644 --- a/apps/web/components/ui/info-tooltip.tsx +++ b/apps/web/components/ui/info-tooltip.tsx @@ -22,8 +22,7 @@ export default function InfoTooltip({ <TooltipTrigger asChild> {variant === "tip" ? ( <Info - color="#494949" - className={cn("z-10 cursor-pointer", className)} + className={cn("z-10 cursor-pointer text-[#494949]", className)} size={size} /> ) : ( diff --git a/apps/web/components/ui/radio-group.tsx b/apps/web/components/ui/radio-group.tsx new file mode 100644 index 00000000..0da1136e --- /dev/null +++ b/apps/web/components/ui/radio-group.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { Circle } from "lucide-react"; + +const RadioGroup = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Root + className={cn("grid gap-2", className)} + {...props} + ref={ref} + /> + ); +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + {...props} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-2.5 w-2.5 fill-current text-current" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/apps/web/components/ui/sonner.tsx b/apps/web/components/ui/sonner.tsx new file mode 100644 index 00000000..d281f4ae --- /dev/null +++ b/apps/web/components/ui/sonner.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { + CircleCheck, + Info, + LoaderCircle, + OctagonX, + TriangleAlert, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { Toaster as Sonner, toast } from "sonner"; + +type ToasterProps = React.ComponentProps<typeof Sonner>; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + icons={{ + success: <CircleCheck className="h-4 w-4" />, + info: <Info className="h-4 w-4" />, + warning: <TriangleAlert className="h-4 w-4" />, + error: <OctagonX className="h-4 w-4" />, + loading: <LoaderCircle className="h-4 w-4 animate-spin" />, + }} + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ); +}; + +/** + * Compat layer for migrating from old toaster to sonner + * @deprecated Use sonner's natie toast instead + */ +const legacyToast = ({ + title, + description, + variant, +}: { + title?: React.ReactNode; + description?: React.ReactNode; + variant?: "destructive" | "default"; +}) => { + let toastTitle = title; + let toastDescription: React.ReactNode | undefined = description; + if (!title) { + toastTitle = description; + toastDescription = undefined; + } + if (variant === "destructive") { + toast.error(toastTitle, { description: toastDescription }); + } else { + toast(toastTitle, { description: toastDescription }); + } +}; + +export { Toaster, legacyToast as toast }; diff --git a/apps/web/components/ui/toaster.tsx b/apps/web/components/ui/toaster.tsx deleted file mode 100644 index 7d82ed55..00000000 --- a/apps/web/components/ui/toaster.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, -} from "@/components/ui/toast"; -import { useToast } from "@/components/ui/use-toast"; - -export function Toaster() { - const { toasts } = useToast(); - - return ( - <ToastProvider> - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - <Toast key={id} {...props}> - <div className="grid gap-1"> - {title && <ToastTitle>{title}</ToastTitle>} - {description && ( - <ToastDescription>{description}</ToastDescription> - )} - </div> - {action} - <ToastClose /> - </Toast> - ); - })} - <ToastViewport /> - </ToastProvider> - ); -} diff --git a/apps/web/components/ui/use-toast.ts b/apps/web/components/ui/use-toast.ts deleted file mode 100644 index c3e7e884..00000000 --- a/apps/web/components/ui/use-toast.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Inspired by react-hot-toast library -import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; -import * as React from "react"; - -const TOAST_LIMIT = 10; -const TOAST_REMOVE_DELAY = 1000000; - -type ToasterToast = ToastProps & { - id: string; - title?: React.ReactNode; - description?: React.ReactNode; - action?: ToastActionElement; -}; - -const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", -} as const; - -let count = 0; - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER; - return count.toString(); -} - -type ActionType = typeof actionTypes; - -type Action = - | { - type: ActionType["ADD_TOAST"]; - toast: ToasterToast; - } - | { - type: ActionType["UPDATE_TOAST"]; - toast: Partial<ToasterToast>; - } - | { - type: ActionType["DISMISS_TOAST"]; - toastId?: ToasterToast["id"]; - } - | { - type: ActionType["REMOVE_TOAST"]; - toastId?: ToasterToast["id"]; - }; - -interface State { - toasts: ToasterToast[]; -} - -const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return; - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId); - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }); - }, TOAST_REMOVE_DELAY); - - toastTimeouts.set(toastId, timeout); -}; - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - }; - - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t, - ), - }; - - case "DISMISS_TOAST": { - const { toastId } = action; - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId); - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id); - }); - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t, - ), - }; - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - }; - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - }; - } -}; - -const listeners: ((_state: State) => void)[] = []; - -let memoryState: State = { toasts: [] }; - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action); - listeners.forEach((listener) => { - listener(memoryState); - }); -} - -type Toast = Omit<ToasterToast, "id">; - -function toast({ ...props }: Toast) { - const id = genId(); - - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }); - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); - - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss(); - }, - }, - }); - - return { - id: id, - dismiss, - update, - }; -} - -function useToast() { - const [state, setState] = React.useState<State>(memoryState); - - React.useEffect(() => { - listeners.push(setState); - return () => { - const index = listeners.indexOf(setState); - if (index > -1) { - listeners.splice(index, 1); - } - }; - }, [state]); - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - }; -} - -export { useToast, toast }; diff --git a/apps/web/components/ui/user-avatar.tsx b/apps/web/components/ui/user-avatar.tsx new file mode 100644 index 00000000..4ebb6ec3 --- /dev/null +++ b/apps/web/components/ui/user-avatar.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useMemo } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; + +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; + +interface UserAvatarProps { + image?: string | null; + name?: string | null; + className?: string; + imgClassName?: string; + fallbackClassName?: string; + fallback?: React.ReactNode; +} + +const isExternalUrl = (value: string) => + value.startsWith("http://") || value.startsWith("https://"); + +export function UserAvatar({ + image, + name, + className, + imgClassName, + fallbackClassName, + fallback, +}: UserAvatarProps) { + const avatarUrl = useMemo(() => { + if (!image) { + return null; + } + return isExternalUrl(image) ? image : getAssetUrl(image); + }, [image]); + + const fallbackContent = fallback ?? name?.charAt(0) ?? "U"; + + return ( + <Avatar className={className}> + {avatarUrl && ( + <AvatarImage + src={avatarUrl} + alt={name ?? "User"} + className={cn("object-cover", imgClassName)} + /> + )} + <AvatarFallback className={cn("text-sm font-medium", fallbackClassName)}> + {fallbackContent} + </AvatarFallback> + </Avatar> + ); +} diff --git a/apps/web/components/utils/ValidAccountCheck.tsx b/apps/web/components/utils/ValidAccountCheck.tsx index 5ca5fd5c..54d27b34 100644 --- a/apps/web/components/utils/ValidAccountCheck.tsx +++ b/apps/web/components/utils/ValidAccountCheck.tsx @@ -2,22 +2,27 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { api } from "@/lib/trpc"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; /** * This component is used to address a confusion when the JWT token exists but the user no longer exists in the database. * So this component synchronusly checks if the user is still valid and if not, signs out the user. */ export default function ValidAccountCheck() { + const api = useTRPC(); const router = useRouter(); - const { error } = api.users.whoami.useQuery(undefined, { - retry: (_failureCount, error) => { - if (error.data?.code === "UNAUTHORIZED") { - return false; - } - return true; - }, - }); + const { error } = useQuery( + api.users.whoami.queryOptions(undefined, { + retry: (_failureCount, error) => { + if (error.data?.code === "UNAUTHORIZED") { + return false; + } + return true; + }, + }), + ); useEffect(() => { if (error?.data?.code === "UNAUTHORIZED") { router.push("/logout"); diff --git a/apps/web/components/wrapped/ShareButton.tsx b/apps/web/components/wrapped/ShareButton.tsx new file mode 100644 index 00000000..048cafea --- /dev/null +++ b/apps/web/components/wrapped/ShareButton.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { RefObject, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Download, Loader2, Share2 } from "lucide-react"; +import { domToPng } from "modern-screenshot"; + +interface ShareButtonProps { + contentRef: RefObject<HTMLDivElement | null>; + fileName?: string; +} + +export function ShareButton({ + contentRef, + fileName = "karakeep-wrapped-2025.png", +}: ShareButtonProps) { + const [isGenerating, setIsGenerating] = useState(false); + + const handleShare = async () => { + if (!contentRef.current) return; + + setIsGenerating(true); + + try { + // Capture the content as PNG data URL + const dataUrl = await domToPng(contentRef.current, { + scale: 2, // Higher resolution + quality: 1, + debug: false, + width: contentRef.current.scrollWidth, // Capture full width + height: contentRef.current.scrollHeight, // Capture full height including scrolled content + drawImageInterval: 100, // Add delay for rendering + }); + + // Convert data URL to blob + const response = await fetch(dataUrl); + const blob = await response.blob(); + + // Try native share API first (works well on mobile) + if ( + typeof navigator.share !== "undefined" && + typeof navigator.canShare !== "undefined" + ) { + const file = new File([blob], fileName, { type: "image/png" }); + if (navigator.canShare({ files: [file] })) { + await navigator.share({ + files: [file], + title: "My 2025 Karakeep Wrapped", + text: "Check out my 2025 Karakeep Wrapped!", + }); + return; + } + } + + // Fallback: download the image + const a = document.createElement("a"); + a.href = dataUrl; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (error) { + console.error("Failed to capture or share image:", error); + } finally { + setIsGenerating(false); + } + }; + + const isNativeShareAvailable = + typeof navigator.share !== "undefined" && + typeof navigator.canShare !== "undefined"; + + return ( + <Button + onClick={handleShare} + disabled={isGenerating} + size="icon" + variant="ghost" + className="h-10 w-10 rounded-full bg-white/10 text-slate-100 hover:bg-white/20" + aria-label={isNativeShareAvailable ? "Share" : "Download"} + title={isNativeShareAvailable ? "Share" : "Download"} + > + {isGenerating ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : isNativeShareAvailable ? ( + <Share2 className="h-4 w-4" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + ); +} diff --git a/apps/web/components/wrapped/WrappedContent.tsx b/apps/web/components/wrapped/WrappedContent.tsx new file mode 100644 index 00000000..261aadfd --- /dev/null +++ b/apps/web/components/wrapped/WrappedContent.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { forwardRef } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { + BookOpen, + Calendar, + Chrome, + Clock, + Code, + FileText, + Globe, + Hash, + Heart, + Highlighter, + Link, + Rss, + Smartphone, + Upload, + Zap, +} from "lucide-react"; +import { z } from "zod"; + +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; +import { zWrappedStatsResponseSchema } from "@karakeep/shared/types/users"; + +type WrappedStats = z.infer<typeof zWrappedStatsResponseSchema>; +type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>; + +interface WrappedContentProps { + stats: WrappedStats; + userName?: string; +} + +const dayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; +const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +function formatSourceName(source: BookmarkSource | null): string { + if (!source) return "Unknown"; + const sourceMap: Record<BookmarkSource, string> = { + api: "API", + web: "Web", + extension: "Browser Extension", + cli: "CLI", + mobile: "Mobile App", + singlefile: "SingleFile", + rss: "RSS Feed", + import: "Import", + }; + return sourceMap[source]; +} + +function getSourceIcon(source: BookmarkSource | null, className = "h-5 w-5") { + const iconProps = { className }; + switch (source) { + case "api": + return <Zap {...iconProps} />; + case "web": + return <Globe {...iconProps} />; + case "extension": + return <Chrome {...iconProps} />; + case "cli": + return <Code {...iconProps} />; + case "mobile": + return <Smartphone {...iconProps} />; + case "singlefile": + return <FileText {...iconProps} />; + case "rss": + return <Rss {...iconProps} />; + case "import": + return <Upload {...iconProps} />; + default: + return <Globe {...iconProps} />; + } +} + +export const WrappedContent = forwardRef<HTMLDivElement, WrappedContentProps>( + ({ stats, userName }, ref) => { + const maxMonthlyCount = Math.max( + ...stats.monthlyActivity.map((m) => m.count), + ); + + return ( + <div + ref={ref} + className="min-h-screen w-full overflow-auto bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)] p-6 text-slate-100 md:p-8" + > + <div className="mx-auto max-w-5xl space-y-4"> + {/* Header */} + <div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between"> + <div> + <h1 className="text-2xl font-semibold md:text-3xl"> + Your {stats.year} Wrapped + </h1> + <p className="mt-1 text-xs text-slate-300 md:text-sm"> + A Year in Karakeep + </p> + {userName && ( + <p className="mt-2 text-sm text-slate-400">{userName}</p> + )} + </div> + </div> + + <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> + <Card className="flex flex-col items-center justify-center border border-white/10 bg-white/5 p-4 text-center text-slate-100 backdrop-blur-sm"> + <p className="text-xs text-slate-300">You saved</p> + <p className="my-2 text-3xl font-semibold md:text-4xl"> + {stats.totalBookmarks} + </p> + <p className="text-xs text-slate-300"> + {stats.totalBookmarks === 1 ? "item" : "items"} this year + </p> + </Card> + {/* First Bookmark */} + {stats.firstBookmark && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <div className="flex h-full flex-col"> + <div className="mb-3 flex items-center gap-2"> + <Calendar className="h-4 w-4 flex-shrink-0 text-emerald-300" /> + <p className="text-[10px] uppercase tracking-wide text-slate-400"> + First Bookmark of {stats.year} + </p> + </div> + <div className="flex-1"> + <p className="text-2xl font-bold text-slate-100"> + {new Date( + stats.firstBookmark.createdAt, + ).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + })} + </p> + {stats.firstBookmark.title && ( + <p className="mt-2 line-clamp-2 text-base leading-relaxed text-slate-300"> + “{stats.firstBookmark.title}” + </p> + )} + </div> + </div> + </Card> + )} + + {/* Activity + Peak */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <h2 className="mb-2 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + <Clock className="h-4 w-4" /> + Activity Highlights + </h2> + <div className="grid gap-2 text-sm"> + {stats.mostActiveDay && ( + <div> + <p className="text-xs text-slate-400">Most Active Day</p> + <p className="text-base font-semibold"> + {new Date(stats.mostActiveDay.date).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + }, + )} + </p> + <p className="text-xs text-slate-400"> + {stats.mostActiveDay.count}{" "} + {stats.mostActiveDay.count === 1 ? "save" : "saves"} + </p> + </div> + )} + <div className="grid grid-cols-2 gap-2"> + <div> + <p className="text-xs text-slate-400">Peak Hour</p> + <p className="text-base font-semibold"> + {stats.peakHour === 0 + ? "12 AM" + : stats.peakHour < 12 + ? `${stats.peakHour} AM` + : stats.peakHour === 12 + ? "12 PM" + : `${stats.peakHour - 12} PM`} + </p> + </div> + <div> + <p className="text-xs text-slate-400">Peak Day</p> + <p className="text-base font-semibold"> + {dayNames[stats.peakDayOfWeek]} + </p> + </div> + </div> + </div> + </Card> + + {/* Top Lists */} + {(stats.topDomains.length > 0 || stats.topTags.length > 0) && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-2"> + <h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + Top Lists + </h2> + <div className="grid gap-3 md:grid-cols-2"> + {stats.topDomains.length > 0 && ( + <div> + <h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400"> + <Globe className="h-3.5 w-3.5" /> + Sites + </h3> + <div className="space-y-1.5 text-sm"> + {stats.topDomains.map((domain, index) => ( + <div + key={domain.domain} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold text-slate-200"> + {index + 1} + </div> + <span className="text-slate-100"> + {domain.domain} + </span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {domain.count} + </Badge> + </div> + ))} + </div> + </div> + )} + {stats.topTags.length > 0 && ( + <div> + <h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400"> + <Hash className="h-3.5 w-3.5" /> + Tags + </h3> + <div className="space-y-1.5 text-sm"> + {stats.topTags.map((tag, index) => ( + <div + key={tag.name} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-[10px] font-semibold text-slate-200"> + {index + 1} + </div> + <span className="text-slate-100">{tag.name}</span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {tag.count} + </Badge> + </div> + ))} + </div> + </div> + )} + </div> + </Card> + )} + + {/* Bookmarks by Source */} + {stats.bookmarksBySource.length > 0 && ( + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm"> + <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-300"> + How You Save + </h2> + <div className="space-y-1.5 text-sm"> + {stats.bookmarksBySource.map((source) => ( + <div + key={source.source || "unknown"} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2 text-slate-100"> + {getSourceIcon(source.source, "h-4 w-4")} + <span>{formatSourceName(source.source)}</span> + </div> + <Badge className="bg-white/10 text-[10px] text-slate-200"> + {source.count} + </Badge> + </div> + ))} + </div> + </Card> + )} + + {/* Monthly Activity */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-3"> + <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-300"> + <Calendar className="h-4 w-4" /> + Your Year in Saves + </h2> + <div className="grid gap-2 text-xs md:grid-cols-2 lg:grid-cols-3"> + {stats.monthlyActivity.map((month) => ( + <div key={month.month} className="flex items-center gap-2"> + <div className="w-7 text-right text-[10px] text-slate-400"> + {monthNames[month.month - 1]} + </div> + <div className="relative h-2 flex-1 overflow-hidden rounded-full bg-white/10"> + <div + className="h-full rounded-full bg-emerald-300/70" + style={{ + width: `${maxMonthlyCount > 0 ? (month.count / maxMonthlyCount) * 100 : 0}%`, + }} + /> + </div> + <div className="w-7 text-[10px] text-slate-300"> + {month.count} + </div> + </div> + ))} + </div> + </Card> + + {/* Summary Stats */} + <Card className="border border-white/10 bg-white/5 p-4 text-slate-100 backdrop-blur-sm md:col-span-2 lg:col-span-3"> + <div className="grid gap-3 text-center sm:grid-cols-3"> + <div className="rounded-lg bg-white/5 p-3"> + <Heart className="mx-auto mb-1 h-4 w-4 text-rose-200" /> + <p className="text-lg font-semibold"> + {stats.totalFavorites} + </p> + <p className="text-[10px] text-slate-400">Favorites</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <Hash className="mx-auto mb-1 h-4 w-4 text-amber-200" /> + <p className="text-lg font-semibold">{stats.totalTags}</p> + <p className="text-[10px] text-slate-400">Tags Created</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <Highlighter className="mx-auto mb-1 h-4 w-4 text-emerald-200" /> + <p className="text-lg font-semibold"> + {stats.totalHighlights} + </p> + <p className="text-[10px] text-slate-400">Highlights</p> + </div> + </div> + <div className="mt-3 grid gap-3 text-center sm:grid-cols-3"> + <div className="rounded-lg bg-white/5 p-3"> + <Link className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.link} + </p> + <p className="text-[10px] text-slate-400">Links</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <FileText className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.text} + </p> + <p className="text-[10px] text-slate-400">Notes</p> + </div> + <div className="rounded-lg bg-white/5 p-3"> + <BookOpen className="mx-auto mb-1 h-4 w-4 text-slate-200" /> + <p className="text-lg font-semibold"> + {stats.bookmarksByType.asset} + </p> + <p className="text-[10px] text-slate-400">Assets</p> + </div> + </div> + </Card> + </div> + + {/* Footer */} + <div className="pb-4 pt-1 text-center text-[10px] text-slate-500"> + Made with Karakeep + </div> + </div> + </div> + ); + }, +); + +WrappedContent.displayName = "WrappedContent"; diff --git a/apps/web/components/wrapped/WrappedModal.tsx b/apps/web/components/wrapped/WrappedModal.tsx new file mode 100644 index 00000000..b8bf3e25 --- /dev/null +++ b/apps/web/components/wrapped/WrappedModal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRef } from "react"; +import { + Dialog, + DialogContent, + DialogOverlay, + DialogTitle, +} from "@/components/ui/dialog"; +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2, X } from "lucide-react"; + +import { useTRPC } from "@karakeep/shared-react/trpc"; + +import { ShareButton } from "./ShareButton"; +import { WrappedContent } from "./WrappedContent"; + +interface WrappedModalProps { + open: boolean; + onClose: () => void; +} + +export function WrappedModal({ open, onClose }: WrappedModalProps) { + const api = useTRPC(); + const contentRef = useRef<HTMLDivElement | null>(null); + const { data: stats, isLoading } = useQuery( + api.users.wrapped.queryOptions(undefined, { + enabled: open, + }), + ); + const { data: whoami } = useQuery( + api.users.whoami.queryOptions(undefined, { + enabled: open, + }), + ); + + return ( + <Dialog open={open} onOpenChange={onClose}> + <DialogOverlay className="z-50" /> + <DialogContent + className="max-w-screen h-screen max-h-screen w-screen overflow-hidden rounded-none border-none p-0" + hideCloseBtn={true} + > + <VisuallyHidden.Root> + <DialogTitle>Your 2025 Wrapped</DialogTitle> + </VisuallyHidden.Root> + <div className="fixed right-4 top-4 z-50 flex items-center gap-2"> + {/* Share button overlay */} + {stats && !isLoading && <ShareButton contentRef={contentRef} />} + {/* Close button overlay */} + <button + onClick={onClose} + className="rounded-full bg-white/10 p-2 backdrop-blur-sm transition-colors hover:bg-white/20" + aria-label="Close" + title="Close" + > + <X className="h-5 w-5 text-white" /> + </button> + </div> + + {/* Content */} + {isLoading ? ( + <div className="flex h-full items-center justify-center bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)]"> + <div className="text-center text-white"> + <Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin" /> + <p className="text-xl">Loading your Wrapped...</p> + </div> + </div> + ) : stats ? ( + <WrappedContent + ref={contentRef} + stats={stats} + userName={whoami?.name || undefined} + /> + ) : ( + <div className="flex h-full items-center justify-center bg-slate-950 bg-[radial-gradient(1200px_600px_at_20%_-10%,rgba(16,185,129,0.18),transparent),radial-gradient(900px_500px_at_90%_10%,rgba(14,116,144,0.2),transparent)]"> + <div className="text-center text-white"> + <p className="text-xl">Failed to load your Wrapped stats</p> + <button + onClick={onClose} + className="mt-4 rounded-lg bg-white/20 px-6 py-2 backdrop-blur-sm hover:bg-white/30" + > + Close + </button> + </div> + </div> + )} + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/components/wrapped/index.ts b/apps/web/components/wrapped/index.ts new file mode 100644 index 00000000..45d142e1 --- /dev/null +++ b/apps/web/components/wrapped/index.ts @@ -0,0 +1,3 @@ +export { WrappedModal } from "./WrappedModal"; +export { WrappedContent } from "./WrappedContent"; +export { ShareButton } from "./ShareButton"; diff --git a/apps/web/instrumentation.node.ts b/apps/web/instrumentation.node.ts new file mode 100644 index 00000000..2f4c1d58 --- /dev/null +++ b/apps/web/instrumentation.node.ts @@ -0,0 +1,3 @@ +import { initTracing } from "@karakeep/shared-server"; + +initTracing("web"); diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts new file mode 100644 index 00000000..41630756 --- /dev/null +++ b/apps/web/instrumentation.ts @@ -0,0 +1,5 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./instrumentation.node"); + } +} diff --git a/apps/web/lib/attachments.tsx b/apps/web/lib/attachments.tsx index 67941098..5d7175ec 100644 --- a/apps/web/lib/attachments.tsx +++ b/apps/web/lib/attachments.tsx @@ -2,8 +2,10 @@ import { Archive, Camera, FileCode, + FileText, Image, Paperclip, + SquareUser, Upload, Video, } from "lucide-react"; @@ -12,6 +14,7 @@ import { ZAssetType } from "@karakeep/shared/types/bookmarks"; export const ASSET_TYPE_TO_ICON: Record<ZAssetType, React.ReactNode> = { screenshot: <Camera className="size-4" />, + pdf: <FileText className="size-4" />, assetScreenshot: <Camera className="size-4" />, fullPageArchive: <Archive className="size-4" />, precrawledArchive: <Archive className="size-4" />, @@ -20,5 +23,6 @@ export const ASSET_TYPE_TO_ICON: Record<ZAssetType, React.ReactNode> = { bookmarkAsset: <Paperclip className="size-4" />, linkHtmlContent: <FileCode className="size-4" />, userUploaded: <Upload className="size-4" />, + avatar: <SquareUser className="size-4" />, unknown: <Paperclip className="size-4" />, }; diff --git a/apps/web/lib/auth/client.ts b/apps/web/lib/auth/client.ts new file mode 100644 index 00000000..7e13f798 --- /dev/null +++ b/apps/web/lib/auth/client.ts @@ -0,0 +1,11 @@ +"use client"; + +/** + * Centralized client-side auth utilities. + * This module re-exports next-auth/react functions to allow for easier + * future migration to a different auth provider. + */ + +export { SessionProvider, signIn, signOut, useSession } from "next-auth/react"; + +export type { Session } from "next-auth"; diff --git a/apps/web/lib/bookmark-drag.ts b/apps/web/lib/bookmark-drag.ts new file mode 100644 index 00000000..8ae4a499 --- /dev/null +++ b/apps/web/lib/bookmark-drag.ts @@ -0,0 +1,5 @@ +/** + * MIME type used in HTML5 drag-and-drop dataTransfer to identify + * bookmark card drags (as opposed to file drops). + */ +export const BOOKMARK_DRAG_MIME = "application/x-karakeep-bookmark"; diff --git a/apps/web/lib/bulkActions.ts b/apps/web/lib/bulkActions.ts index 34a236c6..ef814331 100644 --- a/apps/web/lib/bulkActions.ts +++ b/apps/web/lib/bulkActions.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { ZBookmarkList } from "@karakeep/shared/types/lists"; interface BookmarkState { selectedBookmarks: ZBookmark[]; @@ -13,12 +14,15 @@ interface BookmarkState { selectAll: () => void; unSelectAll: () => void; isEverythingSelected: () => boolean; + setListContext: (listContext: ZBookmarkList | undefined) => void; + listContext: ZBookmarkList | undefined; } const useBulkActionsStore = create<BookmarkState>((set, get) => ({ selectedBookmarks: [], visibleBookmarks: [], isBulkEditEnabled: false, + listContext: undefined, toggleBookmark: (bookmark: ZBookmark) => { const selectedBookmarks = get().selectedBookmarks; @@ -57,6 +61,9 @@ const useBulkActionsStore = create<BookmarkState>((set, get) => ({ visibleBookmarks, }); }, + setListContext: (listContext: ZBookmarkList | undefined) => { + set({ listContext }); + }, })); export default useBulkActionsStore; diff --git a/apps/web/lib/clientConfig.tsx b/apps/web/lib/clientConfig.tsx index 9331a7af..ab367be0 100644 --- a/apps/web/lib/clientConfig.tsx +++ b/apps/web/lib/clientConfig.tsx @@ -14,6 +14,8 @@ export const ClientConfigCtx = createContext<ClientConfig>({ inference: { isConfigured: false, inferredTagLang: "english", + enableAutoTagging: false, + enableAutoSummarization: false, }, serverVersion: undefined, disableNewReleaseCheck: true, diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts index f94e4691..32882006 100644 --- a/apps/web/lib/hooks/bookmark-search.ts +++ b/apps/web/lib/hooks/bookmark-search.ts @@ -1,9 +1,9 @@ import { useEffect, useMemo, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useSortOrderStore } from "@/lib/store/useSortOrderStore"; -import { api } from "@/lib/trpc"; -import { keepPreviousData } from "@tanstack/react-query"; +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { parseSearchQuery } from "@karakeep/shared/searchQueryParser"; import { useInSearchPageStore } from "../store/useInSearchPageStore"; @@ -55,6 +55,7 @@ export function useDoBookmarkSearch() { } export function useBookmarkSearch() { + const api = useTRPC(); const { searchQuery } = useSearchQuery(); const sortOrder = useSortOrderStore((state) => state.sortOrder); @@ -67,17 +68,19 @@ export function useBookmarkSearch() { fetchNextPage, isFetchingNextPage, refetch, - } = api.bookmarks.searchBookmarks.useInfiniteQuery( - { - text: searchQuery, - sortOrder, - }, - { - placeholderData: keepPreviousData, - gcTime: 0, - initialCursor: null, - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, + } = useInfiniteQuery( + api.bookmarks.searchBookmarks.infiniteQueryOptions( + { + text: searchQuery, + sortOrder, + }, + { + placeholderData: keepPreviousData, + gcTime: 0, + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ), ); useEffect(() => { diff --git a/apps/web/lib/hooks/relative-time.ts b/apps/web/lib/hooks/relative-time.ts index f7c38497..8fefa233 100644 --- a/apps/web/lib/hooks/relative-time.ts +++ b/apps/web/lib/hooks/relative-time.ts @@ -1,8 +1,5 @@ import { useEffect, useState } from "react"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; - -dayjs.extend(relativeTime); +import { formatDistanceToNow } from "date-fns"; export default function useRelativeTime(date: Date) { const [state, setState] = useState({ @@ -13,7 +10,7 @@ export default function useRelativeTime(date: Date) { // This is to avoid hydration errors when server and clients are in different timezones useEffect(() => { setState({ - fromNow: dayjs(date).fromNow(), + fromNow: formatDistanceToNow(date, { addSuffix: true }), localCreatedAt: date.toLocaleString(), }); }, [date]); diff --git a/apps/web/lib/hooks/useBookmarkImport.ts b/apps/web/lib/hooks/useBookmarkImport.ts index 0d9bbaaf..35c04c1b 100644 --- a/apps/web/lib/hooks/useBookmarkImport.ts +++ b/apps/web/lib/hooks/useBookmarkImport.ts @@ -1,29 +1,17 @@ "use client"; import { useState } from "react"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; import { useTranslation } from "@/lib/i18n/client"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - useCreateBookmarkWithPostHook, - useUpdateBookmarkTags, -} from "@karakeep/shared-react/hooks/bookmarks"; -import { - useAddBookmarkToList, - useCreateBookmarkList, -} from "@karakeep/shared-react/hooks/lists"; -import { api } from "@karakeep/shared-react/trpc"; +import { useCreateBookmarkList } from "@karakeep/shared-react/hooks/lists"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { importBookmarksFromFile, ImportSource, - ParsedBookmark, parseImportFile, } from "@karakeep/shared/import-export"; -import { - BookmarkTypes, - MAX_BOOKMARK_TITLE_LENGTH, -} from "@karakeep/shared/types/bookmarks"; import { useCreateImportSession } from "./useImportSessions"; @@ -34,18 +22,22 @@ export interface ImportProgress { export function useBookmarkImport() { const { t } = useTranslation(); + const api = useTRPC(); const [importProgress, setImportProgress] = useState<ImportProgress | null>( null, ); const [quotaError, setQuotaError] = useState<string | null>(null); - const apiUtils = api.useUtils(); + const queryClient = useQueryClient(); const { mutateAsync: createImportSession } = useCreateImportSession(); - const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook(); const { mutateAsync: createList } = useCreateBookmarkList(); - const { mutateAsync: addToList } = useAddBookmarkToList(); - const { mutateAsync: updateTags } = useUpdateBookmarkTags(); + const { mutateAsync: stageImportedBookmarks } = useMutation( + api.importSessions.stageImportedBookmarks.mutationOptions(), + ); + const { mutateAsync: finalizeImportStaging } = useMutation( + api.importSessions.finalizeImportStaging.mutationOptions(), + ); const uploadBookmarkFileMutation = useMutation({ mutationFn: async ({ @@ -65,8 +57,9 @@ export function useBookmarkImport() { // Check quota before proceeding if (bookmarkCount > 0) { - const quotaUsage = - await apiUtils.client.subscriptions.getQuotaUsage.query(); + const quotaUsage = await queryClient.fetchQuery( + api.subscriptions.getQuotaUsage.queryOptions(), + ); if ( !quotaUsage.bookmarks.unlimited && @@ -84,7 +77,6 @@ export function useBookmarkImport() { } // Proceed with import if quota check passes - // Use a custom parser to avoid re-parsing the file const result = await importBookmarksFromFile( { file, @@ -93,65 +85,9 @@ export function useBookmarkImport() { deps: { createImportSession, createList, - createBookmark: async ( - bookmark: ParsedBookmark, - sessionId: string, - ) => { - if (bookmark.content === undefined) { - throw new Error("Content is undefined"); - } - const created = await createBookmark({ - crawlPriority: "low", - title: bookmark.title.substring(0, MAX_BOOKMARK_TITLE_LENGTH), - createdAt: bookmark.addDate - ? new Date(bookmark.addDate * 1000) - : undefined, - note: bookmark.notes, - archived: bookmark.archived, - importSessionId: sessionId, - source: "import", - ...(bookmark.content.type === BookmarkTypes.LINK - ? { - type: BookmarkTypes.LINK, - url: bookmark.content.url, - } - : { - type: BookmarkTypes.TEXT, - text: bookmark.content.text, - }), - }); - return created as { id: string; alreadyExists?: boolean }; - }, - addBookmarkToLists: async ({ - bookmarkId, - listIds, - }: { - bookmarkId: string; - listIds: string[]; - }) => { - await Promise.all( - listIds.map((listId) => - addToList({ - bookmarkId, - listId, - }), - ), - ); - }, - updateBookmarkTags: async ({ - bookmarkId, - tags, - }: { - bookmarkId: string; - tags: string[]; - }) => { - if (tags.length > 0) { - await updateTags({ - bookmarkId, - attach: tags.map((t) => ({ tagName: t })), - detach: [], - }); - } + stageImportedBookmarks, + finalizeImportStaging: async (sessionId: string) => { + await finalizeImportStaging({ importSessionId: sessionId }); }, }, onProgress: (done, total) => setImportProgress({ done, total }), @@ -172,19 +108,11 @@ export function useBookmarkImport() { toast({ description: "No bookmarks found in the file." }); return; } - const { successes, failures, alreadyExisted } = result.counts; - if (successes > 0 || alreadyExisted > 0) { - toast({ - description: `Imported ${successes} bookmarks into import session. Background processing will start automatically.`, - variant: "default", - }); - } - if (failures > 0) { - toast({ - description: `Failed to import ${failures} bookmarks. Check console for details.`, - variant: "destructive", - }); - } + + toast({ + description: `Staged ${result.counts.total} bookmarks for import. Background processing will start automatically.`, + variant: "default", + }); }, onError: (error) => { setImportProgress(null); diff --git a/apps/web/lib/hooks/useImportSessions.ts b/apps/web/lib/hooks/useImportSessions.ts index cee99bbc..2cc632ad 100644 --- a/apps/web/lib/hooks/useImportSessions.ts +++ b/apps/web/lib/hooks/useImportSessions.ts @@ -1,62 +1,151 @@ "use client"; -import { toast } from "@/components/ui/use-toast"; +import { toast } from "@/components/ui/sonner"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; -import { api } from "@karakeep/shared-react/trpc"; +import { useTRPC } from "@karakeep/shared-react/trpc"; export function useCreateImportSession() { - const apiUtils = api.useUtils(); + const api = useTRPC(); + const queryClient = useQueryClient(); - return api.importSessions.createImportSession.useMutation({ - onSuccess: () => { - apiUtils.importSessions.listImportSessions.invalidate(); - }, - onError: (error) => { - toast({ - description: error.message || "Failed to create import session", - variant: "destructive", - }); - }, - }); + return useMutation( + api.importSessions.createImportSession.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries( + api.importSessions.listImportSessions.pathFilter(), + ); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to create import session", + variant: "destructive", + }); + }, + }), + ); } export function useListImportSessions() { - return api.importSessions.listImportSessions.useQuery( - {}, - { - select: (data) => data.sessions, - }, + const api = useTRPC(); + return useQuery( + api.importSessions.listImportSessions.queryOptions( + {}, + { + select: (data) => data.sessions, + }, + ), ); } export function useImportSessionStats(importSessionId: string) { - return api.importSessions.getImportSessionStats.useQuery( - { - importSessionId, - }, - { - refetchInterval: 5000, // Refetch every 5 seconds to show progress - enabled: !!importSessionId, - }, + const api = useTRPC(); + return useQuery( + api.importSessions.getImportSessionStats.queryOptions( + { + importSessionId, + }, + { + refetchInterval: (q) => + !q.state.data || + !["completed", "failed"].includes(q.state.data.status) + ? 5000 + : false, // Refetch every 5 seconds to show progress + enabled: !!importSessionId, + }, + ), ); } export function useDeleteImportSession() { - const apiUtils = api.useUtils(); + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.importSessions.deleteImportSession.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries( + api.importSessions.listImportSessions.pathFilter(), + ); + toast({ + description: "Import session deleted successfully", + variant: "default", + }); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to delete import session", + variant: "destructive", + }); + }, + }), + ); +} - return api.importSessions.deleteImportSession.useMutation({ - onSuccess: () => { - apiUtils.importSessions.listImportSessions.invalidate(); - toast({ - description: "Import session deleted successfully", - variant: "default", - }); - }, - onError: (error) => { - toast({ - description: error.message || "Failed to delete import session", - variant: "destructive", - }); - }, - }); +export function usePauseImportSession() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.importSessions.pauseImportSession.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries( + api.importSessions.listImportSessions.pathFilter(), + ); + toast({ + description: "Import session paused", + variant: "default", + }); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to pause import session", + variant: "destructive", + }); + }, + }), + ); +} + +export function useResumeImportSession() { + const api = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + api.importSessions.resumeImportSession.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries( + api.importSessions.listImportSessions.pathFilter(), + ); + toast({ + description: "Import session resumed", + variant: "default", + }); + }, + onError: (error) => { + toast({ + description: error.message || "Failed to resume import session", + variant: "destructive", + }); + }, + }), + ); +} + +export function useImportSessionResults( + importSessionId: string, + filter: "all" | "accepted" | "rejected" | "skipped_duplicate" | "pending", +) { + const api = useTRPC(); + return useInfiniteQuery( + api.importSessions.getImportSessionResults.infiniteQueryOptions( + { importSessionId, filter, limit: 50 }, + { getNextPageParam: (lastPage) => lastPage.nextCursor }, + ), + ); } diff --git a/apps/web/lib/i18n/client.ts b/apps/web/lib/i18n/client.ts index 1c56a88a..0704ce87 100644 --- a/apps/web/lib/i18n/client.ts +++ b/apps/web/lib/i18n/client.ts @@ -4,6 +4,7 @@ import i18next from "i18next"; import resourcesToBackend from "i18next-resources-to-backend"; import { initReactI18next, + Trans as TransOrg, useTranslation as useTranslationOrg, } from "react-i18next"; @@ -30,4 +31,5 @@ i18next }); export const useTranslation = useTranslationOrg; +export const Trans = TransOrg; export const i18n = i18next; diff --git a/apps/web/lib/i18n/locales/ar/translation.json b/apps/web/lib/i18n/locales/ar/translation.json index 023d6f15..e2d9eb7e 100644 --- a/apps/web/lib/i18n/locales/ar/translation.json +++ b/apps/web/lib/i18n/locales/ar/translation.json @@ -39,7 +39,9 @@ "updated_at": "تم التحديث في", "quota": "حصة", "bookmarks": "الإشارات المرجعية", - "storage": "تخزين" + "storage": "تخزين", + "pdf": "نسخة PDF مؤرشفة", + "default": "افتراضي" }, "layouts": { "masonry": "متعدد الأعمدة", @@ -90,7 +92,9 @@ "confirm": "تأكيد", "regenerate": "تجديد", "load_more": "المزيد", - "edit_notes": "تحرير الملاحظات" + "edit_notes": "تحرير الملاحظات", + "preserve_as_pdf": "حفظ كملف PDF", + "offline_copies": "نسخ غير متصلة بالإنترنت" }, "highlights": { "no_highlights": "ليس لديك أي تمييزات بعد." @@ -119,6 +123,49 @@ "show": "اعرض الإشارات المرجعية المؤرشفة في العلامات والقوائم", "hide": "إخفاء الإشارات المرجعية المؤرشفة في العلامات والقوائم" } + }, + "reader_settings": { + "local_overrides_title": "إعدادات خاصة بالجهاز مُفعلة", + "using_default": "استخدام الإعدادات الافتراضية للعميل", + "clear_override_hint": "امسح تجاوز الجهاز لاستخدام الإعداد العام ({{value}})", + "font_size": "حجم الخط", + "font_family": "نوع الخط", + "preview_inline": "(معاينة)", + "tooltip_preview": "تغييرات المعاينة غير المحفوظة", + "save_to_all_devices": "كل الأجهزة", + "tooltip_local": "إعدادات الجهاز تختلف عن الإعدادات العامة", + "reset_preview": "إعادة ضبط المعاينة", + "mono": "Monospace", + "line_height": "ارتفاع السطر", + "tooltip_default": "إعدادات القراءة", + "title": "إعدادات القارئ", + "serif": "Serif", + "preview": "معاينة", + "not_set": "غير مضبوط", + "clear_local_overrides": "مسح إعدادات الجهاز", + "preview_text": "الـ quick brown fox jumps over the lazy dog. ستظهر نصوص عرض القارئ بهذه الطريقة.", + "local_overrides_cleared": "تم مسح إعدادات الجهاز المخصصة", + "local_overrides_description": "يحتوي هذا الجهاز على إعدادات قارئ مختلفة عن الإعدادات الافتراضية العامة:", + "clear_defaults": "مسح كل الإعدادات الافتراضية", + "description": "اضبط إعدادات النص الافتراضية لعرض القارئ. تتم مزامنة هذه الإعدادات عبر جميع أجهزتك.", + "defaults_cleared": "تم مسح الإعدادات الافتراضية للقارئ", + "save_hint": "احفظ الإعدادات لهذا الجهاز فقط أو قم بمزامنتها عبر جميع الأجهزة", + "save_as_default": "حفظ كافتراضي", + "save_to_device": "هذا الجهاز", + "sans": "Sans Serif", + "tooltip_preview_and_local": "تغييرات المعاينة غير المحفوظة؛ إعدادات الجهاز تختلف عن الإعدادات العامة", + "adjust_hint": "اضبط الإعدادات أعلاه لمعاينة التغييرات" + }, + "avatar": { + "upload": "ارفع الصورة الرمزية", + "change": "غير الصورة الرمزية", + "remove_confirm_title": "تشيل الصورة الرمزية؟", + "updated": "تم تحديث الصورة الرمزية", + "removed": "تمت إزالة الصورة الرمزية", + "description": "ارفع صورة مربعة عشان تستخدمها كصورة رمزية.", + "remove_confirm_description": "ده هيمسح صورة ملفك الشخصي الحالية.", + "title": "صورة الملف الشخصي", + "remove": "شيل الصورة الرمزية" } }, "ai": { @@ -132,7 +179,21 @@ "all_tagging": "التوسيم الشامل", "text_tagging": "توسيم النصوص", "image_tagging": "توسيم الصور", - "summarization": "التلخيص" + "summarization": "التلخيص", + "tag_style": "نمط العلامة", + "auto_summarization_description": "إنشاء ملخصات تلقائيًا لعلاماتك المرجعية باستخدام الذكاء الاصطناعي.", + "auto_tagging": "وضع العلامات التلقائي", + "titlecase_spaces": "أحرف استهلالية مع مسافات", + "lowercase_underscores": "أحرف صغيرة مع شرطات سفلية", + "inference_language": "لُغة الاستنتاج", + "titlecase_hyphens": "أحرف استهلالية مع واصلات", + "lowercase_hyphens": "أحرف صغيرة مع واصلات", + "lowercase_spaces": "أحرف صغيرة مع مسافات", + "inference_language_description": "اختر اللغة الخاصة بالعلامات والملخصات التي تم إنشاؤها بواسطة الذكاء الاصطناعي.", + "tag_style_description": "اختر كيف ينبغي تنسيق علاماتك التي تم إنشاؤها تلقائيًا.", + "auto_tagging_description": "إنشاء علامات تلقائيًا لعلاماتك المرجعية باستخدام الذكاء الاصطناعي.", + "camelCase": "camelCase", + "auto_summarization": "التلخيص التلقائي" }, "feeds": { "rss_subscriptions": "اشتراكات RSS", @@ -163,6 +224,7 @@ "import_export_bookmarks": "استيراد / تصدير الإشارات المرجعية", "import_bookmarks_from_html_file": "استيراد إشارات مرجعية من ملف HTML", "import_bookmarks_from_pocket_export": "استيراد إشارات مرجعية من تصدير Pocket", + "import_bookmarks_from_matter_export": "استيراد إشارات مرجعية من تصدير Matter", "import_bookmarks_from_omnivore_export": "استيراد إشارات مرجعية من تصدير Omnivore", "import_bookmarks_from_linkwarden_export": "استيراد إشارات مرجعية من تصدير Linkwarden", "import_bookmarks_from_karakeep_export": "استيراد إشارات مرجعية من تصدير Karakeep", @@ -680,7 +742,14 @@ "week_s_ago": " منذ أسبوع (أسابيع)", "history": "عمليات البحث الأخيرة", "title_contains": "العنوان يحتوي على", - "title_does_not_contain": "العنوان لا يحتوي على" + "title_does_not_contain": "العنوان لا يحتوي على", + "is_broken_link": "لديه رابط معطّل", + "tags": "العلامات", + "no_suggestions": "لا توجد اقتراحات", + "filters": "الفلاتر", + "is_not_broken_link": "لديه رابط صالح", + "lists": "القوائم", + "feeds": "خلاصات الأخبار" }, "preview": { "view_original": "عرض النسخة الأصلية", @@ -689,7 +758,8 @@ "tabs": { "content": "المحتوى", "details": "التفاصيل" - } + }, + "archive_info": "قد لا يتم عرض الأرشيفات بشكل صحيح في السطر إذا كانت تتطلب Javascript. للحصول على أفضل النتائج، <1>قم بتنزيلها وافتحها في متصفحك</1>." }, "editor": { "quickly_focus": "يمكنك التركيز سريعاً على هذا الحقل بالضغط على ⌘ + E", @@ -763,7 +833,8 @@ "refetch": "تم إضافة إعادة الجلب إلى قائمة الانتظار!", "full_page_archive": "تم بدء إنشاء أرشيف الصفحة الكامل", "delete_from_list": "تم حذف الإشارة المرجعية من القائمة", - "clipboard_copied": "تم نسخ الرابط إلى الحافظة!" + "clipboard_copied": "تم نسخ الرابط إلى الحافظة!", + "preserve_pdf": "تم تشغيل حفظ PDF" }, "lists": { "created": "تم إنشاء القائمة!", diff --git a/apps/web/lib/i18n/locales/cs/translation.json b/apps/web/lib/i18n/locales/cs/translation.json index b0df5dab..f13b2100 100644 --- a/apps/web/lib/i18n/locales/cs/translation.json +++ b/apps/web/lib/i18n/locales/cs/translation.json @@ -39,7 +39,9 @@ }, "quota": "Kvóta", "bookmarks": "Záložky", - "storage": "Úložiště" + "storage": "Úložiště", + "pdf": "Archivovaný PDF", + "default": "Výchozí" }, "actions": { "close": "Zavřít", @@ -84,7 +86,9 @@ "confirm": "Potvrdit", "regenerate": "Regenerovat", "load_more": "Načíst další", - "edit_notes": "Upravit poznámky" + "edit_notes": "Upravit poznámky", + "preserve_as_pdf": "Uložit jako PDF", + "offline_copies": "Offline kopie" }, "settings": { "ai": { @@ -98,7 +102,21 @@ "all_tagging": "Všechny štítky", "text_tagging": "Označování textu", "image_tagging": "Označování obrázků", - "summarization": "Shrnutí" + "summarization": "Shrnutí", + "tag_style": "Styl štítků", + "auto_summarization_description": "Automaticky generovat shrnutí pro tvoje záložky pomocí umělý inteligence.", + "auto_tagging": "Automatický štítkování", + "titlecase_spaces": "Velká písmena s mezerami", + "lowercase_underscores": "Malá písmena s podtržítky", + "inference_language": "Jazyk pro odvozování", + "titlecase_hyphens": "Velká písmena s pomlčkami", + "lowercase_hyphens": "Malá písmena s pomlčkami", + "lowercase_spaces": "Malá písmena s mezerami", + "inference_language_description": "Vyber jazyk pro štítky a souhrny generované AI.", + "tag_style_description": "Vyber si, jakým způsobem se mají automaticky generované štítky formátovat.", + "auto_tagging_description": "Automaticky generovat štítky pro tvoje záložky pomocí umělý inteligence.", + "camelCase": "camelCase", + "auto_summarization": "Automatický shrnutí" }, "webhooks": { "webhooks": "Webhooky", @@ -210,7 +228,50 @@ "new_password": "Nový heslo", "confirm_new_password": "Potvrďte nový heslo", "options": "Možnosti", - "interface_lang": "Jazyk rozhraní" + "interface_lang": "Jazyk rozhraní", + "reader_settings": { + "local_overrides_title": "Aktivní nastavení specifická pro zařízení", + "using_default": "Používám výchozí nastavení klienta", + "clear_override_hint": "Vymažte přepsání zařízení, abyste použili globální nastavení ({{value}})", + "font_size": "Velikost písma", + "font_family": "Rodina písem", + "preview_inline": "(náhled)", + "tooltip_preview": "Neuložené změny náhledu", + "save_to_all_devices": "Všechna zařízení", + "tooltip_local": "Nastavení zařízení se liší od globálních", + "reset_preview": "Obnovit náhled", + "mono": "Neproporcionální", + "line_height": "Výška řádku", + "tooltip_default": "Nastavení čtení", + "title": "Nastavení čtečky", + "serif": "Patkové", + "preview": "Náhled", + "not_set": "Nenastaveno", + "clear_local_overrides": "Vymazat nastavení zařízení", + "preview_text": "Příliš žluťoučký kůň úpěl ďábelské ódy. Takto bude vypadat text v zobrazení čtečky.", + "local_overrides_cleared": "Nastavení specifická pro zařízení byla vymazána", + "local_overrides_description": "Toto zařízení má nastavení čtečky, která se liší od výchozích:", + "clear_defaults": "Smazat všechna výchozí nastavení", + "description": "Nastav výchozí nastavení textu pro zobrazení v čtečce. Tato nastavení se synchronizují na všech tvých zařízeních.", + "defaults_cleared": "Výchozí nastavení čtečky byla vymazána", + "save_hint": "Uložit nastavení jen pro toto zařízení, nebo synchronizovat na všech zařízeních", + "save_as_default": "Uložit jako výchozí", + "save_to_device": "Toto zařízení", + "sans": "Bezpatkové", + "tooltip_preview_and_local": "Neuložené změny náhledu; nastavení zařízení se liší od globálních", + "adjust_hint": "Upravte nastavení výše, abyste si prohlédli změny" + }, + "avatar": { + "upload": "Nahrát avatara", + "change": "Změnit avatara", + "remove_confirm_title": "Odebrat avatara?", + "updated": "Avatar aktualizován", + "removed": "Avatar byl odebrán", + "description": "Nahrajte čtvercový obrázek, který se použije jako váš avatar.", + "remove_confirm_description": "Tímto vymažete vaši aktuální profilovou fotku.", + "title": "Profilová fotka", + "remove": "Odebrat avatara" + } }, "feeds": { "rss_subscriptions": "RSS odběry", @@ -223,6 +284,7 @@ "import_export_bookmarks": "Import / Export záložek", "import_bookmarks_from_html_file": "Importovat záložky z HTML souboru", "import_bookmarks_from_pocket_export": "Importovat záložky z exportu Pocket", + "import_bookmarks_from_matter_export": "Importovat záložky z exportu Matter", "import_bookmarks_from_omnivore_export": "Importovat záložky z Omnivore exportu", "import_bookmarks_from_linkwarden_export": "Importovat záložky z exportu Linkwarden", "import_bookmarks_from_karakeep_export": "Importovat záložky z exportu Karakeep", @@ -537,7 +599,14 @@ "or": "Nebo", "history": "Poslední hledání", "title_contains": "Název obsahuje", - "title_does_not_contain": "Název neobsahuje" + "title_does_not_contain": "Název neobsahuje", + "is_broken_link": "Má nefunkční odkaz", + "tags": "Štítky", + "no_suggestions": "Žádné návrhy", + "filters": "Filtry", + "is_not_broken_link": "Má funkční odkaz", + "lists": "Seznamy", + "feeds": "Kanály" }, "editor": { "disabled_submissions": "Odesílání příspěvků je zakázáno", @@ -605,7 +674,8 @@ "refetch": "Opětovné načtení bylo zařazeno do fronty!", "full_page_archive": "Vytváření archivu celé stránky bylo spuštěno", "delete_from_list": "Záložka byla ze seznamu smazána", - "clipboard_copied": "Odkaz byl přidán do schránky!" + "clipboard_copied": "Odkaz byl přidán do schránky!", + "preserve_pdf": "Ukládání do PDF spuštěno" }, "lists": { "created": "Seznam byl vytvořen!", @@ -778,7 +848,8 @@ "tabs": { "content": "Obsah", "details": "Podrobnosti" - } + }, + "archive_info": "Archivy se nemusí vykreslovat správně inline, pokud vyžadují Javascript. Pro nejlepší výsledky si <1>stáhněte a otevřete v prohlížeči</1>." }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/da/translation.json b/apps/web/lib/i18n/locales/da/translation.json index 0026b4d3..be382f86 100644 --- a/apps/web/lib/i18n/locales/da/translation.json +++ b/apps/web/lib/i18n/locales/da/translation.json @@ -42,7 +42,9 @@ "confirm": "Bekræft", "regenerate": "Regenerér", "load_more": "Indlæs mere", - "edit_notes": "Rediger noter" + "edit_notes": "Rediger noter", + "preserve_as_pdf": "Bevar som PDF", + "offline_copies": "Offline kopier" }, "settings": { "import": { @@ -53,6 +55,7 @@ "import_export_bookmarks": "Import / eksport bogmærker", "import_bookmarks_from_html_file": "Importer bogmærker fra HTML-fil", "import_bookmarks_from_pocket_export": "Importer bogmærker fra Pocket-eksport", + "import_bookmarks_from_matter_export": "Importer bogmærker fra Matter-eksport", "imported_bookmarks": "Importerede bogmærker", "import_bookmarks_from_linkwarden_export": "Importer bogmærker fra Linkwarden-eksport", "import_bookmarks_from_tab_session_manager_export": "Importer bogmærker fra Tab Session Manager", @@ -80,6 +83,49 @@ "show": "Vis arkiverede bogmærker i tags og lister", "hide": "Skjul arkiverede bogmærker i tags og lister" } + }, + "reader_settings": { + "local_overrides_title": "Apparatspecifikke indstillinger er aktive", + "using_default": "Bruger klientstandard", + "clear_override_hint": "Ryd tilsidesættelsen af enheden for at bruge den globale indstilling ({{value}})", + "font_size": "Skriftstørrelse", + "font_family": "Skrifttype", + "preview_inline": "(forhåndsvisning)", + "tooltip_preview": "Ikke-gemte ændringer i forhåndsvisning", + "save_to_all_devices": "Alle enheder", + "tooltip_local": "Enhedsindstillinger adskiller sig fra globale", + "reset_preview": "Nulstil forhåndsvisning", + "mono": "Monospace", + "line_height": "Linjehøjde", + "tooltip_default": "Læseindstillinger", + "title": "Læserindstillinger", + "serif": "Serif", + "preview": "Forhåndsvisning", + "not_set": "Ikke angivet", + "clear_local_overrides": "Ryd enhedsindstillinger", + "preview_text": "\"The quick brown fox jumps over the lazy dog.\" Sådan vises din tekst i læsevisning.", + "local_overrides_cleared": "Apparatspecifikke indstillinger er blevet ryddet", + "local_overrides_description": "Denne enhed har læserindstillinger, der afviger fra dine globale standardindstillinger:", + "clear_defaults": "Ryd alle standarder", + "description": "Konfigurer standard tekstindstillinger for læsevisningen. Disse indstillinger synkroniseres på tværs af alle dine enheder.", + "defaults_cleared": "Læserstandarder er blevet ryddet", + "save_hint": "Gem indstillinger kun for denne enhed eller synkroniser på tværs af alle enheder", + "save_as_default": "Gem som standard", + "save_to_device": "Denne enhed", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Ikke-gemte ændringer i forhåndsvisning; enhedsindstillinger adskiller sig fra globale", + "adjust_hint": "Juster indstillingerne ovenfor for at se et eksempel på ændringerne" + }, + "avatar": { + "upload": "Upload avatar", + "change": "Skift avatar", + "remove_confirm_title": "Fjern avatar?", + "updated": "Avatar opdateret", + "removed": "Avatar fjernet", + "description": "Upload et firkantet billede, som du kan bruge som din avatar.", + "remove_confirm_description": "Dette vil fjerne dit nuværende profilbillede.", + "title": "Profilbillede", + "remove": "Fjern avatar" } }, "feeds": { @@ -99,7 +145,21 @@ "summarization": "Opsummering", "all_tagging": "Tagging for alle typer", "text_tagging": "Tekst-tagging", - "image_tagging": "Billede-tagging" + "image_tagging": "Billede-tagging", + "tag_style": "Tag-stil", + "auto_summarization_description": "Generér automatisk opsummeringer til dine bogmærker ved hjælp af AI.", + "auto_tagging": "Automatisk taggning", + "titlecase_spaces": "Store forbogstaver med mellemrum", + "lowercase_underscores": "Små bogstaver med understregninger", + "inference_language": "Inferenssprog", + "titlecase_hyphens": "Store forbogstaver med bindestreger", + "lowercase_hyphens": "Små bogstaver med bindestreger", + "lowercase_spaces": "Små bogstaver med mellemrum", + "inference_language_description": "Vælg sprog for AI-genererede tags og opsummeringer.", + "tag_style_description": "Vælg, hvordan dine automatisk genererede tags skal formateres.", + "auto_tagging_description": "Generér automatisk tags til dine bogmærker ved hjælp af AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatisk opsummering" }, "broken_links": { "crawling_status": "Gennemsøgningsstatus", @@ -604,7 +664,9 @@ "summary": "Opsummering", "quota": "Kvote", "bookmarks": "Bogmærker", - "storage": "Lagring" + "storage": "Lagring", + "pdf": "Arkiveret PDF", + "default": "Standard" }, "layouts": { "masonry": "Fliser", @@ -705,7 +767,8 @@ "tabs": { "content": "Indhold", "details": "Detaljer" - } + }, + "archive_info": "Arkiver gengives muligvis ikke korrekt inline, hvis de kræver Javascript. For at opnå de bedste resultater skal du <1>downloade den og åbne den i din browser</1>." }, "toasts": { "bookmarks": { @@ -714,7 +777,8 @@ "delete_from_list": "Bogmærket er blevet slettet fra listen", "deleted": "Bogmærket er blevet slettet!", "clipboard_copied": "Linket er kopieret til din udklipsholder!", - "updated": "Bogmærket er blevet opdateret!" + "updated": "Bogmærket er blevet opdateret!", + "preserve_pdf": "PDF-bevaring er blevet udløst" }, "lists": { "created": "Listen er oprettet!", @@ -775,7 +839,14 @@ "year_s_ago": " År siden", "history": "Seneste søgninger", "title_contains": "Titel indeholder", - "title_does_not_contain": "Titel indeholder ikke" + "title_does_not_contain": "Titel indeholder ikke", + "is_broken_link": "Har Beskadet Link", + "tags": "Tags", + "no_suggestions": "Ingen forslag", + "filters": "Filtre", + "is_not_broken_link": "Har Fungerende Link", + "lists": "Lister", + "feeds": "Feeds" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/de/translation.json b/apps/web/lib/i18n/locales/de/translation.json index 88bbe275..7192b89e 100644 --- a/apps/web/lib/i18n/locales/de/translation.json +++ b/apps/web/lib/i18n/locales/de/translation.json @@ -39,7 +39,9 @@ "summary": "Zusammenfassung", "quota": "Kontingent", "bookmarks": "Lesezeichen", - "storage": "Speicher" + "storage": "Speicher", + "pdf": "Archivierte PDF-Datei", + "default": "Standard" }, "layouts": { "masonry": "Verschachtelt", @@ -90,7 +92,9 @@ "confirm": "Bestätigen", "regenerate": "Regenerieren", "load_more": "Mehr laden", - "edit_notes": "Notizen bearbeiten" + "edit_notes": "Notizen bearbeiten", + "preserve_as_pdf": "Als PDF speichern", + "offline_copies": "Offline-Kopien" }, "settings": { "back_to_app": "Zurück zur App", @@ -116,6 +120,49 @@ "show": "Archivierte Lesezeichen in Tags und Listen anzeigen", "hide": "Archivierte Lesezeichen in Tags und Listen ausblenden" } + }, + "reader_settings": { + "local_overrides_title": "Gerätespezifische Einstellungen aktiv", + "using_default": "Client-Standard verwenden", + "clear_override_hint": "Geräteüberschreibung löschen, um die globale Einstellung zu verwenden ({{value}})", + "font_size": "Schriftgröße", + "font_family": "Schriftfamilie", + "preview_inline": "(Vorschau)", + "tooltip_preview": "Nicht gespeicherte Vorschaueinstellungen", + "save_to_all_devices": "Alle Geräte", + "tooltip_local": "Geräteeinstellungen weichen von den globalen Einstellungen ab", + "reset_preview": "Vorschau zurücksetzen", + "mono": "Monospace", + "line_height": "Zeilenhöhe", + "tooltip_default": "Leseeinstellungen", + "title": "Lesereinstellungen", + "serif": "Serif", + "preview": "Vorschau", + "not_set": "Nicht festgelegt", + "clear_local_overrides": "Geräteeinstellungen löschen", + "preview_text": "The quick brown fox jumps over the lazy dog. So wird der Text Ihrer Leseransicht aussehen.", + "local_overrides_cleared": "Gerätespezifische Einstellungen wurden gelöscht", + "local_overrides_description": "Dieses Gerät hat Lesereinstellungen, die von Ihren globalen Standardeinstellungen abweichen:", + "clear_defaults": "Alle Standardeinstellungen löschen", + "description": "Standard-Texteinstellungen für die Leseransicht konfigurieren. Diese Einstellungen werden auf allen Ihren Geräten synchronisiert.", + "defaults_cleared": "Die Standardeinstellungen des Readers wurden gelöscht", + "save_hint": "Einstellungen nur für dieses Gerät speichern oder über alle Geräte synchronisieren", + "save_as_default": "Als Standard speichern", + "save_to_device": "Dieses Gerät", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Nicht gespeicherte Vorschaueinstellungen; Geräteeinstellungen weichen von den globalen Einstellungen ab", + "adjust_hint": "Passe die Einstellungen oben an, um eine Vorschau der Änderungen zu sehen" + }, + "avatar": { + "upload": "Avatar hochladen", + "change": "Avatar ändern", + "remove_confirm_title": "Avatar entfernen?", + "updated": "Avatar aktualisiert", + "removed": "Avatar entfernt", + "description": "Lade ein quadratisches Bild hoch, das du als Avatar verwenden möchtest.", + "remove_confirm_description": "Dadurch wird dein aktuelles Profilfoto gelöscht.", + "title": "Profilfoto", + "remove": "Avatar entfernen" } }, "ai": { @@ -129,7 +176,21 @@ "all_tagging": "Gesamtes Tagging", "text_tagging": "Text-Tagging", "image_tagging": "Bild-Tagging", - "summarization": "Zusammenfassung" + "summarization": "Zusammenfassung", + "tag_style": "Tag-Stil", + "auto_summarization_description": "Automatische Zusammenfassung deiner Lesezeichen mithilfe von KI.", + "auto_tagging": "Automatisches Tagging", + "titlecase_spaces": "Titel-Schreibweise mit Leerzeichen", + "lowercase_underscores": "Kleinbuchstaben mit Unterstrichen", + "inference_language": "Schlussfolgerungs-Sprache", + "titlecase_hyphens": "Titel-Schreibweise mit Bindestrichen", + "lowercase_hyphens": "Kleinbuchstaben mit Bindestrichen", + "lowercase_spaces": "Kleinbuchstaben mit Leerzeichen", + "inference_language_description": "Sprache für von KI generierte Tags und Zusammenfassungen auswählen.", + "tag_style_description": "Wähle, wie deine automatisch generierten Tags formatiert werden sollen.", + "auto_tagging_description": "Automatische Tag-Generierung für deine Lesezeichen mithilfe von KI.", + "camelCase": "camelCase", + "auto_summarization": "Automatische Zusammenfassung" }, "feeds": { "rss_subscriptions": "RSS-Abonnements", @@ -142,6 +203,7 @@ "import_export_bookmarks": "Lesezeichen importieren / exportieren", "import_bookmarks_from_html_file": "Lesezeichen aus HTML-Datei importieren", "import_bookmarks_from_pocket_export": "Lesezeichen aus Pocket-Export importieren", + "import_bookmarks_from_matter_export": "Lesezeichen aus Matter-Export importieren", "import_bookmarks_from_omnivore_export": "Lesezeichen aus Omnivore-Export importieren", "import_bookmarks_from_karakeep_export": "Lesezeichen aus Karakeep-Export importieren", "export_links_and_notes": "Links und Notizen exportieren", @@ -646,7 +708,8 @@ "tabs": { "content": "Inhalt", "details": "Details" - } + }, + "archive_info": "Archive werden möglicherweise nicht korrekt inline dargestellt, wenn sie Javascript benötigen. Die besten Ergebnisse erzielst du, wenn du sie <1>herunterlädst und in deinem Browser öffnest</1>." }, "editor": { "quickly_focus": "Sie können schnell auf dieses Feld fokussieren, indem Sie ⌘ + E drücken", @@ -714,7 +777,8 @@ "refetch": "Neuabruf wurde in die Warteschlange gestellt!", "full_page_archive": "Erstellung des vollständigen Seitenarchivs wurde ausgelöst", "delete_from_list": "Das Lesezeichen wurde aus der Liste gelöscht", - "clipboard_copied": "Link wurde in Ihre Zwischenablage kopiert!" + "clipboard_copied": "Link wurde in Ihre Zwischenablage kopiert!", + "preserve_pdf": "Die PDF-Speicherung wurde ausgelöst" }, "lists": { "created": "Liste wurde erstellt!", @@ -781,7 +845,14 @@ "year_s_ago": " Vor Jahr(en)", "history": "Letzte Suchanfragen", "title_contains": "Titel enthält", - "title_does_not_contain": "Titel enthält nicht" + "title_does_not_contain": "Titel enthält nicht", + "is_broken_link": "Hat defekten Link", + "tags": "Schlagwörter", + "no_suggestions": "Keine Vorschläge", + "filters": "Filter", + "is_not_broken_link": "Hat funktionierenden Link", + "lists": "Listen", + "feeds": "Feeds" }, "bookmark_editor": { "subtitle": "Ändere die Details des Lesezeichens. Klicke auf Speichern, wenn du fertig bist.", diff --git a/apps/web/lib/i18n/locales/el/translation.json b/apps/web/lib/i18n/locales/el/translation.json index 203e0f55..6fea6c6e 100644 --- a/apps/web/lib/i18n/locales/el/translation.json +++ b/apps/web/lib/i18n/locales/el/translation.json @@ -39,7 +39,9 @@ }, "quota": "Ποσόστωση", "bookmarks": "Σελιδοδείκτες", - "storage": "Αποθήκευση" + "storage": "Αποθήκευση", + "pdf": "Αρχειοθετημένο PDF", + "default": "Προεπιλογή" }, "layouts": { "masonry": "Πλινθοδομή", @@ -90,7 +92,9 @@ "confirm": "Επιβεβαίωση", "regenerate": "Ανανέωση", "load_more": "Φόρτωσε περισσότερα", - "edit_notes": "Επεξεργασία σημειώσεων" + "edit_notes": "Επεξεργασία σημειώσεων", + "preserve_as_pdf": "Διατήρηση ως PDF", + "offline_copies": "Αντίγραφα εκτός σύνδεσης" }, "highlights": { "no_highlights": "Δεν έχετε ακόμα επιλογές." @@ -119,6 +123,49 @@ "show": "Εμφάνιση αρχειοθετημένων σελιδοδεικτών σε ετικέτες και λίστες", "hide": "Απόκρυψη αρχειοθετημένων σελιδοδεικτών από ετικέτες και λίστες" } + }, + "reader_settings": { + "local_overrides_title": "Ενεργές ρυθμίσεις για συγκεκριμένη συσκευή", + "using_default": "Χρήση της προεπιλογής του πελάτη", + "clear_override_hint": "Εκκαθαρίστε την παράκαμψη συσκευής για να χρησιμοποιήσετε την καθολική ρύθμιση ({{value}})", + "font_size": "Μέγεθος γραμματοσειράς", + "font_family": "Οικογένεια γραμματοσειράς", + "preview_inline": "(προεπισκόπηση)", + "tooltip_preview": "Μη αποθηκευμένες αλλαγές προεπισκόπησης", + "save_to_all_devices": "Όλες οι συσκευές", + "tooltip_local": "Οι ρυθμίσεις της συσκευής διαφέρουν από τις καθολικές ρυθμίσεις", + "reset_preview": "Επαναφορά προεπισκόπησης", + "mono": "Monospace", + "line_height": "Ύψος γραμμής", + "tooltip_default": "Ρυθμίσεις ανάγνωσης", + "title": "Ρυθμίσεις ανάγνωσης", + "serif": "Serif", + "preview": "Προεπισκόπηση", + "not_set": "Δεν έχει οριστεί", + "clear_local_overrides": "Εκκαθάριση ρυθμίσεων συσκευής", + "preview_text": "Η γρήγορη καφέ αλεπού πηδάει πάνω από τον τεμπέλη σκύλο. Έτσι θα φαίνεται το κείμενό σου στην προβολή ανάγνωσης.", + "local_overrides_cleared": "Οι ρυθμίσεις για συγκεκριμένη συσκευή έχουν εκκαθαριστεί", + "local_overrides_description": "Αυτή η συσκευή έχει ρυθμίσεις ανάγνωσης που διαφέρουν από τις καθολικές προεπιλογές σου:", + "clear_defaults": "Εκκαθάριση όλων των προεπιλογών", + "description": "Ρύθμισε τις προεπιλεγμένες ρυθμίσεις κειμένου για την προβολή ανάγνωσης. Αυτές οι ρυθμίσεις συγχρονίζονται απ' όλες τις συσκευές σου.", + "defaults_cleared": "Οι προεπιλογές ανάγνωσης έχουν εκκαθαριστεί", + "save_hint": "Αποθηκεύστε τις ρυθμίσεις μόνο για αυτή τη συσκευή ή συγχρονίστε σε όλες τις συσκευές", + "save_as_default": "Αποθήκευση ως προεπιλογή", + "save_to_device": "Αυτή η συσκευή", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Μη αποθηκευμένες αλλαγές προεπισκόπησης; οι ρυθμίσεις της συσκευής διαφέρουν από τις καθολικές", + "adjust_hint": "Προσαρμόστε τις παραπάνω ρυθμίσεις για να κάνετε προεπισκόπηση των αλλαγών" + }, + "avatar": { + "upload": "Ανέβασε avatar", + "change": "Άλλαξε avatar", + "remove_confirm_title": "Να αφαιρεθεί το avatar;", + "updated": "Το avatar ανανεώθηκε", + "removed": "Το avatar αφαιρέθηκε", + "description": "Ανέβασε μια τετράγωνη εικόνα για να τη χρησιμοποιήσεις ως avatar.", + "remove_confirm_description": "Αυτό θα διαγράψει την τρέχουσα φωτογραφία προφίλ σου.", + "title": "Φωτογραφία Προφίλ", + "remove": "Αφαίρεσε avatar" } }, "ai": { @@ -132,7 +179,21 @@ "all_tagging": "Όλη η Ετικετοποίηση", "text_tagging": "Ετικετοποίηση Κειμένου", "image_tagging": "Ετικετοποίηση Εικόνων", - "summarization": "Περίληψη" + "summarization": "Περίληψη", + "tag_style": "Στυλ ετικέτας", + "auto_summarization_description": "Δημιουργήστε αυτόματα περιλήψεις για τους σελιδοδείκτες σας χρησιμοποιώντας AI.", + "auto_tagging": "Αυτόματη προσθήκη ετικετών", + "titlecase_spaces": "Κεφαλαία ανά λέξη με κενά", + "lowercase_underscores": "Μικρά με κάτω παύλες", + "inference_language": "Γλώσσα εξαγωγής συμπερασμάτων", + "titlecase_hyphens": "Κεφαλαία ανά λέξη με παύλες", + "lowercase_hyphens": "Μικρά με παύλες", + "lowercase_spaces": "Μικρά με κενά", + "inference_language_description": "Διάλεξε γλώσσα για τις ετικέτες και τις περιλήψεις που δημιουργούνται από την AI.", + "tag_style_description": "Διάλεξε πώς να μορφοποιηθούν οι αυτόματα δημιουργημένες ετικέτες σου.", + "auto_tagging_description": "Δημιουργήστε αυτόματα ετικέτες για τους σελιδοδείκτες σας χρησιμοποιώντας AI.", + "camelCase": "camelCase", + "auto_summarization": "Αυτόματη δημιουργία περιλήψεων" }, "feeds": { "rss_subscriptions": "Συνδρομές RSS", @@ -163,6 +224,7 @@ "import_export_bookmarks": "Εισαγωγή / Εξαγωγή Σελιδοδεικτών", "import_bookmarks_from_html_file": "Εισαγωγή Σελιδοδεικτών από αρχείο HTML", "import_bookmarks_from_pocket_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Pocket", + "import_bookmarks_from_matter_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Matter", "import_bookmarks_from_omnivore_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Omnivore", "import_bookmarks_from_linkwarden_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Linkwarden", "import_bookmarks_from_karakeep_export": "Εισαγωγή Σελιδοδεικτών από εξαγωγή Karakeep", @@ -680,7 +742,14 @@ "or": "Ή", "history": "Πρόσφατες αναζητήσεις", "title_contains": "Ο τίτλος περιέχει", - "title_does_not_contain": "Ο τίτλος δεν περιέχει" + "title_does_not_contain": "Ο τίτλος δεν περιέχει", + "is_broken_link": "Έχει κατεστραμμένο σύνδεσμο", + "tags": "Ετικέτες", + "no_suggestions": "Χωρίς προτάσεις", + "filters": "Φίλτρα", + "is_not_broken_link": "Έχει σύνδεσμο που λειτουργεί", + "lists": "Λίστες", + "feeds": "Ροές" }, "preview": { "view_original": "Προβολή Πρωτότυπου", @@ -689,7 +758,8 @@ "tabs": { "content": "Περιεχόμενο", "details": "Λεπτομέρειες" - } + }, + "archive_info": "Τα αρχεία ενδέχεται να μην αποδίδονται σωστά ενσωματωμένα, εάν απαιτούν Javascript. Για καλύτερα αποτελέσματα, <1>κατεβάστε το και ανοίξτε το στο πρόγραμμα περιήγησής σας</1>." }, "editor": { "quickly_focus": "Μπορείτε να εστιάσετε γρήγορα σε αυτό το πεδίο πατώντας ⌘ + E", @@ -763,7 +833,8 @@ "refetch": "Η επαναφόρτωση μπήκε στην ουρά!", "full_page_archive": "Η δημιουργία Πλήρους Αρχείου Σελίδας ενεργοποιήθηκε", "delete_from_list": "Ο σελιδοδείκτης διαγράφηκε από τη λίστα", - "clipboard_copied": "Ο σύνδεσμος προστέθηκε στο πρόχειρό σας!" + "clipboard_copied": "Ο σύνδεσμος προστέθηκε στο πρόχειρό σας!", + "preserve_pdf": "Η διατήρηση PDF έχει ενεργοποιηθεί" }, "lists": { "created": "Η λίστα δημιουργήθηκε!", diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 33c7d6e2..37212ede 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -1,5 +1,7 @@ { "common": { + "default": "Default", + "id": "ID", "url": "URL", "name": "Name", "email": "Email", @@ -8,6 +10,7 @@ "actions": "Actions", "created_at": "Created At", "updated_at": "Updated At", + "last_used": "Last Used", "key": "Key", "role": "Role", "type": "Type", @@ -25,6 +28,7 @@ "highlights": "Highlights", "source": "Source", "screenshot": "Screenshot", + "pdf": "Archived PDF", "video": "Video", "archive": "Archive", "home": "Home", @@ -69,7 +73,11 @@ "toggle_show_archived": "Show Archived", "refresh": "Refresh", "recrawl": "Recrawl", - "download_full_page_archive": "Download Full Page Archive", + "offline_copies": "Offline Copies", + "preserve_offline_archive": "Preserve Offline Archive", + "download_full_page_archive_file": "Download Archive File", + "preserve_as_pdf": "Preserve as PDF", + "download_pdf_file": "Download PDF File", "edit_tags": "Edit Tags", "edit_notes": "Edit Notes", "add_to_list": "Add to List", @@ -82,6 +90,7 @@ "remove_from_list": "Remove from List", "save": "Save", "add": "Add", + "remove": "Remove", "edit": "Edit", "confirm": "Confirm", "open_editor": "Open Editor", @@ -96,6 +105,9 @@ "regenerate": "Regenerate", "apply_all": "Apply All", "ignore": "Ignore", + "more": "More", + "replace_banner": "Replace Banner", + "add_banner": "Add Banner", "sort": { "title": "Sort", "relevant_first": "Most Relevant First", @@ -119,6 +131,17 @@ "confirm_new_password": "Confirm New Password", "options": "Options", "interface_lang": "Interface Language", + "avatar": { + "title": "Profile Photo", + "description": "Upload a square image to use as your avatar.", + "upload": "Upload avatar", + "change": "Change avatar", + "remove": "Remove avatar", + "remove_confirm_title": "Remove avatar?", + "remove_confirm_description": "This will clear your current profile photo.", + "updated": "Avatar updated", + "removed": "Avatar removed" + }, "user_settings": { "user_settings_updated": "User settings have been updated!", "bookmark_click_action": { @@ -131,6 +154,38 @@ "show": "Show archived bookmarks in tags and lists", "hide": "Hide archived bookmarks in tags and lists" } + }, + "reader_settings": { + "title": "Reader Settings", + "description": "Configure default text settings for the reader view. These settings sync across all your devices.", + "font_family": "Font Family", + "font_size": "Font Size", + "line_height": "Line Height", + "save_as_default": "Save as default", + "clear_defaults": "Clear all defaults", + "not_set": "Not set", + "using_default": "Using client default", + "preview": "Preview", + "preview_text": "The quick brown fox jumps over the lazy dog. This is how your reader view text will appear.", + "defaults_cleared": "Reader defaults have been cleared", + "local_overrides_title": "Device-specific settings active", + "local_overrides_description": "This device has reader settings that differ from your global defaults:", + "local_overrides_cleared": "Device-specific settings have been cleared", + "clear_local_overrides": "Clear device settings", + "serif": "Serif", + "sans": "Sans Serif", + "mono": "Monospace", + "tooltip_default": "Reading settings", + "tooltip_preview": "Unsaved preview changes", + "tooltip_local": "Device settings differ from global", + "tooltip_preview_and_local": "Unsaved preview changes; device settings differ from global", + "reset_preview": "Reset preview", + "save_to_device": "This device", + "save_to_all_devices": "All devices", + "save_hint": "Save settings for this device only or sync across all devices", + "adjust_hint": "Adjust settings above to preview changes", + "clear_override_hint": "Clear device override to use global setting ({{value}})", + "preview_inline": "(preview)" } }, "stats": { @@ -189,6 +244,10 @@ }, "ai": { "ai_settings": "AI Settings", + "auto_tagging": "Auto-tagging", + "auto_tagging_description": "Automatically generate tags for your bookmarks using AI.", + "auto_summarization": "Auto-summarization", + "auto_summarization_description": "Automatically generate summaries for your bookmarks using AI.", "tagging_rules": "Tagging Rules", "tagging_rule_description": "Prompts that you add here will be included as rules to the model during tag generation. You can view the final prompts in the prompt preview section.", "prompt_preview": "Prompt Preview", @@ -198,7 +257,22 @@ "all_tagging": "All Tagging", "text_tagging": "Text Tagging", "image_tagging": "Image Tagging", - "summarization": "Summarization" + "summarization": "Summarization", + "tag_style": "Tag Style", + "tag_style_description": "Choose how your auto-generated tags should be formatted.", + "lowercase_hyphens": "Lowercase with hyphens", + "lowercase_spaces": "Lowercase with spaces", + "lowercase_underscores": "Lowercase with underscores", + "titlecase_spaces": "Title case with spaces", + "titlecase_hyphens": "Title case with hyphens", + "camelCase": "camelCase", + "no_preference": "No preference", + "inference_language": "Inference Language", + "inference_language_description": "Choose language for AI-generated tags and summaries.", + "curated_tags": "Curated Tags", + "curated_tags_description": "Optionally restrict AI tagging to only use tags from this list. When no tags are selected, the AI generates tags freely.", + "curated_tags_updated": "Curated tags updated successfully!", + "curated_tags_update_failed": "Failed to update curated tags" }, "feeds": { "rss_subscriptions": "RSS Subscriptions", @@ -229,11 +303,13 @@ "import_export_bookmarks": "Import / Export Bookmarks", "import_bookmarks_from_html_file": "Import Bookmarks from HTML file", "import_bookmarks_from_pocket_export": "Import Bookmarks from Pocket export", + "import_bookmarks_from_matter_export": "Import Bookmarks from Matter export", "import_bookmarks_from_omnivore_export": "Import Bookmarks from Omnivore export", "import_bookmarks_from_linkwarden_export": "Import Bookmarks from Linkwarden export", "import_bookmarks_from_karakeep_export": "Import Bookmarks from Karakeep export", "import_bookmarks_from_tab_session_manager_export": "Import Bookmarks from Tab Session Manager", "import_bookmarks_from_mymind_export": "Import Bookmarks from mymind export", + "import_bookmarks_from_instapaper_export": "Import Bookmarks from Instapaper export", "export_links_and_notes": "Export Links and Notes", "imported_bookmarks": "Imported Bookmarks" }, @@ -285,6 +361,9 @@ "conditions_types": { "always": "Always", "url_contains": "URL Contains", + "url_does_not_contain": "URL Does Not Contain", + "title_contains": "Title Contains", + "title_does_not_contain": "Title Does Not Contain", "imported_from_feed": "Imported From Feed", "bookmark_type_is": "Bookmark Type Is", "has_tag": "Has Tag", @@ -342,11 +421,12 @@ "created_at": "Created {{time}}", "progress": "Progress", "status": { + "staging": "Staging", "pending": "Pending", - "in_progress": "In progress", + "running": "Running", + "paused": "Paused", "completed": "Completed", - "failed": "Failed", - "processing": "Processing" + "failed": "Failed" }, "badges": { "pending": "{{count}} pending", @@ -358,7 +438,33 @@ "view_list": "View List", "delete_dialog_title": "Delete Import Session", "delete_dialog_description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone. The bookmarks themselves will not be deleted.", - "delete_session": "Delete Session" + "delete_session": "Delete Session", + "pause_session": "Pause", + "resume_session": "Resume", + "view_details": "View Details", + "detail": { + "page_title": "Import Session Details", + "back_to_import": "Back to Import", + "filter_all": "All", + "filter_accepted": "Accepted", + "filter_rejected": "Rejected", + "filter_duplicates": "Duplicates", + "filter_pending": "Pending", + "table_title": "Title / URL", + "table_type": "Type", + "table_result": "Result", + "table_reason": "Reason", + "table_bookmark": "Bookmark", + "result_accepted": "Accepted", + "result_rejected": "Rejected", + "result_skipped_duplicate": "Duplicate", + "result_pending": "Pending", + "result_processing": "Processing", + "no_results": "No results found for this filter.", + "view_bookmark": "View Bookmark", + "load_more": "Load More", + "no_title": "No title" + } }, "backups": { "backups": "Backups", @@ -485,11 +591,14 @@ } }, "actions": { + "recrawl_pending_links_only": "Recrawl Pending Links Only", "recrawl_failed_links_only": "Recrawl Failed Links Only", "recrawl_all_links": "Recrawl All Links", "without_inference": "Without Inference", + "regenerate_ai_tags_for_pending_bookmarks_only": "Regenerate AI Tags for Pending Bookmarks Only", "regenerate_ai_tags_for_failed_bookmarks_only": "Regenerate AI Tags for Failed Bookmarks Only", "regenerate_ai_tags_for_all_bookmarks": "Regenerate AI Tags for All Bookmarks", + "regenerate_ai_summaries_for_pending_bookmarks_only": "Regenerate AI Summaries for Pending Bookmarks Only", "regenerate_ai_summaries_for_failed_bookmarks_only": "Regenerate AI Summaries for Failed Bookmarks Only", "regenerate_ai_summaries_for_all_bookmarks": "Regenerate AI Summaries for All Bookmarks", "reindex_all_bookmarks": "Reindex All Bookmarks", @@ -510,11 +619,50 @@ "local_user": "Local User", "confirm_password": "Confirm Password", "unlimited": "Unlimited" + }, + "admin_tools": { + "admin_tools": "Admin Tools", + "bookmark_debugger": "Bookmark Debugger", + "bookmark_id": "Bookmark ID", + "bookmark_id_placeholder": "Enter bookmark ID", + "lookup": "Lookup", + "debug_info": "Debug Information", + "basic_info": "Basic Information", + "status": "Status", + "content": "Content", + "html_preview": "HTML Preview (First 1000 chars)", + "summary": "Summary", + "url": "URL", + "source_url": "Source URL", + "asset_type": "Asset Type", + "file_name": "File Name", + "owner_user_id": "Owner User ID", + "tagging_status": "Tagging Status", + "summarization_status": "Summarization Status", + "crawl_status": "Crawl Status", + "crawl_status_code": "HTTP Status Code", + "crawled_at": "Crawled At", + "recrawl": "Re-crawl", + "reindex": "Re-index", + "retag": "Re-tag", + "resummarize": "Re-summarize", + "bookmark_not_found": "Bookmark not found", + "action_success": "Action completed successfully", + "action_failed": "Action failed", + "recrawl_queued": "Re-crawl job has been queued", + "reindex_queued": "Re-index job has been queued", + "retag_queued": "Re-tag job has been queued", + "resummarize_queued": "Re-summarize job has been queued", + "view": "View", + "fetch_error": "Error fetching bookmark" } }, "options": { "dark_mode": "Dark Mode", - "light_mode": "Light Mode" + "light_mode": "Light Mode", + "apps_extensions": "Apps & Extensions", + "documentation": "Documentation", + "follow_us_on_x": "Follow us on X" }, "lists": { "all_lists": "All Lists", @@ -620,6 +768,8 @@ "create_tag_description": "Create a new tag without attaching it to any bookmark", "tag_name": "Tag Name", "enter_tag_name": "Enter tag name", + "search_placeholder": "Search tags...", + "search_or_create_placeholder": "Search or create tags...", "no_custom_tags": "No custom tags yet", "no_ai_tags": "No AI tags yet", "no_unused_tags": "You don't have any unused tags", @@ -662,6 +812,8 @@ "type_is_not": "Type is not", "is_from_feed": "Is from RSS Feed", "is_not_from_feed": "Is not from RSS Feed", + "is_from_source": "Source is", + "is_not_from_source": "Source is not", "is_broken_link": "Has Broken Link", "is_not_broken_link": "Has Working Link", "and": "And", @@ -677,6 +829,9 @@ "view_original": "View Original", "cached_content": "Cached Content", "reader_view": "Reader View", + "archive_info": "Archives may not render correctly inline if they require Javascript. For best results, <1>download it and open in your browser</1>.", + "fetch_error_title": "Content Unavailable", + "fetch_error_description": "We couldn't fetch the content for this link. The page may be protected, require authentication, or be temporarily unavailable.", "tabs": { "content": "Content", "details": "Details" @@ -752,8 +907,11 @@ "deleted": "The bookmark has been deleted!", "refetch": "Re-fetch has been enqueued!", "full_page_archive": "Full Page Archive creation has been triggered", + "preserve_pdf": "PDF preservation has been triggered", "delete_from_list": "The bookmark has been deleted from the list", - "clipboard_copied": "Link has been added to your clipboard!" + "clipboard_copied": "Link has been added to your clipboard!", + "update_banner": "Banner has been updated!", + "uploading_banner": "Uploading banner..." }, "lists": { "created": "List has been created!", @@ -798,5 +956,54 @@ "no_release_notes": "No release notes were published for this version.", "release_notes_synced": "Release notes are synced from GitHub.", "view_on_github": "View on GitHub" + }, + "wrapped": { + "title": "Your {{year}} Wrapped", + "subtitle": "A Year in Karakeep", + "banner": { + "title": "Your 2025 Wrapped is ready!", + "description": "See your year in bookmarks", + "view_now": "View Now" + }, + "button": "2025 Wrapped", + "loading": "Loading your Wrapped...", + "failed_to_load": "Failed to load your Wrapped stats", + "sections": { + "total_saves": { + "prefix": "You saved", + "suffix": "items this year", + "suffix_singular": "item this year" + }, + "first_bookmark": { + "title": "Your Journey Started", + "description": "First save of {{year}}:" + }, + "top_domains": "Your Top Sites", + "top_tags": "Your Top Tags", + "monthly_activity": "Your Year in Saves", + "most_active_day": "Your Most Active Day", + "peak_times": { + "title": "When You Save", + "peak_hour": "Peak Hour", + "peak_day": "Peak Day" + }, + "how_you_save": "How You Save", + "what_you_saved": "What You Saved", + "summary": { + "favorites": "Favorites", + "tags_created": "Tags Created", + "highlights": "Highlights" + }, + "types": { + "links": "Links", + "notes": "Notes", + "assets": "Assets" + } + }, + "footer": "Made with Karakeep", + "share": "Share", + "download": "Download", + "close": "Close", + "generating": "Generating..." } } diff --git a/apps/web/lib/i18n/locales/en_US/translation.json b/apps/web/lib/i18n/locales/en_US/translation.json index 12af64e8..9e98b09e 100644 --- a/apps/web/lib/i18n/locales/en_US/translation.json +++ b/apps/web/lib/i18n/locales/en_US/translation.json @@ -25,6 +25,7 @@ "admin": "Admin" }, "screenshot": "Screenshot", + "pdf": "Archived PDF", "video": "Video", "archive": "Archive", "home": "Home", @@ -39,7 +40,8 @@ }, "quota": "Quota", "bookmarks": "Bookmarks", - "storage": "Storage" + "storage": "Storage", + "default": "Default" }, "layouts": { "masonry": "Masonry", @@ -62,7 +64,9 @@ "delete": "Delete", "refresh": "Refresh", "recrawl": "Recrawl", + "offline_copies": "Offline Copies", "download_full_page_archive": "Download Full Page Archive", + "preserve_as_pdf": "Preserve as PDF", "edit_tags": "Edit Tags", "add_to_list": "Add to List", "select_all": "Select All", @@ -200,6 +204,17 @@ "confirm_new_password": "Confirm New Password", "options": "Options", "interface_lang": "Interface Language", + "avatar": { + "title": "Profile Photo", + "description": "Upload a square image to use as your avatar.", + "upload": "Upload avatar", + "change": "Change avatar", + "remove": "Remove avatar", + "remove_confirm_title": "Remove avatar?", + "remove_confirm_description": "This will clear your current profile photo.", + "updated": "Avatar updated", + "removed": "Avatar removed" + }, "user_settings": { "user_settings_updated": "User settings have been updated!", "bookmark_click_action": { @@ -212,6 +227,38 @@ "show": "Show archived bookmarks in tags and lists", "hide": "Hide archived bookmarks in tags and lists" } + }, + "reader_settings": { + "local_overrides_title": "Device-specific settings active", + "using_default": "Using client default", + "clear_override_hint": "Clear device override to use global setting ({{value}})", + "font_size": "Font Size", + "font_family": "Font Family", + "preview_inline": "(preview)", + "tooltip_preview": "Unsaved preview changes", + "save_to_all_devices": "All devices", + "tooltip_local": "Device settings differ from global", + "reset_preview": "Reset preview", + "mono": "Monospace", + "line_height": "Line Height", + "tooltip_default": "Reading settings", + "title": "Reader Settings", + "serif": "Serif", + "preview": "Preview", + "not_set": "Not set", + "clear_local_overrides": "Clear device settings", + "preview_text": "The quick brown fox jumps over the lazy dog. This is how your reader view text will appear.", + "local_overrides_cleared": "Device-specific settings have been cleared", + "local_overrides_description": "This device has reader settings that differ from your global defaults:", + "clear_defaults": "Clear all defaults", + "description": "Configure default text settings for the reader view. These settings sync across all your devices.", + "defaults_cleared": "Reader defaults have been cleared", + "save_hint": "Save settings for this device only or sync across all devices", + "save_as_default": "Save as default", + "save_to_device": "This device", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Unsaved preview changes; device settings differ from global", + "adjust_hint": "Adjust settings above to preview changes" } }, "ai": { @@ -225,7 +272,21 @@ "all_tagging": "All Tagging", "text_tagging": "Text Tagging", "image_tagging": "Image Tagging", - "summarization": "Summarization" + "summarization": "Summarization", + "tag_style": "Tag Style", + "auto_summarization_description": "Automatically generate summaries for your bookmarks using AI.", + "auto_tagging": "Auto-tagging", + "titlecase_spaces": "Title case with spaces", + "lowercase_underscores": "Lowercase with underscores", + "inference_language": "Inference Language", + "titlecase_hyphens": "Title case with hyphens", + "lowercase_hyphens": "Lowercase with hyphens", + "lowercase_spaces": "Lowercase with spaces", + "inference_language_description": "Choose language for AI-generated tags and summaries.", + "tag_style_description": "Choose how your auto-generated tags should be formatted.", + "auto_tagging_description": "Automatically generate tags for your bookmarks using AI.", + "camelCase": "camelCase", + "auto_summarization": "Auto-summarization" }, "feeds": { "rss_subscriptions": "RSS Subscriptions", @@ -257,6 +318,7 @@ "import_export_bookmarks": "Import / Export Bookmarks", "import_bookmarks_from_html_file": "Import Bookmarks from HTML file", "import_bookmarks_from_pocket_export": "Import Bookmarks from Pocket export", + "import_bookmarks_from_matter_export": "Import Bookmarks from Matter export", "import_bookmarks_from_omnivore_export": "Import Bookmarks from Omnivore export", "import_bookmarks_from_linkwarden_export": "Import Bookmarks from Linkwarden export", "import_bookmarks_from_karakeep_export": "Import Bookmarks from Karakeep export", @@ -754,7 +816,10 @@ "filters": "Filters", "tags": "Tags", "lists": "Lists", - "no_suggestions": "No suggestions" + "no_suggestions": "No suggestions", + "is_broken_link": "Has Broken Link", + "is_not_broken_link": "Has Working Link", + "feeds": "Feeds" }, "preview": { "view_original": "View Original", @@ -763,13 +828,15 @@ "tabs": { "content": "Content", "details": "Details" - } + }, + "archive_info": "Archives may not render correctly inline if they require Javascript. For best results, <1>download it and open in your browser</1>." }, "toasts": { "bookmarks": { "deleted": "The bookmark has been deleted!", "refetch": "Re-fetch has been enqueued!", "full_page_archive": "Full Page Archive creation has been triggered", + "preserve_pdf": "PDF preservation has been triggered", "delete_from_list": "The bookmark has been deleted from the list", "clipboard_copied": "Link has been added to your clipboard!", "updated": "The bookmark has been updated!" diff --git a/apps/web/lib/i18n/locales/es/translation.json b/apps/web/lib/i18n/locales/es/translation.json index 6b2b78a4..6dd2aa78 100644 --- a/apps/web/lib/i18n/locales/es/translation.json +++ b/apps/web/lib/i18n/locales/es/translation.json @@ -39,7 +39,9 @@ "description": "Descripción", "quota": "Cuota", "bookmarks": "Marcadores", - "storage": "Almacenamiento" + "storage": "Almacenamiento", + "pdf": "PDF archivado", + "default": "Predeterminado" }, "settings": { "info": { @@ -63,6 +65,49 @@ "show": "Mostrar marcadores archivados en etiquetas y listas", "hide": "Ocultar marcadores archivados en etiquetas y listas" } + }, + "reader_settings": { + "local_overrides_title": "Ajustes específicos del dispositivo activos", + "using_default": "Usando el valor predeterminado del cliente", + "clear_override_hint": "Borra la configuración específica del dispositivo para usar la configuración global ({{value}})", + "font_size": "Tamaño de fuente", + "font_family": "Familia de fuentes", + "preview_inline": "(vista previa)", + "tooltip_preview": "Cambios de la vista previa sin guardar", + "save_to_all_devices": "Todos los dispositivos", + "tooltip_local": "Los ajustes del dispositivo difieren de los globales", + "reset_preview": "Restablecer vista previa", + "mono": "Monoespacio", + "line_height": "Altura de la línea", + "tooltip_default": "Ajustes de lectura", + "title": "Ajustes del lector", + "serif": "Con gracias", + "preview": "Vista previa", + "not_set": "No configurado", + "clear_local_overrides": "Borrar la configuración del dispositivo", + "preview_text": "El veloz murciélago hindú comía feliz cardillo y kiwi. Así es como aparecerá el texto en tu vista de lectura.", + "local_overrides_cleared": "Se han borrado los ajustes específicos del dispositivo", + "local_overrides_description": "Este dispositivo tiene ajustes de lector que difieren de los valores predeterminados globales:", + "clear_defaults": "Borrar todos los valores predeterminados", + "description": "Configura los ajustes de texto predeterminados para la vista de lectura. Estos ajustes se sincronizan en todos tus dispositivos.", + "defaults_cleared": "Se han borrado los valores predeterminados del lector", + "save_hint": "Guarda la configuración sólo para este dispositivo o sincronízala en todos los dispositivos", + "save_as_default": "Guardar como predeterminado", + "save_to_device": "Este dispositivo", + "sans": "Sin gracias", + "tooltip_preview_and_local": "Cambios de la vista previa sin guardar; los ajustes del dispositivo difieren de los globales", + "adjust_hint": "Just the tip: ajusta la configuración de arriba para previsualizar los cambios" + }, + "avatar": { + "upload": "Subir avatar", + "change": "Cambiar avatar", + "remove_confirm_title": "¿Eliminar avatar?", + "updated": "Avatar actualizado", + "removed": "Avatar eliminado", + "description": "Sube una imagen cuadrada para usarla como tu avatar.", + "remove_confirm_description": "Esto borrará tu foto de perfil actual.", + "title": "Foto de perfil", + "remove": "Eliminar avatar" } }, "back_to_app": "Volver a la aplicación", @@ -77,7 +122,21 @@ "summarization_prompt": "Indicación de resumen", "all_tagging": "Todo el etiquetado", "text_tagging": "Etiquetado de texto", - "image_tagging": "Etiquetado de imágenes" + "image_tagging": "Etiquetado de imágenes", + "tag_style": "Estilo de etiqueta", + "auto_summarization_description": "Genera resúmenes automáticamente para tus marcadores usando IA.", + "auto_tagging": "Etiquetado automático", + "titlecase_spaces": "Mayúsculas y minúsculas con espacios", + "lowercase_underscores": "Minúsculas con guiones bajos", + "inference_language": "Idioma de Inferencia", + "titlecase_hyphens": "Mayúsculas y minúsculas con guiones", + "lowercase_hyphens": "Minúsculas con guiones", + "lowercase_spaces": "Minúsculas con espacios", + "inference_language_description": "Elige el idioma para las etiquetas y los resúmenes generados por la IA.", + "tag_style_description": "Elige cómo quieres que se formateen las etiquetas que se generan automáticamente.", + "auto_tagging_description": "Genera etiquetas automáticamente para tus marcadores usando IA.", + "camelCase": "camelCase", + "auto_summarization": "Resumen automático" }, "user_settings": "Ajustes de usuario", "feeds": { @@ -90,6 +149,7 @@ "import_export": "Importar / Exportar", "import_export_bookmarks": "Importar / Exportar marcadores", "import_bookmarks_from_pocket_export": "Importar marcadores desde exportación de Pocket", + "import_bookmarks_from_matter_export": "Importar marcadores desde exportación de Matter", "export_links_and_notes": "Exportar links y notas", "imported_bookmarks": "Marcadores importados", "import_bookmarks_from_karakeep_export": "Importar marcadores desde exportación de Karakeep", @@ -387,7 +447,9 @@ "confirm": "Confirmar", "regenerate": "Regenerar", "load_more": "Cargar más", - "edit_notes": "Editar notas" + "edit_notes": "Editar notas", + "preserve_as_pdf": "Conservar como PDF", + "offline_copies": "Copias sin conexión" }, "layouts": { "compact": "Compacto", @@ -646,7 +708,8 @@ "tabs": { "content": "Contenido", "details": "Detalles" - } + }, + "archive_info": "Es posible que los archivos no se rendericen correctamente en línea si requieren Javascript. Para obtener mejores resultados, <1>descárgalo y ábrelo en tu navegador</1>." }, "editor": { "multiple_urls_dialog_title": "¿Importar URLs como marcadores independientes?", @@ -714,7 +777,8 @@ "deleted": "¡El marcador se ha eliminado!", "full_page_archive": "Se ha pedido un Archivo de Página Completa", "delete_from_list": "El marcador se ha borrado de la lista", - "clipboard_copied": "¡El enlace se ha copiado en tu portapapeles!" + "clipboard_copied": "¡El enlace se ha copiado en tu portapapeles!", + "preserve_pdf": "Se ha activado la preservación en PDF" }, "lists": { "created": "¡Enlace creado correctamente!", @@ -775,7 +839,14 @@ "year_s_ago": " Hace año(s)", "history": "Búsquedas recientes", "title_contains": "El título contiene", - "title_does_not_contain": "El título no contiene" + "title_does_not_contain": "El título no contiene", + "is_broken_link": "Tiene enlace roto", + "tags": "Etiquetas", + "no_suggestions": "Sin sugerencias", + "filters": "Filtros", + "is_not_broken_link": "Tiene enlace que funciona", + "lists": "Listas", + "feeds": "Feeds" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/fa/translation.json b/apps/web/lib/i18n/locales/fa/translation.json index 5136e368..6bd97788 100644 --- a/apps/web/lib/i18n/locales/fa/translation.json +++ b/apps/web/lib/i18n/locales/fa/translation.json @@ -39,7 +39,9 @@ "text": "متن", "media": "رسانه" }, - "quota": "سهمیه" + "quota": "سهمیه", + "pdf": "پیدیاف بایگانیشده", + "default": "پیشفرض" }, "layouts": { "grid": "شبکهای", @@ -90,7 +92,9 @@ "oldest_first": "قدیمیترینها ابتدا" }, "load_more": "بارگذاری بیشتر", - "edit_notes": "ویرایش یادداشتها" + "edit_notes": "ویرایش یادداشتها", + "preserve_as_pdf": "به عنوان پیدیاف نگهداریاش کن", + "offline_copies": "نسخههای آفلاین" }, "settings": { "stats": { @@ -169,6 +173,49 @@ "show": "نمایش نشانکهای بایگانیشده در برچسبها و فهرستها", "hide": "مخفیکردن نشانکهای بایگانیشده در برچسبها و فهرستها" } + }, + "reader_settings": { + "local_overrides_title": "تنظیمات مختص دستگاه فعال هستن", + "using_default": "در حال استفاده از پیشفرض مشتری", + "clear_override_hint": "پاک کردن لغو دستگاه برای استفاده از تنظیمات سراسری ({{value}})", + "font_size": "اندازه فونت", + "font_family": "خانواده فونت", + "preview_inline": "(پیشنمایش)", + "tooltip_preview": "تغییرات پیش نمایش ذخیره نشده", + "save_to_all_devices": "همه دستگاهها", + "tooltip_local": "تنظیمات دستگاه با تنظیمات سراسری فرق داره", + "reset_preview": "بازنشانی پیشنمایش", + "mono": "تکفاصله", + "line_height": "ارتفاع خط", + "tooltip_default": "تنظیمات خواندن", + "title": "تنظیمات خواننده", + "serif": "سریدار", + "preview": "پیشنمایش", + "not_set": "تنظیم نشده", + "clear_local_overrides": "تنظیمات دستگاه رو پاک کن", + "preview_text": "روباه قهوهای زرنگ از روی سگ تنبل میپره. متن قسمت خواننده اینجوری نمایش داده میشه.", + "local_overrides_cleared": "تنظیمات دستگاه پاک شدن", + "local_overrides_description": "این دستگاه تنظیمات خوانندهای داره که با تنظیمات پیشفرض کلیت فرق دارن:", + "clear_defaults": "همه پیشفرضها رو پاک کن", + "description": "تنظیم متن پیشفرض برای بخش خواننده رو پیکربندی کن. این تنظیمات با بقیه دستگاههات هماهنگ میشه.", + "defaults_cleared": "پیشفرضهای قسمت خواننده پاک شدن", + "save_hint": "ذخیره تنظیمات فقط برای این دستگاه یا همگام سازی بین همه دستگاه ها", + "save_as_default": "به عنوان پیشفرض ذخیره کن", + "save_to_device": "این دستگاه", + "sans": "بدون سری", + "tooltip_preview_and_local": "تغییرات پیشنمایش ذخیره نشده؛ تنظیمات دستگاه با تنظیمات کلی فرق داره", + "adjust_hint": "برای پیش نمایش تغییرات، تنظیمات بالا را تنظیم کنید" + }, + "avatar": { + "upload": "بارگذاری آواتار", + "change": "تغییر آواتار", + "remove_confirm_title": "آواتار حذف بشه؟", + "updated": "آواتار بهروز شد", + "removed": "آواتار حذف شد", + "description": "یه عکس مربع بارگذاری کن تا به عنوان آواتارت استفاده بشه.", + "remove_confirm_description": "این کار عکس پروفایل فعلیتو پاک میکنه.", + "title": "عکس پروفایل", + "remove": "حذف آواتار" } }, "user_settings": "تنظیمات کاربر", @@ -183,7 +230,21 @@ "text_prompt": "پرامپت متنی", "text_tagging": "برچسبگذاری متن", "summarization_prompt": "پرامپت خلاصهسازی", - "summarization": "خلاصهسازی" + "summarization": "خلاصهسازی", + "tag_style": "استایل برچسب", + "auto_summarization_description": "بهطور خودکار با استفاده از هوش مصنوعی برای نشانکهایت خلاصه تولید کن.", + "auto_tagging": "برچسبگذاری خودکار", + "titlecase_spaces": "حالت عنوان با فاصلهها", + "lowercase_underscores": "حروف کوچک با زیرخطها", + "inference_language": "زبان استنباطی", + "titlecase_hyphens": "حالت عنوان با خط تیره", + "lowercase_hyphens": "حروف کوچک با خط تیره", + "lowercase_spaces": "حروف کوچک با فاصلهها", + "inference_language_description": "زبانی را برای برچسبها و خلاصههای تولید شده توسط هوش مصنوعی انتخاب کنید.", + "tag_style_description": "انتخاب کنید که برچسبهای تولیدشده خودکار شما چگونه قالببندی شوند.", + "auto_tagging_description": "بهطور خودکار با استفاده از هوش مصنوعی برای نشانکهایت برچسب تولید کن.", + "camelCase": "camelCase", + "auto_summarization": "خلاصهسازی خودکار" }, "feeds": { "feed_enabled": "خوراک RSS فعال شد", @@ -214,6 +275,7 @@ "import_bookmarks_from_html_file": "درونریزی نشانکها از فایل HTML", "import_export_bookmarks": "درونریزی / برونبری نشانکها", "import_bookmarks_from_pocket_export": "درونریزی نشانکها از خروجی Pocket", + "import_bookmarks_from_matter_export": "درونریزی نشانکها از خروجی Matter", "import_bookmarks_from_omnivore_export": "درونریزی نشانکها از خروجی Omnivore", "import_bookmarks_from_linkwarden_export": "درونریزی نشانکها از خروجی Linkwarden", "import_bookmarks_from_tab_session_manager_export": "درونریزی نشانکها از Tab Session Manager", @@ -656,7 +718,14 @@ "is_not_from_feed": "از فید RSS نیست", "and": "و", "or": "یا", - "history": "جستجوهای اخیر" + "history": "جستجوهای اخیر", + "is_broken_link": "لینک خراب دارد", + "tags": "برچسبها", + "no_suggestions": "بدون پیشنهادها", + "filters": "فیلترها", + "is_not_broken_link": "لینک درست دارد", + "lists": "فهرستها", + "feeds": "فیدها" }, "preview": { "view_original": "مشاهدهی اصلی", @@ -665,7 +734,8 @@ "tabs": { "content": "محتوا", "details": "جزئیات" - } + }, + "archive_info": "ممکنه آرشیوها اگه نیاز به جاوااسکریپت داشته باشن، درست نشون داده نشن. برای بهترین نتیجه، <1>اونو دانلود و تو مرورگر بازش کن</1>." }, "editor": { "quickly_focus": "با فشردن ⌘ + E میتوانید به سرعت روی این فیلد تمرکز کنید", @@ -739,7 +809,8 @@ "refetch": "دوباره واکشی به صف اضافه شد!", "full_page_archive": "ایجاد بایگانی کامل صفحه آغاز شد", "delete_from_list": "نشانک از فهرست حذف شد", - "clipboard_copied": "لینک به کلیپبورد شما اضافه شد!" + "clipboard_copied": "لینک به کلیپبورد شما اضافه شد!", + "preserve_pdf": "نگهداری پیدیاف فعال شدهاست" }, "lists": { "created": "فهرست درست شد!", diff --git a/apps/web/lib/i18n/locales/fi/translation.json b/apps/web/lib/i18n/locales/fi/translation.json index 33717a24..06660ccd 100644 --- a/apps/web/lib/i18n/locales/fi/translation.json +++ b/apps/web/lib/i18n/locales/fi/translation.json @@ -39,7 +39,9 @@ "url": "URL", "quota": "Kiintiö", "bookmarks": "Kirjanmerkit", - "storage": "Tallennustila" + "storage": "Tallennustila", + "pdf": "Arkistoitu PDF", + "default": "Oletus" }, "layouts": { "masonry": "Tiililadonta", @@ -90,7 +92,9 @@ "confirm": "Vahvista", "regenerate": "Uudista", "load_more": "Lataa lisää", - "edit_notes": "Muokkaa muistiinpanoja" + "edit_notes": "Muokkaa muistiinpanoja", + "preserve_as_pdf": "Säilytä PDF-muodossa", + "offline_copies": "Offline-kopiot" }, "highlights": { "no_highlights": "Sulla ei oo vielä yhtään korostusta." @@ -119,6 +123,49 @@ "show": "Näytä arkistoidut kirjanmerkit tunnisteissa ja listoissa", "hide": "Piilota arkistoidut kirjanmerkit tunnisteissa ja listoissa" } + }, + "reader_settings": { + "local_overrides_title": "Laitteen omat asetukset ovat käytössä", + "using_default": "Käytetään asiakkaan oletusarvoa", + "clear_override_hint": "Tyhjennä laitteen ohitus, jotta voit käyttää globaalia asetusta ({{value}})", + "font_size": "Fonttikoko", + "font_family": "Fonttiperhe", + "preview_inline": "(esikatselu)", + "tooltip_preview": "Tallentamattomia esikatselun muutoksia", + "save_to_all_devices": "Kaikissa laitteissa", + "tooltip_local": "Laitteen asetukset poikkeavat globaaleista", + "reset_preview": "Nollaa esikatselu", + "mono": "Monospace", + "line_height": "Rivikorkeus", + "tooltip_default": "Lukemisen asetukset", + "title": "Lukijan asetukset", + "serif": "Serif", + "preview": "Esikatselu", + "not_set": "Ei asetettu", + "clear_local_overrides": "Tyhjennä laitteen asetukset", + "preview_text": "The quick brown fox jumps over the lazy dog. Näin lukijanäkymän tekstisi näkyy.", + "local_overrides_cleared": "Laitteen omat asetukset on tyhjennetty", + "local_overrides_description": "Tässä laitteessa on lukija-asetukset, jotka poikkeavat yleisistä oletusarvoistasi:", + "clear_defaults": "Tyhjennä kaikki oletusarvot", + "description": "Määritä lukijanäkymän oletustekstiasetukset. Nämä asetukset synkronoidaan kaikkien laitteidesi välillä.", + "defaults_cleared": "Lukijan oletusarvot on tyhjennetty", + "save_hint": "Tallenna asetukset vain tälle laitteelle tai synkronoi kaikkiin laitteisiin", + "save_as_default": "Tallenna oletusarvoksi", + "save_to_device": "Tällä laitteella", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Tallentamattomia esikatselun muutoksia; laitteen asetukset poikkeavat globaaleista", + "adjust_hint": "Säädä yllä olevia asetuksia, jotta näet muutokset" + }, + "avatar": { + "upload": "Lataa avatar", + "change": "Vaihda avatar", + "remove_confirm_title": "Poistetaanko avatar?", + "updated": "Avatar päivitetty", + "removed": "Avatar poistettu", + "description": "Lataa neliön muotoinen kuva, jota käytetään avatarinasi.", + "remove_confirm_description": "Tämä poistaa nykyisen profiilikuvasi.", + "title": "Profiilikuva", + "remove": "Poista avatar" } }, "ai": { @@ -132,7 +179,21 @@ "all_tagging": "Kaikki tägääminen", "text_tagging": "Tekstin merkitseminen", "image_tagging": "Kuvien merkitseminen", - "summarization": "Yhteenvedon luonti" + "summarization": "Yhteenvedon luonti", + "tag_style": "Tagityyli", + "auto_summarization_description": "Luo kirjanmerkeillesi automaattisesti tiivistelmiä tekoälyn avulla.", + "auto_tagging": "Automaattinen tägääminen", + "titlecase_spaces": "Isot alkukirjaimet ja välilyönnit", + "lowercase_underscores": "Pienet kirjaimet ja alleviivat", + "inference_language": "Päättelykieli", + "titlecase_hyphens": "Isot alkukirjaimet ja yhdysmerkit", + "lowercase_hyphens": "Pienet kirjaimet ja yhdysmerkit", + "lowercase_spaces": "Pienet kirjaimet ja välilyönnit", + "inference_language_description": "Valitse kieli AI-generoiduille tunnisteille ja yhteenvedoille.", + "tag_style_description": "Valitse, miten automaattisesti luotujen tunnisteiden muoto tulisi olla.", + "auto_tagging_description": "Luo kirjanmerkeillesi automaattisesti tägejä tekoälyn avulla.", + "camelCase": "camelCase", + "auto_summarization": "Automaattinen tiivistys" }, "feeds": { "rss_subscriptions": "RSS-tilaukset", @@ -163,6 +224,7 @@ "import_export_bookmarks": "Kirjanmerkkien tuonti / vienti", "import_bookmarks_from_html_file": "Tuo kirjanmerkkejä HTML-tiedostosta", "import_bookmarks_from_pocket_export": "Tuo kirjanmerkit Pocket-viennistä", + "import_bookmarks_from_matter_export": "Tuo kirjanmerkit Matter-viennistä", "import_bookmarks_from_omnivore_export": "Tuo kirjanmerkit Omnivore-viennistä", "import_bookmarks_from_linkwarden_export": "Tuo kirjanmerkit Linkwarden-viennistä", "import_bookmarks_from_hoarder_export": "Tuo kirjanmerkit Hoarder-viennistä", @@ -681,7 +743,14 @@ "year_s_ago": " Vuosi(a) sitten", "history": "Viimeaikaiset haut", "title_contains": "Otsikko sisältää", - "title_does_not_contain": "Otsikko ei sisällä" + "title_does_not_contain": "Otsikko ei sisällä", + "is_broken_link": "On rikkinäinen linkki", + "tags": "Tunnisteet", + "no_suggestions": "Ei ehdotuksia", + "filters": "Suodattimet", + "is_not_broken_link": "On toimiva linkki", + "lists": "Listat", + "feeds": "Syötteet" }, "preview": { "view_original": "Näytä alkuperäinen", @@ -690,7 +759,8 @@ "tabs": { "content": "Sisältö", "details": "Tiedot" - } + }, + "archive_info": "Arkistot eivät välttämättä hahmotu oikein, jos ne vaativat Javascriptiä. Parhaan tuloksen saat, kun <1>lataat sen ja avaat sen selaimessasi</1>." }, "editor": { "quickly_focus": "Voit nopeasti kohdistaa tähän kenttään painamalla ⌘ + E", @@ -764,7 +834,8 @@ "refetch": "Uudelleennouto on jonossa!", "full_page_archive": "Koko sivun arkiston luonti on käynnistetty", "delete_from_list": "Kirjanmerkki on poistettu luettelosta", - "clipboard_copied": "Linkki on lisätty leikepöydälle!" + "clipboard_copied": "Linkki on lisätty leikepöydälle!", + "preserve_pdf": "PDF:nä säilytys on käynnistetty" }, "lists": { "created": "Lista on luotu!", diff --git a/apps/web/lib/i18n/locales/fr/translation.json b/apps/web/lib/i18n/locales/fr/translation.json index 3028d91d..94cb7b03 100644 --- a/apps/web/lib/i18n/locales/fr/translation.json +++ b/apps/web/lib/i18n/locales/fr/translation.json @@ -39,7 +39,9 @@ "summary": "Résumé", "quota": "Quota", "bookmarks": "Marque-pages", - "storage": "Stockage" + "storage": "Stockage", + "pdf": "PDF archivé", + "default": "Par défaut" }, "layouts": { "masonry": "Mosaïque", @@ -90,7 +92,9 @@ "confirm": "Confirmer", "regenerate": "Régénérer", "load_more": "En charger plus", - "edit_notes": "Modifier les notes" + "edit_notes": "Modifier les notes", + "preserve_as_pdf": "Conserver en PDF", + "offline_copies": "Copies hors ligne" }, "settings": { "back_to_app": "Retour à l'application", @@ -116,6 +120,49 @@ "open_external_url": "Ouvrir l’URL d’origine", "open_bookmark_details": "Ouvrir les détails du marque-page" } + }, + "reader_settings": { + "local_overrides_title": "Paramètres spécifiques à l’appareil actifs", + "using_default": "Utilisation des paramètres par défaut du client", + "clear_override_hint": "Effacer la substitution de l’appareil pour utiliser le paramètre général ({{value}})", + "font_size": "Taille de la police", + "font_family": "Famille de polices", + "preview_inline": "(aperçu)", + "tooltip_preview": "Modifications de l’aperçu non enregistrées", + "save_to_all_devices": "Tous les appareils", + "tooltip_local": "Les paramètres de l’appareil diffèrent des paramètres généraux", + "reset_preview": "Réinitialiser l’aperçu", + "mono": "Monospace", + "line_height": "Hauteur de ligne", + "tooltip_default": "Paramètres de lecture", + "title": "Paramètres du lecteur", + "serif": "Avec empattement", + "preview": "Aperçu", + "not_set": "Non défini", + "clear_local_overrides": "Effacer les paramètres de l’appareil", + "preview_text": "Le rapide renard brun saute par-dessus le chien paresseux. Voici comment apparaîtra le texte de votre affichage de lecteur.", + "local_overrides_cleared": "Les paramètres spécifiques à l’appareil ont été effacés", + "local_overrides_description": "Cet appareil a des paramètres de lecteur qui diffèrent de vos paramètres par défaut globaux :", + "clear_defaults": "Effacer toutes les valeurs par défaut", + "description": "Configurez les paramètres de texte par défaut pour l’affichage du lecteur. Ces paramètres sont synchronisés sur tous vos appareils.", + "defaults_cleared": "Les paramètres par défaut du lecteur ont été supprimés", + "save_hint": "Enregistrer les paramètres pour cet appareil uniquement ou synchroniser avec tous les appareils", + "save_as_default": "Enregistrer comme valeurs par défaut", + "save_to_device": "Cet appareil", + "sans": "Sans empattement", + "tooltip_preview_and_local": "Modifications de l’aperçu non enregistrées ; les paramètres de l’appareil diffèrent des paramètres généraux", + "adjust_hint": "Ajustez les paramètres ci-dessus pour prévisualiser les modifications" + }, + "avatar": { + "upload": "Téléverser un avatar", + "change": "Changer d’avatar", + "remove_confirm_title": "Supprimer l’avatar ?", + "updated": "Avatar mis à jour", + "removed": "Avatar supprimé", + "description": "Téléversez une image carrée à utiliser comme avatar.", + "remove_confirm_description": "Cela supprimera votre photo de profil actuelle.", + "title": "Photo de profil", + "remove": "Supprimer l’avatar" } }, "ai": { @@ -129,7 +176,21 @@ "all_tagging": "Tout le tagging", "text_tagging": "Balises de texte", "image_tagging": "Marquage d'image", - "summarization": "Résumer" + "summarization": "Résumer", + "tag_style": "Style des balises", + "auto_summarization_description": "Générez automatiquement des résumés pour vos favoris à l’aide de l’IA.", + "auto_tagging": "Attribution automatique de balises", + "titlecase_spaces": "Majuscule en début de mot avec espaces", + "lowercase_underscores": "Minuscules avec traits de soulignement", + "inference_language": "Langue d’inférence", + "titlecase_hyphens": "Majuscule en début de mot avec tirets", + "lowercase_hyphens": "Minuscules avec tirets", + "lowercase_spaces": "Minuscules avec espaces", + "inference_language_description": "Choisissez la langue pour les balises et les résumés générés par l’IA.", + "tag_style_description": "Choisissez le format de vos balises générées automatiquement.", + "auto_tagging_description": "Générez automatiquement des balises pour vos favoris à l’aide de l’IA.", + "camelCase": "camelCase", + "auto_summarization": "Résumés automatiques" }, "feeds": { "rss_subscriptions": "Abonnements RSS", @@ -142,6 +203,7 @@ "import_export_bookmarks": "Importer / Exporter des favoris", "import_bookmarks_from_html_file": "Importer des favoris depuis un fichier HTML", "import_bookmarks_from_pocket_export": "Importer des favoris depuis une exportation Pocket", + "import_bookmarks_from_matter_export": "Importer des favoris depuis une exportation Matter", "import_bookmarks_from_omnivore_export": "Importer des favoris depuis une exportation Omnivore", "import_bookmarks_from_karakeep_export": "Importer des favoris depuis une exportation Karakeep", "export_links_and_notes": "Exporter les liens et les notes", @@ -646,7 +708,8 @@ "tabs": { "details": "Détails", "content": "Contenu" - } + }, + "archive_info": "Les archives peuvent ne pas s'afficher correctement en ligne si elles nécessitent Javascript. Pour de meilleurs résultats, <1>téléchargez-les et ouvrez-les dans votre navigateur</1>." }, "editor": { "quickly_focus": "Vous pouvez rapidement vous concentrer sur ce champ en appuyant sur ⌘ + E", @@ -714,7 +777,8 @@ "refetch": "Re-fetch a été mis en file d'attente !", "full_page_archive": "La création de l'archive de la page complète a été déclenchée", "delete_from_list": "Le favori a été supprimé de la liste", - "clipboard_copied": "Le lien a été ajouté à votre presse-papiers !" + "clipboard_copied": "Le lien a été ajouté à votre presse-papiers !", + "preserve_pdf": "La conservation en PDF a été déclenchée" }, "lists": { "created": "La liste a été créée !", @@ -772,7 +836,14 @@ "year_s_ago": " Il y a {years} an(s)", "history": "Recherches récentes", "title_contains": "Le titre contient", - "title_does_not_contain": "Le titre ne contient pas" + "title_does_not_contain": "Le titre ne contient pas", + "is_broken_link": "A un lien brisé", + "tags": "Balises", + "no_suggestions": "Pas de suggestions", + "filters": "Filtres", + "is_not_broken_link": "A un lien fonctionnel", + "lists": "Listes", + "feeds": "Flux" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/ga/translation.json b/apps/web/lib/i18n/locales/ga/translation.json index abf4ecaf..b132ca45 100644 --- a/apps/web/lib/i18n/locales/ga/translation.json +++ b/apps/web/lib/i18n/locales/ga/translation.json @@ -39,7 +39,9 @@ }, "quota": "Cuóta", "bookmarks": "Leabhair mharcála", - "storage": "Stóráil" + "storage": "Stóráil", + "pdf": "PDF Cartlainne", + "default": "Réamhshocrú" }, "actions": { "close": "Dún", @@ -84,7 +86,9 @@ "confirm": "Deimhnigh", "regenerate": "Athghinigh", "load_more": "Luchtaigh Níos Mó", - "edit_notes": "Nótaí a Chur in Eagar" + "edit_notes": "Nótaí a Chur in Eagar", + "preserve_as_pdf": "Caomhnaigh mar PDF", + "offline_copies": "Cóipeanna As Líne" }, "settings": { "ai": { @@ -98,7 +102,21 @@ "all_tagging": "Gach Clibeáil", "text_tagging": "Clibeáil Téacs", "image_tagging": "Clibeáil Íomhá", - "summarization": "Achoimre" + "summarization": "Achoimre", + "tag_style": "Stíl Clibe", + "auto_summarization_description": "Achoimrí a ghiniúint go huathoibríoch do do leabharmharcanna ag úsáid AI.", + "auto_tagging": "Uathchlibeáil", + "titlecase_spaces": "Cás teidil le spásanna", + "lowercase_underscores": "Cás íseal le fostríocaí", + "inference_language": "Teanga Inbhainte", + "titlecase_hyphens": "Cás teidil le fleiscíní", + "lowercase_hyphens": "Cás íseal le fleiscíní", + "lowercase_spaces": "Cás íseal le spásanna", + "inference_language_description": "Roghnaigh teanga do chlibeanna agus achoimrí arna nginiúint ag AI.", + "tag_style_description": "Roghnaigh conas ar cheart do chlibeanna uathghinte a bheith formáidithe.", + "auto_tagging_description": "Clibeanna a ghiniúint go huathoibríoch do do leabharmharcanna ag úsáid AI.", + "camelCase": "camelCase", + "auto_summarization": "Uathachoimriú" }, "webhooks": { "webhooks": "Crúcaí Gréasáin", @@ -210,6 +228,49 @@ "show": "Taispeáin leabhair mharcáilte atá cartlannaithe i gclibeanna agus i liostaí", "hide": "Folaigh leabharmharcanna cartlannaithe i gclibeanna agus i liostaí" } + }, + "reader_settings": { + "local_overrides_title": "Socruithe gléas-sonracha gníomhach", + "using_default": "Ag baint úsáide as réamhshocrú an chliaint", + "clear_override_hint": "Glan sárú gléis chun socrú ginearálta a úsáid ({{value}})", + "font_size": "Méid Cló", + "font_family": "Cló-Aicme", + "preview_inline": "(réamhamharc)", + "tooltip_preview": "Athruithe réamhamhairc neamhshábháilte", + "save_to_all_devices": "Gach gléas", + "tooltip_local": "Tá socruithe gléis difriúil ó shocruithe ginearálta", + "reset_preview": "Athshocraigh réamhamharc", + "mono": "Monaspás", + "line_height": "Airde Líne", + "tooltip_default": "Socruithe léitheoireachta", + "title": "Socruithe Léitheora", + "serif": "Searif", + "preview": "Réamhamharc", + "not_set": "Níl sé socraithe", + "clear_local_overrides": "Glan socruithe gléis", + "preview_text": "Léimeann an sionnach rua tapa thar an madra leisciúil. Seo an chuma a bheidh ar théacs do radhairc léitheora.", + "local_overrides_cleared": "Tá socruithe gléas-sonracha glanta", + "local_overrides_description": "Tá socruithe léitheora ag an ngléas seo atá difriúil ó do réamhshocruithe domhanda:", + "clear_defaults": "Glan gach réamhshocrú", + "description": "Cumraigh socruithe téacs réamhshocraithe do radharc an léitheora. Déantar na socruithe seo a shioncronú ar fud do ghléasanna go léir.", + "defaults_cleared": "Tá réamhshocruithe léitheora glanta", + "save_hint": "Sábháil socruithe don ghléas seo amháin nó sioncronaigh ar gach gléas", + "save_as_default": "Sábháil mar réamhshocrú", + "save_to_device": "An gléas seo", + "sans": "Sans Searif", + "tooltip_preview_and_local": "Athruithe réamhamhairc neamhshábháilte; tá socruithe gléis difriúil ó shocruithe ginearálta", + "adjust_hint": "Coigeartaigh na socruithe thuas chun athruithe a réamhamharc" + }, + "avatar": { + "upload": "Uaslódáil avatar", + "change": "Athraigh avatar", + "remove_confirm_title": "Bain avatar?", + "updated": "Nuashonraíodh avatar", + "removed": "Baineadh avatar", + "description": "Uaslódáil íomhá chearnach le húsáid mar avatar.", + "remove_confirm_description": "Glanfaidh sé seo an grianghraf próifíle atá agat faoi láthair.", + "title": "Grianghraf Próifíle", + "remove": "Bain avatar" } }, "feeds": { @@ -223,6 +284,7 @@ "import_export_bookmarks": "Iompórtáil / Easpórtáil Leabharmharcanna", "import_bookmarks_from_html_file": "Iompórtáil Leabharmharcanna ó chomhad HTML", "import_bookmarks_from_pocket_export": "Iompórtáil Leabharmharcanna ó onnmhairiú Pocket", + "import_bookmarks_from_matter_export": "Iompórtáil Leabharmharcanna ó onnmhairiú Matter", "import_bookmarks_from_omnivore_export": "Iompórtáil Leabharcmharcanna ó onnmhairiú Omnivore", "import_bookmarks_from_linkwarden_export": "Iompórtáil Leabharmharcanna ó onnmhairiú Linkwarden", "import_bookmarks_from_karakeep_export": "Iompórtáil Leabharmharcanna ó onnmhairiú Karakeep", @@ -537,7 +599,14 @@ "or": "Nó", "history": "Cuardaigh Déanaí", "title_contains": "Tá Teideal I Láthair", - "title_does_not_contain": "Níl Teideal I Láthair" + "title_does_not_contain": "Níl Teideal I Láthair", + "is_broken_link": "Tá Nasc Briste Ann", + "tags": "Clibeanna", + "no_suggestions": "Níl moltaí ar bith ann", + "filters": "Scagairí", + "is_not_broken_link": "Tá Nasc Oibre Ann", + "lists": "Liostaí", + "feeds": "Fothaí" }, "editor": { "disabled_submissions": "Tá aighneachtaí díchumasaithe", @@ -605,7 +674,8 @@ "refetch": "Cuireadh atógáil sa scuaine!", "full_page_archive": "Tá cruthú Cartlainne Leathanach Iomlán tosaithe", "delete_from_list": "Scriosadh an leabharmharc ón liosta", - "clipboard_copied": "Tá an nasc curtha le do ghearrthaisce!" + "clipboard_copied": "Tá an nasc curtha le do ghearrthaisce!", + "preserve_pdf": "Tá caomhnú PDF tosaithe" }, "lists": { "created": "Cruthaíodh liosta!", @@ -778,7 +848,8 @@ "tabs": { "content": "Ábhar", "details": "Sonraí" - } + }, + "archive_info": "Seans nach ndéanfaidh cartlanna rindreáil i gceart inline má tá Javascript ag teastáil uathu. Chun na torthaí is fearr a fháil, <1>íoslódáil é agus oscail i do bhrabhsálaí</1>." }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/gl/translation.json b/apps/web/lib/i18n/locales/gl/translation.json index 40dcc3a6..9fe11f1a 100644 --- a/apps/web/lib/i18n/locales/gl/translation.json +++ b/apps/web/lib/i18n/locales/gl/translation.json @@ -39,7 +39,9 @@ "summary": "Resumo", "quota": "Cota", "bookmarks": "Marcadores", - "storage": "Almacenamento" + "storage": "Almacenamento", + "pdf": "PDF Arquivado", + "default": "Predeterminado" }, "actions": { "favorite": "Marcar como favorito", @@ -84,7 +86,9 @@ "confirm": "Confirmar", "regenerate": "Rexenerar", "load_more": "Cargar máis", - "edit_notes": "Editar notas" + "edit_notes": "Editar notas", + "preserve_as_pdf": "Gardar como PDF", + "offline_copies": "Copias sen conexión" }, "tags": { "drag_and_drop_merging_info": "Arrastra e solta etiquetas sobre outras para unilas", @@ -117,7 +121,8 @@ "refetch": "Solicitouse a actualización!", "full_page_archive": "Pediuse un Arquivo de Páxina Completa", "delete_from_list": "O marcador borrouse da lista", - "clipboard_copied": "A ligazón copiouse no teu portapapeis!" + "clipboard_copied": "A ligazón copiouse no teu portapapeis!", + "preserve_pdf": "Activouse a preservación en PDF" }, "lists": { "updated": "A lista foi actualizada!", @@ -149,6 +154,7 @@ "import_export_bookmarks": "Importar / Exportar marcadores", "import_bookmarks_from_html_file": "Importar marcadores desde arquivo HTML", "import_bookmarks_from_pocket_export": "Importar marcadores desde Pocket", + "import_bookmarks_from_matter_export": "Importar marcadores desde Matter", "import_bookmarks_from_omnivore_export": "Importar marcadores desde Omnivore", "import_bookmarks_from_linkwarden_export": "Importar marcadores desde Linkwarden", "import_bookmarks_from_karakeep_export": "Importar marcadores desde Karakeep", @@ -180,6 +186,49 @@ "show": "Mostrar os marcadores arquivados en etiquetas e listas", "hide": "Ocultar os marcadores arquivados en etiquetas e listas" } + }, + "reader_settings": { + "local_overrides_title": "Axustes específicos do dispositivo activos", + "using_default": "Usando o predeterminado do cliente", + "clear_override_hint": "Limpa a anulación do dispositivo para usar a configuración global ({{value}})", + "font_size": "Tamaño da letra", + "font_family": "Familia tipográfica", + "preview_inline": "(vista previa)", + "tooltip_preview": "Cambios da vista previa sen gardar", + "save_to_all_devices": "Todos os dispositivos", + "tooltip_local": "Os axustes do dispositivo difiren dos globais", + "reset_preview": "Restabelecer a vista previa", + "mono": "Monoespazo", + "line_height": "Alto de liña", + "tooltip_default": "Axustes de lectura", + "title": "Axustes do Reader", + "serif": "Con serifas", + "preview": "Vista previa", + "not_set": "Sen axustar", + "clear_local_overrides": "Eliminar axustes do dispositivo", + "preview_text": "A raposa marrón rápida salta sobre o can preguiceiro. Así é como aparecerá o texto da vista do lector.", + "local_overrides_cleared": "Elimináronse os axustes específicos do dispositivo", + "local_overrides_description": "Este dispositivo ten parámetros de lector que difieren dos teus predeterminados globais:", + "clear_defaults": "Borrar todos os predeterminados", + "description": "Configure os axustes de texto predeterminados para a vista do lector. Estes axustes sincronízanse en todos os teus dispositivos.", + "defaults_cleared": "Elimináronse os valores predeterminados do lector", + "save_hint": "Garda os axustes só para este dispositivo ou sincronízaos en todos os dispositivos", + "save_as_default": "Gardar como predeterminado", + "save_to_device": "Este dispositivo", + "sans": "Sen serifas", + "tooltip_preview_and_local": "Cambios da vista previa sen gardar; os axustes do dispositivo difiren dos globais", + "adjust_hint": "Axusta os axustes de arriba para previsualizar os cambios" + }, + "avatar": { + "upload": "Subir avatar", + "change": "Cambiar o avatar", + "remove_confirm_title": "Queres eliminar o avatar?", + "updated": "Avatar actualizado", + "removed": "Avatar eliminado", + "description": "Sube unha imaxe cadrada para usar como avatar.", + "remove_confirm_description": "Isto borrará a túa foto de perfil actual.", + "title": "Foto de perfil", + "remove": "Eliminar o avatar" } }, "ai": { @@ -193,7 +242,21 @@ "all_tagging": "Todas as etiquetas", "text_tagging": "Etiquetaxe de texto", "image_tagging": "Etiquetaxe de imaxes", - "summarization": "Resumo" + "summarization": "Resumo", + "tag_style": "Estilo da etiqueta", + "auto_summarization_description": "Xera automaticamente resumos para os teus marcadores usando a intelixencia artificial.", + "auto_tagging": "Etiquetado automático", + "titlecase_spaces": "Maiúsculas e minúsculas con espazos", + "lowercase_underscores": "Minúsculas con guións baixos", + "inference_language": "Linguaxe dedución", + "titlecase_hyphens": "Maiúsculas só na primeira palabra con guións", + "lowercase_hyphens": "Minúsculas con guións", + "lowercase_spaces": "Minúsculas con espazos", + "inference_language_description": "Elixe a lingua para as etiquetas e os resumos xerados pola IA.", + "tag_style_description": "Elixe como se deben formatar as etiquetas xeradas automaticamente.", + "auto_tagging_description": "Xera automaticamente etiquetas para os teus marcadores usando a intelixencia artificial.", + "camelCase": "camelCase (a primeira palabra en minúsculas e as seguintes en maiúsculas)", + "auto_summarization": "Resumo automático" }, "feeds": { "rss_subscriptions": "Subscricións RSS", @@ -676,7 +739,8 @@ "tabs": { "content": "Contido", "details": "Detalles" - } + }, + "archive_info": "É posible que os arquivos non se representen correctamente en liña se requiren Javascript. Para obter os mellores resultados, <1>descárgueo e ábreo no navegador</1>." }, "editor": { "quickly_focus": "Podes enfocar este campo pulsando ⌘ + E", @@ -775,7 +839,14 @@ "year_s_ago": " Hai anos", "history": "Buscas recentes", "title_contains": "O título contén", - "title_does_not_contain": "O título non contén" + "title_does_not_contain": "O título non contén", + "is_broken_link": "Ten Ligazón Rota", + "tags": "Etiquetas", + "no_suggestions": "Sen suxestións", + "filters": "Filtros", + "is_not_broken_link": "Ten Ligazón Válida", + "lists": "Listas", + "feeds": "Fontes" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/hr/translation.json b/apps/web/lib/i18n/locales/hr/translation.json index bd7a7a9d..7ef093d0 100644 --- a/apps/web/lib/i18n/locales/hr/translation.json +++ b/apps/web/lib/i18n/locales/hr/translation.json @@ -175,7 +175,9 @@ "summary": "Sažetak", "quota": "Kvota", "bookmarks": "Oznake", - "storage": "Pohrana" + "storage": "Pohrana", + "pdf": "Arhivirani PDF", + "default": "Zadano" }, "settings": { "ai": { @@ -189,7 +191,21 @@ "text_tagging": "Označavanje teksta", "image_tagging": "Označavanje slika", "summarization": "Sažetak", - "all_tagging": "Sve oznake" + "all_tagging": "Sve oznake", + "tag_style": "Stil oznake", + "auto_summarization_description": "Automatski generiraj sažetke za svoje knjižne oznake pomoću AI-ja.", + "auto_tagging": "Automatsko označavanje", + "titlecase_spaces": "Veliko početno slovo s razmacima", + "lowercase_underscores": "Mala slova s podvlakama", + "inference_language": "Jezik zaključka", + "titlecase_hyphens": "Veliko početno slovo s crticama", + "lowercase_hyphens": "Mala slova s crticama", + "lowercase_spaces": "Mala slova s razmacima", + "inference_language_description": "Odaberi jezik za oznake i sažetke generirane pomoću AI-a.", + "tag_style_description": "Odaberi kako će tvoje automatski generirane oznake biti formatirane.", + "auto_tagging_description": "Automatski generiraj oznake za svoje knjižne oznake pomoću AI-ja.", + "camelCase": "camelCase", + "auto_summarization": "Automatsko sažimanje" }, "import": { "import_bookmarks_from_html_file": "Import knjižnih oznaka iz HTML datoteke", @@ -197,6 +213,7 @@ "import_export_bookmarks": "Import / Export knjižnih oznaka", "import_bookmarks_from_linkwarden_export": "Import oznaka iz Linkwarden exporta", "import_bookmarks_from_pocket_export": "Import oznaka iz Pocket exporta", + "import_bookmarks_from_matter_export": "Import oznaka iz Matter exporta", "import_bookmarks_from_karakeep_export": "Import oznaka iz Karakeep exporta", "export_links_and_notes": "Export veza i bilješki", "imported_bookmarks": "Importirane oznake", @@ -227,6 +244,49 @@ "show": "Prikaži arhivirane oznake u oznakama i popisima", "hide": "Sakrij arhivirane oznake u oznakama i popisima" } + }, + "reader_settings": { + "local_overrides_title": "Aktivne postavke specifične za uređaj", + "using_default": "Korištenje zadanih postavki klijenta", + "clear_override_hint": "Obriši nadjačavanje uređaja za korištenje globalne postavke ({{value}})", + "font_size": "Veličina fonta", + "font_family": "Vrsta fonta", + "preview_inline": "(pregled)", + "tooltip_preview": "Nespremljene promjene pregleda", + "save_to_all_devices": "Svi uređaji", + "tooltip_local": "Postavke uređaja razlikuju se od globalnih", + "reset_preview": "Resetiraj pregled", + "mono": "Monospace", + "line_height": "Visina retka", + "tooltip_default": "Postavke čitanja", + "title": "Postavke čitača", + "serif": "Serif", + "preview": "Pregled", + "not_set": "Nije postavljeno", + "clear_local_overrides": "Očisti postavke uređaja", + "preview_text": "Smeđi lisac brzo skače preko lijenog psa. Ovako će izgledati tekst u prikazu čitača.", + "local_overrides_cleared": "Postavke specifične za uređaj su očišćene", + "local_overrides_description": "Ovaj uređaj ima postavke čitanja koje se razlikuju od tvojih globalnih zadanih postavki:", + "clear_defaults": "Očisti sve zadane vrijednosti", + "description": "Konfiguriraj zadane postavke teksta za prikaz čitača. Ove se postavke sinkroniziraju na svim tvojim uređajima.", + "defaults_cleared": "Zadane postavke čitača su očišćene", + "save_hint": "Spremi postavke samo za ovaj uređaj ili sinkroniziraj na svim uređajima", + "save_as_default": "Spremi kao zadane", + "save_to_device": "Ovaj uređaj", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Nespremljene promjene pregleda; postavke uređaja razlikuju se od globalnih", + "adjust_hint": "Prilagodite postavke iznad za pregled promjena" + }, + "avatar": { + "upload": "Učitaj avatar", + "change": "Promijeni avatar", + "remove_confirm_title": "Ukloniti avatar?", + "updated": "Avatar ažuriran", + "removed": "Avatar uklonjen", + "description": "Učitaj kvadratnu sliku koju ćeš koristiti kao avatar.", + "remove_confirm_description": "Ovim ćeš ukloniti trenutnu fotku profila.", + "title": "Fotka profila", + "remove": "Ukloni avatar" } }, "api_keys": { @@ -523,7 +583,9 @@ "confirm": "Potvrdi", "regenerate": "Ponovo stvori", "load_more": "Učitaj više", - "edit_notes": "Uredi bilješke" + "edit_notes": "Uredi bilješke", + "preserve_as_pdf": "Spremi kao PDF", + "offline_copies": "Izvanmrežne kopije" }, "highlights": { "no_highlights": "Još nemate nijednu istaknutu stavku." @@ -649,7 +711,8 @@ "tabs": { "content": "Sadržaj", "details": "Detalji" - } + }, + "archive_info": "Arhive se možda neće ispravno prikazati inline ako zahtijevaju Javascript. Za najbolje rezultate, <1>preuzmite ih i otvorite u svom pregledniku</1>." }, "editor": { "quickly_focus": "Možete brzo fokusirati ovo polje pritiskanjem ⌘ + E", @@ -717,7 +780,8 @@ "refetch": "Ponovno preuzimanje je stavljeno u čekanje!", "full_page_archive": "Pokrenuto je stvaranje potpune arhive stranice", "delete_from_list": "Oznaka je izbrisana s popisa", - "clipboard_copied": "Veza je dodana u vaš međuspremnik!" + "clipboard_copied": "Veza je dodana u vaš međuspremnik!", + "preserve_pdf": "Spremanje u PDF formatu je pokrenuto" }, "lists": { "created": "Popis je kreiran!", @@ -775,7 +839,14 @@ "year_s_ago": " Godina(e) prije", "history": "Nedavne pretrage", "title_contains": "Naslov sadrži", - "title_does_not_contain": "Naslov ne sadrži" + "title_does_not_contain": "Naslov ne sadrži", + "is_broken_link": "Ima pokvareni link", + "tags": "Oznake", + "no_suggestions": "Nema prijedloga", + "filters": "Filtri", + "is_not_broken_link": "Ima radni link", + "lists": "Popisi", + "feeds": "Kanali" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/hu/translation.json b/apps/web/lib/i18n/locales/hu/translation.json index 72439434..1399e4a8 100644 --- a/apps/web/lib/i18n/locales/hu/translation.json +++ b/apps/web/lib/i18n/locales/hu/translation.json @@ -42,7 +42,9 @@ "confirm": "Megerősít", "regenerate": "Újragenerálás", "load_more": "Továbbiak betöltése", - "edit_notes": "Jegyzetek szerkesztése" + "edit_notes": "Jegyzetek szerkesztése", + "preserve_as_pdf": "Mentés PDF-ként", + "offline_copies": "Offline példányok" }, "settings": { "user_settings": "Felhasználói beállítások", @@ -73,6 +75,49 @@ "show": "Archivált könyvjelzők megjelenítése címkékben és listákban", "hide": "Archivált könyvjelzők elrejtése címkékben és listákban" } + }, + "reader_settings": { + "local_overrides_title": "Eszközspecifikus beállítások aktívak", + "using_default": "Ügyfél alapértelmezettjének használata", + "clear_override_hint": "Eszközfelülírás törlése a globális beállítás ({{value}}) használatához", + "font_size": "Betűméret", + "font_family": "Betűtípus családja", + "preview_inline": "(előnézet)", + "tooltip_preview": "El nem mentett előnézeti módosítások", + "save_to_all_devices": "Minden eszköz", + "tooltip_local": "Az eszköz beállításai eltérnek a globálistól", + "reset_preview": "Előnézet visszaállítása", + "mono": "Monospace", + "line_height": "Sortávolság", + "tooltip_default": "Olvasási beállítások", + "title": "Olvasó beállításai", + "serif": "Serif", + "preview": "Előnézet", + "not_set": "Nincs beállítva", + "clear_local_overrides": "Eszközbeállítások törlése", + "preview_text": "A gyors barna róka átugorja a lusta kutyát. Így fog megjelenni az olvasónézeti szöveg.", + "local_overrides_cleared": "Az eszközspecifikus beállítások törölve lettek", + "local_overrides_description": "Ennek az eszköznek az olvasási beállításai eltérnek a globális alapértelmezésektől:", + "clear_defaults": "Összes alapértelmezett törlése", + "description": "Az olvasónézet alapértelmezett szövegbeállításainak konfigurálása. Ezek a beállítások szinkronizálva vannak az összes eszközén.", + "defaults_cleared": "Az olvasó alapértelmezései törölve", + "save_hint": "Beállítások mentése csak ehhez az eszközhöz, vagy szinkronizálás minden eszközre", + "save_as_default": "Mentés alapértelmezettként", + "save_to_device": "Ez az eszköz", + "sans": "Sans Serif", + "tooltip_preview_and_local": "El nem mentett előnézeti módosítások; az eszköz beállításai eltérnek a globálistól", + "adjust_hint": "A módosítások előnézetéhez állítsa be a fenti beállításokat" + }, + "avatar": { + "upload": "Avatár feltöltése", + "change": "Avatár módosítása", + "remove_confirm_title": "Avatár eltávolítása?", + "updated": "Avatár frissítve", + "removed": "Avatár eltávolítva", + "description": "Tölts fel egy négyzet alakú képet, amit avatárként használhatsz.", + "remove_confirm_description": "Ezzel törlöd a jelenlegi profilképed.", + "title": "Profilkép", + "remove": "Avatár eltávolítása" } }, "webhooks": { @@ -104,7 +149,21 @@ "images_prompt": "Utasítás képpel", "text_tagging": "Szöveg címkézés", "image_tagging": "Kép címkézés", - "summarization": "Összesítés" + "summarization": "Összesítés", + "tag_style": "Címke stílusa", + "auto_summarization_description": "A MI használatával automatikusan összefoglalókat generálhatsz a könyvjelzőidhez.", + "auto_tagging": "Automatikus címkézés", + "titlecase_spaces": "Címzett nagybetűs, szóközökkel", + "lowercase_underscores": "Kisbetűs, aláhúzásokkal", + "inference_language": "Következtetési nyelv", + "titlecase_hyphens": "Címzett nagybetűs, kötőjelekkel", + "lowercase_hyphens": "Kisbetűs, kötőjelekkel", + "lowercase_spaces": "Kisbetűs, szóközökkel", + "inference_language_description": "Válaszd ki az AI által generált címkék és összefoglalók nyelvét.", + "tag_style_description": "Válaszd ki, hogyan legyenek formázva az automatikusan létrehozott címkék.", + "auto_tagging_description": "A MI használatával automatikusan címkéket generálhatsz a könyvjelzőidhez.", + "camelCase": "camelCase", + "auto_summarization": "Automatikus összefoglalás" }, "api_keys": { "new_api_key": "Új API kulcs", @@ -125,6 +184,7 @@ "import_export_bookmarks": "Könyvjelző importálása / exportálása", "import_bookmarks_from_html_file": "Könyvjelző importálása HTML fájlból", "import_bookmarks_from_pocket_export": "Könyvjelző importálása Pocket-ből", + "import_bookmarks_from_matter_export": "Könyvjelző importálása Matter-ből", "import_bookmarks_from_linkwarden_export": "Könyvjelző importálása Linkwarden-ből", "export_links_and_notes": "Jegyzetek és hivatkozások exportálása", "imported_bookmarks": "Importált könyvjelzők", @@ -387,7 +447,9 @@ "summary": "Összegzés", "quota": "Keret", "bookmarks": "Könyvjelzők", - "storage": "Tárhely" + "storage": "Tárhely", + "pdf": "Archivált PDF", + "default": "Alapértelmezett" }, "editor": { "import_as_text": "Importálás szöveges könyvjelzőként", @@ -486,7 +548,14 @@ "year_s_ago": " Év(ek)kel ezelőtt", "history": "Legutóbbi keresések", "title_contains": "A cím tartalmazza", - "title_does_not_contain": "A cím nem tartalmazza" + "title_does_not_contain": "A cím nem tartalmazza", + "is_broken_link": "Van hibás link", + "tags": "Címkék", + "no_suggestions": "Nincsenek javaslatok", + "filters": "Szűrők", + "is_not_broken_link": "Van működő link", + "lists": "Listák", + "feeds": "Hírcsatornák" }, "lists": { "manual_list": "Manuális lista", @@ -745,7 +814,8 @@ "tabs": { "content": "Tartalom", "details": "Részletek" - } + }, + "archive_info": "Lehetséges, hogy a JavaScriptet igénylő archívumok nem jelennek meg helyesen beágyazva. A legjobb eredmény érdekében <1>töltsd le és nyisd meg a böngésződben</1>." }, "dialogs": { "bookmarks": { @@ -760,7 +830,8 @@ "refetch": "Újra begyűjtés beütemezve!", "full_page_archive": "Minden oldal lecserélése beütemezésre került", "delete_from_list": "A könyvjelző törlődött a listából", - "clipboard_copied": "A hivatkozás kimásolva a memóriába!" + "clipboard_copied": "A hivatkozás kimásolva a memóriába!", + "preserve_pdf": "A PDF archiválás elindult." }, "lists": { "created": "A hivatkozás létrejött!", diff --git a/apps/web/lib/i18n/locales/it/translation.json b/apps/web/lib/i18n/locales/it/translation.json index f154466d..d7fa773d 100644 --- a/apps/web/lib/i18n/locales/it/translation.json +++ b/apps/web/lib/i18n/locales/it/translation.json @@ -42,7 +42,9 @@ "confirm": "Conferma", "regenerate": "Rigenera", "load_more": "Carica altro", - "edit_notes": "Modifica note" + "edit_notes": "Modifica note", + "preserve_as_pdf": "Salva come PDF", + "offline_copies": "Copie offline" }, "common": { "attachments": "Allegati", @@ -84,7 +86,9 @@ "summary": "Riepilogo", "quota": "Quota", "bookmarks": "Segnalibri", - "storage": "Archiviazione" + "storage": "Archiviazione", + "pdf": "PDF archiviato", + "default": "Predefinito" }, "settings": { "broken_links": { @@ -114,6 +118,49 @@ "show": "Mostra i segnalibri archiviati in tag e liste", "hide": "Nascondi i segnalibri archiviati in tag e liste" } + }, + "reader_settings": { + "local_overrides_title": "Impostazioni specifiche del dispositivo attive", + "using_default": "Utilizzo predefinito del client", + "clear_override_hint": "Cancella la sostituzione del dispositivo per utilizzare l'impostazione globale ({{value}})", + "font_size": "Dimensione del font", + "font_family": "Famiglia di caratteri", + "preview_inline": "(anteprima)", + "tooltip_preview": "Modifiche all'anteprima non salvate", + "save_to_all_devices": "Tutti i dispositivi", + "tooltip_local": "Le impostazioni del dispositivo differiscono da quelle globali", + "reset_preview": "Ripristina l'anteprima", + "mono": "Monospace", + "line_height": "Altezza della linea", + "tooltip_default": "Impostazioni di lettura", + "title": "Impostazioni lettore", + "serif": "Serif", + "preview": "Anteprima", + "not_set": "Non impostato", + "clear_local_overrides": "Cancella impostazioni del dispositivo", + "preview_text": "The quick brown fox jumps over the lazy dog. Ecco come apparirà il testo nella visualizzazione del lettore.", + "local_overrides_cleared": "Le impostazioni specifiche del dispositivo sono state cancellate", + "local_overrides_description": "Questo dispositivo ha impostazioni del lettore diverse da quelle predefinite globali:", + "clear_defaults": "Cancella tutti i predefiniti", + "description": "Configura le impostazioni di testo predefinite per la visualizzazione del lettore. Queste impostazioni si sincronizzano su tutti i tuoi dispositivi.", + "defaults_cleared": "Le impostazioni predefinite del lettore sono state cancellate", + "save_hint": "Salva le impostazioni solo per questo dispositivo o sincronizza su tutti i dispositivi", + "save_as_default": "Salva come predefinito", + "save_to_device": "Questo dispositivo", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Modifiche all'anteprima non salvate; le impostazioni del dispositivo differiscono da quelle globali", + "adjust_hint": "Regola le impostazioni sopra per visualizzare l'anteprima delle modifiche" + }, + "avatar": { + "upload": "Carica avatar", + "change": "Cambia avatar", + "remove_confirm_title": "Rimuovere l'avatar?", + "updated": "Avatar aggiornato", + "removed": "Avatar rimosso", + "description": "Carica un'immagine quadrata da usare come avatar.", + "remove_confirm_description": "Ehm... rimuoverai la tua attuale foto del profilo.", + "title": "Foto profilo", + "remove": "Rimuovi avatar" } }, "back_to_app": "Torna all'App", @@ -129,7 +176,21 @@ "image_tagging": "Tagging immagini", "text_tagging": "Tagging testo", "all_tagging": "Tutte le etichette", - "summarization": "Riassunto" + "summarization": "Riassunto", + "tag_style": "Stile etichetta", + "auto_summarization_description": "Genera automaticamente riassunti per i tuoi segnalibri usando l'AI.", + "auto_tagging": "Tagging automatico", + "titlecase_spaces": "Maiuscola con spazi", + "lowercase_underscores": "Minuscolo con trattini bassi", + "inference_language": "Lingua di inferenza", + "titlecase_hyphens": "Maiuscola con trattini", + "lowercase_hyphens": "Minuscolo con trattini", + "lowercase_spaces": "Minuscolo con spazi", + "inference_language_description": "Scegli la lingua per i tag e i riepiloghi generati dall'AI.", + "tag_style_description": "Scegli come formattare le etichette generate automaticamente.", + "auto_tagging_description": "Genera automaticamente i tag per i tuoi segnalibri usando l'AI.", + "camelCase": "camelCase", + "auto_summarization": "Riassunto automatico" }, "feeds": { "rss_subscriptions": "Iscrizione RSS", @@ -140,6 +201,7 @@ "import": { "import_export": "Importa / Esporta", "import_bookmarks_from_pocket_export": "Importa segnalibri da esportazione Pocket", + "import_bookmarks_from_matter_export": "Importa segnalibri da esportazione Matter", "import_bookmarks_from_karakeep_export": "Importa segnalibri da esportazione Karakeep", "export_links_and_notes": "Esporta link e note", "imported_bookmarks": "Segnalibri importati", @@ -705,7 +767,8 @@ "tabs": { "content": "Contenuto", "details": "Dettagli" - } + }, + "archive_info": "Gli archivi potrebbero non essere visualizzati correttamente in linea se richiedono Javascript. Per risultati ottimali, <1>scaricalo e aprilo nel tuo browser</1>." }, "toasts": { "bookmarks": { @@ -714,7 +777,8 @@ "refetch": "L'aggiornamento è stato messo in coda!", "full_page_archive": "L'archivio della pagina completa è stato attivato", "delete_from_list": "Il segnalibro è stato eliminato dalla lista", - "clipboard_copied": "Il link è stato copiato!" + "clipboard_copied": "Il link è stato copiato!", + "preserve_pdf": "È stato attivato il salvataggio in PDF" }, "lists": { "created": "Lista creata!", @@ -772,7 +836,14 @@ "year_s_ago": " Anni fa", "history": "Ricerche recenti", "title_contains": "Il titolo contiene", - "title_does_not_contain": "Il titolo non contiene" + "title_does_not_contain": "Il titolo non contiene", + "is_broken_link": "Ha Link Non Funzionante", + "tags": "Tag", + "no_suggestions": "Nessun suggerimento", + "filters": "Filtri", + "is_not_broken_link": "Ha Link Funzionante", + "lists": "Elenchi", + "feeds": "Feed" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/ja/translation.json b/apps/web/lib/i18n/locales/ja/translation.json index b6c350e2..58315b3e 100644 --- a/apps/web/lib/i18n/locales/ja/translation.json +++ b/apps/web/lib/i18n/locales/ja/translation.json @@ -42,7 +42,9 @@ "confirm": "確認", "regenerate": "再生成", "load_more": "もっと読み込む", - "edit_notes": "注釈を編集" + "edit_notes": "注釈を編集", + "preserve_as_pdf": "PDFとして保存する", + "offline_copies": "オフラインコピー" }, "admin": { "actions": { @@ -196,6 +198,49 @@ "title": "アーカイブされたブックマーク", "hide": "タグとリストにアーカイブされたブックマークを非表示にする" } + }, + "reader_settings": { + "local_overrides_title": "デバイス固有の設定が有効", + "using_default": "クライアントの既定を使用中", + "clear_override_hint": "デバイスのオーバーライドをクリアして、全体設定 ({{value}}) を使用します", + "font_size": "フォントサイズ", + "font_family": "フォントファミリー", + "preview_inline": "(プレビュー)", + "tooltip_preview": "未保存のプレビュー変更", + "save_to_all_devices": "すべてのデバイス", + "tooltip_local": "デバイス設定が全体設定と異なります", + "reset_preview": "プレビューをリセット", + "mono": "等幅", + "line_height": "行の高さ", + "tooltip_default": "リーディング設定", + "title": "リーダー設定", + "serif": "セリフ", + "preview": "プレビュー", + "not_set": "未設定", + "clear_local_overrides": "デバイス設定をクリア", + "preview_text": "すばやい茶色のキツネがのろまな犬を飛び越えます。リーダー表示のテキストはこんな感じになります。", + "local_overrides_cleared": "デバイス固有の設定がクリアされました", + "local_overrides_description": "このデバイスには、グローバル既定と異なるリーダー設定があります。", + "clear_defaults": "すべての既定をクリア", + "description": "リーダー表示の既定のテキスト設定を構成します。これらの設定は、すべてのデバイス間で同期されます。", + "defaults_cleared": "リーダーの既定がクリアされました", + "save_hint": "このデバイスのみの設定を保存するか、すべてのデバイス間で同期します", + "save_as_default": "既定として保存", + "save_to_device": "このデバイス", + "sans": "サンセリフ", + "tooltip_preview_and_local": "未保存のプレビュー変更; デバイス設定が全体設定と異なります", + "adjust_hint": "変更をプレビューするには、上記の設定を調整してください" + }, + "avatar": { + "upload": "アバターをアップロードする", + "change": "アバターを変更する", + "remove_confirm_title": "アバターを削除する?", + "updated": "アバターを更新したで", + "removed": "アバターを削除したで", + "description": "アバターとして使う正方形の画像をアップロードしてちょ。", + "remove_confirm_description": "現在のプロフィール写真が消去されるけど、ええんか?", + "title": "プロフィール画像", + "remove": "アバターを削除する" } }, "ai": { @@ -209,7 +254,21 @@ "all_tagging": "すべてのタグ付け", "text_tagging": "テキストタグ付け", "image_tagging": "画像タグ付け", - "summarization": "要約" + "summarization": "要約", + "tag_style": "タグのスタイル", + "auto_summarization_description": "AIを使ってブックマークの要約を自動生成する。", + "auto_tagging": "自動タグ付け", + "titlecase_spaces": "タイトルケース、スペース区切り", + "lowercase_underscores": "小文字、アンダースコア区切り", + "inference_language": "推論言語", + "titlecase_hyphens": "タイトルケース、ハイフン区切り", + "lowercase_hyphens": "小文字、ハイフン区切り", + "lowercase_spaces": "小文字、スペース区切り", + "inference_language_description": "AIが生成するタグや概要の言語を選んでくれ。", + "tag_style_description": "自動生成されるタグの書式を選んでくれ。", + "auto_tagging_description": "AIを使ってブックマークのタグを自動生成する。", + "camelCase": "camelCase", + "auto_summarization": "自動要約" }, "import": { "import_export_bookmarks": "ブックマークのインポート/エクスポート", @@ -217,6 +276,7 @@ "import_bookmarks_from_karakeep_export": "Karakeep エクスポートからブックマークをインポート", "imported_bookmarks": "インポートされたブックマーク", "import_bookmarks_from_pocket_export": "Pocketのエクスポートからブックマークをインポート", + "import_bookmarks_from_matter_export": "Matterのエクスポートからブックマークをインポート", "import_bookmarks_from_omnivore_export": "Omnivoreエクスポートからブックマークをインポート", "export_links_and_notes": "リンクとメモをエクスポートする", "import_export": "インポート/エクスポート", @@ -517,7 +577,9 @@ "summary": "概要", "quota": "割り当て", "bookmarks": "ブックマーク", - "storage": "ストレージ" + "storage": "ストレージ", + "pdf": "PDFをアーカイブしたよ", + "default": "既定" }, "layouts": { "grid": "グリッド", @@ -677,7 +739,14 @@ "year_s_ago": " ~年前", "history": "最近の検索", "title_contains": "タイトルに含む", - "title_does_not_contain": "タイトルに含まない" + "title_does_not_contain": "タイトルに含まない", + "is_broken_link": "リンク切れ", + "tags": "タグ", + "no_suggestions": "サジェストはありません", + "filters": "フィルター", + "is_not_broken_link": "リンクは有効です", + "lists": "リスト", + "feeds": "フィード" }, "preview": { "cached_content": "キャッシュされたコンテンツ", @@ -686,7 +755,8 @@ "tabs": { "content": "コンテンツ", "details": "詳細" - } + }, + "archive_info": "アーカイブは Javascript を必要とする場合、インラインで正しく表示されないことがあります。最良の結果を得るには、<1>ダウンロードしてブラウザで開いてください</1>。" }, "editor": { "quickly_focus": "⌘ + E を押すと、このフィールドにすばやくフォーカスできます", @@ -760,7 +830,8 @@ "full_page_archive": "フルページアーカイブの作成が開始されました", "delete_from_list": "ブックマークがリストから削除されました", "deleted": "ブックマークが削除されたよ!", - "refetch": "再取得をエンキューしたぞ!" + "refetch": "再取得をエンキューしたぞ!", + "preserve_pdf": "PDF保存が開始されたよ" }, "lists": { "created": "リストが作成されました!", diff --git a/apps/web/lib/i18n/locales/ko/translation.json b/apps/web/lib/i18n/locales/ko/translation.json index 7af4fd3e..52be7917 100644 --- a/apps/web/lib/i18n/locales/ko/translation.json +++ b/apps/web/lib/i18n/locales/ko/translation.json @@ -39,7 +39,9 @@ "description": "설명", "quota": "할당량", "bookmarks": "북마크", - "storage": "저장 공간" + "storage": "저장 공간", + "pdf": "보관된 PDF", + "default": "기본값" }, "layouts": { "list": "목록", @@ -90,7 +92,9 @@ "confirm": "확인", "regenerate": "다시 생성", "load_more": "더 불러오기", - "edit_notes": "노트 편집" + "edit_notes": "노트 편집", + "preserve_as_pdf": "PDF로 보존", + "offline_copies": "오프라인 사본" }, "tags": { "unused_tags": "사용되지 않은 태그", @@ -154,7 +158,14 @@ "year_s_ago": " 년 전", "history": "최근 검색어", "title_contains": "제목에 다음 내용이 포함됨", - "title_does_not_contain": "제목에 다음 내용이 포함되지 않음" + "title_does_not_contain": "제목에 다음 내용이 포함되지 않음", + "is_broken_link": "깨진 링크 있음", + "tags": "태그", + "no_suggestions": "추천 항목 없음", + "filters": "필터", + "is_not_broken_link": "작동하는 링크 있음", + "lists": "목록", + "feeds": "피드" }, "preview": { "view_original": "원본 보기", @@ -163,7 +174,8 @@ "tabs": { "content": "콘텐츠", "details": "세부 정보" - } + }, + "archive_info": "보관 파일은 Javascript가 필요한 경우 인라인으로 올바르게 렌더링되지 않을 수 있습니다. 최상의 결과를 얻으려면 <1>다운로드하여 브라우저에서 여세요</1>." }, "editor": { "quickly_focus": "⌘ + E를 누르면 이 필드에 초점이 옮겨집니다", @@ -237,7 +249,8 @@ "refetch": "다시 가져오기가 큐에 추가 되었습니다!", "full_page_archive": "전체 페이지 보관 생성이 요청되었습니다", "delete_from_list": "북마크를 목록에서 삭제했습니다", - "clipboard_copied": "링크를 클립보드에 복사했습니다!" + "clipboard_copied": "링크를 클립보드에 복사했습니다!", + "preserve_pdf": "PDF 보존이 시작되었습니다" }, "lists": { "created": "목록이 생성 되었습니다!", @@ -309,6 +322,49 @@ "show": "보관된 북마크를 태그 및 목록에 표시", "hide": "보관된 북마크를 태그 및 목록에서 숨기기" } + }, + "reader_settings": { + "local_overrides_title": "장치별 설정 활성화됨", + "using_default": "클라이언트 기본값 사용", + "clear_override_hint": "전역 설정을 사용하려면 기기 재정의를 지우세요 ({{value}})", + "font_size": "글꼴 크기", + "font_family": "글꼴", + "preview_inline": "(미리보기)", + "tooltip_preview": "저장되지 않은 미리 보기 변경 사항", + "save_to_all_devices": "모든 기기", + "tooltip_local": "기기 설정이 전역 설정과 다름", + "reset_preview": "미리 보기 초기화", + "mono": "고정폭", + "line_height": "줄 높이", + "tooltip_default": "읽기 설정", + "title": "글 뷰어 설정", + "serif": "세리프", + "preview": "미리 보기", + "not_set": "설정 안 됨", + "clear_local_overrides": "장치 설정 삭제", + "preview_text": "The quick brown fox jumps over the lazy dog. 글 뷰어 텍스트는 다음과 같이 표시됩니다.", + "local_overrides_cleared": "장치별 설정이 삭제됨", + "local_overrides_description": "이 장치에는 글로벌 기본값과 다른 글 뷰어 설정이 있습니다.", + "clear_defaults": "모든 기본값 삭제", + "description": "글 뷰어의 기본 텍스트 설정을 구성합니다. 이 설정은 모든 장치에서 동기화됩니다.", + "defaults_cleared": "글 뷰어 기본값이 삭제됨", + "save_hint": "이 기기 설정만 저장하거나 모든 기기에서 동기화", + "save_as_default": "기본값으로 저장", + "save_to_device": "이 기기", + "sans": "산세리프", + "tooltip_preview_and_local": "저장되지 않은 미리 보기 변경 사항, 기기 설정이 전역 설정과 다름", + "adjust_hint": "위에 설정을 조정하여 변경 사항 미리 보기" + }, + "avatar": { + "upload": "아바타 올려", + "change": "아바타 바꿔", + "remove_confirm_title": "아바타 지울까?", + "updated": "아바타 업데이트 완료", + "removed": "아바타 삭제 완료", + "description": "프로필 사진으로 쓸 정사각형 이미지를 올려 줘.", + "remove_confirm_description": "지금 프로필 사진이 싹 날아갈 텐데.", + "title": "프로필 사진", + "remove": "아바타 삭제" } }, "ai": { @@ -322,7 +378,21 @@ "all_tagging": "모든 태깅", "text_tagging": "텍스트 태깅", "image_tagging": "이미지 태깅", - "summarization": "요약" + "summarization": "요약", + "tag_style": "태그 스타일", + "auto_summarization_description": "AI를 사용하여 책갈피에 대한 요약을 자동으로 생성합니다.", + "auto_tagging": "자동 태그 지정", + "titlecase_spaces": "공백을 넣은 제목 케이스", + "lowercase_underscores": "밑줄을 넣은 소문자", + "inference_language": "추론 언어", + "titlecase_hyphens": "하이픈을 넣은 제목 케이스", + "lowercase_hyphens": "하이픈을 넣은 소문자", + "lowercase_spaces": "공백을 넣은 소문자", + "inference_language_description": "AI가 생성한 태그 및 요약에 사용할 언어를 선택합니다.", + "tag_style_description": "자동 생성 태그 형식을 선택하세요.", + "auto_tagging_description": "AI를 사용하여 책갈피에 대한 태그를 자동으로 생성합니다.", + "camelCase": "camelCase", + "auto_summarization": "자동 요약" }, "feeds": { "add_a_subscription": "구독 추가", @@ -336,6 +406,7 @@ "import_export_bookmarks": "북마크 가져오기 / 내보내기", "import_bookmarks_from_html_file": "HTML 파일에서 북마크 가져오기", "import_bookmarks_from_pocket_export": "Pocket 내보내기에서 북마크 가져오기", + "import_bookmarks_from_matter_export": "Matter 내보내기에서 북마크 가져오기", "import_bookmarks_from_omnivore_export": "Omnivore 내보내기에서 북마크 가져오기", "import_bookmarks_from_karakeep_export": "Karakeep 내보내기에서 북마크 가져오기", "export_links_and_notes": "링크와 주석 내보내기", diff --git a/apps/web/lib/i18n/locales/nb_NO/translation.json b/apps/web/lib/i18n/locales/nb_NO/translation.json index 6cfebfc3..8f1fde21 100644 --- a/apps/web/lib/i18n/locales/nb_NO/translation.json +++ b/apps/web/lib/i18n/locales/nb_NO/translation.json @@ -39,7 +39,9 @@ "title": "Tittel", "quota": "Kvote", "bookmarks": "Bokmerker", - "storage": "Lagring" + "storage": "Lagring", + "pdf": "Arkivert PDF", + "default": "Standard" }, "admin": { "users_list": { @@ -214,7 +216,9 @@ "confirm": "Bekreft", "regenerate": "Regenerer", "load_more": "Last inn mer", - "edit_notes": "Rediger notater" + "edit_notes": "Rediger notater", + "preserve_as_pdf": "Bevar som PDF", + "offline_copies": "Offline kopier" }, "settings": { "info": { @@ -238,6 +242,49 @@ "show": "Vis arkiverte bokmerker i tagger og lister", "hide": "Skjul arkiverte bokmerker i tagger og lister" } + }, + "reader_settings": { + "local_overrides_title": "Enhetsspesifikke innstillinger er aktive", + "using_default": "Bruker klientstandard", + "clear_override_hint": "Fjern overstyring av enhet for å bruke global innstilling ({{value}})", + "font_size": "Skriftstørrelse", + "font_family": "Skrifttype", + "preview_inline": "(forhåndsvisning)", + "tooltip_preview": "Ulagrede forhåndsvisningsendringer", + "save_to_all_devices": "Alle enheter", + "tooltip_local": "Enhetsinnstillingene er forskjellige fra de globale", + "reset_preview": "Tilbakestill forhåndsvisning", + "mono": "Monospace", + "line_height": "Linjehøyde", + "tooltip_default": "Leseinnstillinger", + "title": "Leserinnstillinger", + "serif": "Serif", + "preview": "Forhåndsvisning", + "not_set": "Ikke angitt", + "clear_local_overrides": "Fjern enhetsinnstillinger", + "preview_text": "Den rappe, brune reven hopper over den late hunden. Slik vil teksten i leservisningen din se ut.", + "local_overrides_cleared": "Enhetsspesifikke innstillinger er fjernet", + "local_overrides_description": "Denne enheten har leserinnstillinger som er forskjellige fra dine globale standardinnstillinger:", + "clear_defaults": "Fjern alle standarder", + "description": "Konfigurer standard tekstinnstillinger for leservisningen. Disse innstillingene synkroniseres på tvers av alle enhetene dine.", + "defaults_cleared": "Leserstandarder er fjernet", + "save_hint": "Lagre innstillinger bare for denne enheten eller synkroniser på tvers av alle enheter", + "save_as_default": "Lagre som standard", + "save_to_device": "Denne enheten", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Ulagrede forhåndsvisningsendringer; enhetsinnstillingene er forskjellige fra de globale", + "adjust_hint": "Juster innstillingene ovenfor for å forhåndsvise endringer" + }, + "avatar": { + "upload": "Last opp avatar", + "change": "Endre avatar", + "remove_confirm_title": "Fjerne avatar?", + "updated": "Avatar oppdatert", + "removed": "Avatar fjernet", + "description": "Last opp et kvadratisk bilde som avatar.", + "remove_confirm_description": "Dette vil fjerne ditt nåværende profilbilde.", + "title": "Profilbilde", + "remove": "Fjern avatar" } }, "ai": { @@ -251,7 +298,21 @@ "text_tagging": "Teksttagging", "image_tagging": "Bilde-tagging", "summarization": "Oppsummering", - "images_prompt": "Bildeledetekst" + "images_prompt": "Bildeledetekst", + "tag_style": "Stil for merkelapper", + "auto_summarization_description": "Generer automatisk sammendrag for bokmerkene dine ved hjelp av AI.", + "auto_tagging": "Automatisk merking", + "titlecase_spaces": "Tittel-case med mellomrom", + "lowercase_underscores": "Små bokstaver med understreker", + "inference_language": "Språk for inferens", + "titlecase_hyphens": "Tittel-case med bindestreker", + "lowercase_hyphens": "Små bokstaver med bindestreker", + "lowercase_spaces": "Små bokstaver med mellomrom", + "inference_language_description": "Velg språk for AI-genererte merkelapper og sammendrag.", + "tag_style_description": "Velg hvordan de automatisk genererte merkelappene dine skal formateres.", + "auto_tagging_description": "Generer automatisk tagger for bokmerkene dine ved hjelp av AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatisk oppsummering" }, "import": { "import_bookmarks_from_omnivore_export": "Importer bokmerker fra Omnivore-eksport", @@ -259,6 +320,7 @@ "import_export_bookmarks": "Importer / eksporter bokmerker", "import_bookmarks_from_html_file": "Importer bokmerker fra HTML-fil", "import_bookmarks_from_pocket_export": "Importer bokmerker fra Pocket-eksport", + "import_bookmarks_from_matter_export": "Importer bokmerker fra Matter-eksport", "import_bookmarks_from_linkwarden_export": "Importer bokmerker fra Linkwarden-eksport", "import_bookmarks_from_karakeep_export": "Importer bokmerker fra Karakeepp-eksport", "export_links_and_notes": "Eksporter lenker og notater", @@ -671,7 +733,14 @@ "year_s_ago": " År siden", "history": "Nylige søk", "title_contains": "Tittel inneholder", - "title_does_not_contain": "Tittel inneholder ikke" + "title_does_not_contain": "Tittel inneholder ikke", + "is_broken_link": "Har ødelagt lenke", + "tags": "Merker", + "no_suggestions": "Ingen forslag", + "filters": "Filtere", + "is_not_broken_link": "Har fungerende lenke", + "lists": "Lister", + "feeds": "Feeder" }, "editor": { "text_toolbar": { @@ -739,7 +808,8 @@ "delete_from_list": "Bokmerket er sletta fra lista", "clipboard_copied": "Lenken er lagt til utklippstavlen din!", "updated": "Bokmerket er oppdatert!", - "deleted": "Bokmerket er slettet!" + "deleted": "Bokmerket er slettet!", + "preserve_pdf": "PDF-bevaring er trigget" }, "lists": { "created": "Liste er opprettet!", @@ -775,7 +845,8 @@ "tabs": { "content": "Innhold", "details": "Detaljer" - } + }, + "archive_info": "Det kan hende at arkiver ikke gjengis riktig direkte hvis de krever Javascript. For best resultat, <1>last ned og åpne i nettleseren din</1>." }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/nl/translation.json b/apps/web/lib/i18n/locales/nl/translation.json index 9510d215..c4987872 100644 --- a/apps/web/lib/i18n/locales/nl/translation.json +++ b/apps/web/lib/i18n/locales/nl/translation.json @@ -39,7 +39,9 @@ "summary": "Samenvatting", "quota": "Quota", "bookmarks": "Bladwijzers", - "storage": "Opslag" + "storage": "Opslag", + "pdf": "Gearchiveerde PDF", + "default": "Standaard" }, "layouts": { "list": "Lijst", @@ -90,7 +92,9 @@ "confirm": "Bevestigen", "regenerate": "Opnieuw genereren", "load_more": "Laad meer", - "edit_notes": "Notities bewerken" + "edit_notes": "Notities bewerken", + "preserve_as_pdf": "Opslaan als PDF", + "offline_copies": "Offline kopieën" }, "settings": { "ai": { @@ -104,7 +108,21 @@ "all_tagging": "Alle tags", "text_tagging": "Tekst taggen", "image_tagging": "Afbeeldingen taggen", - "tagging_rule_description": "Prompts die je hier toevoegt, worden opgenomen als regels voor het model tijdens het genereren van tags. Je kunt de uiteindelijke prompts bekijken in het promptvoorbeeldgedeelte." + "tagging_rule_description": "Prompts die je hier toevoegt, worden opgenomen als regels voor het model tijdens het genereren van tags. Je kunt de uiteindelijke prompts bekijken in het promptvoorbeeldgedeelte.", + "tag_style": "Tagstijl", + "auto_summarization_description": "Genereer automatisch samenvattingen voor je bladwijzers met behulp van AI.", + "auto_tagging": "Automatisch labelen", + "titlecase_spaces": "Hoofdletters met spaties", + "lowercase_underscores": "Kleine letters met underscores", + "inference_language": "Inferentietalen", + "titlecase_hyphens": "Hoofdletters met koppeltekens", + "lowercase_hyphens": "Kleine letters met koppeltekens", + "lowercase_spaces": "Kleine letters met spaties", + "inference_language_description": "Kies taal voor door AI gegenereerde tags en samenvattingen.", + "tag_style_description": "Kies hoe je automatisch gegenereerde tags moeten worden opgemaakt.", + "auto_tagging_description": "Genereer automatisch tags voor je bladwijzers met behulp van AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatische samenvatting" }, "import": { "import_export": "Importeren / Exporteren", @@ -112,6 +130,7 @@ "import_export_bookmarks": "Importeer / Exporteer Bladwijzers", "import_bookmarks_from_html_file": "Importeer Bladwijzers van HTML bestand", "import_bookmarks_from_pocket_export": "Importeer Bladwijzers van Pocket export", + "import_bookmarks_from_matter_export": "Importeer Bladwijzers van Matter export", "import_bookmarks_from_omnivore_export": "Bladwijzers importeren uit Omnivore export", "import_bookmarks_from_linkwarden_export": "Bladwijzers importeren uit Linkwarden-export", "import_bookmarks_from_karakeep_export": "Bladwijzers importeren uit Karakeep-export", @@ -158,6 +177,49 @@ "show": "Gearchiveerde bladwijzers weergeven in tags en lijsten", "hide": "Gearchiveerde bladwijzers verbergen in tags en lijsten" } + }, + "reader_settings": { + "local_overrides_title": "Apparaatspecifieke instellingen actief", + "using_default": "Standaardinstelling van de client gebruiken", + "clear_override_hint": "Apparaatoverschrijving wissen om algemene instelling te gebruiken ({{value}})", + "font_size": "Lettergrootte", + "font_family": "Lettertypefamilie", + "preview_inline": "(voorbeeld)", + "tooltip_preview": "Niet-opgeslagen voorbeeldwijzigingen", + "save_to_all_devices": "Alle apparaten", + "tooltip_local": "Apparaatinstellingen verschillen van algemene instellingen", + "reset_preview": "Voorbeeld resetten", + "mono": "Monospace", + "line_height": "Regelhoogte", + "tooltip_default": "Leesinstellingen", + "title": "Lezerinstellingen", + "serif": "Serif", + "preview": "Voorbeeld", + "not_set": "Niet ingesteld", + "clear_local_overrides": "Apparaatinstellingen wissen", + "preview_text": "The quick brown fox jumps over the lazy dog. Zo ziet de tekst in je lezerweergave eruit.", + "local_overrides_cleared": "Apparaatspecifieke instellingen zijn gewist", + "local_overrides_description": "Dit apparaat heeft lezerinstellingen die afwijken van je globale standaardwaarden:", + "clear_defaults": "Alle standaarden wissen", + "description": "Configureer de standaard tekstinstellingen voor de lezerweergave. Deze instellingen worden gesynchroniseerd op al je apparaten.", + "defaults_cleared": "Standaardwaarden van de lezer zijn gewist", + "save_hint": "Instellingen opslaan alleen voor dit apparaat of synchroniseren op alle apparaten", + "save_as_default": "Opslaan als standaard", + "save_to_device": "Dit apparaat", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Niet-opgeslagen voorbeeldwijzigingen; apparaatinstellingen verschillen van algemene instellingen", + "adjust_hint": "Pas de bovenstaande instellingen aan om een voorbeeld van de wijzigingen te bekijken" + }, + "avatar": { + "upload": "Avatar uploaden", + "change": "Avatar wijzigen", + "remove_confirm_title": "Avatar verwijderen?", + "updated": "Avatar bijgewerkt", + "removed": "Avatar verwijderd", + "description": "Upload een vierkante afbeelding om als je avatar te gebruiken.", + "remove_confirm_description": "Hiermee verwijder je je huidige profielfoto.", + "title": "Profielfoto", + "remove": "Avatar verwijderen" } }, "back_to_app": "Terug Naar App", @@ -556,7 +618,8 @@ "tabs": { "content": "Inhoud", "details": "Details" - } + }, + "archive_info": "Archieven worden mogelijk niet correct inline weergegeven als ze Javascript vereisen. Voor de beste resultaten kun je het <1>downloaden en openen in je browser</1>." }, "editor": { "text_toolbar": { @@ -745,7 +808,14 @@ "year_s_ago": " Jaar geleden", "history": "Recente zoekopdrachten", "title_contains": "Titel bevat", - "title_does_not_contain": "Titel bevat niet" + "title_does_not_contain": "Titel bevat niet", + "is_broken_link": "Heeft een verbroken link", + "tags": "Labels", + "no_suggestions": "Geen suggesties", + "filters": "Filters", + "is_not_broken_link": "Heeft een werkende link", + "lists": "Lijsten", + "feeds": "Feeds" }, "dialogs": { "bookmarks": { @@ -760,7 +830,8 @@ "updated": "De bladwijzer is bijgewerkt!", "deleted": "De bladwijzer is verwijderd!", "delete_from_list": "De bladwijzer is uit de lijst verwijderd", - "clipboard_copied": "Link is naar je klembord gekopieerd!" + "clipboard_copied": "Link is naar je klembord gekopieerd!", + "preserve_pdf": "PDF-opslag is geactiveerd" }, "lists": { "updated": "Lijst is bijgewerkt!", diff --git a/apps/web/lib/i18n/locales/pl/translation.json b/apps/web/lib/i18n/locales/pl/translation.json index e82e8921..8cb621e7 100644 --- a/apps/web/lib/i18n/locales/pl/translation.json +++ b/apps/web/lib/i18n/locales/pl/translation.json @@ -39,7 +39,9 @@ "summary": "Podsumowanie", "quota": "Limit", "bookmarks": "Zakładki", - "storage": "Miejsce na dane" + "storage": "Miejsce na dane", + "pdf": "Zarchiwizowane PDF", + "default": "Domyślne" }, "actions": { "remove_from_list": "Usuń z listy", @@ -84,7 +86,9 @@ "confirm": "Potwierdź", "regenerate": "Wygeneruj ponownie", "load_more": "Załaduj więcej", - "edit_notes": "Edytuj notatki" + "edit_notes": "Edytuj notatki", + "preserve_as_pdf": "Zachowaj jako PDF", + "offline_copies": "Kopie offline" }, "settings": { "info": { @@ -108,11 +112,55 @@ "open_external_url": "Otwórz oryginalny URL", "open_bookmark_details": "Otwórz szczegóły zakładki" } + }, + "reader_settings": { + "local_overrides_title": "Aktywne ustawienia specyficzne dla urządzenia", + "using_default": "Użyj ustawień domyślnych klienta", + "clear_override_hint": "Wyczyść ustawienia urządzenia, aby użyć ustawień globalnych ({{value}})", + "font_size": "Rozmiar czcionki", + "font_family": "Rodzina czcionek", + "preview_inline": "(podgląd)", + "tooltip_preview": "Niezapisane zmiany podglądu", + "save_to_all_devices": "Wszystkie urządzenia", + "tooltip_local": "Ustawienia urządzenia różnią się od globalnych", + "reset_preview": "Zresetuj podgląd", + "mono": "Monospace", + "line_height": "Wysokość linii", + "tooltip_default": "Ustawienia czytania", + "title": "Ustawienia czytnika", + "serif": "Z szeryfami", + "preview": "Podgląd", + "not_set": "Nie ustawiono", + "clear_local_overrides": "Wyczyść ustawienia urządzenia", + "preview_text": "The quick brown fox jumps over the lazy dog. Tak będzie wyglądał tekst w widoku czytnika.", + "local_overrides_cleared": "Ustawienia specyficzne dla urządzenia zostały wyczyszczone", + "local_overrides_description": "To urządzenie ma ustawienia czytnika, które różnią się od globalnych ustawień domyślnych:", + "clear_defaults": "Wyczyść wszystkie ustawienia domyślne", + "description": "Skonfiguruj domyślne ustawienia tekstu dla widoku czytnika. Ustawienia te synchronizują się na wszystkich Twoich urządzeniach.", + "defaults_cleared": "Ustawienia domyślne czytnika zostały wyczyszczone", + "save_hint": "Zapisz ustawienia tylko dla tego urządzenia lub synchronizuj na wszystkich urządzeniach", + "save_as_default": "Zapisz jako domyślne", + "save_to_device": "To urządzenie", + "sans": "Bezszeryfowa", + "tooltip_preview_and_local": "Nie zapisano zmian w podglądzie; ustawienia urządzenia różnią się od globalnych", + "adjust_hint": "Dostosuj powyższe ustawienia, aby wyświetlić zmiany w podglądzie" + }, + "avatar": { + "upload": "Wrzuć awatar", + "change": "Zmień awatar", + "remove_confirm_title": "Usunąć awatar?", + "updated": "Awatar zaktualizowany", + "removed": "Awatar usunięty", + "description": "Wrzuć kwadratowy obrazek, który będzie Twoim awatarem.", + "remove_confirm_description": "To wyczyści Twoje aktualne zdjęcie profilowe.", + "title": "Zdjęcie profilowe", + "remove": "Usuń awatar" } }, "import": { "import_bookmarks_from_html_file": "Importuj zakładki z pliku HTML", "import_bookmarks_from_pocket_export": "Importuj zakładki z eksportu Pocket", + "import_bookmarks_from_matter_export": "Importuj zakładki z eksportu Matter", "import_export": "Import / Eksport", "import_export_bookmarks": "Import / Eksport zakładek", "import_bookmarks_from_omnivore_export": "Importuj zakładki z eksportu Omnivore", @@ -136,7 +184,21 @@ "summarization_prompt": "Monit o podsumowanie", "all_tagging": "Wszystkie tagi", "text_tagging": "Tagowanie tekstu", - "image_tagging": "Tagowanie obrazów" + "image_tagging": "Tagowanie obrazów", + "tag_style": "Styl tagów", + "auto_summarization_description": "Automatycznie generuj streszczenia dla zakładek za pomocą AI.", + "auto_tagging": "Automatyczne tagowanie", + "titlecase_spaces": "Wielkie litery ze spacjami", + "lowercase_underscores": "Małe litery z podkreślnikami", + "inference_language": "Język wnioskowania", + "titlecase_hyphens": "Wielkie litery z myślnikami", + "lowercase_hyphens": "Małe litery z myślnikami", + "lowercase_spaces": "Małe litery ze spacjami", + "inference_language_description": "Wybierz język dla tagów i podsumowań generowanych przez AI.", + "tag_style_description": "Wybierz, jak powinny być formatowane autogenerowane tagi.", + "auto_tagging_description": "Automatycznie generuj tagi dla zakładek za pomocą AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatyczne podsumowywanie" }, "feeds": { "rss_subscriptions": "Subskrypcje RSS", @@ -622,7 +684,8 @@ "refetch": "Pobieranie ponownie zostało zaplanowane!", "full_page_archive": "Tworzenie pełnego archiwum strony zostało rozpoczęte", "delete_from_list": "Zakładka została usunięta z listy", - "clipboard_copied": "Link został skopiowany do schowka!" + "clipboard_copied": "Link został skopiowany do schowka!", + "preserve_pdf": "Zapis PDF został uruchomiony" }, "tags": { "created": "Etykieta została utworzona!", @@ -732,7 +795,8 @@ "tabs": { "content": "Treść", "details": "Szczegóły" - } + }, + "archive_info": "Archiwa mogą się nie wyświetlać poprawnie w wierszu, jeśli wymagają Javascript. Dla najlepszych rezultatów, <1>pobierz i otwórz w przeglądarce</1>." }, "highlights": { "no_highlights": "Nie masz jeszcze żadnych wyróżnień." @@ -775,7 +839,14 @@ "year_s_ago": " Lat(a) temu", "history": "Ostatnie wyszukiwania", "title_contains": "Tytuł zawiera", - "title_does_not_contain": "Tytuł nie zawiera" + "title_does_not_contain": "Tytuł nie zawiera", + "is_broken_link": "Ma Zepsuty Link", + "tags": "Tagi", + "no_suggestions": "Brak propozycji", + "filters": "Filtry", + "is_not_broken_link": "Ma Działający Link", + "lists": "Listy", + "feeds": "Kanały" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/pt/translation.json b/apps/web/lib/i18n/locales/pt/translation.json index a154726c..7bf1ccae 100644 --- a/apps/web/lib/i18n/locales/pt/translation.json +++ b/apps/web/lib/i18n/locales/pt/translation.json @@ -39,7 +39,9 @@ "summary": "Resumo", "quota": "Quota", "bookmarks": "Favoritos", - "storage": "Armazenamento" + "storage": "Armazenamento", + "pdf": "PDF arquivado", + "default": "Padrão" }, "actions": { "close": "Fechar", @@ -84,7 +86,9 @@ "confirm": "Confirmar", "regenerate": "Regenerar", "load_more": "Carregar mais", - "edit_notes": "Editar notas" + "edit_notes": "Editar notas", + "preserve_as_pdf": "Preservar como PDF", + "offline_copies": "Cópias offline" }, "settings": { "webhooks": { @@ -107,6 +111,7 @@ }, "import": { "import_bookmarks_from_pocket_export": "Importar marcadores da exportação do Pocket", + "import_bookmarks_from_matter_export": "Importar marcadores da exportação do Matter", "import_bookmarks_from_omnivore_export": "Importar marcadores da exportação do Omnivore", "import_export": "Importar / Exportar", "import_export_bookmarks": "Importar/Exportar Marcadores", @@ -139,6 +144,49 @@ "show": "Mostrar marcadores arquivados em tags e listas", "hide": "Ocultar marcadores arquivados em tags e listas" } + }, + "reader_settings": { + "local_overrides_title": "Configurações específicas do dispositivo ativas", + "using_default": "Usando o padrão do cliente", + "clear_override_hint": "Limpar a substituição do dispositivo para usar a configuração global ({{value}})", + "font_size": "Tamanho da fonte", + "font_family": "Família da fonte", + "preview_inline": "(visualização)", + "tooltip_preview": "Alterações não salvas na pré-visualização", + "save_to_all_devices": "Todos os dispositivos", + "tooltip_local": "Configurações do dispositivo são diferentes das globais", + "reset_preview": "Redefinir pré-visualização", + "mono": "Monoespaçada", + "line_height": "Altura da linha", + "tooltip_default": "Configurações de leitura", + "title": "Configurações do Leitor", + "serif": "Com serifa", + "preview": "Pré-visualização", + "not_set": "Não definido", + "clear_local_overrides": "Limpar configurações do dispositivo", + "preview_text": "A raposa marrom rápida pula sobre o cachorro preguiçoso. É assim que o texto da visualização do leitor será exibido.", + "local_overrides_cleared": "As configurações específicas do dispositivo foram apagadas", + "local_overrides_description": "Este dispositivo tem configurações de leitor que diferem de suas configurações padrão globais:", + "clear_defaults": "Limpar todos os padrões", + "description": "Configure as configurações de texto padrão para a visualização do leitor. Essas configurações são sincronizadas em todos os seus dispositivos.", + "defaults_cleared": "Os padrões do leitor foram apagados", + "save_hint": "Salvar configurações apenas para este dispositivo ou sincronizar entre todos os dispositivos", + "save_as_default": "Salvar como padrão", + "save_to_device": "Este dispositivo", + "sans": "Sem serifa", + "tooltip_preview_and_local": "Alterações não salvas na pré-visualização; as configurações do dispositivo são diferentes das globais", + "adjust_hint": "Ajuste as configurações acima para visualizar as alterações" + }, + "avatar": { + "upload": "Mandar avatar", + "change": "Trocar avatar", + "remove_confirm_title": "Remover avatar?", + "updated": "Avatar atualizado", + "removed": "Avatar removido", + "description": "Manda uma imagem quadrada para usar como teu avatar.", + "remove_confirm_description": "Isso vai apagar tua foto de perfil atual.", + "title": "Foto do perfil", + "remove": "Remover avatar" } }, "ai": { @@ -152,7 +200,21 @@ "text_tagging": "Marcação de texto", "image_tagging": "Marcação de imagem", "summarization": "Sumarização", - "ai_settings": "Configurações de IA" + "ai_settings": "Configurações de IA", + "tag_style": "Estilo da etiqueta", + "auto_summarization_description": "Gerar automaticamente resumos para seus favoritos usando IA.", + "auto_tagging": "Marcação automática", + "titlecase_spaces": "Maiúsculas e minúsculas com espaços", + "lowercase_underscores": "Minúsculas com underscores", + "inference_language": "Linguagem de Inferência", + "titlecase_hyphens": "Maiúsculas e minúsculas com hífens", + "lowercase_hyphens": "Minúsculas com hífens", + "lowercase_spaces": "Minúsculas com espaços", + "inference_language_description": "Escolha o idioma para as tags e resumos gerados por IA.", + "tag_style_description": "Escolha como as suas etiquetas geradas automaticamente devem ser formatadas.", + "auto_tagging_description": "Gerar automaticamente tags para seus favoritos usando IA.", + "camelCase": "camelCase", + "auto_summarization": "Resumo automático" }, "api_keys": { "new_api_key": "Nova chave da API", @@ -671,7 +733,14 @@ "year_s_ago": " Ano(s) atrás", "history": "Pesquisas recentes", "title_contains": "O título contém…", - "title_does_not_contain": "O título não contém…" + "title_does_not_contain": "O título não contém…", + "is_broken_link": "Tem link quebrado", + "tags": "Etiquetas", + "no_suggestions": "Sem sugestões", + "filters": "Filtros", + "is_not_broken_link": "Tem link funcionando", + "lists": "Listas", + "feeds": "Feeds" }, "preview": { "cached_content": "Conteúdo em cache", @@ -680,7 +749,8 @@ "tabs": { "content": "Conteúdo", "details": "Detalhes" - } + }, + "archive_info": "Os arquivos podem não ser renderizados corretamente embutidos se exigirem Javascript. Para obter melhores resultados, <1>baixe-o e abra-o no seu navegador</1>." }, "editor": { "new_item": "NOVO ITEM", @@ -748,7 +818,8 @@ "clipboard_copied": "Link foi adicionado à sua área de transferência!", "updated": "O marcador foi atualizado!", "deleted": "O marcador foi excluído!", - "refetch": "A nova busca foi enfileirada!" + "refetch": "A nova busca foi enfileirada!", + "preserve_pdf": "A preservação em PDF foi acionada" }, "lists": { "updated": "A lista foi atualizada!", diff --git a/apps/web/lib/i18n/locales/pt_BR/translation.json b/apps/web/lib/i18n/locales/pt_BR/translation.json index 881c9783..2d1a7f8a 100644 --- a/apps/web/lib/i18n/locales/pt_BR/translation.json +++ b/apps/web/lib/i18n/locales/pt_BR/translation.json @@ -39,7 +39,9 @@ "summary": "Resumo", "quota": "Cota", "bookmarks": "Favoritos", - "storage": "Armazenamento" + "storage": "Armazenamento", + "pdf": "PDF Arquivado", + "default": "Padrão" }, "actions": { "unarchive": "Desarquivar", @@ -84,7 +86,9 @@ "confirm": "Confirmar", "regenerate": "Regenerar", "load_more": "Carregar mais", - "edit_notes": "Editar notas" + "edit_notes": "Editar notas", + "preserve_as_pdf": "Preservar como PDF", + "offline_copies": "Cópias Offline" }, "settings": { "info": { @@ -108,6 +112,49 @@ "open_external_url": "Abrir URL original", "open_bookmark_details": "Abrir detalhes do favorito" } + }, + "reader_settings": { + "local_overrides_title": "Configurações específicas do dispositivo ativas", + "using_default": "Usando o padrão do cliente", + "clear_override_hint": "Limpar substituição do dispositivo para usar a configuração global ({{value}})", + "font_size": "Tamanho da Fonte", + "font_family": "Família da Fonte", + "preview_inline": "(visualização)", + "tooltip_preview": "Alterações de visualização não salvas", + "save_to_all_devices": "Todos os dispositivos", + "tooltip_local": "Configurações do dispositivo diferentes das globais", + "reset_preview": "Redefinir visualização", + "mono": "Monoespaçado", + "line_height": "Altura da Linha", + "tooltip_default": "Configurações de leitura", + "title": "Configurações do Leitor", + "serif": "Serifa", + "preview": "Visualização", + "not_set": "Não definido", + "clear_local_overrides": "Limpar configurações do dispositivo", + "preview_text": "A raposa marrom rápida pula sobre o cão preguiçoso. É assim que o texto da sua visualização do leitor aparecerá.", + "local_overrides_cleared": "As configurações específicas do dispositivo foram apagadas", + "local_overrides_description": "Este dispositivo tem configurações de leitor que diferem de suas configurações padrão globais:", + "clear_defaults": "Limpar todos os padrões", + "description": "Configure as configurações de texto padrão para a visualização do leitor. Essas configurações são sincronizadas em todos os seus dispositivos.", + "defaults_cleared": "Os padrões do leitor foram apagados", + "save_hint": "Salvar configurações apenas para este dispositivo ou sincronizar entre todos os dispositivos", + "save_as_default": "Salvar como padrão", + "save_to_device": "Este dispositivo", + "sans": "Sem serifa", + "tooltip_preview_and_local": "Alterações de visualização não salvas; configurações do dispositivo diferentes das globais", + "adjust_hint": "Ajuste as configurações acima para visualizar as alterações" + }, + "avatar": { + "upload": "Enviar avatar", + "change": "Mudar avatar", + "remove_confirm_title": "Remover avatar?", + "updated": "Avatar atualizado", + "removed": "Avatar removido", + "description": "Envie uma imagem quadrada para usar como seu avatar.", + "remove_confirm_description": "Isso vai apagar a foto do seu perfil atual.", + "title": "Foto do perfil", + "remove": "Remover avatar" } }, "back_to_app": "Voltar ao App", @@ -123,7 +170,21 @@ "all_tagging": "Todas as Tags", "text_tagging": "Tags de Texto", "image_tagging": "Tags de Imagem", - "summarization": "Resumo" + "summarization": "Resumo", + "tag_style": "Estilo da etiqueta", + "auto_summarization_description": "Gere automaticamente resumos para seus favoritos usando IA.", + "auto_tagging": "Marcação automática", + "titlecase_spaces": "Maiúsculas e minúsculas com espaços", + "lowercase_underscores": "Minúsculas com sublinhados", + "inference_language": "Linguagem de inferência", + "titlecase_hyphens": "Maiúsculas e minúsculas com hífens", + "lowercase_hyphens": "Minúsculas com hífens", + "lowercase_spaces": "Minúsculas com espaços", + "inference_language_description": "Escolha o idioma para tags e resumos gerados por IA.", + "tag_style_description": "Escolha como suas tags auto-geradas devem ser formatadas.", + "auto_tagging_description": "Gere automaticamente tags para seus favoritos usando IA.", + "camelCase": "camelCase", + "auto_summarization": "Resumo automático" }, "feeds": { "rss_subscriptions": "Assinaturas de RSS", @@ -154,6 +215,7 @@ "import_export_bookmarks": "Importar / Exportar Favoritos", "import_bookmarks_from_html_file": "Importar Favoritos de arquivo HTML", "import_bookmarks_from_pocket_export": "Importar Favoritos de exportação do Pocket", + "import_bookmarks_from_matter_export": "Importar Favoritos de exportação do Matter", "import_bookmarks_from_omnivore_export": "Importar Favoritos de exportação do Omnivore", "import_bookmarks_from_linkwarden_export": "Importar Favoritos de exportação do Linkwarden", "import_bookmarks_from_karakeep_export": "Importar Favoritos de exportação do Karakeep", @@ -680,7 +742,14 @@ "year_s_ago": " Ano(s) atrás", "history": "Pesquisas recentes", "title_contains": "Título Contém", - "title_does_not_contain": "Título Não Contém" + "title_does_not_contain": "Título Não Contém", + "is_broken_link": "Possui link quebrado", + "tags": "Tags", + "no_suggestions": "Sem sugestões", + "filters": "Filtros", + "is_not_broken_link": "Possui link funcionando", + "lists": "Listas", + "feeds": "Feeds" }, "preview": { "view_original": "Ver Original", @@ -689,7 +758,8 @@ "tabs": { "content": "Conteúdo", "details": "Detalhes" - } + }, + "archive_info": "Arquivos podem não renderizar corretamente embutidos se eles exigirem Javascript. Para obter melhores resultados, <1>baixe-o e abra-o no seu navegador</1>." }, "editor": { "quickly_focus": "Você pode acessar rapidamente este campo pressionando ⌘ + E", @@ -763,7 +833,8 @@ "refetch": "A nova busca foi enfileirada!", "full_page_archive": "A criação do arquivo de página inteira foi acionada", "delete_from_list": "O favorito foi excluído da lista", - "clipboard_copied": "O link foi adicionado à sua área de transferência!" + "clipboard_copied": "O link foi adicionado à sua área de transferência!", + "preserve_pdf": "A preservação em PDF foi acionada" }, "lists": { "created": "A lista foi criada!", diff --git a/apps/web/lib/i18n/locales/ru/translation.json b/apps/web/lib/i18n/locales/ru/translation.json index 05a82088..f3da6169 100644 --- a/apps/web/lib/i18n/locales/ru/translation.json +++ b/apps/web/lib/i18n/locales/ru/translation.json @@ -39,7 +39,9 @@ "summary": "Краткое содержание", "quota": "Квота", "bookmarks": "Закладки", - "storage": "Хранилище" + "storage": "Хранилище", + "pdf": "Архивированный PDF", + "default": "По умолчанию" }, "lists": { "new_list": "Новый список", @@ -150,6 +152,49 @@ "title": "Архивированные закладки", "show": "Показывать архивированные закладки в тегах и списках" } + }, + "reader_settings": { + "local_overrides_title": "Активны настройки для этого устройства", + "using_default": "Используются настройки клиента по умолчанию", + "clear_override_hint": "Удалите переопределение устройства, чтобы использовать глобальную настройку ({{value}})", + "font_size": "Размер шрифта", + "font_family": "Тип шрифта", + "preview_inline": "(предпросмотр)", + "tooltip_preview": "Несохраненные изменения предпросмотра", + "save_to_all_devices": "Все устройства", + "tooltip_local": "Настройки устройства отличаются от глобальных", + "reset_preview": "Сбросить предпросмотр", + "mono": "Моноширинный", + "line_height": "Высота строки", + "tooltip_default": "Настройки чтения", + "title": "Настройки читалки", + "serif": "С засечками", + "preview": "Предварительный просмотр", + "not_set": "Не задано", + "clear_local_overrides": "Сбросить настройки для устройства", + "preview_text": "Шустрая бурая лиса перепрыгивает ленивого пса. Вот так будет выглядеть текст в режиме чтения.", + "local_overrides_cleared": "Настройки для устройства сброшены", + "local_overrides_description": "На этом устройстве параметры читалки отличаются от ваших глобальных настроек:", + "clear_defaults": "Сбросить все значения по умолчанию", + "description": "Настройте параметры текста по умолчанию для режима чтения. Эти параметры синхронизируются на всех ваших устройствах.", + "defaults_cleared": "Настройки читалки по умолчанию сброшены", + "save_hint": "Сохранить настройки только для этого устройства или синхронизировать на всех устройствах", + "save_as_default": "Сохранить как значения по умолчанию", + "save_to_device": "Это устройство", + "sans": "Без засечек", + "tooltip_preview_and_local": "Несохраненные изменения предпросмотра; настройки устройства отличаются от глобальных", + "adjust_hint": "Отрегулируйте настройки выше, чтобы просмотреть изменения" + }, + "avatar": { + "upload": "Загрузить аватар", + "change": "Сменить аватар", + "remove_confirm_title": "Удалить аватар?", + "updated": "Аватар обновлён", + "removed": "Аватар удалён", + "description": "Загрузи квадратное изображение, которое будет твоим аватаром.", + "remove_confirm_description": "Текущее фото профиля будет удалено.", + "title": "Фото профиля", + "remove": "Удалить аватар" } }, "import": { @@ -157,13 +202,14 @@ "import_export": "Импорт / Экспорт", "import_export_bookmarks": "Импорт / Экспорт закладок", "import_bookmarks_from_pocket_export": "Импортировать закладки из экспорта Pocket", + "import_bookmarks_from_matter_export": "Импортировать закладки из экспорта Matter", "import_bookmarks_from_omnivore_export": "Импортировать закладки из экспорта Omnivore", "imported_bookmarks": "Импортировано закладок", "import_bookmarks_from_html_file": "Импортировать закладки из HTML файла", "export_links_and_notes": "Экспортировать ссылки и заметки", "import_bookmarks_from_linkwarden_export": "Импортировать закладки из экспорта Linkwarden", "import_bookmarks_from_tab_session_manager_export": "Импортировать закладки из Tab Session Manager", - "import_bookmarks_from_mymind_export": "Импортируй закладки из экспорта mymind." + "import_bookmarks_from_mymind_export": "Импортировать закладки из экспорта mymind" }, "api_keys": { "key_success": "Ключ был успешно создан", @@ -188,7 +234,21 @@ "image_tagging": "Пометка изображений тегами", "summarization": "Суммирование", "summarization_prompt": "Подсказка для суммирования", - "all_tagging": "Все теги" + "all_tagging": "Все теги", + "tag_style": "Стиль тегов", + "auto_summarization_description": "Автоматически генерируйте сводки для своих закладок с помощью ИИ.", + "auto_tagging": "Автоматическая расстановка тегов", + "titlecase_spaces": "Заглавные с пробелами", + "lowercase_underscores": "Строчные с подчеркиваниями", + "inference_language": "Язык логического вывода", + "titlecase_hyphens": "Заглавные с дефисами", + "lowercase_hyphens": "Строчные с дефисами", + "lowercase_spaces": "Строчные с пробелами", + "inference_language_description": "Выбери язык для тегов и саммари, которые генерит ИИ.", + "tag_style_description": "Выбери, как форматировать автосгенерированные теги.", + "auto_tagging_description": "Автоматически генерируйте теги для ваших закладок с помощью ИИ.", + "camelCase": "camelCase", + "auto_summarization": "Автоматическое создание сводок" }, "feeds": { "rss_subscriptions": "RSS подписки", @@ -473,7 +533,9 @@ "confirm": "Подтвердить", "regenerate": "Обновить", "load_more": "Загрузить еще", - "edit_notes": "Редактировать заметки" + "edit_notes": "Редактировать заметки", + "preserve_as_pdf": "Сохранить как PDF", + "offline_copies": "Автономные копии" }, "editor": { "text_toolbar": { @@ -705,7 +767,8 @@ "tabs": { "content": "Содержание", "details": "Подробности" - } + }, + "archive_info": "Архивы могут неправильно отображаться во встроенном режиме, если для них требуется Javascript. Для достижения наилучших результатов <1>загрузите их и откройте в браузере</1>." }, "toasts": { "bookmarks": { @@ -714,7 +777,8 @@ "delete_from_list": "Закладка была удалена из списка", "clipboard_copied": "Ссылка была скопирована в буфер обмена!", "deleted": "Закладка была удалена!", - "updated": "Закладка была обновлена!" + "updated": "Закладка была обновлена!", + "preserve_pdf": "Сохранение в формате PDF было запущено" }, "lists": { "created": "Список был создан!", @@ -772,12 +836,19 @@ "year_s_ago": " Год(а) назад", "history": "Недавние поиски", "title_contains": "Содержит в заголовке", - "title_does_not_contain": "Не содержит в заголовке" + "title_does_not_contain": "Не содержит в заголовке", + "is_broken_link": "Битые ссылки", + "tags": "Теги", + "no_suggestions": "Нет предложений", + "filters": "Фильтры", + "is_not_broken_link": "Рабочие ссылки", + "lists": "Списки", + "feeds": "Ленты" }, "dialogs": { "bookmarks": { "delete_confirmation_title": "Удалить закладку?", - "delete_confirmation_description": "Ты уверен, что хочешь удалить эту закладку?" + "delete_confirmation_description": "Вы уверены, что хотите удалить эту закладку?" } }, "highlights": { diff --git a/apps/web/lib/i18n/locales/sk/translation.json b/apps/web/lib/i18n/locales/sk/translation.json index 4fbcb06b..00196d26 100644 --- a/apps/web/lib/i18n/locales/sk/translation.json +++ b/apps/web/lib/i18n/locales/sk/translation.json @@ -39,7 +39,9 @@ "summary": "Zhrnutie", "quota": "Kvóta", "bookmarks": "Záložky", - "storage": "Úložisko" + "storage": "Úložisko", + "pdf": "Archivované PDF", + "default": "Predvolené" }, "actions": { "cancel": "Zrušiť", @@ -84,7 +86,9 @@ "confirm": "Potvrdiť", "regenerate": "Obnoviť", "load_more": "Načítať viac", - "edit_notes": "Upraviť poznámky" + "edit_notes": "Upraviť poznámky", + "preserve_as_pdf": "Uložiť ako PDF", + "offline_copies": "Offline kópie" }, "lists": { "favourites": "Obľúbené", @@ -178,6 +182,7 @@ "import_export_bookmarks": "Importovať / exportovať záložky", "import_bookmarks_from_html_file": "Importovať záložky z HTML súboru", "import_bookmarks_from_pocket_export": "Importovať záložky z Pocket exportu", + "import_bookmarks_from_matter_export": "Importovať záložky z Matter exportu", "import_bookmarks_from_linkwarden_export": "Importovať záložky z Linkwarden exportu", "import_bookmarks_from_karakeep_export": "Importovať záložky z Karakeep exportu", "export_links_and_notes": "Exportovať odkazy a poznámky", @@ -209,6 +214,49 @@ "hide": "Skryť archivované záložky v tagoch a zoznamoch", "show": "Zobraziť archivované záložky v tagoch a zoznamoch" } + }, + "reader_settings": { + "local_overrides_title": "Sú aktívne nastavenia špecifické pre zariadenie", + "using_default": "Používa sa predvolené nastavenie pre klienta", + "clear_override_hint": "Vymažte prepísanie zariadenia a použite globálne nastavenie ({{value}})", + "font_size": "Veľkosť písma", + "font_family": "Rodina písma", + "preview_inline": "(Náhľad)", + "tooltip_preview": "Neuložené zmeny ukážky", + "save_to_all_devices": "Všetky zariadenia", + "tooltip_local": "Nastavenia zariadenia sa líšia od globálnych", + "reset_preview": "Resetovať ukážku", + "mono": "Neproporcionálne", + "line_height": "Výška riadku", + "tooltip_default": "Nastavenia čítania", + "title": "Nastavenia čítačky", + "serif": "Pätkové", + "preview": "Náhľad", + "not_set": "Nenastavené", + "clear_local_overrides": "Vymazať nastavenia zariadenia", + "preview_text": "The quick brown fox jumps over the lazy dog. Takto sa bude zobrazovať text v režime čítačky.", + "local_overrides_cleared": "Nastavenia špecifické pre zariadenie boli vymazané", + "local_overrides_description": "Toto zariadenie má nastavenia čítačky, ktoré sa líšia od tvojich globálnych predvolených nastavení:", + "clear_defaults": "Vymazať všetky predvolené", + "description": "Konfigurácia predvolených nastavení textu pre zobrazenie čítačky. Tieto nastavenia sa synchronizujú medzi všetkými tvojimi zariadeniami.", + "defaults_cleared": "Predvolené nastavenia čítačky boli vymazané", + "save_hint": "Uložte nastavenia iba pre toto zariadenie alebo ich synchronizujte na všetkých zariadeniach", + "save_as_default": "Uložiť ako predvolené", + "save_to_device": "Toto zariadenie", + "sans": "Bez pätiek", + "tooltip_preview_and_local": "Neuložené zmeny ukážky; nastavenia zariadenia sa líšia od globálnych", + "adjust_hint": "Upravte nastavenia vyššie, aby ste si prezreli zmeny" + }, + "avatar": { + "upload": "Nahrať avatara", + "change": "Zmeniť avatara", + "remove_confirm_title": "Odstrániť avatara?", + "updated": "Avatar aktualizovaný", + "removed": "Avatar odstránený", + "description": "Nahraj štvorcový obrázok, ktorý sa použije ako tvoj avatar.", + "remove_confirm_description": "Týmto sa vymaže tvoja aktuálna profilová fotka.", + "title": "Profilová fotka", + "remove": "Odstrániť avatara" } }, "ai": { @@ -222,7 +270,21 @@ "image_tagging": "Označovanie obrázkov", "summarization": "Zhrnutie", "images_prompt": "Výzva obrázka", - "summarization_prompt": "Výzva na sumarizáciu" + "summarization_prompt": "Výzva na sumarizáciu", + "tag_style": "Štýl tagov", + "auto_summarization_description": "Automaticky generujte zhrnutia pre vaše záložky pomocou AI.", + "auto_tagging": "Automatické označovanie štítkami", + "titlecase_spaces": "Veľké začiatočné písmená s medzerami", + "lowercase_underscores": "Malé písmená s podčiarkovníkmi", + "inference_language": "Jazyk inferencie", + "titlecase_hyphens": "Veľké začiatočné písmená s pomlčkami", + "lowercase_hyphens": "Malé písmená s pomlčkami", + "lowercase_spaces": "Malé písmená s medzerami", + "inference_language_description": "Vyber jazyk pre tagy a súhrny generované AI.", + "tag_style_description": "Vyber si, ako majú byť formátované automaticky generované tagy.", + "auto_tagging_description": "Automaticky generujte štítky pre vaše záložky pomocou AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatické zhrnutie" }, "webhooks": { "add_auth_token": "Pridať autorizačný token", @@ -513,7 +575,14 @@ "year_s_ago": " Rok(y) dozadu", "history": "Nedávne vyhľadávania", "title_contains": "Názov obsahuje", - "title_does_not_contain": "Názov neobsahuje" + "title_does_not_contain": "Názov neobsahuje", + "is_broken_link": "Má nefunkčný odkaz", + "tags": "Značky", + "no_suggestions": "Žiadne návrhy", + "filters": "Filtre", + "is_not_broken_link": "Má funkčný odkaz", + "lists": "Zoznamy", + "feeds": "Kanály" }, "layouts": { "masonry": "Dlaždice", @@ -745,7 +814,8 @@ "tabs": { "content": "Obsah", "details": "Podrobnosti" - } + }, + "archive_info": "Archívy sa nemusia vykresľovať správne priamo, ak vyžadujú Javascript. Pre dosiahnutie najlepších výsledkov si ich <1>stiahni a otvor v prehliadači</1>." }, "toasts": { "bookmarks": { @@ -754,7 +824,8 @@ "delete_from_list": "Záložka bola odstránená zo zoznamu", "deleted": "Záložka bola zmazaná!", "refetch": "Opätovné načítanie bolo zaradené do frontu!", - "full_page_archive": "Bolo spustené vytváranie archívu celej stránky" + "full_page_archive": "Bolo spustené vytváranie archívu celej stránky", + "preserve_pdf": "Ukladanie do PDF bolo spustené" }, "lists": { "updated": "Zoznam bol aktualizovaný!", diff --git a/apps/web/lib/i18n/locales/sl/translation.json b/apps/web/lib/i18n/locales/sl/translation.json index 671f34ca..8b99a153 100644 --- a/apps/web/lib/i18n/locales/sl/translation.json +++ b/apps/web/lib/i18n/locales/sl/translation.json @@ -17,6 +17,7 @@ "import_bookmarks_from_linkwarden_export": "Uvozi zaznamke iz Linkwarden izvoza", "imported_bookmarks": "Uvoženi zaznamki", "import_bookmarks_from_pocket_export": "Uvozi zaznamke iz Pocket izvoza", + "import_bookmarks_from_matter_export": "Uvozi zaznamke iz Matter izvoza", "import_export_bookmarks": "Uvoz / Izvoz zaznamkov", "import_bookmarks_from_omnivore_export": "Uvozi zaznamke iz Omnivore izvoza", "export_links_and_notes": "Izvozi povezave in zapiske", @@ -46,6 +47,49 @@ "show": "Prikaži arhivirane zaznamke v oznakah in seznamih", "hide": "Skrij arhivirane zaznamke v oznakah in seznamih" } + }, + "reader_settings": { + "local_overrides_title": "Aktivne nastavitve, specifične za napravo", + "using_default": "Uporaba privzete vrednosti odjemalca", + "clear_override_hint": "Počisti preglasitev naprave, da uporabiš globalno nastavitev ({{value}})", + "font_size": "Velikost pisave", + "font_family": "Družina pisav", + "preview_inline": "(predogled)", + "tooltip_preview": "Neshranjene spremembe predogleda", + "save_to_all_devices": "Vse naprave", + "tooltip_local": "Nastavitve naprave se razlikujejo od globalnih", + "reset_preview": "Ponastavi predogled", + "mono": "Enoprostorska", + "line_height": "Višina vrstice", + "tooltip_default": "Nastavitve branja", + "title": "Nastavitve bralnika", + "serif": "Serif", + "preview": "Predogled", + "not_set": "Ni nastavljeno", + "clear_local_overrides": "Počisti nastavitve naprave", + "preview_text": "Rjava lisica skoči čez lenega psa. Tako bo videti vaše besedilo v pogledu bralnika.", + "local_overrides_cleared": "Nastavitve, specifične za napravo, so bile počiscene", + "local_overrides_description": "Ta naprava ima nastavitve bralnika, ki se razlikujejo od vaših splošnih privzetih nastavitev:", + "clear_defaults": "Počisti vse privzete nastavitve", + "description": "Nastavite privzete nastavitve besedila za pogled bralnika. Te nastavitve se sinhronizirajo v vseh vaših napravah.", + "defaults_cleared": "Privzeti bralnik je bil počiščen", + "save_hint": "Shrani nastavitve samo za to napravo ali sinhroniziraj med vsemi napravami", + "save_as_default": "Shrani kot privzeto", + "save_to_device": "Ta naprava", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Neshranjene spremembe predogleda; nastavitve naprave se razlikujejo od globalnih", + "adjust_hint": "Prilagodite nastavitve zgoraj za predogled sprememb" + }, + "avatar": { + "upload": "Naloži avatar", + "change": "Spremeni avatar", + "remove_confirm_title": "Odstranim avatar?", + "updated": "Avatar posodobljen", + "removed": "Avatar odstranjen", + "description": "Naloži kvadratno sliko, ki jo boš uporabil kot svoj avatar.", + "remove_confirm_description": "S tem boš odstranil svojo trenutno sliko profila.", + "title": "Fotka profila", + "remove": "Odstrani avatar" } }, "ai": { @@ -59,7 +103,21 @@ "summarization_prompt": "Povzemni ukaz", "all_tagging": "Vse oznake", "tagging_rules": "Pravila za označevanje", - "tagging_rule_description": "Pozivi, ki jih dodaš tukaj, bodo vključeni kot pravila za model med ustvarjanjem oznak. Končne pozive si lahko ogledaš v razdelku za predogled pozivov." + "tagging_rule_description": "Pozivi, ki jih dodaš tukaj, bodo vključeni kot pravila za model med ustvarjanjem oznak. Končne pozive si lahko ogledaš v razdelku za predogled pozivov.", + "tag_style": "Slog oznake", + "auto_summarization_description": "Samodejno ustvari povzetke za tvoje zaznamke z uporabo UI.", + "auto_tagging": "Samodejno označevanje", + "titlecase_spaces": "Velike začetnice s presledki", + "lowercase_underscores": "Male črke s podčrtaji", + "inference_language": "Jezik sklepanja", + "titlecase_hyphens": "Velike začetnice s povezaji", + "lowercase_hyphens": "Male črke s povezaji", + "lowercase_spaces": "Male črke s presledki", + "inference_language_description": "Izberi jezik za oznake in povzetke, ustvarjene z umetno inteligenco.", + "tag_style_description": "Izberi obliko samodejno ustvarjenih oznak.", + "auto_tagging_description": "Samodejno ustvari oznake za tvoje zaznamke z uporabo UI.", + "camelCase": "camelCase", + "auto_summarization": "Samodejno povzemanje" }, "back_to_app": "Nazaj v aplikacijo", "webhooks": { @@ -470,7 +528,14 @@ "year_s_ago": " Let(a) nazaj", "history": "Nedavna iskanja", "title_contains": "Naslov vsebuje", - "title_does_not_contain": "Naslov ne vsebuje" + "title_does_not_contain": "Naslov ne vsebuje", + "is_broken_link": "Ima polomljeno povezavo", + "tags": "Oznake", + "no_suggestions": "Ni predlogov", + "filters": "Filtri", + "is_not_broken_link": "Ima delujočo povezavo", + "lists": "Seznami", + "feeds": "Viri" }, "tags": { "your_tags_info": "Oznake, ki si jih dodelil/a vsaj enkrat", @@ -536,7 +601,9 @@ "summary": "Povzetek", "quota": "Količina", "bookmarks": "Zaznamki", - "storage": "Shranjevanje" + "storage": "Shranjevanje", + "pdf": "Arhiviran PDF", + "default": "Privzeto" }, "actions": { "close_bulk_edit": "Zapri množično urejanje", @@ -581,7 +648,9 @@ "confirm": "Potrdi", "regenerate": "Osveži", "load_more": "Naloži več", - "edit_notes": "Uredi opombe" + "edit_notes": "Uredi opombe", + "preserve_as_pdf": "Shrani kot PDF", + "offline_copies": "Kopije brez povezave" }, "layouts": { "compact": "Kompaktno", @@ -745,7 +814,8 @@ "clipboard_copied": "Povezava je bila kopirana v odložišče!", "updated": "Zaznamek je bil posodobljen!", "refetch": "Ponovno pridobivanje je bilo dodano v čakalno vrsto!", - "full_page_archive": "Ustvarjanje arhiva celotne strani je bilo sproženo" + "full_page_archive": "Ustvarjanje arhiva celotne strani je bilo sproženo", + "preserve_pdf": "Ohranjanje PDF je bilo sproženo" }, "lists": { "created": "Seznam je bil ustvarjen!", @@ -778,7 +848,8 @@ "tabs": { "content": "Vsebina", "details": "Podrobnosti" - } + }, + "archive_info": "Arhivi se morda ne bodo pravilno izrisali v vrstici, če zahtevajo Javascript. Za najboljše rezultate <1>jih prenesi in odpri v brskalniku</1>." }, "highlights": { "no_highlights": "Še nimaš nobenih poudarkov." diff --git a/apps/web/lib/i18n/locales/sv/translation.json b/apps/web/lib/i18n/locales/sv/translation.json index f97949b7..b03c3d2e 100644 --- a/apps/web/lib/i18n/locales/sv/translation.json +++ b/apps/web/lib/i18n/locales/sv/translation.json @@ -39,7 +39,9 @@ "summary": "Sammanfattning", "quota": "Kvot", "bookmarks": "Bokmärken", - "storage": "Lagring" + "storage": "Lagring", + "pdf": "Arkiverad PDF", + "default": "Standard" }, "layouts": { "grid": "Rutnät", @@ -90,7 +92,9 @@ "confirm": "Bekräfta", "regenerate": "Återskapa", "load_more": "Ladda mer", - "edit_notes": "Redigera anteckningar" + "edit_notes": "Redigera anteckningar", + "preserve_as_pdf": "Spara som PDF", + "offline_copies": "Offlinelagrade kopior" }, "settings": { "back_to_app": "Tillbaka till app", @@ -115,6 +119,49 @@ "show": "Visa arkiverade bokmärken i taggar och listor", "hide": "Dölj arkiverade bokmärken i taggar och listor" } + }, + "reader_settings": { + "local_overrides_title": "Enhetsspecifika inställningar aktiva", + "using_default": "Använder klientstandard", + "clear_override_hint": "Rensa enhetsåsidosättning för att använda global inställning ({{value}})", + "font_size": "Teckenstorlek", + "font_family": "Typsnittsfamilj", + "preview_inline": "(förhandsvisning)", + "tooltip_preview": "Osparade förhandsvisningsändringar", + "save_to_all_devices": "Alla enheter", + "tooltip_local": "Enhetsinställningar skiljer sig från globala", + "reset_preview": "Återställ förhandsvisning", + "mono": "Monospace", + "line_height": "Radhöjd", + "tooltip_default": "Läsningsinställningar", + "title": "Läsarinställningar", + "serif": "Serif", + "preview": "Förhandsgranskning", + "not_set": "Ej inställt", + "clear_local_overrides": "Rensa enhetsinställningar", + "preview_text": "The quick brown fox jumps over the lazy dog. Så här kommer din läsarvytext att se ut.", + "local_overrides_cleared": "Enhetsspecifika inställningar har rensats", + "local_overrides_description": "Den här enheten har läsarinställningar som skiljer sig från dina globala standardinställningar:", + "clear_defaults": "Rensa alla standardvärden", + "description": "Konfigurera standardtextinställningar för läsarvyn. Dessa inställningar synkroniseras mellan alla dina enheter.", + "defaults_cleared": "Läsarstandardvärden har rensats", + "save_hint": "Spara inställningar endast för den här enheten eller synkronisera över alla enheter", + "save_as_default": "Spara som standard", + "save_to_device": "Den här enheten", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Osparade ändringar i förhandsvisningen; enhetsinställningarna skiljer sig från de globala", + "adjust_hint": "Justera inställningarna ovan för att förhandsvisa ändringarna" + }, + "avatar": { + "upload": "Ladda upp avatar", + "change": "Ändra avatar", + "remove_confirm_title": "Ta bort avatar?", + "updated": "Avatar uppdaterad", + "removed": "Avatar borttagen", + "description": "Ladda upp en kvadratisk bild för att använda som din avatar.", + "remove_confirm_description": "Detta kommer att ta bort ditt nuvarande profilfoto.", + "title": "Profilbild", + "remove": "Ta bort avatar" } }, "feeds": { @@ -134,7 +181,21 @@ "image_tagging": "Bildtaggning", "summarization": "Sammanfattning", "summarization_prompt": "Sammanfattningsprompt", - "all_tagging": "All taggning" + "all_tagging": "All taggning", + "tag_style": "Taggstil", + "auto_summarization_description": "Generera automatisk sammanfattning för dina bokmärken genom att använda AI.", + "auto_tagging": "Automatisk taggning", + "titlecase_spaces": "Versala inledande bokstäver med mellanslag", + "lowercase_underscores": "Små bokstäver med understreck", + "inference_language": "Språk för inferens", + "titlecase_hyphens": "Versala inledande bokstäver med bindestreck", + "lowercase_hyphens": "Små bokstäver med bindestreck", + "lowercase_spaces": "Små bokstäver med mellanslag", + "inference_language_description": "Välj språk för AI-genererade taggar och sammanfattningar.", + "tag_style_description": "Välj hur dina automatiskt genererade taggar ska formateras.", + "auto_tagging_description": "Generera automatiskt taggar för dina bokmärken genom att använda AI.", + "camelCase": "camelCase", + "auto_summarization": "Automatisk sammanfattning" }, "import": { "import_export": "Importera / exportera", @@ -144,6 +205,7 @@ "import_bookmarks_from_karakeep_export": "Importera bokmärken från Karakeep-export", "import_bookmarks_from_html_file": "Importera bokmärken från HTML-fil", "import_bookmarks_from_pocket_export": "Importera bokmärken från Pocket-export", + "import_bookmarks_from_matter_export": "Importera bokmärken från Matter-export", "export_links_and_notes": "Exportera länkar och anteckningar", "import_bookmarks_from_linkwarden_export": "Importera bokmärken från Linkwarden-export", "import_bookmarks_from_tab_session_manager_export": "Importera bokmärken från Tab Session Manager", @@ -705,7 +767,8 @@ "deleted": "Bokmärket har raderats!", "delete_from_list": "Bokmärket har raderats från listan", "clipboard_copied": "Länken har lags till i ditt urklipp!", - "refetch": "Hämtning har köats!" + "refetch": "Hämtning har köats!", + "preserve_pdf": "PDF-sparande har triggats" }, "lists": { "created": "Listan har skapats!", @@ -732,7 +795,8 @@ "tabs": { "content": "Innehåll", "details": "Detaljer" - } + }, + "archive_info": "Arkiv kanske inte återges korrekt inbäddade om de kräver Javascript. För bästa resultat, <1>ladda ner den och öppna den i din webbläsare</1>." }, "dialogs": { "bookmarks": { @@ -778,7 +842,14 @@ "year_s_ago": " År sedan", "history": "Senaste sökningar", "title_contains": "Titeln innehåller", - "title_does_not_contain": "Titeln innehåller inte" + "title_does_not_contain": "Titeln innehåller inte", + "is_broken_link": "Har trasig länk", + "tags": "Taggar", + "no_suggestions": "Inga förslag", + "filters": "Filter", + "is_not_broken_link": "Har fungerande länk", + "lists": "Listor", + "feeds": "Feeds" }, "highlights": { "no_highlights": "Du har inga markeringar ännu." diff --git a/apps/web/lib/i18n/locales/tr/translation.json b/apps/web/lib/i18n/locales/tr/translation.json index 97af51e0..8cd31dc0 100644 --- a/apps/web/lib/i18n/locales/tr/translation.json +++ b/apps/web/lib/i18n/locales/tr/translation.json @@ -39,7 +39,9 @@ "summary": "Özet", "quota": "Kota", "bookmarks": "Yer İmleri", - "storage": "Depolama" + "storage": "Depolama", + "pdf": "Arşivlenmiş PDF", + "default": "Varsayılan" }, "layouts": { "masonry": "Döşeme", @@ -90,7 +92,9 @@ "confirm": "Onayla", "regenerate": "Yeniden oluştur", "load_more": "Daha Fazla Yükle", - "edit_notes": "Notları Düzenle" + "edit_notes": "Notları Düzenle", + "preserve_as_pdf": "PDF olarak sakla", + "offline_copies": "Çevrimdışı Kopyalar" }, "highlights": { "no_highlights": "Henüz hiçbir öne çıkarılmış içeriğiniz yok." @@ -119,6 +123,49 @@ "show": "Arşivlenmiş yer imlerini etiketlerde ve listelerde göster", "hide": "Arşivlenmiş yer imlerini etiketlerde ve listelerde gizle" } + }, + "reader_settings": { + "local_overrides_title": "Cihaza özel ayarlar etkin", + "using_default": "İstemci varsayılanı kullanılıyor", + "clear_override_hint": "Genel ayarı ({{value}}) kullanmak için cihaz geçersiz kılmasını temizle", + "font_size": "Yazı Tipi Boyutu", + "font_family": "Yazı Tipi Ailesi", + "preview_inline": "(önizleme)", + "tooltip_preview": "Kaydedilmemiş önizleme değişiklikleri", + "save_to_all_devices": "Tüm cihazlar", + "tooltip_local": "Cihaz ayarları, genel ayarlardan farklı", + "reset_preview": "Önizlemeyi sıfırla", + "mono": "Tek Aralık", + "line_height": "Satır Yüksekliği", + "tooltip_default": "Okuma ayarları", + "title": "Okuyucu Ayarları", + "serif": "Serif", + "preview": "Önizleme", + "not_set": "Ayarlanmadı", + "clear_local_overrides": "Cihaz ayarlarını temizle", + "preview_text": "Hızlı kahverengi tilki tembel köpeğin üzerinden atlar. Okuyucu görünümü metniniz bu şekilde görünecek.", + "local_overrides_cleared": "Cihaza özel ayarlar temizlendi", + "local_overrides_description": "Bu cihaz, genel varsayılanlarınızdan farklı okuyucu ayarlarına sahiptir:", + "clear_defaults": "Tüm varsayılanları temizle", + "description": "Okuyucu görünümü için varsayılan metin ayarlarını yapılandır. Bu ayarlar tüm cihazlarınızda senkronize edilir.", + "defaults_cleared": "Okuyucu varsayılanları temizlendi", + "save_hint": "Ayarları yalnızca bu cihaz için kaydet veya tüm cihazlarda senkronize et", + "save_as_default": "Varsayılan olarak kaydet", + "save_to_device": "Bu cihaz", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Kaydedilmemiş önizleme değişiklikleri; cihaz ayarları genel ayarlardan farklı", + "adjust_hint": "Değişiklikleri önizlemek için yukarıdaki ayarları düzenle" + }, + "avatar": { + "upload": "Avatar yükle", + "change": "Avatarı değiştir", + "remove_confirm_title": "Avatarı kaldırılsın mı?", + "updated": "Avatar güncellendi", + "removed": "Avatar silindi", + "description": "Avatarınız olarak kullanmak için kare bir resim yükleyin.", + "remove_confirm_description": "Bu, mevcut profil fotoğrafınızı temizleyecek.", + "title": "Profil Fotoğrafı", + "remove": "Avatarı kaldır" } }, "ai": { @@ -132,7 +179,21 @@ "summarization_prompt": "Özetleme İstemi", "all_tagging": "Tüm Etiketleme", "text_tagging": "Metin Etiketleme", - "summarization": "Özetleme" + "summarization": "Özetleme", + "tag_style": "Etiket Stili", + "auto_summarization_description": "Yapay zeka kullanarak yer işaretlerin için otomatik olarak özet oluştur.", + "auto_tagging": "Otomatik etiketleme", + "titlecase_spaces": "Büyük harf ve boşluklu", + "lowercase_underscores": "Küçük harf ve alt çizgili", + "inference_language": "Çıkarım Dili", + "titlecase_hyphens": "Büyük harf ve tireli", + "lowercase_hyphens": "Küçük harf ve tireli", + "lowercase_spaces": "Küçük harf ve boşluklu", + "inference_language_description": "Yapay zeka tarafından oluşturulan etiketler ve özetler için dili seç.", + "tag_style_description": "Otomatik oluşturulan etiketlerinin nasıl biçimlendirileceğini seç.", + "auto_tagging_description": "Yapay zeka kullanarak yer işaretlerin için otomatik olarak etiket oluştur.", + "camelCase": "camelCase", + "auto_summarization": "Otomatik özetleme" }, "feeds": { "rss_subscriptions": "RSS Abonelikleri", @@ -145,6 +206,7 @@ "import_export_bookmarks": "Yer İşaretlerini İçe / Dışa Aktar", "import_bookmarks_from_html_file": "HTML Dosyasından Yer İşaretlerini İçe Aktar", "import_bookmarks_from_pocket_export": "Pocket Dışa Aktarımından Yer İşaretlerini İçe Aktar", + "import_bookmarks_from_matter_export": "Matter Dışa Aktarımından Yer İşaretlerini İçe Aktar", "import_bookmarks_from_omnivore_export": "Omnivore Dışa Aktarımından Yer İşaretlerini İçe Aktar", "import_bookmarks_from_karakeep_export": "Karakeep Dışa Aktarımından Yer İşaretlerini İçe Aktar", "export_links_and_notes": "Bağlantı ve Notları Dışa Aktar", @@ -649,7 +711,8 @@ "tabs": { "content": "İçerik", "details": "Ayrıntılar" - } + }, + "archive_info": "Arşivler Javascript gerektiriyorsa satır içi olarak doğru şekilde işlenmeyebilir. En iyi sonuçlar için, <1>indirin ve tarayıcınızda açın</1>." }, "editor": { "quickly_focus": "Bu alana hızlıca odaklanmak için ⌘ + E tuşlarına basabilirsiniz", @@ -717,7 +780,8 @@ "refetch": "Yeniden getir kuyruğa alındı!", "full_page_archive": "Tüm Sayfa Arşivi oluşturma başlatıldı", "delete_from_list": "Yer işareti listeden silindi", - "clipboard_copied": "Bağlantı panonuza eklendi!" + "clipboard_copied": "Bağlantı panonuza eklendi!", + "preserve_pdf": "PDF olarak saklama tetiklendi" }, "lists": { "created": "Liste oluşturuldu!", @@ -775,7 +839,14 @@ "month_s_ago": " Ay Önce", "history": "Son Aramalar", "title_contains": "Başlık İçeriyor", - "title_does_not_contain": "Başlık İçermiyor" + "title_does_not_contain": "Başlık İçermiyor", + "is_broken_link": "Bozuk Bağlantısı Var", + "tags": "Etiketler", + "no_suggestions": "Öneri yok", + "filters": "Filtreler", + "is_not_broken_link": "Çalışan Bağlantısı Var", + "lists": "Listeler", + "feeds": "Akışlar" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/uk/translation.json b/apps/web/lib/i18n/locales/uk/translation.json index 819584ef..1329db9c 100644 --- a/apps/web/lib/i18n/locales/uk/translation.json +++ b/apps/web/lib/i18n/locales/uk/translation.json @@ -39,7 +39,9 @@ "summary": "Короткий зміст", "quota": "Квота", "bookmarks": "Закладки", - "storage": "Сховище" + "storage": "Сховище", + "pdf": "Архівні PDF", + "default": "За замовчуванням" }, "actions": { "sign_out": "Вийти", @@ -84,7 +86,9 @@ "confirm": "Підтвердити", "regenerate": "Відновити", "load_more": "Завантажити більше", - "edit_notes": "Редагувати примітки" + "edit_notes": "Редагувати примітки", + "preserve_as_pdf": "Зберегти як PDF", + "offline_copies": "Офлайн копії" }, "settings": { "webhooks": { @@ -128,6 +132,49 @@ "show": "Показувати заархівовані закладки в тегах і списках", "hide": "Приховувати заархівовані закладки в тегах і списках" } + }, + "reader_settings": { + "local_overrides_title": "Активні налаштування для конкретного пристрою", + "using_default": "Використовується типове значення клієнта", + "clear_override_hint": "Очистити переналаштування пристрою, щоб використовувати глобальні налаштування ({{value}})", + "font_size": "Розмір шрифту", + "font_family": "Сімейство шрифтів", + "preview_inline": "(попередній перегляд)", + "tooltip_preview": "Не збережені зміни попереднього перегляду", + "save_to_all_devices": "Усі пристрої", + "tooltip_local": "Налаштування пристрою відрізняються від глобальних", + "reset_preview": "Скинути попередній перегляд", + "mono": "Моноширинний", + "line_height": "міжрядковий інтервал", + "tooltip_default": "Налаштування читання", + "title": "Параметри читання", + "serif": "Serif", + "preview": "Перегляд", + "not_set": "Не встановлено", + "clear_local_overrides": "Очистити налаштування пристрою", + "preview_text": "Швидкий бурий лис стрибає через ледачого пса. Ось як виглядатиме ваш текст у режимі читання.", + "local_overrides_cleared": "Налаштування для конкретного пристрою очищено", + "local_overrides_description": "На цьому пристрої параметри читання відрізняються від ваших глобальних типових значень:", + "clear_defaults": "Очистити всі типові налаштування", + "description": "Налаштуйте параметри тексту для перегляду в режимі читання. Ці параметри синхронізуються на всіх ваших пристроях.", + "defaults_cleared": "Типові значення читання очищено", + "save_hint": "Зберегти налаштування тільки для цього пристрою або синхронізувати на всіх пристроях", + "save_as_default": "Зберегти як типові", + "save_to_device": "Цей пристрій", + "sans": "Sans Serif", + "tooltip_preview_and_local": "Не збережені зміни попереднього перегляду; налаштування пристрою відрізняються від глобальних", + "adjust_hint": "Налаштуйте параметри вище, щоб попередньо переглянути зміни" + }, + "avatar": { + "upload": "Завантажити аватар", + "change": "Змінити аватар", + "remove_confirm_title": "Видалити аватар?", + "updated": "Аватар оновлено", + "removed": "Аватар видалено", + "description": "Завантаж квадратне зображення, щоб використовувати його як свій аватар.", + "remove_confirm_description": "Це видалить поточне фото профілю.", + "title": "Фото профілю", + "remove": "Видалити аватар" } }, "ai": { @@ -141,7 +188,21 @@ "image_tagging": "Тегування зображень", "summarization": "Підсумовування", "prompt_preview": "Попередній перегляд підказки", - "tagging_rules": "Правила тегів" + "tagging_rules": "Правила тегів", + "tag_style": "Стиль тегів", + "auto_summarization_description": "Автоматично створюйте підсумки для закладок, використовуючи штучний інтелект.", + "auto_tagging": "Автоматичне тегування", + "titlecase_spaces": "З великої літери з пробілами", + "lowercase_underscores": "З маленької літери з підкресленнями", + "inference_language": "Мова висновування", + "titlecase_hyphens": "З великої літери з дефісами", + "lowercase_hyphens": "З маленької літери з дефісами", + "lowercase_spaces": "З маленької літери з пробілами", + "inference_language_description": "Вибери мову для тегів і підсумків, згенерованих ШІ.", + "tag_style_description": "Обери, як форматуватимуться твої автоматично створені теги.", + "auto_tagging_description": "Автоматично генеруйте теги для своїх закладок за допомогою штучного інтелекту.", + "camelCase": "camelCase", + "auto_summarization": "Автоматичне підсумовування" }, "feeds": { "rss_subscriptions": "RSS-підписки", @@ -154,6 +215,7 @@ "import_export_bookmarks": "Імпорт / Експорт закладок", "import_bookmarks_from_html_file": "Імпортувати закладки з HTML-файлу", "import_bookmarks_from_pocket_export": "Імпортувати закладки з експорту Pocket", + "import_bookmarks_from_matter_export": "Імпортувати закладки з експорту Matter", "import_bookmarks_from_omnivore_export": "Імпорт закладок з експорту Omnivore", "import_bookmarks_from_linkwarden_export": "Імпортувати закладки з експорту Linkwarden", "import_bookmarks_from_karakeep_export": "Імпортувати закладки з експорту Karakeep", @@ -427,7 +489,14 @@ "year_s_ago": " Років тому", "history": "Нещодавні пошуки", "title_contains": "Назва містить", - "title_does_not_contain": "Назва не містить" + "title_does_not_contain": "Назва не містить", + "is_broken_link": "Має недійсне посилання", + "tags": "Теги", + "no_suggestions": "Немає пропозицій", + "filters": "Фільтри", + "is_not_broken_link": "Має дійсне посилання", + "lists": "Списки", + "feeds": "Стрічки новин" }, "preview": { "cached_content": "Кешований вміст", @@ -436,7 +505,8 @@ "tabs": { "details": "Деталі", "content": "Вміст" - } + }, + "archive_info": "Архіви можуть неправильно відображатися вбудовано, якщо їм потрібен Javascript. Для кращого результату, <1>завантажте їх і відкрийте у своєму браузері</1>." }, "layouts": { "masonry": "Кам'яна кладка", @@ -763,7 +833,8 @@ "delete_from_list": "Закладку видалено зі списку", "clipboard_copied": "Посилання додано до вашого буфера обміну!", "updated": "Закладку оновлено!", - "deleted": "Закладку видалено!" + "deleted": "Закладку видалено!", + "preserve_pdf": "Збереження PDF ініційовано" }, "lists": { "created": "Список створено!", diff --git a/apps/web/lib/i18n/locales/vi/translation.json b/apps/web/lib/i18n/locales/vi/translation.json index 920f3435..06993802 100644 --- a/apps/web/lib/i18n/locales/vi/translation.json +++ b/apps/web/lib/i18n/locales/vi/translation.json @@ -42,7 +42,9 @@ "confirm": "Xác nhận", "regenerate": "Tạo lại", "load_more": "Tải thêm", - "edit_notes": "Sửa ghi chú" + "edit_notes": "Sửa ghi chú", + "preserve_as_pdf": "Lưu giữ dưới dạng PDF", + "offline_copies": "Bản sao ngoại tuyến" }, "layouts": { "list": "Danh sách", @@ -65,6 +67,7 @@ "import_export_bookmarks": "Nhập / Xuất đánh dấu trang", "import_bookmarks_from_linkwarden_export": "Nhập dấu trang từ bản xuất Linkwarden", "import_bookmarks_from_pocket_export": "Nhập dấu trang từ bản xuất Pocket", + "import_bookmarks_from_matter_export": "Nhập dấu trang từ bản xuất Matter", "import_bookmarks_from_omnivore_export": "Nhập dấu trang từ xuất Omnivore", "import_bookmarks_from_karakeep_export": "Nhập dấu trang từ bản xuất Karakeep", "import_bookmarks_from_tab_session_manager_export": "Nhập dấu trang từ Tab Session Manager", @@ -111,7 +114,21 @@ "summarization": "Tóm tắt", "all_tagging": "Tất cả nhãn", "image_tagging": "Nhãn cho hình ảnh", - "text_tagging": "Nhãn cho văn bản" + "text_tagging": "Nhãn cho văn bản", + "tag_style": "Kiểu Thẻ", + "auto_summarization_description": "Tự động tạo bản tóm tắt cho dấu trang bằng AI.", + "auto_tagging": "Tự động gắn thẻ", + "titlecase_spaces": "Tiêu đề viết hoa có dấu cách", + "lowercase_underscores": "Chữ thường có dấu gạch dưới", + "inference_language": "Ngôn ngữ Suy luận", + "titlecase_hyphens": "Tiêu đề viết hoa có dấu gạch ngang", + "lowercase_hyphens": "Chữ thường có dấu gạch ngang", + "lowercase_spaces": "Chữ thường có dấu cách", + "inference_language_description": "Chọn ngôn ngữ cho các thẻ và tóm tắt do AI tạo.", + "tag_style_description": "Chọn cách định dạng các thẻ tự động tạo của bạn.", + "auto_tagging_description": "Tự động tạo thẻ cho dấu trang bằng AI.", + "camelCase": "camelCase", + "auto_summarization": "Tự động tóm tắt" }, "info": { "basic_details": "Thông tin cơ bản", @@ -134,6 +151,49 @@ "show": "Hiển thị các bookmark đã lưu trữ trong tag và danh sách", "hide": "Ẩn các bookmark đã lưu trữ trong tag và danh sách" } + }, + "reader_settings": { + "local_overrides_title": "Đã kích hoạt cài đặt dành riêng cho thiết bị", + "using_default": "Sử dụng mặc định của ứng dụng", + "clear_override_hint": "Xóa ghi đè thiết bị để sử dụng cài đặt chung ({{value}})", + "font_size": "Cỡ chữ", + "font_family": "Họ phông chữ", + "preview_inline": "(xem trước)", + "tooltip_preview": "Các thay đổi xem trước chưa được lưu", + "save_to_all_devices": "Tất cả các thiết bị", + "tooltip_local": "Cài đặt thiết bị khác với cài đặt chung", + "reset_preview": "Đặt lại bản xem trước", + "mono": "Đơn cách", + "line_height": "Chiều cao dòng", + "tooltip_default": "Cài đặt đọc", + "title": "Cài đặt Trình đọc", + "serif": "Chân phương", + "preview": "Xem trước", + "not_set": "Chưa đặt", + "clear_local_overrides": "Xóa cài đặt thiết bị", + "preview_text": "Một con cáo nâu nhanh chóng nhảy qua con chó lười biếng. Đây là cách mà văn bản chế độ xem trình đọc của bạn sẽ hiển thị.", + "local_overrides_cleared": "Đã xóa cài đặt cho thiết bị", + "local_overrides_description": "Thiết bị này có các cài đặt trình đọc khác với cài đặt mặc định toàn cầu của bạn:", + "clear_defaults": "Xóa tất cả mặc định", + "description": "Cấu hình cài đặt văn bản mặc định cho chế độ xem trình đọc. Các cài đặt này đồng bộ hóa trên tất cả các thiết bị của bạn.", + "defaults_cleared": "Đã xóa các mặc định của trình đọc", + "save_hint": "Lưu cài đặt chỉ cho thiết bị này hoặc đồng bộ hóa trên tất cả các thiết bị", + "save_as_default": "Lưu làm mặc định", + "save_to_device": "Thiết bị này", + "sans": "Không chân phương", + "tooltip_preview_and_local": "Các thay đổi xem trước chưa được lưu; cài đặt thiết bị khác với cài đặt chung", + "adjust_hint": "Điều chỉnh các cài đặt ở trên để xem trước các thay đổi" + }, + "avatar": { + "upload": "Tải lên ảnh đại diện", + "change": "Đổi ảnh đại diện", + "remove_confirm_title": "Xóa ảnh đại diện?", + "updated": "Đã cập nhật ảnh đại diện", + "removed": "Đã xóa ảnh đại diện", + "description": "Tải lên ảnh vuông để dùng làm ảnh đại diện nha.", + "remove_confirm_description": "Hành động này sẽ xóa ảnh hồ sơ hiện tại của bạn đó.", + "title": "Ảnh hồ sơ", + "remove": "Xóa ảnh đại diện" } }, "user_settings": "Cài đặt người dùng", @@ -523,7 +583,9 @@ "summary": "Tóm tắt", "quota": "Hạn ngạch", "bookmarks": "Dấu trang", - "storage": "Lưu trữ" + "storage": "Lưu trữ", + "pdf": "PDF đã lưu trữ", + "default": "Mặc định" }, "highlights": { "no_highlights": "Bạn chưa có đánh dấu nào." @@ -652,7 +714,14 @@ "year_s_ago": " Năm trước", "history": "Tìm kiếm gần đây", "title_contains": "Chứa trong tiêu đề", - "title_does_not_contain": "Không chứa trong tiêu đề" + "title_does_not_contain": "Không chứa trong tiêu đề", + "is_broken_link": "Có liên kết hỏng", + "tags": "Thẻ", + "no_suggestions": "Không có đề xuất nào", + "filters": "Bộ lọc", + "is_not_broken_link": "Có liên kết hoạt động", + "lists": "Danh sách", + "feeds": "Nguồn cấp dữ liệu" }, "tags": { "all_tags": "Tất cả nhãn", @@ -761,7 +830,8 @@ "refetch": "Đã xếp hàng tìm nạp lại!", "full_page_archive": "Đã kích hoạt tạo bản lưu trữ toàn trang", "delete_from_list": "Đã xóa dấu trang khỏi danh sách", - "clipboard_copied": "Liên kết đã được thêm vào bảng nhớ tạm của bạn!" + "clipboard_copied": "Liên kết đã được thêm vào bảng nhớ tạm của bạn!", + "preserve_pdf": "Đã kích hoạt lưu giữ PDF" }, "lists": { "created": "Đã tạo danh sách!", @@ -781,7 +851,8 @@ "tabs": { "content": "Nội dung", "details": "Chi tiết" - } + }, + "archive_info": "Các bản lưu trữ có thể không hiển thị chính xác nội dòng nếu chúng yêu cầu Javascript. Để có kết quả tốt nhất, <1>hãy tải xuống và mở trong trình duyệt của bạn</1>." }, "bookmark_editor": { "title": "Sửa dấu trang", diff --git a/apps/web/lib/i18n/locales/zh/translation.json b/apps/web/lib/i18n/locales/zh/translation.json index 771f47f8..7f16a5f6 100644 --- a/apps/web/lib/i18n/locales/zh/translation.json +++ b/apps/web/lib/i18n/locales/zh/translation.json @@ -39,7 +39,9 @@ "summary": "摘要", "quota": "配额", "bookmarks": "书签", - "storage": "存储" + "storage": "存储", + "pdf": "已存档的 PDF", + "default": "默认" }, "layouts": { "masonry": "砌体", @@ -90,7 +92,9 @@ "confirm": "确认", "regenerate": "重新生成", "load_more": "加载更多", - "edit_notes": "编辑备注" + "edit_notes": "编辑备注", + "preserve_as_pdf": "另存为 PDF", + "offline_copies": "离线副本" }, "settings": { "back_to_app": "返回应用", @@ -116,6 +120,49 @@ "show": "在标签和列表中显示已存档的书签", "hide": "在标签和列表中隐藏已存档的书签" } + }, + "reader_settings": { + "local_overrides_title": "设备特定的设置已激活", + "using_default": "正在使用客户端默认值", + "clear_override_hint": "清除设备覆盖以使用全局设置({{value}})", + "font_size": "字体大小", + "font_family": "字体系列", + "preview_inline": "(预览)", + "tooltip_preview": "未保存的预览更改", + "save_to_all_devices": "所有设备", + "tooltip_local": "设备设置与全局设置不同", + "reset_preview": "重置预览", + "mono": "等宽", + "line_height": "行高", + "tooltip_default": "阅读设置", + "title": "阅读器设置", + "serif": "衬线", + "preview": "预览", + "not_set": "未设置", + "clear_local_overrides": "清除设备设置", + "preview_text": "敏捷的棕色狐狸跳过懒惰的狗。这是您的阅读器视图文本的显示方式。", + "local_overrides_cleared": "设备特定的设置已清除", + "local_overrides_description": "此设备上的阅读器设置与您的全局默认值不同:", + "clear_defaults": "清除所有默认值", + "description": "配置阅读器视图的默认文本设置。这些设置将在您的所有设备上同步。", + "defaults_cleared": "阅读器默认值已清除", + "save_hint": "仅保存此设备的设置,还是在所有设备同步", + "save_as_default": "保存为默认值", + "save_to_device": "此设备", + "sans": "无衬线", + "tooltip_preview_and_local": "未保存的预览更改;设备设置与全局设置不同", + "adjust_hint": "调整以上设置以预览更改" + }, + "avatar": { + "upload": "上传虚拟形象", + "change": "更改虚拟形象", + "remove_confirm_title": "移除虚拟形象?", + "updated": "虚拟形象已更新", + "removed": "虚拟形象已移除", + "description": "上传一张方形图片作为您的虚拟形象。", + "remove_confirm_description": "这会清除您当前的头像照片。", + "title": "头像照片", + "remove": "移除虚拟形象" } }, "ai": { @@ -129,7 +176,21 @@ "image_tagging": "图片标记", "text_tagging": "文字标记", "all_tagging": "所有标记", - "summarization_prompt": "摘要生成提示" + "summarization_prompt": "摘要生成提示", + "tag_style": "标签样式", + "auto_summarization_description": "使用 AI 自动为你的书签生成摘要。", + "auto_tagging": "自动添加标签", + "titlecase_spaces": "带空格的首字母大写", + "lowercase_underscores": "带下划线的小写", + "inference_language": "推理语言", + "titlecase_hyphens": "带连字符的首字母大写", + "lowercase_hyphens": "带连字符的小写", + "lowercase_spaces": "带空格的小写", + "inference_language_description": "为 AI 生成的标签和摘要选择语言。", + "tag_style_description": "选择自动生成的标签应如何格式化。", + "auto_tagging_description": "使用 AI 自动为你的书签生成标签。", + "camelCase": "驼峰式命名", + "auto_summarization": "自动摘要" }, "feeds": { "rss_subscriptions": "RSS订阅", @@ -142,6 +203,7 @@ "import_export_bookmarks": "导入/导出书签", "import_bookmarks_from_html_file": "从HTML文件导入书签", "import_bookmarks_from_pocket_export": "从Pocket导出导入书签", + "import_bookmarks_from_matter_export": "从Matter导出导入书签", "import_bookmarks_from_omnivore_export": "从Omnivore导出导入书签", "import_bookmarks_from_karakeep_export": "从Karakeep导出导入书签", "export_links_and_notes": "导出链接和笔记", @@ -646,7 +708,8 @@ "tabs": { "content": "内容", "details": "详情" - } + }, + "archive_info": "如果存档需要 Javascript,则可能无法正确地以内联方式呈现。为了获得最佳效果,<1>请下载并在浏览器中打开它</1>。" }, "editor": { "quickly_focus": "您可以按⌘ + E快速聚焦到此字段", @@ -714,7 +777,8 @@ "refetch": "重新获取已排队!", "full_page_archive": "已触发完整页面归档创建", "delete_from_list": "书签已从列表中删除", - "clipboard_copied": "链接已添加到您的剪贴板!" + "clipboard_copied": "链接已添加到您的剪贴板!", + "preserve_pdf": "已触发 PDF 保存" }, "lists": { "created": "列表已创建!", @@ -772,7 +836,14 @@ "week_s_ago": " {weeks} 周前", "history": "最近搜索", "title_contains": "标题包含", - "title_does_not_contain": "标题不包含" + "title_does_not_contain": "标题不包含", + "is_broken_link": "有损坏的链接", + "tags": "标签", + "no_suggestions": "没有建议", + "filters": "筛选器", + "is_not_broken_link": "有可用的链接", + "lists": "列表", + "feeds": "订阅" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/zhtw/translation.json b/apps/web/lib/i18n/locales/zhtw/translation.json index 92c4f41b..cafa02d6 100644 --- a/apps/web/lib/i18n/locales/zhtw/translation.json +++ b/apps/web/lib/i18n/locales/zhtw/translation.json @@ -39,7 +39,9 @@ "summary": "摘要", "quota": "配額", "bookmarks": "書籤", - "storage": "儲存空間" + "storage": "儲存空間", + "pdf": "已封存的 PDF", + "default": "預設" }, "layouts": { "masonry": "瀑布式", @@ -90,7 +92,9 @@ "confirm": "確認", "regenerate": "重新產生", "load_more": "載入更多", - "edit_notes": "編輯註解" + "edit_notes": "編輯註解", + "preserve_as_pdf": "儲存為 PDF", + "offline_copies": "離線副本" }, "settings": { "back_to_app": "返回應用程式", @@ -116,6 +120,49 @@ "open_external_url": "開啟原始網址", "open_bookmark_details": "開啟書籤詳細資訊" } + }, + "reader_settings": { + "local_overrides_title": "裝置專用設定已啟動", + "using_default": "使用用戶端預設值", + "clear_override_hint": "清除裝置覆寫以使用全域設定({{value}})", + "font_size": "字型大小", + "font_family": "字型", + "preview_inline": "(預覽)", + "tooltip_preview": "未儲存的預覽變更", + "save_to_all_devices": "所有裝置", + "tooltip_local": "裝置設定與全域不同", + "reset_preview": "重設預覽", + "mono": "等寬字體", + "line_height": "行高", + "tooltip_default": "閱讀設定", + "title": "閱讀器設定", + "serif": "襯線體", + "preview": "預覽", + "not_set": "未設定", + "clear_local_overrides": "清除裝置設定", + "preview_text": "敏捷的棕色狐狸跳過懶惰的狗。您的閱讀器檢視文字會像這樣顯示。", + "local_overrides_cleared": "裝置專用設定已清除", + "local_overrides_description": "此裝置具有與全域預設值不同的閱讀器設定:", + "clear_defaults": "清除所有預設值", + "description": "設定閱讀器檢視的預設文字設定。這些設定會在您的所有裝置之間同步。", + "defaults_cleared": "閱讀器預設值已清除", + "save_hint": "僅儲存此裝置的設定,或跨所有裝置同步", + "save_as_default": "儲存為預設值", + "save_to_device": "此裝置", + "sans": "無襯線體", + "tooltip_preview_and_local": "未儲存的預覽變更數目;裝置設定與全域不同", + "adjust_hint": "調整以上設定以預覽變更" + }, + "avatar": { + "upload": "上傳頭像", + "change": "變更頭像", + "remove_confirm_title": "要移除頭像嗎?", + "updated": "頭像已更新", + "removed": "頭像已移除", + "description": "上傳一張正方形圖片做為您的頭像。", + "remove_confirm_description": "這會清除您目前的個人資料相片。", + "title": "個人資料相片", + "remove": "移除頭像" } }, "ai": { @@ -129,7 +176,21 @@ "text_tagging": "文字標籤", "image_tagging": "圖片標籤", "summarization": "摘要", - "summarization_prompt": "摘要提示詞" + "summarization_prompt": "摘要提示詞", + "tag_style": "標籤樣式", + "auto_summarization_description": "使用 AI 自動為你的書籤產生摘要。", + "auto_tagging": "自動標記", + "titlecase_spaces": "首字大寫,含空格", + "lowercase_underscores": "小寫,含底線", + "inference_language": "推論語言", + "titlecase_hyphens": "首字大寫,含連字號", + "lowercase_hyphens": "小寫,含連字號", + "lowercase_spaces": "小寫,含空格", + "inference_language_description": "選擇 AI 產生的標籤和摘要的語言。", + "tag_style_description": "選擇自動產生的標籤應如何格式化。", + "auto_tagging_description": "使用 AI 自動為你的書籤產生標籤。", + "camelCase": "駝峰式大小寫", + "auto_summarization": "自動摘要" }, "feeds": { "rss_subscriptions": "RSS 訂閱", @@ -142,6 +203,7 @@ "import_export_bookmarks": "匯入/匯出書籤", "import_bookmarks_from_html_file": "從 HTML 檔案匯入書籤", "import_bookmarks_from_pocket_export": "從 Pocket 匯出檔案匯入書籤", + "import_bookmarks_from_matter_export": "從 Matter 匯出檔案匯入書籤", "import_bookmarks_from_omnivore_export": "從 Omnivore 匯出檔案匯入書籤", "import_bookmarks_from_karakeep_export": "從 Karakeep 匯出檔案匯入書籤", "export_links_and_notes": "匯出連結和筆記", @@ -646,7 +708,8 @@ "tabs": { "content": "內容", "details": "詳細資訊" - } + }, + "archive_info": "如果封存檔需要 Javascript,可能無法正確地內嵌呈現。為了獲得最佳效果,<1>請下載並在瀏覽器中開啟</1>。" }, "editor": { "quickly_focus": "您可以按下 ⌘ + E 快速聚焦此欄位", @@ -714,7 +777,8 @@ "refetch": "已將重新抓取加入佇列!", "full_page_archive": "已觸發完整網頁封存建立", "delete_from_list": "已從清單中移除書籤", - "clipboard_copied": "連結已複製到剪貼簿!" + "clipboard_copied": "連結已複製到剪貼簿!", + "preserve_pdf": "已觸發 PDF 儲存" }, "lists": { "created": "清單已建立!", @@ -772,7 +836,14 @@ "year_s_ago": " 幾年前", "history": "近期搜尋", "title_contains": "標題包含", - "title_does_not_contain": "標題不包含" + "title_does_not_contain": "標題不包含", + "is_broken_link": "連結已損毀", + "tags": "標籤", + "no_suggestions": "沒有任何建議", + "filters": "篩選器", + "is_not_broken_link": "擁有可用的連結", + "lists": "清單", + "feeds": "動態饋給" }, "dialogs": { "bookmarks": { diff --git a/apps/web/lib/providers.tsx b/apps/web/lib/providers.tsx index a3debdb9..8e247f6f 100644 --- a/apps/web/lib/providers.tsx +++ b/apps/web/lib/providers.tsx @@ -1,21 +1,21 @@ "use client"; import type { UserLocalSettings } from "@/lib/userLocalSettings/types"; -import type { Session } from "next-auth"; import React, { useState } from "react"; import { ThemeProvider } from "@/components/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { Session, SessionProvider } from "@/lib/auth/client"; import { UserLocalSettingsCtx } from "@/lib/userLocalSettings/bookmarksLayout"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchLink, loggerLink } from "@trpc/client"; -import { SessionProvider } from "next-auth/react"; +import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client"; import superjson from "superjson"; import type { ClientConfig } from "@karakeep/shared/config"; +import type { AppRouter } from "@karakeep/trpc/routers/_app"; +import { TRPCProvider } from "@karakeep/shared-react/trpc"; import { ClientConfigCtx } from "./clientConfig"; import CustomI18nextProvider from "./i18n/provider"; -import { api } from "./trpc"; function makeQueryClient() { return new QueryClient({ @@ -59,7 +59,7 @@ export default function Providers({ const queryClient = getQueryClient(); const [trpcClient] = useState(() => - api.createClient({ + createTRPCClient<AppRouter>({ links: [ loggerLink({ enabled: (op) => @@ -80,8 +80,8 @@ export default function Providers({ <ClientConfigCtx.Provider value={clientConfig}> <UserLocalSettingsCtx.Provider value={userLocalSettings}> <SessionProvider session={session}> - <api.Provider client={trpcClient} queryClient={queryClient}> - <QueryClientProvider client={queryClient}> + <QueryClientProvider client={queryClient}> + <TRPCProvider trpcClient={trpcClient} queryClient={queryClient}> <CustomI18nextProvider lang={userLocalSettings.lang}> <ThemeProvider attribute="class" @@ -94,8 +94,8 @@ export default function Providers({ </TooltipProvider> </ThemeProvider> </CustomI18nextProvider> - </QueryClientProvider> - </api.Provider> + </TRPCProvider> + </QueryClientProvider> </SessionProvider> </UserLocalSettingsCtx.Provider> </ClientConfigCtx.Provider> diff --git a/apps/web/lib/readerSettings.tsx b/apps/web/lib/readerSettings.tsx new file mode 100644 index 00000000..5966f287 --- /dev/null +++ b/apps/web/lib/readerSettings.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +import { + ReaderSettingsProvider as BaseReaderSettingsProvider, + useReaderSettingsContext, +} from "@karakeep/shared-react/hooks/reader-settings"; +import { + ReaderSettings, + ReaderSettingsPartial, +} from "@karakeep/shared/types/readers"; + +const LOCAL_STORAGE_KEY = "karakeep-reader-settings"; + +function getLocalOverridesFromStorage(): ReaderSettingsPartial { + if (typeof window === "undefined") return {}; + try { + const stored = localStorage.getItem(LOCAL_STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +} + +function saveLocalOverridesToStorage(overrides: ReaderSettingsPartial): void { + if (typeof window === "undefined") return; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(overrides)); +} + +// Session overrides context - web-specific feature for live preview +interface SessionOverridesContextValue { + sessionOverrides: ReaderSettingsPartial; + setSessionOverrides: React.Dispatch< + React.SetStateAction<ReaderSettingsPartial> + >; +} + +const SessionOverridesContext = + createContext<SessionOverridesContextValue | null>(null); + +export function ReaderSettingsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [sessionOverrides, setSessionOverrides] = + useState<ReaderSettingsPartial>({}); + + const sessionValue = useMemo( + () => ({ + sessionOverrides, + setSessionOverrides, + }), + [sessionOverrides], + ); + + // Memoize callbacks to prevent unnecessary re-renders + const getLocalOverrides = useCallback(getLocalOverridesFromStorage, []); + const saveLocalOverrides = useCallback(saveLocalOverridesToStorage, []); + const onClearSessionOverrides = useCallback(() => { + setSessionOverrides({}); + }, []); + + return ( + <BaseReaderSettingsProvider + getLocalOverrides={getLocalOverrides} + saveLocalOverrides={saveLocalOverrides} + sessionOverrides={sessionOverrides} + onClearSessionOverrides={onClearSessionOverrides} + > + <SessionOverridesContext.Provider value={sessionValue}> + {children} + </SessionOverridesContext.Provider> + </BaseReaderSettingsProvider> + ); +} + +export function useReaderSettings() { + const sessionContext = useContext(SessionOverridesContext); + if (!sessionContext) { + throw new Error( + "useReaderSettings must be used within a ReaderSettingsProvider", + ); + } + + const { sessionOverrides, setSessionOverrides } = sessionContext; + const baseSettings = useReaderSettingsContext(); + + // Update session override (live preview, not persisted) + const updateSession = useCallback( + (updates: ReaderSettingsPartial) => { + setSessionOverrides((prev) => ({ ...prev, ...updates })); + }, + [setSessionOverrides], + ); + + // Clear all session overrides + const clearSession = useCallback(() => { + setSessionOverrides({}); + }, [setSessionOverrides]); + + // Save current settings to local storage (this device only) + const saveToDevice = useCallback(() => { + const newLocalOverrides = { + ...baseSettings.localOverrides, + ...sessionOverrides, + }; + baseSettings.setLocalOverrides(newLocalOverrides); + saveLocalOverridesToStorage(newLocalOverrides); + setSessionOverrides({}); + }, [baseSettings, sessionOverrides, setSessionOverrides]); + + // Clear a single local override + const clearLocalOverride = useCallback( + (key: keyof ReaderSettings) => { + baseSettings.clearLocal(key); + }, + [baseSettings], + ); + + // Check if there are unsaved session changes + const hasSessionChanges = Object.keys(sessionOverrides).length > 0; + + return { + // Current effective settings (what should be displayed) + settings: baseSettings.settings, + + // Raw values for UI indicators + serverSettings: baseSettings.serverDefaults, + localOverrides: baseSettings.localOverrides, + sessionOverrides, + + // State indicators + hasSessionChanges, + hasLocalOverrides: baseSettings.hasLocalOverrides, + isSaving: baseSettings.isSaving, + + // Actions + updateSession, + clearSession, + saveToDevice, + clearLocalOverrides: baseSettings.clearAllLocal, + clearLocalOverride, + saveToServer: baseSettings.saveAsDefault, + updateServerSetting: baseSettings.saveAsDefault, + clearServerDefaults: baseSettings.clearAllDefaults, + }; +} diff --git a/apps/web/lib/trpc.tsx b/apps/web/lib/trpc.tsx deleted file mode 100644 index 1478684f..00000000 --- a/apps/web/lib/trpc.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import { createTRPCReact } from "@trpc/react-query"; - -import type { AppRouter } from "@karakeep/trpc/routers/_app"; - -export const api = createTRPCReact<AppRouter>(); diff --git a/apps/web/lib/userSettings.tsx b/apps/web/lib/userSettings.tsx index c7a133b7..105e258e 100644 --- a/apps/web/lib/userSettings.tsx +++ b/apps/web/lib/userSettings.tsx @@ -1,11 +1,11 @@ "use client"; import { createContext, useContext } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@karakeep/shared-react/trpc"; import { ZUserSettings } from "@karakeep/shared/types/users"; -import { api } from "./trpc"; - export const UserSettingsContext = createContext<ZUserSettings>({ bookmarkClickAction: "open_original_link", archiveDisplayBehaviour: "show", @@ -13,6 +13,14 @@ export const UserSettingsContext = createContext<ZUserSettings>({ backupsEnabled: false, backupsFrequency: "daily", backupsRetentionDays: 7, + readerFontSize: null, + readerLineHeight: null, + readerFontFamily: null, + autoTaggingEnabled: null, + autoSummarizationEnabled: null, + tagStyle: "as-generated", + curatedTagIds: null, + inferredTagLang: null, }); export function UserSettingsContextProvider({ @@ -22,9 +30,12 @@ export function UserSettingsContextProvider({ userSettings: ZUserSettings; children: React.ReactNode; }) { - const { data } = api.users.settings.useQuery(undefined, { - initialData: userSettings, - }); + const api = useTRPC(); + const { data } = useQuery( + api.users.settings.queryOptions(undefined, { + initialData: userSettings, + }), + ); return ( <UserSettingsContext.Provider value={data}> diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 5f1c2bf6..136f6a22 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,5 +1,10 @@ +import bundleAnalyzer from "@next/bundle-analyzer"; import pwa from "next-pwa"; +const withBundleAnalyzer = bundleAnalyzer({ + enabled: process.env.ANALYZE === "true", +}); + const withPWA = pwa({ dest: "public", disable: process.env.NODE_ENV != "production", @@ -53,4 +58,4 @@ const nextConfig = withPWA({ typescript: { ignoreBuildErrors: true }, }); -export default nextConfig; +export default withBundleAnalyzer(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json index 9d41af9b..c89a5bca 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,12 +33,14 @@ "@lexical/react": "^0.20.2", "@lexical/rich-text": "^0.20.2", "@marsidev/react-turnstile": "^1.3.1", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", @@ -53,32 +55,32 @@ "@svgr/webpack": "^8.1.0", "@tanstack/react-query": "5.90.2", "@tanstack/react-query-devtools": "5.90.2", - "@trpc/client": "^11.4.3", - "@trpc/react-query": "^11.4.3", - "@trpc/server": "^11.4.3", + "@trpc/client": "^11.9.0", + "@trpc/server": "^11.9.0", + "@trpc/tanstack-react-query": "^11.9.0", "cheerio": "^1.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "cmdk": "^1.1.1", "csv-parse": "^5.5.6", "date-fns": "^3.6.0", - "dayjs": "^1.11.10", "drizzle-orm": "^0.44.2", "fastest-levenshtein": "^1.0.16", "i18next": "^23.16.5", "i18next-resources-to-backend": "^1.2.1", "lexical": "^0.20.2", "lucide-react": "^0.501.0", - "next": "15.3.6", + "modern-screenshot": "^4.6.7", + "next": "15.3.8", "next-auth": "^4.24.11", "next-i18next": "^15.3.1", "next-pwa": "^5.6.0", - "next-themes": "^0.4.0", + "next-themes": "^0.4.6", "nuqs": "^2.4.3", "prettier": "^3.4.2", - "react": "^19.1.0", + "react": "^19.2.1", "react-day-picker": "^9.7.0", - "react-dom": "^19.1.0", + "react-dom": "^19.2.1", "react-draggable": "^4.5.0", "react-dropzone": "^14.2.3", "react-error-boundary": "^5.0.0", @@ -95,6 +97,7 @@ "remark-gfm": "^4.0.0", "request-ip": "^3.3.0", "sharp": "^0.33.3", + "sonner": "^2.0.7", "superjson": "^2.2.1", "tailwind-merge": "^2.2.1", "zod": "^3.24.2", @@ -104,6 +107,7 @@ "@karakeep/prettier-config": "workspace:^0.1.0", "@karakeep/tailwind-config": "workspace:^0.1.0", "@karakeep/tsconfig": "workspace:^0.1.0", + "@next/bundle-analyzer": "15.3.8", "@types/csv-parse": "^1.2.5", "@types/emoji-mart": "^3.0.14", "@types/react": "^19.1.6", diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts index 833cf174..52c5e9b3 100644 --- a/apps/web/server/auth.ts +++ b/apps/web/server/auth.ts @@ -141,7 +141,6 @@ if (oauth.wellKnownUrl) { id: profile.sub, name: profile.name || profile.email, email: profile.email, - image: profile.picture, role: admin || firstUser ? "admin" : "user", }; }, |
