aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/admin/admin_tools/page.tsx19
-rw-r--r--apps/web/app/admin/layout.tsx7
-rw-r--r--apps/web/app/admin/users/page.tsx15
-rw-r--r--apps/web/app/check-email/page.tsx32
-rw-r--r--apps/web/app/dashboard/error.tsx43
-rw-r--r--apps/web/app/dashboard/highlights/page.tsx16
-rw-r--r--apps/web/app/dashboard/layout.tsx37
-rw-r--r--apps/web/app/dashboard/lists/page.tsx15
-rw-r--r--apps/web/app/layout.tsx2
-rw-r--r--apps/web/app/logout/page.tsx2
-rw-r--r--apps/web/app/reader/[bookmarkId]/page.tsx176
-rw-r--r--apps/web/app/reader/layout.tsx39
-rw-r--r--apps/web/app/settings/assets/page.tsx22
-rw-r--r--apps/web/app/settings/broken-links/page.tsx18
-rw-r--r--apps/web/app/settings/import/[sessionId]/page.tsx20
-rw-r--r--apps/web/app/settings/info/page.tsx4
-rw-r--r--apps/web/app/settings/layout.tsx41
-rw-r--r--apps/web/app/settings/rules/page.tsx25
-rw-r--r--apps/web/app/settings/stats/page.tsx38
-rw-r--r--apps/web/app/signup/page.tsx15
-rw-r--r--apps/web/app/verify-email/page.tsx89
-rw-r--r--apps/web/components/admin/AddUserDialog.tsx430
-rw-r--r--apps/web/components/admin/AdminNotices.tsx7
-rw-r--r--apps/web/components/admin/BackgroundJobs.tsx162
-rw-r--r--apps/web/components/admin/BasicStats.tsx14
-rw-r--r--apps/web/components/admin/BookmarkDebugger.tsx661
-rw-r--r--apps/web/components/admin/CreateInviteDialog.tsx47
-rw-r--r--apps/web/components/admin/InvitesList.tsx63
-rw-r--r--apps/web/components/admin/InvitesListSkeleton.tsx55
-rw-r--r--apps/web/components/admin/ResetPasswordDialog.tsx295
-rw-r--r--apps/web/components/admin/ServiceConnections.tsx13
-rw-r--r--apps/web/components/admin/UpdateUserDialog.tsx50
-rw-r--r--apps/web/components/admin/UserList.tsx246
-rw-r--r--apps/web/components/admin/UserListSkeleton.tsx56
-rw-r--r--apps/web/components/dashboard/BulkBookmarksAction.tsx80
-rw-r--r--apps/web/components/dashboard/ErrorFallback.tsx43
-rw-r--r--apps/web/components/dashboard/UploadDropzone.tsx10
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkCard.tsx32
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx10
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx135
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx345
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkOwnerIcon.tsx31
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkTagsEditor.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx19
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarksGridSkeleton.tsx12
-rw-r--r--apps/web/components/dashboard/bookmarks/BulkManageListsModal.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/BulkTagModal.tsx14
-rw-r--r--apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx34
-rw-r--r--apps/web/components/dashboard/bookmarks/EditorCard.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/ManageListsModal.tsx13
-rw-r--r--apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/TagList.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/TagsEditor.tsx69
-rw-r--r--apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx28
-rw-r--r--apps/web/components/dashboard/bookmarks/action-buttons/ArchiveBookmarkButton.tsx22
-rw-r--r--apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx18
-rw-r--r--apps/web/components/dashboard/feeds/FeedSelector.tsx13
-rw-r--r--apps/web/components/dashboard/header/ProfileOptions.tsx56
-rw-r--r--apps/web/components/dashboard/highlights/AllHighlights.tsx52
-rw-r--r--apps/web/components/dashboard/highlights/HighlightCard.tsx2
-rw-r--r--apps/web/components/dashboard/lists/AllListsView.tsx9
-rw-r--r--apps/web/components/dashboard/lists/CollapsibleBookmarkLists.tsx13
-rw-r--r--apps/web/components/dashboard/lists/DeleteListConfirmationDialog.tsx2
-rw-r--r--apps/web/components/dashboard/lists/EditListModal.tsx4
-rw-r--r--apps/web/components/dashboard/lists/LeaveListConfirmationDialog.tsx56
-rw-r--r--apps/web/components/dashboard/lists/ListHeader.tsx86
-rw-r--r--apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx223
-rw-r--r--apps/web/components/dashboard/lists/MergeListModal.tsx2
-rw-r--r--apps/web/components/dashboard/lists/PendingInvitationsCard.tsx94
-rw-r--r--apps/web/components/dashboard/lists/RssLink.tsx34
-rw-r--r--apps/web/components/dashboard/preview/ActionBar.tsx2
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx2
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx34
-rw-r--r--apps/web/components/dashboard/preview/HighlightsBox.tsx10
-rw-r--r--apps/web/components/dashboard/preview/LinkContentSection.tsx80
-rw-r--r--apps/web/components/dashboard/preview/NoteEditor.tsx2
-rw-r--r--apps/web/components/dashboard/preview/ReaderSettingsPopover.tsx457
-rw-r--r--apps/web/components/dashboard/preview/ReaderView.tsx42
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineConditionBuilder.tsx75
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineRuleEditor.tsx3
-rw-r--r--apps/web/components/dashboard/rules/RuleEngineRuleList.tsx2
-rw-r--r--apps/web/components/dashboard/search/QueryExplainerTooltip.tsx11
-rw-r--r--apps/web/components/dashboard/search/useSearchAutocomplete.ts115
-rw-r--r--apps/web/components/dashboard/sidebar/AllLists.tsx267
-rw-r--r--apps/web/components/dashboard/sidebar/InvitationNotificationBadge.tsx12
-rw-r--r--apps/web/components/dashboard/tags/AllTagsView.tsx2
-rw-r--r--apps/web/components/dashboard/tags/BulkTagAction.tsx3
-rw-r--r--apps/web/components/dashboard/tags/CreateTagModal.tsx2
-rw-r--r--apps/web/components/dashboard/tags/DeleteTagConfirmationDialog.tsx2
-rw-r--r--apps/web/components/dashboard/tags/EditableTagName.tsx2
-rw-r--r--apps/web/components/dashboard/tags/MergeTagModal.tsx2
-rw-r--r--apps/web/components/dashboard/tags/TagAutocomplete.tsx11
-rw-r--r--apps/web/components/dashboard/tags/TagPill.tsx2
-rw-r--r--apps/web/components/invite/InviteAcceptForm.tsx13
-rw-r--r--apps/web/components/public/lists/PublicBookmarkGrid.tsx34
-rw-r--r--apps/web/components/settings/AISettings.tsx669
-rw-r--r--apps/web/components/settings/AddApiKey.tsx33
-rw-r--r--apps/web/components/settings/ApiKeySettings.tsx37
-rw-r--r--apps/web/components/settings/BackupSettings.tsx49
-rw-r--r--apps/web/components/settings/ChangePassword.tsx43
-rw-r--r--apps/web/components/settings/DeleteAccount.tsx2
-rw-r--r--apps/web/components/settings/DeleteApiKey.tsx27
-rw-r--r--apps/web/components/settings/FeedSettings.tsx87
-rw-r--r--apps/web/components/settings/ImportExport.tsx38
-rw-r--r--apps/web/components/settings/ImportSessionCard.tsx83
-rw-r--r--apps/web/components/settings/ImportSessionDetail.tsx596
-rw-r--r--apps/web/components/settings/ReaderSettings.tsx311
-rw-r--r--apps/web/components/settings/RegenerateApiKey.tsx35
-rw-r--r--apps/web/components/settings/SubscriptionSettings.tsx34
-rw-r--r--apps/web/components/settings/UserAvatar.tsx149
-rw-r--r--apps/web/components/settings/UserOptions.tsx2
-rw-r--r--apps/web/components/settings/WebhookSettings.tsx58
-rw-r--r--apps/web/components/shared/sidebar/Sidebar.tsx5
-rw-r--r--apps/web/components/shared/sidebar/SidebarItem.tsx15
-rw-r--r--apps/web/components/shared/sidebar/SidebarLayout.tsx10
-rw-r--r--apps/web/components/shared/sidebar/SidebarVersion.tsx56
-rw-r--r--apps/web/components/signin/CredentialsForm.tsx2
-rw-r--r--apps/web/components/signin/ForgotPasswordForm.tsx9
-rw-r--r--apps/web/components/signin/ResetPasswordForm.tsx8
-rw-r--r--apps/web/components/signin/SignInProviderButton.tsx2
-rw-r--r--apps/web/components/signup/SignUpForm.tsx28
-rw-r--r--apps/web/components/subscription/QuotaProgress.tsx10
-rw-r--r--apps/web/components/theme-provider.tsx6
-rw-r--r--apps/web/components/ui/avatar.tsx49
-rw-r--r--apps/web/components/ui/copy-button.tsx2
-rw-r--r--apps/web/components/ui/field.tsx244
-rw-r--r--apps/web/components/ui/info-tooltip.tsx3
-rw-r--r--apps/web/components/ui/radio-group.tsx43
-rw-r--r--apps/web/components/ui/sonner.tsx71
-rw-r--r--apps/web/components/ui/toaster.tsx35
-rw-r--r--apps/web/components/ui/use-toast.ts188
-rw-r--r--apps/web/components/ui/user-avatar.tsx52
-rw-r--r--apps/web/components/utils/ValidAccountCheck.tsx23
-rw-r--r--apps/web/components/wrapped/ShareButton.tsx92
-rw-r--r--apps/web/components/wrapped/WrappedContent.tsx390
-rw-r--r--apps/web/components/wrapped/WrappedModal.tsx92
-rw-r--r--apps/web/components/wrapped/index.ts3
-rw-r--r--apps/web/instrumentation.node.ts3
-rw-r--r--apps/web/instrumentation.ts5
-rw-r--r--apps/web/lib/attachments.tsx4
-rw-r--r--apps/web/lib/auth/client.ts11
-rw-r--r--apps/web/lib/bookmark-drag.ts5
-rw-r--r--apps/web/lib/bulkActions.ts7
-rw-r--r--apps/web/lib/clientConfig.tsx2
-rw-r--r--apps/web/lib/hooks/bookmark-search.ts29
-rw-r--r--apps/web/lib/hooks/relative-time.ts7
-rw-r--r--apps/web/lib/hooks/useBookmarkImport.ts118
-rw-r--r--apps/web/lib/hooks/useImportSessions.ts175
-rw-r--r--apps/web/lib/i18n/client.ts2
-rw-r--r--apps/web/lib/i18n/locales/ar/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/cs/translation.json85
-rw-r--r--apps/web/lib/i18n/locales/da/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/de/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/el/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json223
-rw-r--r--apps/web/lib/i18n/locales/en_US/translation.json75
-rw-r--r--apps/web/lib/i18n/locales/es/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/fa/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/fi/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/fr/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/ga/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/gl/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/hr/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/hu/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/it/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/ja/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/ko/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/nb_NO/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/nl/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/pl/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/pt/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/pt_BR/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/ru/translation.json87
-rw-r--r--apps/web/lib/i18n/locales/sk/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/sl/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/sv/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/tr/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/uk/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/vi/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/zh/translation.json83
-rw-r--r--apps/web/lib/i18n/locales/zhtw/translation.json83
-rw-r--r--apps/web/lib/providers.tsx18
-rw-r--r--apps/web/lib/readerSettings.tsx155
-rw-r--r--apps/web/lib/trpc.tsx7
-rw-r--r--apps/web/lib/userSettings.tsx21
-rw-r--r--apps/web/next.config.mjs7
-rw-r--r--apps/web/package.json20
-rw-r--r--apps/web/server/auth.ts1
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&apos;re sorry, but an unexpected error occurred. Please try again
- or contact support if the issue persists.
- </p>
- </div>
-
- {/* Action Buttons */}
- <div className="space-y-3">
- <Button className="w-full" onClick={() => window.location.reload()}>
- <RefreshCw className="mr-2 h-4 w-4" />
- Try Again
- </Button>
-
- <Link href="/" className="block">
- <Button variant="outline" className="w-full">
- <Home className="mr-2 h-4 w-4" />
- Go Home
- </Button>
- </Link>
- </div>
- </div>
- </div>
- );
+ return <ErrorFallback />;
}
diff --git a/apps/web/app/dashboard/highlights/page.tsx b/apps/web/app/dashboard/highlights/page.tsx
index 5945de00..ed0b16c0 100644
--- a/apps/web/app/dashboard/highlights/page.tsx
+++ b/apps/web/app/dashboard/highlights/page.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import AllHighlights from "@/components/dashboard/highlights/AllHighlights";
-import { Separator } from "@/components/ui/separator";
import { useTranslation } from "@/lib/i18n/server";
import { api } from "@/server/api/client";
import { Highlighter } from "lucide-react";
@@ -18,13 +17,14 @@ export default async function HighlightsPage() {
const { t } = await useTranslation();
const highlights = await api.highlights.getAll({});
return (
- <div className="flex flex-col gap-8 rounded-md border bg-background p-4">
- <span className="flex items-center gap-1 text-2xl">
- <Highlighter className="size-6" />
- {t("common.highlights")}
- </span>
- <Separator />
- <AllHighlights highlights={highlights} />
+ <div className="flex flex-col gap-4">
+ <div className="flex items-center">
+ <Highlighter className="mr-2" />
+ <p className="text-2xl">{t("common.highlights")}</p>
+ </div>
+ <div className="flex flex-col gap-8 rounded-md border bg-background p-4">
+ <AllHighlights highlights={highlights} />
+ </div>
</div>
);
}
diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx
index 911d542c..be65e66a 100644
--- a/apps/web/app/dashboard/layout.tsx
+++ b/apps/web/app/dashboard/layout.tsx
@@ -4,6 +4,7 @@ import MobileSidebar from "@/components/shared/sidebar/MobileSidebar";
import Sidebar from "@/components/shared/sidebar/Sidebar";
import SidebarLayout from "@/components/shared/sidebar/SidebarLayout";
import { Separator } from "@/components/ui/separator";
+import { ReaderSettingsProvider } from "@/lib/readerSettings";
import { UserSettingsContextProvider } from "@/lib/userSettings";
import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth";
@@ -98,23 +99,25 @@ export default async function Dashboard({
return (
<UserSettingsContextProvider userSettings={userSettings.data}>
- <SidebarLayout
- sidebar={
- <Sidebar
- items={items}
- extraSections={
- <>
- <Separator />
- <AllLists initialData={lists.data} />
- </>
- }
- />
- }
- mobileSidebar={<MobileSidebar items={mobileSidebar} />}
- modal={modal}
- >
- {children}
- </SidebarLayout>
+ <ReaderSettingsProvider>
+ <SidebarLayout
+ sidebar={
+ <Sidebar
+ items={items}
+ extraSections={
+ <>
+ <Separator />
+ <AllLists initialData={lists.data} />
+ </>
+ }
+ />
+ }
+ mobileSidebar={<MobileSidebar items={mobileSidebar} />}
+ modal={modal}
+ >
+ {children}
+ </SidebarLayout>
+ </ReaderSettingsProvider>
</UserSettingsContextProvider>
);
}
diff --git a/apps/web/app/dashboard/lists/page.tsx b/apps/web/app/dashboard/lists/page.tsx
index 7950cd76..2f9e54c6 100644
--- a/apps/web/app/dashboard/lists/page.tsx
+++ b/apps/web/app/dashboard/lists/page.tsx
@@ -1,8 +1,10 @@
import AllListsView from "@/components/dashboard/lists/AllListsView";
+import { EditListModal } from "@/components/dashboard/lists/EditListModal";
import { PendingInvitationsCard } from "@/components/dashboard/lists/PendingInvitationsCard";
-import { Separator } from "@/components/ui/separator";
+import { Button } from "@/components/ui/button";
import { useTranslation } from "@/lib/i18n/server";
import { api } from "@/server/api/client";
+import { Plus } from "lucide-react";
export default async function ListsPage() {
// oxlint-disable-next-line rules-of-hooks
@@ -11,10 +13,17 @@ export default async function ListsPage() {
return (
<div className="flex flex-col gap-4">
+ <div className="flex items-center justify-between">
+ <p className="text-2xl">📋 {t("lists.all_lists")}</p>
+ <EditListModal>
+ <Button className="flex items-center">
+ <Plus className="mr-2 size-4" />
+ <span>{t("lists.new_list")}</span>
+ </Button>
+ </EditListModal>
+ </div>
<PendingInvitationsCard />
<div className="flex flex-col gap-3 rounded-md border bg-background p-4">
- <p className="text-2xl">📋 {t("lists.all_lists")}</p>
- <Separator />
<AllListsView initialData={lists.lists} />
</div>
</div>
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 8514b8ad..ba09a973 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -8,11 +8,11 @@ import "@karakeep/tailwind-config/globals.css";
import type { Viewport } from "next";
import React from "react";
-import { Toaster } from "@/components/ui/toaster";
import Providers from "@/lib/providers";
import { getUserLocalSettings } from "@/lib/userLocalSettings/userLocalSettings";
import { getServerAuthSession } from "@/server/auth";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { Toaster } from "sonner";
import { clientConfig } from "@karakeep/shared/config";
diff --git a/apps/web/app/logout/page.tsx b/apps/web/app/logout/page.tsx
index 91ad684d..1e43622e 100644
--- a/apps/web/app/logout/page.tsx
+++ b/apps/web/app/logout/page.tsx
@@ -2,7 +2,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
-import { signOut } from "next-auth/react";
+import { signOut } from "@/lib/auth/client";
import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history";
diff --git a/apps/web/app/reader/[bookmarkId]/page.tsx b/apps/web/app/reader/[bookmarkId]/page.tsx
index e32811a9..0ba72016 100644
--- a/apps/web/app/reader/[bookmarkId]/page.tsx
+++ b/apps/web/app/reader/[bookmarkId]/page.tsx
@@ -3,63 +3,42 @@
import { Suspense, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import HighlightCard from "@/components/dashboard/highlights/HighlightCard";
+import ReaderSettingsPopover from "@/components/dashboard/preview/ReaderSettingsPopover";
import ReaderView from "@/components/dashboard/preview/ReaderView";
import { Button } from "@/components/ui/button";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
-import { Slider } from "@/components/ui/slider";
-import {
- HighlighterIcon as Highlight,
- Minus,
- Plus,
- Printer,
- Settings,
- Type,
- X,
-} from "lucide-react";
-import { useSession } from "next-auth/react";
+import { useSession } from "@/lib/auth/client";
+import { useReaderSettings } from "@/lib/readerSettings";
+import { useQuery } from "@tanstack/react-query";
+import { HighlighterIcon as Highlight, Printer, X } from "lucide-react";
-import { api } from "@karakeep/shared-react/trpc";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers";
import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
export default function ReaderViewPage() {
+ const api = useTRPC();
const params = useParams<{ bookmarkId: string }>();
const bookmarkId = params.bookmarkId;
- const { data: highlights } = api.highlights.getForBookmark.useQuery({
- bookmarkId,
- });
- const { data: bookmark } = api.bookmarks.getBookmark.useQuery({
- bookmarkId,
- });
+ const { data: highlights } = useQuery(
+ api.highlights.getForBookmark.queryOptions({
+ bookmarkId,
+ }),
+ );
+ const { data: bookmark } = useQuery(
+ api.bookmarks.getBookmark.queryOptions({
+ bookmarkId,
+ }),
+ );
const { data: session } = useSession();
const router = useRouter();
- const [fontSize, setFontSize] = useState([18]);
- const [lineHeight, setLineHeight] = useState([1.6]);
- const [fontFamily, setFontFamily] = useState("serif");
+ const { settings } = useReaderSettings();
const [showHighlights, setShowHighlights] = useState(false);
- const [showSettings, setShowSettings] = useState(false);
const isOwner = session?.user?.id === bookmark?.userId;
- const fontFamilies = {
- serif: "ui-serif, Georgia, Cambria, serif",
- sans: "ui-sans-serif, system-ui, sans-serif",
- mono: "ui-monospace, Menlo, Monaco, monospace",
- };
-
const onClose = () => {
if (window.history.length > 1) {
router.back();
@@ -89,94 +68,7 @@ export default function ReaderViewPage() {
<Printer className="h-4 w-4" />
</Button>
- <Popover open={showSettings} onOpenChange={setShowSettings}>
- <PopoverTrigger asChild>
- <Button variant="ghost" size="icon">
- <Settings className="h-4 w-4" />
- </Button>
- </PopoverTrigger>
- <PopoverContent side="bottom" align="end" className="w-80">
- <div className="space-y-4">
- <div className="flex items-center gap-2 pb-2">
- <Type className="h-4 w-4" />
- <h3 className="font-semibold">Reading Settings</h3>
- </div>
-
- <div className="space-y-4">
- <div className="space-y-2">
- <label className="text-sm font-medium">Font Family</label>
- <Select value={fontFamily} onValueChange={setFontFamily}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="serif">Serif</SelectItem>
- <SelectItem value="sans">Sans Serif</SelectItem>
- <SelectItem value="mono">Monospace</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <label className="text-sm font-medium">Font Size</label>
- <span className="text-sm text-muted-foreground">
- {fontSize[0]}px
- </span>
- </div>
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="icon"
- className="h-7 w-7 bg-transparent"
- onClick={() =>
- setFontSize([Math.max(12, fontSize[0] - 1)])
- }
- >
- <Minus className="h-3 w-3" />
- </Button>
- <Slider
- value={fontSize}
- onValueChange={setFontSize}
- max={24}
- min={12}
- step={1}
- className="flex-1"
- />
- <Button
- variant="outline"
- size="icon"
- className="h-7 w-7 bg-transparent"
- onClick={() =>
- setFontSize([Math.min(24, fontSize[0] + 1)])
- }
- >
- <Plus className="h-3 w-3" />
- </Button>
- </div>
- </div>
-
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <label className="text-sm font-medium">
- Line Height
- </label>
- <span className="text-sm text-muted-foreground">
- {lineHeight[0]}
- </span>
- </div>
- <Slider
- value={lineHeight}
- onValueChange={setLineHeight}
- max={2.5}
- min={1.2}
- step={0.1}
- />
- </div>
- </div>
- </div>
- </PopoverContent>
- </Popover>
+ <ReaderSettingsPopover variant="ghost" />
<Button
variant={showHighlights ? "default" : "ghost"}
@@ -216,10 +108,9 @@ export default function ReaderViewPage() {
<h1
className="font-bold leading-tight"
style={{
- fontFamily:
- fontFamilies[fontFamily as keyof typeof fontFamilies],
- fontSize: `${fontSize[0] * 1.8}px`,
- lineHeight: lineHeight[0] * 0.9,
+ fontFamily: READER_FONT_FAMILIES[settings.fontFamily],
+ fontSize: `${settings.fontSize * 1.8}px`,
+ lineHeight: settings.lineHeight * 0.9,
}}
>
{getBookmarkTitle(bookmark)}
@@ -239,10 +130,9 @@ export default function ReaderViewPage() {
<ReaderView
className="prose prose-neutral max-w-none break-words dark:prose-invert [&_code]:break-all [&_img]:h-auto [&_img]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto"
style={{
- fontFamily:
- fontFamilies[fontFamily as keyof typeof fontFamilies],
- fontSize: `${fontSize[0]}px`,
- lineHeight: lineHeight[0],
+ fontFamily: READER_FONT_FAMILIES[settings.fontFamily],
+ fontSize: `${settings.fontSize}px`,
+ lineHeight: settings.lineHeight,
}}
bookmarkId={bookmarkId}
readOnly={!isOwner}
@@ -256,20 +146,6 @@ export default function ReaderViewPage() {
</article>
</main>
- {/* Mobile backdrop */}
- {showHighlights && (
- <button
- className="fixed inset-0 top-14 z-40 bg-black/50 lg:hidden"
- onClick={() => setShowHighlights(false)}
- onKeyDown={(e) => {
- if (e.key === "Escape") {
- setShowHighlights(false);
- }
- }}
- aria-label="Close highlights sidebar"
- />
- )}
-
{/* Highlights Sidebar */}
{showHighlights && highlights && (
<aside className="fixed right-0 top-14 z-50 h-[calc(100vh-3.5rem)] w-full border-l bg-background sm:w-80 lg:z-auto lg:bg-background/95 lg:backdrop-blur lg:supports-[backdrop-filter]:bg-background/60 print:hidden">
diff --git a/apps/web/app/reader/layout.tsx b/apps/web/app/reader/layout.tsx
new file mode 100644
index 00000000..b0c27c84
--- /dev/null
+++ b/apps/web/app/reader/layout.tsx
@@ -0,0 +1,39 @@
+import { redirect } from "next/navigation";
+import { ReaderSettingsProvider } from "@/lib/readerSettings";
+import { UserSettingsContextProvider } from "@/lib/userSettings";
+import { api } from "@/server/api/client";
+import { getServerAuthSession } from "@/server/auth";
+import { TRPCError } from "@trpc/server";
+
+import { tryCatch } from "@karakeep/shared/tryCatch";
+
+export default async function ReaderLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/");
+ }
+
+ const userSettings = await tryCatch(api.users.settings());
+
+ if (userSettings.error) {
+ if (userSettings.error instanceof TRPCError) {
+ if (
+ userSettings.error.code === "NOT_FOUND" ||
+ userSettings.error.code === "UNAUTHORIZED"
+ ) {
+ redirect("/logout");
+ }
+ }
+ throw userSettings.error;
+ }
+
+ return (
+ <UserSettingsContextProvider userSettings={userSettings.data}>
+ <ReaderSettingsProvider>{children}</ReaderSettingsProvider>
+ </UserSettingsContextProvider>
+ );
+}
diff --git a/apps/web/app/settings/assets/page.tsx b/apps/web/app/settings/assets/page.tsx
index 14144455..77b3d159 100644
--- a/apps/web/app/settings/assets/page.tsx
+++ b/apps/web/app/settings/assets/page.tsx
@@ -5,6 +5,7 @@ import { ActionButton } from "@/components/ui/action-button";
import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
import { Button } from "@/components/ui/button";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
+import { toast } from "@/components/ui/sonner";
import {
Table,
TableBody,
@@ -13,14 +14,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import { toast } from "@/components/ui/use-toast";
import { ASSET_TYPE_TO_ICON } from "@/lib/attachments";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
import { formatBytes } from "@/lib/utils";
+import { useInfiniteQuery } from "@tanstack/react-query";
import { ExternalLink, Trash2 } from "lucide-react";
import { useDetachBookmarkAsset } from "@karakeep/shared-react/hooks/assets";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
import {
humanFriendlyNameForAssertType,
@@ -28,6 +29,7 @@ import {
} from "@karakeep/trpc/lib/attachments";
export default function AssetsSettingsPage() {
+ const api = useTRPC();
const { t } = useTranslation();
const { mutate: detachAsset, isPending: isDetaching } =
useDetachBookmarkAsset({
@@ -49,13 +51,15 @@ export default function AssetsSettingsPage() {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
- } = api.assets.list.useInfiniteQuery(
- {
- limit: 20,
- },
- {
- getNextPageParam: (lastPage) => lastPage.nextCursor,
- },
+ } = useInfiniteQuery(
+ api.assets.list.infiniteQueryOptions(
+ {
+ limit: 20,
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ ),
);
const assets = data?.pages.flatMap((page) => page.assets) ?? [];
diff --git a/apps/web/app/settings/broken-links/page.tsx b/apps/web/app/settings/broken-links/page.tsx
index e2b42d07..4197d62e 100644
--- a/apps/web/app/settings/broken-links/page.tsx
+++ b/apps/web/app/settings/broken-links/page.tsx
@@ -2,6 +2,7 @@
import { ActionButton } from "@/components/ui/action-button";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
+import { toast } from "@/components/ui/sonner";
import {
Table,
TableBody,
@@ -10,7 +11,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import { toast } from "@/components/ui/use-toast";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -18,20 +19,23 @@ import {
useDeleteBookmark,
useRecrawlBookmark,
} from "@karakeep/shared-react/hooks/bookmarks";
-import { api } from "@karakeep/shared-react/trpc";
+import { useTRPC } from "@karakeep/shared-react/trpc";
export default function BrokenLinksPage() {
+ const api = useTRPC();
const { t } = useTranslation();
- const apiUtils = api.useUtils();
- const { data, isPending } = api.bookmarks.getBrokenLinks.useQuery();
+ const queryClient = useQueryClient();
+ const { data, isPending } = useQuery(
+ api.bookmarks.getBrokenLinks.queryOptions(),
+ );
const { mutate: deleteBookmark, isPending: isDeleting } = useDeleteBookmark({
onSuccess: () => {
toast({
description: t("toasts.bookmarks.deleted"),
});
- apiUtils.bookmarks.getBrokenLinks.invalidate();
+ queryClient.invalidateQueries(api.bookmarks.getBrokenLinks.pathFilter());
},
onError: () => {
toast({
@@ -47,7 +51,9 @@ export default function BrokenLinksPage() {
toast({
description: t("toasts.bookmarks.refetch"),
});
- apiUtils.bookmarks.getBrokenLinks.invalidate();
+ queryClient.invalidateQueries(
+ api.bookmarks.getBrokenLinks.pathFilter(),
+ );
},
onError: () => {
toast({
diff --git a/apps/web/app/settings/import/[sessionId]/page.tsx b/apps/web/app/settings/import/[sessionId]/page.tsx
new file mode 100644
index 00000000..968de13a
--- /dev/null
+++ b/apps/web/app/settings/import/[sessionId]/page.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from "next";
+import ImportSessionDetail from "@/components/settings/ImportSessionDetail";
+import { useTranslation } from "@/lib/i18n/server";
+
+export async function generateMetadata(): Promise<Metadata> {
+ // oxlint-disable-next-line rules-of-hooks
+ const { t } = await useTranslation();
+ return {
+ title: `${t("settings.import_sessions.detail.page_title")} | Karakeep`,
+ };
+}
+
+export default async function ImportSessionDetailPage({
+ params,
+}: {
+ params: Promise<{ sessionId: string }>;
+}) {
+ const { sessionId } = await params;
+ return <ImportSessionDetail sessionId={sessionId} />;
+}
diff --git a/apps/web/app/settings/info/page.tsx b/apps/web/app/settings/info/page.tsx
index 1807b538..da9b2e51 100644
--- a/apps/web/app/settings/info/page.tsx
+++ b/apps/web/app/settings/info/page.tsx
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { ChangePassword } from "@/components/settings/ChangePassword";
import { DeleteAccount } from "@/components/settings/DeleteAccount";
+import ReaderSettings from "@/components/settings/ReaderSettings";
+import UserAvatar from "@/components/settings/UserAvatar";
import UserDetails from "@/components/settings/UserDetails";
import UserOptions from "@/components/settings/UserOptions";
import { useTranslation } from "@/lib/i18n/server";
@@ -16,9 +18,11 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function InfoPage() {
return (
<div className="flex flex-col gap-4">
+ <UserAvatar />
<UserDetails />
<ChangePassword />
<UserOptions />
+ <ReaderSettings />
<DeleteAccount />
</div>
);
diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx
index 1c7d25ac..8d211e53 100644
--- a/apps/web/app/settings/layout.tsx
+++ b/apps/web/app/settings/layout.tsx
@@ -1,8 +1,12 @@
+import { redirect } from "next/navigation";
import MobileSidebar from "@/components/shared/sidebar/MobileSidebar";
import Sidebar from "@/components/shared/sidebar/Sidebar";
import SidebarLayout from "@/components/shared/sidebar/SidebarLayout";
+import { ReaderSettingsProvider } from "@/lib/readerSettings";
import { UserSettingsContextProvider } from "@/lib/userSettings";
import { api } from "@/server/api/client";
+import { getServerAuthSession } from "@/server/auth";
+import { TRPCError } from "@trpc/server";
import { TFunction } from "i18next";
import {
ArrowLeft,
@@ -21,6 +25,7 @@ import {
} from "lucide-react";
import serverConfig from "@karakeep/shared/config";
+import { tryCatch } from "@karakeep/shared/tryCatch";
const settingsSidebarItems = (
t: TFunction,
@@ -111,15 +116,35 @@ export default async function SettingsLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
- const userSettings = await api.users.settings();
+ const session = await getServerAuthSession();
+ if (!session) {
+ redirect("/");
+ }
+
+ const userSettings = await tryCatch(api.users.settings());
+
+ if (userSettings.error) {
+ if (userSettings.error instanceof TRPCError) {
+ if (
+ userSettings.error.code === "NOT_FOUND" ||
+ userSettings.error.code === "UNAUTHORIZED"
+ ) {
+ redirect("/logout");
+ }
+ }
+ throw userSettings.error;
+ }
+
return (
- <UserSettingsContextProvider userSettings={userSettings}>
- <SidebarLayout
- sidebar={<Sidebar items={settingsSidebarItems} />}
- mobileSidebar={<MobileSidebar items={settingsSidebarItems} />}
- >
- {children}
- </SidebarLayout>
+ <UserSettingsContextProvider userSettings={userSettings.data}>
+ <ReaderSettingsProvider>
+ <SidebarLayout
+ sidebar={<Sidebar items={settingsSidebarItems} />}
+ mobileSidebar={<MobileSidebar items={settingsSidebarItems} />}
+ >
+ {children}
+ </SidebarLayout>
+ </ReaderSettingsProvider>
</UserSettingsContextProvider>
);
}
diff --git a/apps/web/app/settings/rules/page.tsx b/apps/web/app/settings/rules/page.tsx
index 98a30bcc..2e739343 100644
--- a/apps/web/app/settings/rules/page.tsx
+++ b/apps/web/app/settings/rules/page.tsx
@@ -6,22 +6,25 @@ import RuleList from "@/components/dashboard/rules/RuleEngineRuleList";
import { Button } from "@/components/ui/button";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
-import { Tooltip, TooltipContent, TooltipTrigger } from "components/ui/tooltip";
-import { FlaskConical, PlusCircle } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { PlusCircle } from "lucide-react";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { RuleEngineRule } from "@karakeep/shared/types/rules";
export default function RulesSettingsPage() {
+ const api = useTRPC();
const { t } = useTranslation();
const [editingRule, setEditingRule] = useState<
(Omit<RuleEngineRule, "id"> & { id: string | null }) | null
>(null);
- const { data: rules, isLoading } = api.rules.list.useQuery(undefined, {
- refetchOnWindowFocus: true,
- refetchOnMount: true,
- });
+ const { data: rules, isLoading } = useQuery(
+ api.rules.list.queryOptions(undefined, {
+ refetchOnWindowFocus: true,
+ refetchOnMount: true,
+ }),
+ );
const handleCreateRule = () => {
const newRule = {
@@ -49,14 +52,6 @@ export default function RulesSettingsPage() {
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-lg font-medium">
{t("settings.rules.rules")}
- <Tooltip>
- <TooltipTrigger className="text-muted-foreground">
- <FlaskConical size={15} />
- </TooltipTrigger>
- <TooltipContent side="bottom">
- {t("common.experimental")}
- </TooltipContent>
- </Tooltip>
</span>
<Button onClick={handleCreateRule} variant="default">
<PlusCircle className="mr-2 h-4 w-4" />
diff --git a/apps/web/app/settings/stats/page.tsx b/apps/web/app/settings/stats/page.tsx
index 944d1c59..a8896a03 100644
--- a/apps/web/app/settings/stats/page.tsx
+++ b/apps/web/app/settings/stats/page.tsx
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslation } from "@/lib/i18n/client";
-import { api } from "@/lib/trpc";
+import { useQuery } from "@tanstack/react-query";
import {
Archive,
BarChart3,
@@ -32,6 +32,7 @@ import {
} from "lucide-react";
import { z } from "zod";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks";
type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>;
@@ -159,9 +160,10 @@ function StatCard({
}
export default function StatsPage() {
+ const api = useTRPC();
const { t } = useTranslation();
- const { data: stats, isLoading } = api.users.stats.useQuery();
- const { data: userSettings } = api.users.settings.useQuery();
+ const { data: stats, isLoading } = useQuery(api.users.stats.queryOptions());
+ const { data: userSettings } = useQuery(api.users.settings.queryOptions());
const maxHourlyActivity = useMemo(() => {
if (!stats) return 0;
@@ -222,20 +224,21 @@ export default function StatsPage() {
return (
<div className="space-y-6">
- <div>
- <h1 className="text-3xl font-bold">
- {t("settings.stats.usage_statistics")}
- </h1>
- <p className="text-muted-foreground">
- Insights into your bookmarking habits and collection
- {userSettings?.timezone && userSettings.timezone !== "UTC" && (
- <span className="block text-sm">
- Times shown in {userSettings.timezone} timezone
- </span>
- )}
- </p>
+ <div className="flex items-start justify-between">
+ <div>
+ <h1 className="text-3xl font-bold">
+ {t("settings.stats.usage_statistics")}
+ </h1>
+ <p className="text-muted-foreground">
+ Insights into your bookmarking habits and collection
+ {userSettings?.timezone && userSettings.timezone !== "UTC" && (
+ <span className="block text-sm">
+ Times shown in {userSettings.timezone} timezone
+ </span>
+ )}
+ </p>
+ </div>
</div>
-
{/* Overview Stats */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
@@ -287,7 +290,6 @@ export default function StatsPage() {
description={t("settings.stats.overview.bookmarks_added")}
/>
</div>
-
<div className="grid gap-6 md:grid-cols-2">
{/* Bookmark Types */}
<Card>
@@ -530,7 +532,6 @@ export default function StatsPage() {
</CardContent>
</Card>
</div>
-
{/* Activity Patterns */}
<div className="grid gap-6 md:grid-cols-2">
{/* Hourly Activity */}
@@ -581,7 +582,6 @@ export default function StatsPage() {
</CardContent>
</Card>
</div>
-
{/* Asset Storage */}
{stats.assetsByType.length > 0 && (
<Card>
diff --git a/apps/web/app/signup/page.tsx b/apps/web/app/signup/page.tsx
index ee77f65e..5c8b943e 100644
--- a/apps/web/app/signup/page.tsx
+++ b/apps/web/app/signup/page.tsx
@@ -3,10 +3,19 @@ import KarakeepLogo from "@/components/KarakeepIcon";
import SignUpForm from "@/components/signup/SignUpForm";
import { getServerAuthSession } from "@/server/auth";
-export default async function SignUpPage() {
+import { validateRedirectUrl } from "@karakeep/shared/utils/redirectUrl";
+
+export default async function SignUpPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ redirectUrl?: string }>;
+}) {
const session = await getServerAuthSession();
+ const { redirectUrl: rawRedirectUrl } = await searchParams;
+ const redirectUrl = validateRedirectUrl(rawRedirectUrl) ?? "/";
+
if (session) {
- redirect("/");
+ redirect(redirectUrl);
}
return (
@@ -15,7 +24,7 @@ export default async function SignUpPage() {
<div className="flex items-center justify-center">
<KarakeepLogo height={80} />
</div>
- <SignUpForm />
+ <SignUpForm redirectUrl={redirectUrl} />
</div>
</div>
);
diff --git a/apps/web/app/verify-email/page.tsx b/apps/web/app/verify-email/page.tsx
index da9b8b6b..5044c63e 100644
--- a/apps/web/app/verify-email/page.tsx
+++ b/apps/web/app/verify-email/page.tsx
@@ -11,10 +11,17 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
-import { api } from "@/lib/trpc";
+import { useMutation } from "@tanstack/react-query";
import { CheckCircle, Loader2, XCircle } from "lucide-react";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+import {
+ isMobileAppRedirect,
+ validateRedirectUrl,
+} from "@karakeep/shared/utils/redirectUrl";
+
export default function VerifyEmailPage() {
+ const api = useTRPC();
const searchParams = useSearchParams();
const router = useRouter();
const [status, setStatus] = useState<"loading" | "success" | "error">(
@@ -24,33 +31,51 @@ export default function VerifyEmailPage() {
const token = searchParams.get("token");
const email = searchParams.get("email");
+ const redirectUrl =
+ validateRedirectUrl(searchParams.get("redirectUrl")) ?? "/";
- const verifyEmailMutation = api.users.verifyEmail.useMutation({
- onSuccess: () => {
- setStatus("success");
- setMessage(
- "Your email has been successfully verified! You can now sign in.",
- );
- },
- onError: (error) => {
- setStatus("error");
- setMessage(
- error.message ||
- "Failed to verify email. The link may be invalid or expired.",
- );
- },
- });
+ const verifyEmailMutation = useMutation(
+ api.users.verifyEmail.mutationOptions({
+ onSuccess: () => {
+ setStatus("success");
+ if (isMobileAppRedirect(redirectUrl)) {
+ setMessage(
+ "Your email has been successfully verified! Redirecting to the app...",
+ );
+ // Redirect to mobile app after a brief delay
+ setTimeout(() => {
+ window.location.href = redirectUrl;
+ }, 1500);
+ } else {
+ setMessage(
+ "Your email has been successfully verified! You can now sign in.",
+ );
+ }
+ },
+ onError: (error) => {
+ setStatus("error");
+ setMessage(
+ error.message ||
+ "Failed to verify email. The link may be invalid or expired.",
+ );
+ },
+ }),
+ );
- const resendEmailMutation = api.users.resendVerificationEmail.useMutation({
- onSuccess: () => {
- setMessage(
- "A new verification email has been sent to your email address.",
- );
- },
- onError: (error) => {
- setMessage(error.message || "Failed to resend verification email.");
- },
- });
+ const resendEmailMutation = useMutation(
+ api.users.resendVerificationEmail.mutationOptions({
+ onSuccess: () => {
+ setMessage(
+ "A new verification email has been sent to your email address.",
+ );
+ },
+ onError: (error) => {
+ setMessage(error.message || "Failed to resend verification email.");
+ },
+ }),
+ );
+
+ const isMobileRedirect = isMobileAppRedirect(redirectUrl);
useEffect(() => {
if (token && email) {
@@ -63,12 +88,18 @@ export default function VerifyEmailPage() {
const handleResendEmail = () => {
if (email) {
- resendEmailMutation.mutate({ email });
+ resendEmailMutation.mutate({ email, redirectUrl });
}
};
const handleSignIn = () => {
- router.push("/signin");
+ if (isMobileRedirect) {
+ window.location.href = redirectUrl;
+ } else if (redirectUrl !== "/") {
+ router.push(`/signin?redirectUrl=${encodeURIComponent(redirectUrl)}`);
+ } else {
+ router.push("/signin");
+ }
};
return (
@@ -102,7 +133,7 @@ export default function VerifyEmailPage() {
</AlertDescription>
</Alert>
<Button onClick={handleSignIn} className="w-full">
- Sign In
+ {isMobileRedirect ? "Open App" : "Sign In"}
</Button>
</>
)}
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&apos;re sorry, but an unexpected error occurred. Please try again
+ or contact support if the issue persists.
+ </p>
+ </div>
+
+ <div className="space-y-3">
+ <Button className="w-full" onClick={() => window.location.reload()}>
+ <RefreshCw className="mr-2 h-4 w-4" />
+ Try Again
+ </Button>
+
+ <Link href="/" className="block">
+ <Button variant="outline" className="w-full">
+ <Home className="mr-2 h-4 w-4" />
+ Go Home
+ </Button>
+ </Link>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/components/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 &quot;{trimmedInputValue}&quot;</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&apos;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&apos;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">
+ &ldquo;{stats.firstBookmark.title}&rdquo;
+ </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",
};
},