aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/browser-extension/package.json10
-rw-r--r--apps/browser-extension/src/OptionsPage.tsx22
-rw-r--r--apps/browser-extension/src/SavePage.tsx38
-rw-r--r--apps/browser-extension/src/SignInPage.tsx32
-rw-r--r--apps/browser-extension/src/background/background.ts6
-rw-r--r--apps/browser-extension/src/components/BookmarkLists.tsx8
-rw-r--r--apps/browser-extension/src/components/ListsSelector.tsx12
-rw-r--r--apps/browser-extension/src/components/NoteEditor.tsx1
-rw-r--r--apps/browser-extension/src/components/TagsSelector.tsx6
-rw-r--r--apps/browser-extension/src/utils/providers.tsx6
-rw-r--r--apps/browser-extension/src/utils/trpc.ts4
-rw-r--r--apps/cli/package.json6
-rw-r--r--apps/cli/src/commands/admin.ts89
-rw-r--r--apps/cli/src/commands/bookmarks.ts89
-rw-r--r--apps/cli/src/commands/lists.ts11
-rw-r--r--apps/cli/src/commands/migrate.ts1
-rw-r--r--apps/cli/src/index.ts2
-rw-r--r--apps/landing/package.json4
-rw-r--r--apps/landing/src/App.tsx2
-rw-r--r--apps/landing/src/Apps.tsx112
-rw-r--r--apps/landing/src/Homepage.tsx34
-rw-r--r--apps/landing/src/Navbar.tsx19
-rw-r--r--apps/landing/src/Pricing.tsx157
-rw-r--r--apps/landing/src/constants.ts2
-rw-r--r--apps/mcp/README.md10
-rw-r--r--apps/mcp/src/shared.ts12
-rw-r--r--apps/mobile/app.config.js6
-rw-r--r--apps/mobile/app/_layout.tsx21
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx50
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(home)/index.tsx (renamed from apps/mobile/app/dashboard/(tabs)/index.tsx)53
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx284
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx225
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx18
-rw-r--r--apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx140
-rw-r--r--apps/mobile/app/dashboard/(tabs)/_layout.tsx117
-rw-r--r--apps/mobile/app/dashboard/(tabs)/highlights.tsx56
-rw-r--r--apps/mobile/app/dashboard/(tabs)/lists.tsx179
-rw-r--r--apps/mobile/app/dashboard/(tabs)/settings.tsx135
-rw-r--r--apps/mobile/app/dashboard/(tabs)/tags.tsx140
-rw-r--r--apps/mobile/app/dashboard/_layout.tsx23
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx41
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx6
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx100
-rw-r--r--apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx42
-rw-r--r--apps/mobile/app/dashboard/bookmarks/new.tsx37
-rw-r--r--apps/mobile/app/dashboard/lists/[slug]/edit.tsx156
-rw-r--r--apps/mobile/app/dashboard/lists/[slug]/index.tsx (renamed from apps/mobile/app/dashboard/lists/[slug].tsx)50
-rw-r--r--apps/mobile/app/dashboard/lists/new.tsx30
-rw-r--r--apps/mobile/app/dashboard/search.tsx33
-rw-r--r--apps/mobile/app/dashboard/settings/reader-settings.tsx301
-rw-r--r--apps/mobile/app/dashboard/tags/[slug].tsx11
-rw-r--r--apps/mobile/app/server-address.tsx231
-rw-r--r--apps/mobile/app/sharing.tsx190
-rw-r--r--apps/mobile/app/signin.tsx173
-rw-r--r--apps/mobile/app/test-connection.tsx25
-rw-r--r--apps/mobile/components/SplashScreenController.tsx14
-rw-r--r--apps/mobile/components/bookmarks/BookmarkAssetImage.tsx15
-rw-r--r--apps/mobile/components/bookmarks/BookmarkAssetView.tsx2
-rw-r--r--apps/mobile/components/bookmarks/BookmarkCard.tsx115
-rw-r--r--apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx85
-rw-r--r--apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx21
-rw-r--r--apps/mobile/components/bookmarks/BookmarkLinkView.tsx3
-rw-r--r--apps/mobile/components/bookmarks/BookmarkList.tsx1
-rw-r--r--apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx24
-rw-r--r--apps/mobile/components/highlights/HighlightCard.tsx32
-rw-r--r--apps/mobile/components/highlights/HighlightList.tsx1
-rw-r--r--apps/mobile/components/navigation/stack.tsx7
-rw-r--r--apps/mobile/components/navigation/tabs.tsx28
-rw-r--r--apps/mobile/components/reader/ReaderPreview.tsx117
-rw-r--r--apps/mobile/components/settings/UserProfileHeader.tsx27
-rw-r--r--apps/mobile/components/sharing/ErrorAnimation.tsx41
-rw-r--r--apps/mobile/components/sharing/LoadingAnimation.tsx120
-rw-r--r--apps/mobile/components/sharing/SuccessAnimation.tsx140
-rw-r--r--apps/mobile/components/ui/Avatar.tsx112
-rw-r--r--apps/mobile/components/ui/CustomSafeAreaView.tsx21
-rw-r--r--apps/mobile/components/ui/List.tsx469
-rw-r--r--apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx6
-rw-r--r--apps/mobile/components/ui/Toast.tsx204
-rw-r--r--apps/mobile/globals.css80
-rw-r--r--apps/mobile/lib/hooks.ts31
-rw-r--r--apps/mobile/lib/providers.tsx14
-rw-r--r--apps/mobile/lib/readerSettings.tsx93
-rw-r--r--apps/mobile/lib/session.ts9
-rw-r--r--apps/mobile/lib/settings.ts15
-rw-r--r--apps/mobile/lib/trpc.ts5
-rw-r--r--apps/mobile/lib/upload.ts17
-rw-r--r--apps/mobile/lib/useColorScheme.tsx12
-rw-r--r--apps/mobile/package.json69
-rw-r--r--apps/mobile/tailwind.config.js12
-rw-r--r--apps/mobile/theme/colors.ts77
-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
-rw-r--r--apps/workers/index.ts84
-rw-r--r--apps/workers/metascraper-plugins/metascraper-amazon-improved.ts77
-rw-r--r--apps/workers/metascraper-plugins/metascraper-reddit.ts358
-rw-r--r--apps/workers/metrics.ts14
-rw-r--r--apps/workers/network.ts11
-rw-r--r--apps/workers/package.json3
-rw-r--r--apps/workers/workerTracing.ts43
-rw-r--r--apps/workers/workerUtils.ts4
-rw-r--r--apps/workers/workers/adminMaintenanceWorker.ts6
-rw-r--r--apps/workers/workers/assetPreprocessingWorker.ts72
-rw-r--r--apps/workers/workers/backupWorker.ts3
-rw-r--r--apps/workers/workers/crawlerWorker.ts1950
-rw-r--r--apps/workers/workers/feedWorker.ts7
-rw-r--r--apps/workers/workers/importWorker.ts698
-rw-r--r--apps/workers/workers/inference/inferenceWorker.ts3
-rw-r--r--apps/workers/workers/inference/summarize.ts54
-rw-r--r--apps/workers/workers/inference/tagging.ts165
-rw-r--r--apps/workers/workers/ruleEngineWorker.ts14
-rw-r--r--apps/workers/workers/searchWorker.ts28
-rw-r--r--apps/workers/workers/videoWorker.ts3
-rw-r--r--apps/workers/workers/webhookWorker.ts3
305 files changed, 16465 insertions, 5432 deletions
diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json
index 62ed39f8..575bb8b7 100644
--- a/apps/browser-extension/package.json
+++ b/apps/browser-extension/package.json
@@ -24,16 +24,16 @@
"@tanstack/query-async-storage-persister": "5.90.2",
"@tanstack/react-query": "5.90.2",
"@tanstack/react-query-persist-client": "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",
"@uidotdev/usehooks": "^2.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.1.1",
"lucide-react": "^0.501.0",
- "react": "^19.1.0",
- "react-dom": "^19.1.0",
+ "react": "^19.2.1",
+ "react-dom": "^19.2.1",
"react-router-dom": "^6.22.0",
"superjson": "^2.2.1",
"tailwind-merge": "^2.2.1",
diff --git a/apps/browser-extension/src/OptionsPage.tsx b/apps/browser-extension/src/OptionsPage.tsx
index cac32eff..1b1dc8b6 100644
--- a/apps/browser-extension/src/OptionsPage.tsx
+++ b/apps/browser-extension/src/OptionsPage.tsx
@@ -1,4 +1,5 @@
import React, { useEffect } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { Button } from "./components/ui/button";
@@ -17,27 +18,32 @@ import usePluginSettings, {
DEFAULT_BADGE_CACHE_EXPIRE_MS,
} from "./utils/settings";
import { useTheme } from "./utils/ThemeProvider";
-import { api } from "./utils/trpc";
+import { useTRPC } from "./utils/trpc";
export default function OptionsPage() {
+ const api = useTRPC();
+ const queryClient = useQueryClient();
const navigate = useNavigate();
const { settings, setSettings } = usePluginSettings();
const { setTheme, theme } = useTheme();
- const { data: whoami, error: whoAmIError } = api.users.whoami.useQuery(
- undefined,
- {
+ const { data: whoami, error: whoAmIError } = useQuery(
+ api.users.whoami.queryOptions(undefined, {
enabled: settings.address != "",
- },
+ }),
);
- const { mutate: deleteKey } = api.apiKeys.revoke.useMutation();
+ const { mutate: deleteKey } = useMutation(
+ api.apiKeys.revoke.mutationOptions(),
+ );
- const invalidateWhoami = api.useUtils().users.whoami.refetch;
+ const invalidateWhoami = () => {
+ queryClient.refetchQueries(api.users.whoami.queryFilter());
+ };
useEffect(() => {
invalidateWhoami();
- }, [settings, invalidateWhoami]);
+ }, [settings]);
let loggedInMessage: React.ReactNode;
if (whoAmIError) {
diff --git a/apps/browser-extension/src/SavePage.tsx b/apps/browser-extension/src/SavePage.tsx
index b4b9ce95..5f55e164 100644
--- a/apps/browser-extension/src/SavePage.tsx
+++ b/apps/browser-extension/src/SavePage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { useMutation } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import {
@@ -9,33 +10,36 @@ import {
import { NEW_BOOKMARK_REQUEST_KEY_NAME } from "./background/protocol";
import Spinner from "./Spinner";
-import { api } from "./utils/trpc";
+import { useTRPC } from "./utils/trpc";
import { MessageType } from "./utils/type";
import { isHttpUrl } from "./utils/url";
export default function SavePage() {
+ const api = useTRPC();
const [error, setError] = useState<string | undefined>(undefined);
const {
data,
mutate: createBookmark,
status,
- } = api.bookmarks.createBookmark.useMutation({
- onError: (e) => {
- setError("Something went wrong: " + e.message);
- },
- onSuccess: async () => {
- // After successful creation, update badge cache and notify background
- const [currentTab] = await chrome.tabs.query({
- active: true,
- lastFocusedWindow: true,
- });
- await chrome.runtime.sendMessage({
- type: MessageType.BOOKMARK_REFRESH_BADGE,
- currentTab: currentTab,
- });
- },
- });
+ } = useMutation(
+ api.bookmarks.createBookmark.mutationOptions({
+ onError: (e) => {
+ setError("Something went wrong: " + e.message);
+ },
+ onSuccess: async () => {
+ // After successful creation, update badge cache and notify background
+ const [currentTab] = await chrome.tabs.query({
+ active: true,
+ lastFocusedWindow: true,
+ });
+ await chrome.runtime.sendMessage({
+ type: MessageType.BOOKMARK_REFRESH_BADGE,
+ currentTab: currentTab,
+ });
+ },
+ }),
+ );
useEffect(() => {
async function getNewBookmarkRequestFromBackgroundScriptIfAny(): Promise<ZNewBookmarkRequest | null> {
const { [NEW_BOOKMARK_REQUEST_KEY_NAME]: req } =
diff --git a/apps/browser-extension/src/SignInPage.tsx b/apps/browser-extension/src/SignInPage.tsx
index 6cf8b35d..8a7229b6 100644
--- a/apps/browser-extension/src/SignInPage.tsx
+++ b/apps/browser-extension/src/SignInPage.tsx
@@ -1,11 +1,12 @@
import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import Logo from "./Logo";
import usePluginSettings from "./utils/settings";
-import { api } from "./utils/trpc";
+import { useTRPC } from "./utils/trpc";
const enum LoginState {
NONE = "NONE",
@@ -14,6 +15,7 @@ const enum LoginState {
}
export default function SignInPage() {
+ const api = useTRPC();
const navigate = useNavigate();
const { settings, setSettings } = usePluginSettings();
@@ -21,23 +23,27 @@ export default function SignInPage() {
mutate: login,
error: usernamePasswordError,
isPending: userNamePasswordRequestIsPending,
- } = api.apiKeys.exchange.useMutation({
- onSuccess: (resp) => {
- setSettings((s) => ({ ...s, apiKey: resp.key, apiKeyId: resp.id }));
- navigate("/options");
- },
- });
+ } = useMutation(
+ api.apiKeys.exchange.mutationOptions({
+ onSuccess: (resp) => {
+ setSettings((s) => ({ ...s, apiKey: resp.key, apiKeyId: resp.id }));
+ navigate("/options");
+ },
+ }),
+ );
const {
mutate: validateApiKey,
error: apiKeyValidationError,
isPending: apiKeyValueRequestIsPending,
- } = api.apiKeys.validate.useMutation({
- onSuccess: () => {
- setSettings((s) => ({ ...s, apiKey: apiKeyFormData.apiKey }));
- navigate("/options");
- },
- });
+ } = useMutation(
+ api.apiKeys.validate.mutationOptions({
+ onSuccess: () => {
+ setSettings((s) => ({ ...s, apiKey: apiKeyFormData.apiKey }));
+ navigate("/options");
+ },
+ }),
+ );
const [lastLoginAttemptSource, setLastLoginAttemptSource] =
useState<LoginState>(LoginState.NONE);
diff --git a/apps/browser-extension/src/background/background.ts b/apps/browser-extension/src/background/background.ts
index 04089cc8..0acaa919 100644
--- a/apps/browser-extension/src/background/background.ts
+++ b/apps/browser-extension/src/background/background.ts
@@ -115,12 +115,16 @@ async function handleContextMenuClick(
} else if (menuItemId === CLEAR_ALL_CACHE_ID) {
await clearAllCache();
} else if (menuItemId === ADD_LINK_TO_KARAKEEP_ID) {
+ // Only pass the current page title when the URL being saved is the
+ // page itself. When saving a link or image, the title would
+ // incorrectly be the current page's title instead of the target's.
+ const isCurrentPage = !srcUrl && !linkUrl;
addLinkToKarakeep({
selectionText,
srcUrl,
linkUrl,
pageUrl,
- title: tab?.title,
+ title: isCurrentPage ? tab?.title : undefined,
});
// NOTE: Firefox only allows opening context menus if it's triggered by a user action.
diff --git a/apps/browser-extension/src/components/BookmarkLists.tsx b/apps/browser-extension/src/components/BookmarkLists.tsx
index 1d70d257..8debef5c 100644
--- a/apps/browser-extension/src/components/BookmarkLists.tsx
+++ b/apps/browser-extension/src/components/BookmarkLists.tsx
@@ -1,3 +1,4 @@
+import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react";
import {
@@ -5,15 +6,18 @@ import {
useRemoveBookmarkFromList,
} from "@karakeep/shared-react/hooks/lists";
-import { api } from "../utils/trpc";
+import { useTRPC } from "../utils/trpc";
import { Button } from "./ui/button";
export default function BookmarkLists({ bookmarkId }: { bookmarkId: string }) {
+ const api = useTRPC();
const { data: allLists } = useBookmarkLists();
const { mutate: deleteFromList } = useRemoveBookmarkFromList();
- const { data: lists } = api.lists.getListsOfBookmark.useQuery({ bookmarkId });
+ const { data: lists } = useQuery(
+ api.lists.getListsOfBookmark.queryOptions({ bookmarkId }),
+ );
if (!lists || !allLists) {
return null;
}
diff --git a/apps/browser-extension/src/components/ListsSelector.tsx b/apps/browser-extension/src/components/ListsSelector.tsx
index 86c151d1..b27e866a 100644
--- a/apps/browser-extension/src/components/ListsSelector.tsx
+++ b/apps/browser-extension/src/components/ListsSelector.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { useQuery } from "@tanstack/react-query";
import { useSet } from "@uidotdev/usehooks";
import { Check, ChevronsUpDown } from "lucide-react";
@@ -9,7 +10,7 @@ import {
} from "@karakeep/shared-react/hooks/lists";
import { cn } from "../utils/css";
-import { api } from "../utils/trpc";
+import { useTRPC } from "../utils/trpc";
import { Button } from "./ui/button";
import {
Command,
@@ -23,14 +24,17 @@ import { DynamicPopoverContent } from "./ui/dynamic-popover";
import { Popover, PopoverTrigger } from "./ui/popover";
export function ListsSelector({ bookmarkId }: { bookmarkId: string }) {
+ const api = useTRPC();
const currentlyUpdating = useSet<string>();
const [open, setOpen] = React.useState(false);
const { mutate: addToList } = useAddBookmarkToList();
const { mutate: removeFromList } = useRemoveBookmarkFromList();
- const { data: existingLists } = api.lists.getListsOfBookmark.useQuery({
- bookmarkId,
- });
+ const { data: existingLists } = useQuery(
+ api.lists.getListsOfBookmark.queryOptions({
+ bookmarkId,
+ }),
+ );
const { data: allLists } = useBookmarkLists();
diff --git a/apps/browser-extension/src/components/NoteEditor.tsx b/apps/browser-extension/src/components/NoteEditor.tsx
index 15f1515b..fb94f83b 100644
--- a/apps/browser-extension/src/components/NoteEditor.tsx
+++ b/apps/browser-extension/src/components/NoteEditor.tsx
@@ -55,6 +55,7 @@ export function NoteEditor({ bookmarkId }: { bookmarkId: string }) {
return (
<div className="flex flex-col gap-2">
<Textarea
+ autoFocus
className="h-32 w-full overflow-auto rounded bg-background p-2 text-sm text-gray-400 dark:text-gray-300"
value={noteValue}
placeholder="Write some notes ..."
diff --git a/apps/browser-extension/src/components/TagsSelector.tsx b/apps/browser-extension/src/components/TagsSelector.tsx
index ce404ac8..30cdcafc 100644
--- a/apps/browser-extension/src/components/TagsSelector.tsx
+++ b/apps/browser-extension/src/components/TagsSelector.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { useQuery } from "@tanstack/react-query";
import { useSet } from "@uidotdev/usehooks";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
@@ -8,7 +9,7 @@ import {
} from "@karakeep/shared-react/hooks/bookmarks";
import { cn } from "../utils/css";
-import { api } from "../utils/trpc";
+import { useTRPC } from "../utils/trpc";
import { Button } from "./ui/button";
import {
Command,
@@ -22,7 +23,8 @@ import { DynamicPopoverContent } from "./ui/dynamic-popover";
import { Popover, PopoverTrigger } from "./ui/popover";
export function TagsSelector({ bookmarkId }: { bookmarkId: string }) {
- const { data: allTags } = api.tags.list.useQuery({});
+ const api = useTRPC();
+ const { data: allTags } = useQuery(api.tags.list.queryOptions({}));
const { data: bookmark } = useAutoRefreshingBookmarkQuery({ bookmarkId });
const existingTagIds = new Set(bookmark?.tags.map((t) => t.id) ?? []);
diff --git a/apps/browser-extension/src/utils/providers.tsx b/apps/browser-extension/src/utils/providers.tsx
index 86489d6d..4c09084d 100644
--- a/apps/browser-extension/src/utils/providers.tsx
+++ b/apps/browser-extension/src/utils/providers.tsx
@@ -1,4 +1,4 @@
-import { TRPCProvider } from "@karakeep/shared-react/providers/trpc-provider";
+import { TRPCSettingsProvider } from "@karakeep/shared-react/providers/trpc-provider";
import usePluginSettings from "./settings";
import { ThemeProvider } from "./ThemeProvider";
@@ -7,8 +7,8 @@ export function Providers({ children }: { children: React.ReactNode }) {
const { settings } = usePluginSettings();
return (
- <TRPCProvider settings={settings}>
+ <TRPCSettingsProvider settings={settings}>
<ThemeProvider>{children}</ThemeProvider>
- </TRPCProvider>
+ </TRPCSettingsProvider>
);
}
diff --git a/apps/browser-extension/src/utils/trpc.ts b/apps/browser-extension/src/utils/trpc.ts
index b3215d9d..73fe68c5 100644
--- a/apps/browser-extension/src/utils/trpc.ts
+++ b/apps/browser-extension/src/utils/trpc.ts
@@ -1,7 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
-import { createTRPCReact } from "@trpc/react-query";
+import { createTRPCContext } from "@trpc/tanstack-react-query";
import superjson from "superjson";
import type { AppRouter } from "@karakeep/trpc/routers/_app";
@@ -9,7 +9,7 @@ import type { AppRouter } from "@karakeep/trpc/routers/_app";
import { getPluginSettings } from "./settings";
import { createChromeStorage } from "./storagePersister";
-export const api = createTRPCReact<AppRouter>();
+export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
let apiClient: ReturnType<typeof createTRPCClient<AppRouter>> | null = null;
let queryClient: QueryClient | null = null;
diff --git a/apps/cli/package.json b/apps/cli/package.json
index e0e9b188..04c72f81 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@karakeep/cli",
- "version": "0.29.0",
+ "version": "0.30.0",
"description": "Command Line Interface (CLI) for Karakeep",
"license": "GNU Affero General Public License version 3",
"type": "module",
@@ -20,8 +20,8 @@
"@karakeep/shared": "workspace:^0.1.0",
"@karakeep/trpc": "workspace:^0.1.0",
"@karakeep/tsconfig": "workspace:^0.1.0",
- "@trpc/client": "^11.4.3",
- "@trpc/server": "^11.4.3",
+ "@trpc/client": "^11.9.0",
+ "@trpc/server": "^11.9.0",
"@tsconfig/node22": "^22.0.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
diff --git a/apps/cli/src/commands/admin.ts b/apps/cli/src/commands/admin.ts
new file mode 100644
index 00000000..181126f0
--- /dev/null
+++ b/apps/cli/src/commands/admin.ts
@@ -0,0 +1,89 @@
+import { getGlobalOptions } from "@/lib/globals";
+import { printErrorMessageWithReason, printObject } from "@/lib/output";
+import { getAPIClient } from "@/lib/trpc";
+import { Command } from "@commander-js/extra-typings";
+import { getBorderCharacters, table } from "table";
+
+export const adminCmd = new Command()
+ .name("admin")
+ .description("admin commands");
+
+function toHumanReadableSize(size: number): string {
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ if (size === 0) return "0 Bytes";
+ const i = Math.floor(Math.log(size) / Math.log(1024));
+ return (size / Math.pow(1024, i)).toFixed(2) + " " + sizes[i];
+}
+
+const usersCmd = new Command()
+ .name("users")
+ .description("user management commands");
+
+usersCmd
+ .command("list")
+ .description("list all users")
+ .action(async () => {
+ const api = getAPIClient();
+
+ try {
+ const [usersResp, userStats] = await Promise.all([
+ api.users.list.query(),
+ api.admin.userStats.query(),
+ ]);
+
+ if (getGlobalOptions().json) {
+ printObject({
+ users: usersResp.users.map((u) => ({
+ ...u,
+ numBookmarks: userStats[u.id]?.numBookmarks ?? 0,
+ assetSizes: userStats[u.id]?.assetSizes ?? 0,
+ })),
+ });
+ } else {
+ const data: string[][] = [
+ [
+ "Name",
+ "Email",
+ "Num Bookmarks",
+ "Asset Sizes",
+ "Role",
+ "Local User",
+ ],
+ ];
+
+ usersResp.users.forEach((user) => {
+ const stats = userStats[user.id] ?? {
+ numBookmarks: 0,
+ assetSizes: 0,
+ };
+
+ const numBookmarksDisplay = `${stats.numBookmarks} / ${user.bookmarkQuota?.toString() ?? "Unlimited"}`;
+ const assetSizesDisplay = `${toHumanReadableSize(stats.assetSizes)} / ${user.storageQuota ? toHumanReadableSize(user.storageQuota) : "Unlimited"}`;
+
+ data.push([
+ user.name,
+ user.email,
+ numBookmarksDisplay,
+ assetSizesDisplay,
+ user.role ?? "",
+ user.localUser ? "✓" : "✗",
+ ]);
+ });
+
+ console.log(
+ table(data, {
+ border: getBorderCharacters("ramac"),
+ drawHorizontalLine: (lineIndex, rowCount) => {
+ return (
+ lineIndex === 0 || lineIndex === 1 || lineIndex === rowCount
+ );
+ },
+ }),
+ );
+ }
+ } catch (error) {
+ printErrorMessageWithReason("Failed to list all users", error as object);
+ }
+ });
+
+adminCmd.addCommand(usersCmd);
diff --git a/apps/cli/src/commands/bookmarks.ts b/apps/cli/src/commands/bookmarks.ts
index 021e344f..e2e8efb6 100644
--- a/apps/cli/src/commands/bookmarks.ts
+++ b/apps/cli/src/commands/bookmarks.ts
@@ -29,18 +29,10 @@ type Bookmark = Omit<ZBookmark, "tags"> & {
};
function normalizeBookmark(bookmark: ZBookmark): Bookmark {
- const ret = {
+ return {
...bookmark,
tags: bookmark.tags.map((t) => t.name),
};
-
- if (ret.content.type == BookmarkTypes.LINK && ret.content.htmlContent) {
- if (ret.content.htmlContent.length > 10) {
- ret.content.htmlContent =
- ret.content.htmlContent.substring(0, 10) + "... <CROPPED>";
- }
- }
- return ret;
}
function printBookmark(bookmark: ZBookmark) {
@@ -151,10 +143,15 @@ bookmarkCmd
.command("get")
.description("fetch information about a bookmark")
.argument("<id>", "The id of the bookmark to get")
- .action(async (id) => {
+ .option(
+ "--include-content",
+ "include full bookmark content in results",
+ false,
+ )
+ .action(async (id, opts) => {
const api = getAPIClient();
await api.bookmarks.getBookmark
- .query({ bookmarkId: id })
+ .query({ bookmarkId: id, includeContent: opts.includeContent })
.then(printBookmark)
.catch(printError(`Failed to get the bookmark with id "${id}"`));
});
@@ -254,6 +251,11 @@ bookmarkCmd
false,
)
.option("--list-id <id>", "if set, only items from that list will be fetched")
+ .option(
+ "--include-content",
+ "include full bookmark content in results",
+ false,
+ )
.action(async (opts) => {
const api = getAPIClient();
@@ -262,6 +264,7 @@ bookmarkCmd
listId: opts.listId,
limit: MAX_NUM_BOOKMARKS_PER_PAGE,
useCursorV2: true,
+ includeContent: opts.includeContent,
};
try {
@@ -282,6 +285,70 @@ bookmarkCmd
});
bookmarkCmd
+ .command("search")
+ .description("search bookmarks using query matchers")
+ .argument(
+ "<query>",
+ "the search query (supports matchers like tag:name, is:fav, etc.)",
+ )
+ .option(
+ "--limit <limit>",
+ "number of results per page",
+ (val) => parseInt(val, 10),
+ 50,
+ )
+ .option(
+ "--sort-order <order>",
+ "sort order for results",
+ (val) => {
+ if (val !== "relevance" && val !== "asc" && val !== "desc") {
+ throw new Error("sort-order must be one of: relevance, asc, desc");
+ }
+ return val;
+ },
+ "relevance",
+ )
+ .option(
+ "--include-content",
+ "include full bookmark content in results",
+ false,
+ )
+ .option("--all", "fetch all results (paginate through all pages)", false)
+ .action(async (query, opts) => {
+ const api = getAPIClient();
+
+ const request = {
+ text: query,
+ limit: opts.limit,
+ sortOrder: opts.sortOrder as "relevance" | "asc" | "desc",
+ includeContent: opts.includeContent,
+ };
+
+ try {
+ let resp = await api.bookmarks.searchBookmarks.query(request);
+ let results: ZBookmark[] = resp.bookmarks;
+
+ // If --all flag is set, fetch all pages
+ if (opts.all) {
+ while (resp.nextCursor) {
+ resp = await api.bookmarks.searchBookmarks.query({
+ ...request,
+ cursor: resp.nextCursor,
+ });
+ results = [...results, ...resp.bookmarks];
+ }
+ }
+
+ printObject(results.map(normalizeBookmark), { maxArrayLength: null });
+ } catch (error) {
+ printStatusMessage(false, "Failed to search bookmarks");
+ if (error instanceof Error) {
+ printStatusMessage(false, error.message);
+ }
+ }
+ });
+
+bookmarkCmd
.command("delete")
.description("delete a bookmark")
.argument("<id>", "the id of the bookmark to delete")
diff --git a/apps/cli/src/commands/lists.ts b/apps/cli/src/commands/lists.ts
index 864fa790..1d9341d7 100644
--- a/apps/cli/src/commands/lists.ts
+++ b/apps/cli/src/commands/lists.ts
@@ -86,15 +86,24 @@ listsCmd
.command("get")
.description("gets all the ids of the bookmarks assigned to the list")
.requiredOption("--list <id>", "the id of the list")
+ .option(
+ "--include-content",
+ "include full bookmark content in results",
+ false,
+ )
.action(async (opts) => {
const api = getAPIClient();
try {
- let resp = await api.bookmarks.getBookmarks.query({ listId: opts.list });
+ let resp = await api.bookmarks.getBookmarks.query({
+ listId: opts.list,
+ includeContent: opts.includeContent,
+ });
let results: string[] = resp.bookmarks.map((b) => b.id);
while (resp.nextCursor) {
resp = await api.bookmarks.getBookmarks.query({
listId: opts.list,
cursor: resp.nextCursor,
+ includeContent: opts.includeContent,
});
results = [...results, ...resp.bookmarks.map((b) => b.id)];
}
diff --git a/apps/cli/src/commands/migrate.ts b/apps/cli/src/commands/migrate.ts
index ee0d85c8..6527be23 100644
--- a/apps/cli/src/commands/migrate.ts
+++ b/apps/cli/src/commands/migrate.ts
@@ -695,6 +695,7 @@ async function migrateBookmarks(
summary: b.summary ?? undefined,
createdAt: b.createdAt,
crawlPriority: "low" as const,
+ source: b.source === null ? undefined : b.source,
};
let createdId: string | null = null;
switch (b.content.type) {
diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts
index df7d9512..8158c0b8 100644
--- a/apps/cli/src/index.ts
+++ b/apps/cli/src/index.ts
@@ -1,3 +1,4 @@
+import { adminCmd } from "@/commands/admin";
import { bookmarkCmd } from "@/commands/bookmarks";
import { dumpCmd } from "@/commands/dump";
import { listsCmd } from "@/commands/lists";
@@ -31,6 +32,7 @@ const program = new Command()
: "0.0.0",
);
+program.addCommand(adminCmd);
program.addCommand(bookmarkCmd);
program.addCommand(listsCmd);
program.addCommand(tagsCmd);
diff --git a/apps/landing/package.json b/apps/landing/package.json
index b8329356..7c2a485d 100644
--- a/apps/landing/package.json
+++ b/apps/landing/package.json
@@ -20,8 +20,8 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.501.0",
- "react": "^19.1.0",
- "react-dom": "^19.1.0",
+ "react": "^19.2.1",
+ "react-dom": "^19.2.1",
"react-router": "^7.7.1",
"sharp": "^0.33.3",
"tailwind-merge": "^2.2.1",
diff --git a/apps/landing/src/App.tsx b/apps/landing/src/App.tsx
index 45a019c5..7448aa86 100644
--- a/apps/landing/src/App.tsx
+++ b/apps/landing/src/App.tsx
@@ -1,3 +1,4 @@
+import Apps from "@/src/Apps";
import Homepage from "@/src/Homepage";
import Pricing from "@/src/Pricing";
import Privacy from "@/src/Privacy";
@@ -10,6 +11,7 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/" element={<Homepage />} />
+ <Route path="/apps" element={<Apps />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/privacy" element={<Privacy />} />
</Routes>
diff --git a/apps/landing/src/Apps.tsx b/apps/landing/src/Apps.tsx
new file mode 100644
index 00000000..7e6a7137
--- /dev/null
+++ b/apps/landing/src/Apps.tsx
@@ -0,0 +1,112 @@
+import NavBar from "./Navbar";
+import appStoreBadge from "/app-store-badge.png?url";
+import chromeExtensionBadge from "/chrome-extension-badge.png?url";
+import firefoxAddonBadge from "/firefox-addon.png?url";
+import playStoreBadge from "/google-play-badge.webp?url";
+
+interface Listing {
+ name: string;
+ description: string;
+ url: string;
+ badge: string;
+}
+
+const mobileApps: Listing[] = [
+ {
+ name: "iOS App",
+ description: "Save links and notes from your iPhone and iPad.",
+ url: "https://apps.apple.com/us/app/karakeep-app/id6479258022",
+ badge: appStoreBadge,
+ },
+ {
+ name: "Android App",
+ description: "Capture and organize content on Android devices.",
+ url: "https://play.google.com/store/apps/details?id=app.hoarder.hoardermobile&pcampaignid=web_share",
+ badge: playStoreBadge,
+ },
+];
+
+const browserExtensions: Listing[] = [
+ {
+ name: "Chrome Extension",
+ description: "One-click saving from Chrome.",
+ url: "https://chromewebstore.google.com/detail/karakeep/kgcjekpmcjjogibpjebkhaanilehneje",
+ badge: chromeExtensionBadge,
+ },
+ {
+ name: "Firefox Add-on",
+ description: "Save pages directly from Firefox.",
+ url: "https://addons.mozilla.org/en-US/firefox/addon/karakeep/",
+ badge: firefoxAddonBadge,
+ },
+];
+
+function ListingSection({
+ title,
+ description,
+ items,
+}: {
+ title: string;
+ description: string;
+ items: Listing[];
+}) {
+ return (
+ <section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
+ <h2 className="text-2xl font-semibold text-gray-900">{title}</h2>
+ <p className="mt-2 text-gray-600">{description}</p>
+ <div className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
+ {items.map((item) => (
+ <a
+ key={item.name}
+ href={item.url}
+ target="_blank"
+ rel="noreferrer"
+ className="flex flex-col gap-4 rounded-xl border border-gray-200 p-4 transition-colors hover:border-gray-300"
+ >
+ <div>
+ <h3 className="font-semibold text-gray-900">{item.name}</h3>
+ <p className="mt-1 text-sm text-gray-600">{item.description}</p>
+ </div>
+ <div className="h-12 w-fit">
+ <img
+ className="h-full w-auto object-contain"
+ alt={item.name}
+ src={item.badge}
+ />
+ </div>
+ </a>
+ ))}
+ </div>
+ </section>
+ );
+}
+
+export default function Apps() {
+ return (
+ <div className="min-h-screen bg-gray-50">
+ <div className="container mx-auto pb-16">
+ <NavBar />
+ <main className="px-4 py-8 sm:px-6 sm:py-14">
+ <h1 className="text-4xl font-bold text-gray-900 sm:text-5xl">
+ Apps & Extensions
+ </h1>
+ <p className="mt-3 max-w-2xl text-base text-gray-600 sm:text-lg">
+ Use Karakeep anywhere with our mobile apps and browser extensions.
+ </p>
+ <div className="mt-10 space-y-6">
+ <ListingSection
+ title="Mobile Apps"
+ description="Take your bookmarks with you on iOS and Android."
+ items={mobileApps}
+ />
+ <ListingSection
+ title="Browser Extensions"
+ description="Save content from your browser in one click."
+ items={browserExtensions}
+ />
+ </div>
+ </main>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/landing/src/Homepage.tsx b/apps/landing/src/Homepage.tsx
index 07229549..26a0f2fe 100644
--- a/apps/landing/src/Homepage.tsx
+++ b/apps/landing/src/Homepage.tsx
@@ -8,6 +8,7 @@ import {
Github,
Highlighter,
Plug,
+ Rocket,
Rss,
Server,
Star,
@@ -17,7 +18,12 @@ import {
Workflow,
} from "lucide-react";
-import { DEMO_LINK, DOCS_LINK, GITHUB_LINK } from "./constants";
+import {
+ CLOUD_SIGNUP_LINK,
+ DEMO_LINK,
+ DOCS_LINK,
+ GITHUB_LINK,
+} from "./constants";
import NavBar from "./Navbar";
import appStoreBadge from "/app-store-badge.png?url";
import chromeExtensionBadge from "/chrome-extension-badge.png?url";
@@ -119,6 +125,29 @@ const featuresList = [
const currentYear = new Date().getFullYear();
+function Banner() {
+ return (
+ <div className="border-b border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 px-3 py-2 text-center sm:px-4 sm:py-3">
+ <div className="container flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs text-slate-700 sm:gap-3 sm:text-base">
+ <div className="flex flex-wrap items-center justify-center gap-1 px-2 py-0.5 sm:px-3 sm:py-1">
+ <Rocket className="size-4 text-amber-600 sm:size-5" />
+ <span className="font-semibold text-slate-800">
+ Karakeep Cloud Public Beta is Now Live
+ </span>
+ </div>
+ <a
+ href={CLOUD_SIGNUP_LINK}
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center justify-center gap-1 text-xs font-semibold text-amber-700 underline decoration-amber-400 underline-offset-2 transition-all hover:text-amber-800 sm:rounded-full sm:border sm:border-amber-300 sm:bg-amber-500 sm:px-3 sm:py-1 sm:text-sm sm:text-white sm:no-underline sm:shadow-sm sm:hover:border-amber-400 sm:hover:bg-amber-600"
+ >
+ Join Now <span className="hidden sm:inline">→</span>
+ </a>
+ </div>
+ </div>
+ );
+}
+
function Hero() {
return (
<div className="mt-10 flex flex-grow flex-col items-center justify-center gap-6 sm:mt-20">
@@ -144,7 +173,7 @@ function Hero() {
className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700 shadow-sm transition-all hover:border-gray-300 hover:shadow-md"
>
<Star className="size-4 fill-yellow-400 text-yellow-400" />
- <span className="font-semibold">21k</span>
+ <span className="font-semibold">22k</span>
<span className="text-gray-500">stars on GitHub</span>
</a>
</div>
@@ -269,6 +298,7 @@ function Screenshots() {
export default function Homepage() {
return (
<div className="flex min-h-screen flex-col">
+ <Banner />
<div className="container flex flex-col pb-10">
<NavBar />
<Hero />
diff --git a/apps/landing/src/Navbar.tsx b/apps/landing/src/Navbar.tsx
index 15355170..e60a6d7b 100644
--- a/apps/landing/src/Navbar.tsx
+++ b/apps/landing/src/Navbar.tsx
@@ -2,7 +2,7 @@ import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Link } from "react-router";
-import { DEMO_LINK, DOCS_LINK, GITHUB_LINK } from "./constants";
+import { CLOUD_SIGNUP_LINK, DOCS_LINK, GITHUB_LINK } from "./constants";
import Logo from "/icons/karakeep-full.svg?url";
export default function NavBar() {
@@ -31,6 +31,17 @@ export default function NavBar() {
>
Login
</a>
+ <a
+ href={CLOUD_SIGNUP_LINK}
+ target="_blank"
+ className={cn(
+ "px-3 py-1.5 text-xs",
+ buttonVariants({ variant: "default", size: "sm" }),
+ )}
+ rel="noreferrer"
+ >
+ Get Started
+ </a>
</div>
{/* Desktop navigation - show all items */}
@@ -66,15 +77,15 @@ export default function NavBar() {
Login
</a>
<a
- href={DEMO_LINK}
+ href={CLOUD_SIGNUP_LINK}
target="_blank"
className={cn(
- "text flex h-full w-28 gap-2",
+ "text flex h-full w-32 gap-2",
buttonVariants({ variant: "default" }),
)}
rel="noreferrer"
>
- Try Demo
+ Get Started
</a>
</div>
</div>
diff --git a/apps/landing/src/Pricing.tsx b/apps/landing/src/Pricing.tsx
index 9240ba76..962a09fe 100644
--- a/apps/landing/src/Pricing.tsx
+++ b/apps/landing/src/Pricing.tsx
@@ -2,9 +2,11 @@ import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Check, ExternalLink } from "lucide-react";
-import { DOCS_LINK, GITHUB_LINK, WAITLIST_LINK } from "./constants";
+import { CLOUD_SIGNUP_LINK, DOCS_LINK, GITHUB_LINK } from "./constants";
import NavBar from "./Navbar";
+const CONTACT_EMAIL = "mailto:support@karakeep.app";
+
const pricingTiers = [
{
name: "Free",
@@ -17,7 +19,7 @@ const pricingTiers = [
"Mobile & web apps",
"Browser extensions",
],
- buttonText: "Join Waitlist",
+ buttonText: "Get Started",
buttonVariant: "outline" as const,
popular: false,
},
@@ -34,7 +36,7 @@ const pricingTiers = [
"Mobile & web apps",
"Browser extensions",
],
- buttonText: "Join Waitlist",
+ buttonText: "Get Started",
buttonVariant: "default" as const,
popular: true,
},
@@ -56,6 +58,23 @@ const pricingTiers = [
popular: false,
isGitHub: true,
},
+ {
+ name: "Corporate",
+ price: "Custom",
+ period: "per seat",
+ description: "For teams and organizations",
+ features: [
+ "Everything in Pro",
+ "Custom deployment & domain",
+ "Single Sign-On (SSO)",
+ "User management",
+ "Priority support",
+ ],
+ buttonText: "Contact Us",
+ buttonVariant: "outline" as const,
+ popular: false,
+ isContact: true,
+ },
];
function PricingHeader() {
@@ -75,66 +94,86 @@ function PricingHeader() {
}
function PricingCards() {
- return (
- <div className="mx-auto mt-16 grid max-w-6xl grid-cols-1 gap-8 px-6 md:grid-cols-3">
- {pricingTiers.map((tier) => (
- <div
- key={tier.name}
- className={cn(
- "relative rounded-2xl border bg-white p-8 shadow-sm",
- tier.popular && "border-purple-500 shadow-lg",
+ const renderCard = (tier: (typeof pricingTiers)[number]) => (
+ <div
+ key={tier.name}
+ className={cn(
+ "relative rounded-2xl border bg-white p-8 shadow-sm",
+ tier.popular && "border-purple-500 shadow-lg",
+ )}
+ >
+ <div className="text-center">
+ <h3 className="text-xl font-semibold">{tier.name}</h3>
+ <div className="mt-4 flex items-baseline justify-center">
+ <span className="text-4xl font-bold">{tier.price}</span>
+ {tier.period && (
+ <span className="ml-1 text-gray-500">/{tier.period}</span>
)}
- >
- <div className="text-center">
- <h3 className="text-xl font-semibold">{tier.name}</h3>
- <div className="mt-4 flex items-baseline justify-center">
- <span className="text-4xl font-bold">{tier.price}</span>
- {tier.period && (
- <span className="ml-1 text-gray-500">/{tier.period}</span>
- )}
- </div>
- <p className="mt-2 text-gray-600">{tier.description}</p>
- </div>
+ </div>
+ <p className="mt-2 text-gray-600">{tier.description}</p>
+ </div>
- <ul className="mt-8 space-y-3">
- {tier.features.map((feature) => (
- <li key={feature} className="flex items-center">
- <Check className="h-5 w-5 text-green-500" />
- <span className="ml-3 text-gray-700">{feature}</span>
- </li>
- ))}
- </ul>
+ <ul className="mt-8 space-y-3">
+ {tier.features.map((feature) => (
+ <li key={feature} className="flex items-center">
+ <Check className="h-5 w-5 text-green-500" />
+ <span className="ml-3 text-gray-700">{feature}</span>
+ </li>
+ ))}
+ </ul>
- <div className="mt-8">
- {tier.isGitHub ? (
- <a
- href={GITHUB_LINK}
- target="_blank"
- className={cn(
- "flex w-full items-center justify-center gap-2",
- buttonVariants({ variant: tier.buttonVariant, size: "lg" }),
- )}
- rel="noreferrer"
- >
- <ExternalLink className="h-4 w-4" />
- {tier.buttonText}
- </a>
- ) : (
- <a
- href={WAITLIST_LINK}
- target="_blank"
- className={cn(
- "flex w-full items-center justify-center",
- buttonVariants({ variant: tier.buttonVariant, size: "lg" }),
- )}
- rel="noreferrer"
- >
- {tier.buttonText}
- </a>
+ <div className="mt-8">
+ {tier.isContact ? (
+ <a
+ href={CONTACT_EMAIL}
+ className={cn(
+ "flex w-full items-center justify-center",
+ buttonVariants({ variant: tier.buttonVariant, size: "lg" }),
)}
- </div>
- </div>
- ))}
+ >
+ {tier.buttonText}
+ </a>
+ ) : tier.isGitHub ? (
+ <a
+ href={GITHUB_LINK}
+ target="_blank"
+ className={cn(
+ "flex w-full items-center justify-center gap-2",
+ buttonVariants({ variant: tier.buttonVariant, size: "lg" }),
+ )}
+ rel="noreferrer"
+ >
+ <ExternalLink className="h-4 w-4" />
+ {tier.buttonText}
+ </a>
+ ) : (
+ <a
+ href={CLOUD_SIGNUP_LINK}
+ target="_blank"
+ className={cn(
+ "flex w-full items-center justify-center",
+ buttonVariants({ variant: tier.buttonVariant, size: "lg" }),
+ )}
+ rel="noreferrer"
+ >
+ {tier.buttonText}
+ </a>
+ )}
+ </div>
+ </div>
+ );
+
+ return (
+ <div className="mx-auto mt-16 max-w-6xl px-6">
+ {/* First 3 tiers */}
+ <div className="grid grid-cols-1 gap-8 md:grid-cols-3">
+ {pricingTiers.slice(0, 3).map(renderCard)}
+ </div>
+
+ {/* Corporate tier - centered below */}
+ <div className="mt-8 flex justify-center">
+ <div className="w-full md:max-w-sm">{renderCard(pricingTiers[3])}</div>
+ </div>
</div>
);
}
diff --git a/apps/landing/src/constants.ts b/apps/landing/src/constants.ts
index b75cecae..617b8f4b 100644
--- a/apps/landing/src/constants.ts
+++ b/apps/landing/src/constants.ts
@@ -1,4 +1,4 @@
export const GITHUB_LINK = "https://github.com/karakeep-app/karakeep";
export const DOCS_LINK = "https://docs.karakeep.app";
export const DEMO_LINK = "https://try.karakeep.app";
-export const WAITLIST_LINK = "https://tally.so/r/wo8zzx";
+export const CLOUD_SIGNUP_LINK = "https://cloud.karakeep.app/signup";
diff --git a/apps/mcp/README.md b/apps/mcp/README.md
index 0d134c34..45b0d79f 100644
--- a/apps/mcp/README.md
+++ b/apps/mcp/README.md
@@ -1,6 +1,7 @@
# Karakeep MCP Server
-This is the Karakeep MCP server, which is a server that can be used to interact with Karakeep from other tools.
+This is the Karakeep MCP server, which is a server that can be used to interact
+with Karakeep from other tools.
## Supported Tools
@@ -22,11 +23,12 @@ From NPM:
"karakeep": {
"command": "npx",
"args": [
- "@karakeep/mcp",
+ "@karakeep/mcp"
],
"env": {
"KARAKEEP_API_ADDR": "https://<YOUR_SERVER_ADDR>",
- "KARAKEEP_API_KEY": "<YOUR_TOKEN>"
+ "KARAKEEP_API_KEY": "<YOUR_TOKEN>",
+ "KARAKEEP_CUSTOM_HEADERS": "{\"CF-Access-Client-Id\": \"...\", \"CF-Access-Client-Secret\": \"...\"}"
}
}
}
@@ -46,6 +48,8 @@ From Docker:
"KARAKEEP_API_ADDR=https://<YOUR_SERVER_ADDR>",
"-e",
"KARAKEEP_API_KEY=<YOUR_TOKEN>",
+ "-e",
+ "KARAKEEP_CUSTOM_HEADERS={\"CF-Access-Client-Id\": \"...\", \"CF-Access-Client-Secret\": \"...\"}",
"ghcr.io/karakeep-app/karakeep-mcp:latest"
]
}
diff --git a/apps/mcp/src/shared.ts b/apps/mcp/src/shared.ts
index a80c3620..b2ffff05 100644
--- a/apps/mcp/src/shared.ts
+++ b/apps/mcp/src/shared.ts
@@ -6,9 +6,21 @@ import { createKarakeepClient } from "@karakeep/sdk";
const addr = process.env.KARAKEEP_API_ADDR;
const apiKey = process.env.KARAKEEP_API_KEY;
+const getCustomHeaders = () => {
+ try {
+ return process.env.KARAKEEP_CUSTOM_HEADERS
+ ? JSON.parse(process.env.KARAKEEP_CUSTOM_HEADERS)
+ : {};
+ } catch (e) {
+ console.error("Failed to parse KARAKEEP_CUSTOM_HEADERS", e);
+ return {};
+ }
+};
+
export const karakeepClient = createKarakeepClient({
baseUrl: `${addr}/api/v1`,
headers: {
+ ...getCustomHeaders(),
"Content-Type": "application/json",
authorization: `Bearer ${apiKey}`,
},
diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js
index c6b92bff..43167fef 100644
--- a/apps/mobile/app.config.js
+++ b/apps/mobile/app.config.js
@@ -3,7 +3,7 @@ export default {
name: "Karakeep",
slug: "hoarder",
scheme: "karakeep",
- version: "1.8.3",
+ version: "1.8.5",
orientation: "portrait",
icon: {
light: "./assets/icon.png",
@@ -35,7 +35,7 @@ export default {
NSAllowsArbitraryLoads: true,
},
},
- buildNumber: "30",
+ buildNumber: "32",
},
android: {
adaptiveIcon: {
@@ -54,7 +54,7 @@ export default {
},
},
package: "app.hoarder.hoardermobile",
- versionCode: 30,
+ versionCode: 32,
},
plugins: [
"./plugins/trust-local-certs.js",
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index 1e6128c7..ab0f9c52 100644
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -2,13 +2,16 @@ import "@/globals.css";
import "expo-dev-client";
import { useEffect } from "react";
+import { Platform } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
+import { SafeAreaProvider } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { Stack } from "expo-router/stack";
import { ShareIntentProvider, useShareIntent } from "expo-share-intent";
import { StatusBar } from "expo-status-bar";
import { StyledStack } from "@/components/navigation/stack";
+import SplashScreenController from "@/components/SplashScreenController";
import { Providers } from "@/lib/providers";
import { useColorScheme, useInitialAndroidBarSync } from "@/lib/useColorScheme";
import { cn } from "@/lib/utils";
@@ -30,9 +33,13 @@ export default function RootLayout() {
}, [hasShareIntent]);
return (
- <>
- <KeyboardProvider statusBarTranslucent navigationBarTranslucent>
+ <SafeAreaProvider>
+ <KeyboardProvider
+ statusBarTranslucent={Platform.OS !== "android" ? true : undefined}
+ navigationBarTranslucent={Platform.OS !== "android" ? true : undefined}
+ >
<NavThemeProvider value={NAV_THEME[colorScheme]}>
+ <SplashScreenController />
<StyledStack
layout={(props) => {
return (
@@ -64,6 +71,14 @@ export default function RootLayout() {
/>
<Stack.Screen name="sharing" />
<Stack.Screen
+ name="server-address"
+ options={{
+ title: "Server Address",
+ headerShown: true,
+ presentation: "modal",
+ }}
+ />
+ <Stack.Screen
name="test-connection"
options={{
title: "Test Connection",
@@ -78,6 +93,6 @@ export default function RootLayout() {
key={`root-status-bar-${isDarkColorScheme ? "light" : "dark"}`}
style={isDarkColorScheme ? "light" : "dark"}
/>
- </>
+ </SafeAreaProvider>
);
}
diff --git a/apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx
new file mode 100644
index 00000000..961df836
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(highlights)/_layout.tsx
@@ -0,0 +1,18 @@
+import { Stack } from "expo-router/stack";
+
+export default function Layout() {
+ return (
+ <Stack
+ screenOptions={{
+ headerLargeTitle: true,
+ headerTransparent: true,
+ headerBlurEffect: "systemMaterial",
+ headerShadowVisible: false,
+ headerLargeTitleShadowVisible: false,
+ headerLargeStyle: { backgroundColor: "transparent" },
+ }}
+ >
+ <Stack.Screen name="index" options={{ title: "Highlights" }} />
+ </Stack>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx
new file mode 100644
index 00000000..48a190c1
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(highlights)/index.tsx
@@ -0,0 +1,50 @@
+import FullPageError from "@/components/FullPageError";
+import HighlightList from "@/components/highlights/HighlightList";
+import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
+
+import { useTRPC } from "@karakeep/shared-react/trpc";
+
+export default function Highlights() {
+ const api = useTRPC();
+ const queryClient = useQueryClient();
+ const {
+ data,
+ isPending,
+ isPlaceholderData,
+ error,
+ fetchNextPage,
+ isFetchingNextPage,
+ refetch,
+ } = useInfiniteQuery(
+ api.highlights.getAll.infiniteQueryOptions(
+ {},
+ {
+ initialCursor: null,
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ ),
+ );
+
+ if (error) {
+ return <FullPageError error={error.message} onRetry={() => refetch()} />;
+ }
+
+ if (isPending || !data) {
+ return <FullPageSpinner />;
+ }
+
+ const onRefresh = () => {
+ queryClient.invalidateQueries(api.highlights.getAll.pathFilter());
+ };
+
+ return (
+ <HighlightList
+ highlights={data.pages.flatMap((p) => p.highlights)}
+ onRefresh={onRefresh}
+ fetchNextPage={fetchNextPage}
+ isFetchingNextPage={isFetchingNextPage}
+ isRefreshing={isPending || isPlaceholderData}
+ />
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx
new file mode 100644
index 00000000..1ba65211
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(home)/_layout.tsx
@@ -0,0 +1,18 @@
+import { Stack } from "expo-router/stack";
+
+export default function Layout() {
+ return (
+ <Stack
+ screenOptions={{
+ headerLargeTitle: true,
+ headerTransparent: true,
+ headerBlurEffect: "systemMaterial",
+ headerShadowVisible: false,
+ headerLargeTitleShadowVisible: false,
+ headerLargeStyle: { backgroundColor: "transparent" },
+ }}
+ >
+ <Stack.Screen name="index" options={{ title: "Home" }} />
+ </Stack>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(home)/index.tsx
index 0a51b817..65034419 100644
--- a/apps/mobile/app/dashboard/(tabs)/index.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/(home)/index.tsx
@@ -1,11 +1,9 @@
import { Platform, Pressable, View } from "react-native";
import * as Haptics from "expo-haptics";
import * as ImagePicker from "expo-image-picker";
-import { router } from "expo-router";
+import { router, Stack } from "expo-router";
import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList";
import { TailwindResolver } from "@/components/TailwindResolver";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
-import PageTitle from "@/components/ui/PageTitle";
import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
import useAppSettings from "@/lib/settings";
@@ -76,34 +74,35 @@ function HeaderRight({
export default function Home() {
return (
- <CustomSafeAreaView>
+ <>
+ <Stack.Screen
+ options={{
+ headerRight: () => (
+ <HeaderRight
+ openNewBookmarkModal={() =>
+ router.push("/dashboard/bookmarks/new")
+ }
+ />
+ ),
+ }}
+ />
<UpdatingBookmarkList
query={{ archived: false }}
header={
- <View className="flex flex-col gap-1">
- <View className="flex flex-row justify-between">
- <PageTitle title="Home" className="pb-2" />
- <HeaderRight
- openNewBookmarkModal={() =>
- router.push("/dashboard/bookmarks/new")
- }
- />
- </View>
- <Pressable
- className="flex flex-row items-center gap-1 rounded-lg border border-input bg-card px-4 py-1"
- onPress={() => router.push("/dashboard/search")}
- >
- <TailwindResolver
- className="text-muted"
- comp={(styles) => (
- <Search size={16} color={styles?.color?.toString()} />
- )}
- />
- <Text className="text-muted">Search</Text>
- </Pressable>
- </View>
+ <Pressable
+ className="flex flex-row items-center gap-1 rounded-lg border border-input bg-card px-4 py-1"
+ onPress={() => router.push("/dashboard/search")}
+ >
+ <TailwindResolver
+ className="text-muted"
+ comp={(styles) => (
+ <Search size={16} color={styles?.color?.toString()} />
+ )}
+ />
+ <Text className="text-muted">Search</Text>
+ </Pressable>
}
/>
- </CustomSafeAreaView>
+ </>
);
}
diff --git a/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx
new file mode 100644
index 00000000..398ba650
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(lists)/_layout.tsx
@@ -0,0 +1,18 @@
+import { Stack } from "expo-router/stack";
+
+export default function Layout() {
+ return (
+ <Stack
+ screenOptions={{
+ headerLargeTitle: true,
+ headerTransparent: true,
+ headerBlurEffect: "systemMaterial",
+ headerShadowVisible: false,
+ headerLargeTitleShadowVisible: false,
+ headerLargeStyle: { backgroundColor: "transparent" },
+ }}
+ >
+ <Stack.Screen name="index" options={{ title: "Lists" }} />
+ </Stack>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx
new file mode 100644
index 00000000..4c98ef2c
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(lists)/index.tsx
@@ -0,0 +1,284 @@
+import { useEffect, useMemo, useState } from "react";
+import { FlatList, Pressable, View } from "react-native";
+import * as Haptics from "expo-haptics";
+import { Link, router, Stack } from "expo-router";
+import FullPageError from "@/components/FullPageError";
+import ChevronRight from "@/components/ui/ChevronRight";
+import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { Text } from "@/components/ui/Text";
+import { useColorScheme } from "@/lib/useColorScheme";
+import { condProps } from "@/lib/utils";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Plus } from "lucide-react-native";
+
+import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils";
+
+function HeaderRight({ openNewListModal }: { openNewListModal: () => void }) {
+ return (
+ <Pressable
+ className="my-auto px-4"
+ onPress={() => {
+ Haptics.selectionAsync();
+ openNewListModal();
+ }}
+ >
+ <Plus color="rgb(0, 122, 255)" />
+ </Pressable>
+ );
+}
+
+interface ListLink {
+ id: string;
+ logo: string;
+ name: string;
+ href: string;
+ level: number;
+ parent?: string;
+ numChildren: number;
+ collapsed: boolean;
+ isSharedSection?: boolean;
+ numBookmarks?: number;
+}
+
+function traverseTree(
+ node: ZBookmarkListTreeNode,
+ links: ListLink[],
+ showChildrenOf: Record<string, boolean>,
+ listStats?: Map<string, number>,
+ parent?: string,
+ level = 0,
+) {
+ links.push({
+ id: node.item.id,
+ logo: node.item.icon,
+ name: node.item.name,
+ href: `/dashboard/lists/${node.item.id}`,
+ level,
+ parent,
+ numChildren: node.children?.length ?? 0,
+ collapsed: !showChildrenOf[node.item.id],
+ numBookmarks: listStats?.get(node.item.id),
+ });
+
+ if (node.children && showChildrenOf[node.item.id]) {
+ node.children.forEach((child) =>
+ traverseTree(
+ child,
+ links,
+ showChildrenOf,
+ listStats,
+ node.item.id,
+ level + 1,
+ ),
+ );
+ }
+}
+
+export default function Lists() {
+ const { colors } = useColorScheme();
+ const [refreshing, setRefreshing] = useState(false);
+ const { data: lists, isPending, error, refetch } = useBookmarkLists();
+ const [showChildrenOf, setShowChildrenOf] = useState<Record<string, boolean>>(
+ {},
+ );
+ const api = useTRPC();
+ const queryClient = useQueryClient();
+ const { data: listStats } = useQuery(api.lists.stats.queryOptions());
+
+ // Check if there are any shared lists
+ const hasSharedLists = useMemo(() => {
+ return lists?.data.some((list) => list.userRole !== "owner") ?? false;
+ }, [lists?.data]);
+
+ // Check if any list has children to determine if we need chevron spacing
+ const hasAnyListsWithChildren = useMemo(() => {
+ const checkForChildren = (node: ZBookmarkListTreeNode): boolean => {
+ if (node.children && node.children.length > 0) return true;
+ return false;
+ };
+ return (
+ Object.values(lists?.root ?? {}).some(checkForChildren) || hasSharedLists
+ );
+ }, [lists?.root, hasSharedLists]);
+
+ useEffect(() => {
+ setRefreshing(isPending);
+ }, [isPending]);
+
+ if (error) {
+ return <FullPageError error={error.message} onRetry={() => refetch()} />;
+ }
+
+ if (!lists) {
+ return <FullPageSpinner />;
+ }
+
+ const onRefresh = () => {
+ queryClient.invalidateQueries(api.lists.list.pathFilter());
+ queryClient.invalidateQueries(api.lists.stats.pathFilter());
+ };
+
+ const links: ListLink[] = [
+ {
+ id: "fav",
+ logo: "⭐️",
+ name: "Favourites",
+ href: "/dashboard/favourites",
+ level: 0,
+ numChildren: 0,
+ collapsed: false,
+ },
+ {
+ id: "arch",
+ logo: "🗄️",
+ name: "Archive",
+ href: "/dashboard/archive",
+ level: 0,
+ numChildren: 0,
+ collapsed: false,
+ },
+ ];
+
+ // Add shared lists section if there are any
+ if (hasSharedLists) {
+ // Count shared lists to determine if section has children
+ const sharedListsCount = Object.values(lists.root).filter(
+ (list) => list.item.userRole !== "owner",
+ ).length;
+
+ links.push({
+ id: "shared-section",
+ logo: "👥",
+ name: "Shared Lists",
+ href: "#",
+ level: 0,
+ numChildren: sharedListsCount,
+ collapsed: !showChildrenOf["shared-section"],
+ isSharedSection: true,
+ });
+
+ // Add shared lists as children if section is expanded
+ if (showChildrenOf["shared-section"]) {
+ Object.values(lists.root).forEach((list) => {
+ if (list.item.userRole !== "owner") {
+ traverseTree(
+ list,
+ links,
+ showChildrenOf,
+ listStats?.stats,
+ "shared-section",
+ 1,
+ );
+ }
+ });
+ }
+ }
+
+ // Add owned lists only
+ Object.values(lists.root).forEach((list) => {
+ if (list.item.userRole === "owner") {
+ traverseTree(list, links, showChildrenOf, listStats?.stats);
+ }
+ });
+
+ return (
+ <>
+ <Stack.Screen
+ options={{
+ headerRight: () => (
+ <HeaderRight
+ openNewListModal={() => router.push("/dashboard/lists/new")}
+ />
+ ),
+ }}
+ />
+ <FlatList
+ className="h-full"
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{
+ gap: 6,
+ paddingBottom: 20,
+ }}
+ renderItem={(l) => (
+ <View
+ className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2"
+ style={{
+ borderCurve: "continuous",
+ ...condProps({
+ condition: l.item.level > 0,
+ props: { marginLeft: l.item.level * 20 },
+ }),
+ }}
+ >
+ {hasAnyListsWithChildren && (
+ <View style={{ width: 32 }}>
+ {l.item.numChildren > 0 && (
+ <Pressable
+ className="pr-2"
+ onPress={() => {
+ setShowChildrenOf((prev) => ({
+ ...prev,
+ [l.item.id]: !prev[l.item.id],
+ }));
+ }}
+ >
+ <ChevronRight
+ color={colors.foreground}
+ style={{
+ transform: [
+ { rotate: l.item.collapsed ? "0deg" : "90deg" },
+ ],
+ }}
+ />
+ </Pressable>
+ )}
+ </View>
+ )}
+
+ {l.item.isSharedSection ? (
+ <Pressable
+ className="flex flex-1 flex-row items-center justify-between"
+ onPress={() => {
+ setShowChildrenOf((prev) => ({
+ ...prev,
+ [l.item.id]: !prev[l.item.id],
+ }));
+ }}
+ >
+ <Text>
+ {l.item.logo} {l.item.name}
+ </Text>
+ </Pressable>
+ ) : (
+ <Link
+ asChild
+ key={l.item.id}
+ href={l.item.href}
+ className="flex-1"
+ >
+ <Pressable className="flex flex-row items-center justify-between">
+ <Text className="shrink">
+ {l.item.logo} {l.item.name}
+ </Text>
+ <View className="flex flex-row items-center">
+ {l.item.numBookmarks !== undefined && (
+ <Text className="mr-2 text-xs text-muted-foreground">
+ {l.item.numBookmarks}
+ </Text>
+ )}
+ <ChevronRight />
+ </View>
+ </Pressable>
+ </Link>
+ )}
+ </View>
+ )}
+ data={links}
+ refreshing={refreshing}
+ onRefresh={onRefresh}
+ />
+ </>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx
new file mode 100644
index 00000000..8c51d5a3
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(settings)/_layout.tsx
@@ -0,0 +1,18 @@
+import { Stack } from "expo-router/stack";
+
+export default function Layout() {
+ return (
+ <Stack
+ screenOptions={{
+ headerLargeTitle: true,
+ headerTransparent: true,
+ headerBlurEffect: "systemMaterial",
+ headerShadowVisible: false,
+ headerLargeTitleShadowVisible: false,
+ headerLargeStyle: { backgroundColor: "transparent" },
+ }}
+ >
+ <Stack.Screen name="index" options={{ title: "Settings" }} />
+ </Stack>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx
new file mode 100644
index 00000000..de17ff5a
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(settings)/index.tsx
@@ -0,0 +1,225 @@
+import { useEffect } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ Switch,
+ View,
+} from "react-native";
+import { Slider } from "react-native-awesome-slider";
+import { useSharedValue } from "react-native-reanimated";
+import Constants from "expo-constants";
+import { Link } from "expo-router";
+import { UserProfileHeader } from "@/components/settings/UserProfileHeader";
+import ChevronRight from "@/components/ui/ChevronRight";
+import { Divider } from "@/components/ui/Divider";
+import { Text } from "@/components/ui/Text";
+import { useServerVersion } from "@/lib/hooks";
+import { useSession } from "@/lib/session";
+import useAppSettings from "@/lib/settings";
+import { useQuery } from "@tanstack/react-query";
+
+import { useTRPC } from "@karakeep/shared-react/trpc";
+
+function SectionHeader({ title }: { title: string }) {
+ return (
+ <Text className="px-4 pb-1 pt-4 text-xs uppercase tracking-wide text-muted-foreground">
+ {title}
+ </Text>
+ );
+}
+
+export default function Settings() {
+ const { logout } = useSession();
+ const {
+ settings,
+ setSettings,
+ isLoading: isSettingsLoading,
+ } = useAppSettings();
+ const api = useTRPC();
+
+ const imageQuality = useSharedValue(0);
+ const imageQualityMin = useSharedValue(0);
+ const imageQualityMax = useSharedValue(100);
+
+ useEffect(() => {
+ imageQuality.value = settings.imageQuality * 100;
+ }, [settings]);
+
+ const { data, error } = useQuery(api.users.whoami.queryOptions());
+ const {
+ data: serverVersion,
+ isLoading: isServerVersionLoading,
+ error: serverVersionError,
+ } = useServerVersion();
+
+ if (error?.data?.code === "UNAUTHORIZED") {
+ logout();
+ }
+
+ return (
+ <ScrollView
+ contentInsetAdjustmentBehavior="automatic"
+ contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 40 }}
+ >
+ <UserProfileHeader
+ image={data?.image}
+ name={data?.name}
+ email={data?.email}
+ />
+
+ <SectionHeader title="Appearance" />
+ <View
+ className="w-full rounded-xl bg-card py-2"
+ style={{ borderCurve: "continuous" }}
+ >
+ <View className="flex flex-row items-center justify-between gap-8 px-4 py-1">
+ <Link asChild href="/dashboard/settings/theme" className="flex-1">
+ <Pressable className="flex flex-row justify-between">
+ <Text>Theme</Text>
+ <View className="flex flex-row items-center gap-2">
+ <Text className="text-muted-foreground">
+ {
+ { light: "Light", dark: "Dark", system: "System" }[
+ settings.theme
+ ]
+ }
+ </Text>
+ <ChevronRight />
+ </View>
+ </Pressable>
+ </Link>
+ </View>
+ <Divider orientation="horizontal" className="mx-6 my-1" />
+ <View className="flex flex-row items-center justify-between gap-8 px-4 py-1">
+ <Link
+ asChild
+ href="/dashboard/settings/bookmark-default-view"
+ className="flex-1"
+ >
+ <Pressable className="flex flex-row justify-between">
+ <Text>Default Bookmark View</Text>
+ <View className="flex flex-row items-center gap-2">
+ {isSettingsLoading ? (
+ <ActivityIndicator size="small" />
+ ) : (
+ <Text className="text-muted-foreground">
+ {settings.defaultBookmarkView === "reader"
+ ? "Reader"
+ : "Browser"}
+ </Text>
+ )}
+ <ChevronRight />
+ </View>
+ </Pressable>
+ </Link>
+ </View>
+ </View>
+
+ <SectionHeader title="Reading" />
+ <View
+ className="w-full rounded-xl bg-card py-2"
+ style={{ borderCurve: "continuous" }}
+ >
+ <View className="flex flex-row items-center justify-between gap-8 px-4 py-1">
+ <Link
+ asChild
+ href="/dashboard/settings/reader-settings"
+ className="flex-1"
+ >
+ <Pressable className="flex flex-row justify-between">
+ <Text>Reader Text Settings</Text>
+ <ChevronRight />
+ </Pressable>
+ </Link>
+ </View>
+ <Divider orientation="horizontal" className="mx-6 my-1" />
+ <View className="flex flex-row items-center justify-between gap-8 px-4 py-1">
+ <Text className="flex-1" numberOfLines={1}>
+ Show notes in bookmark card
+ </Text>
+ <Switch
+ className="shrink-0"
+ value={settings.showNotes}
+ onValueChange={(value) =>
+ setSettings({
+ ...settings,
+ showNotes: value,
+ })
+ }
+ />
+ </View>
+ </View>
+
+ <SectionHeader title="Media" />
+ <View
+ className="w-full rounded-xl bg-card py-2"
+ style={{ borderCurve: "continuous" }}
+ >
+ <View className="flex w-full flex-row items-center justify-between gap-8 px-4 py-1">
+ <Text>Upload Image Quality</Text>
+ <View className="flex flex-1 flex-row items-center justify-center gap-2">
+ <Text className="text-foreground">
+ {Math.round(settings.imageQuality * 100)}%
+ </Text>
+ <Slider
+ onSlidingComplete={(value) =>
+ setSettings({
+ ...settings,
+ imageQuality: Math.round(value) / 100,
+ })
+ }
+ progress={imageQuality}
+ minimumValue={imageQualityMin}
+ maximumValue={imageQualityMax}
+ />
+ </View>
+ </View>
+ </View>
+
+ <SectionHeader title="Account" />
+ <View
+ className="w-full rounded-xl bg-card py-2"
+ style={{ borderCurve: "continuous" }}
+ >
+ <Pressable
+ className="flex flex-row items-center px-4 py-1"
+ onPress={logout}
+ >
+ <Text className="text-destructive">Log Out</Text>
+ </Pressable>
+ </View>
+
+ <SectionHeader title="About" />
+ <View
+ className="w-full rounded-xl bg-card py-2"
+ style={{ borderCurve: "continuous" }}
+ >
+ <View className="flex flex-row items-center justify-between px-4 py-1">
+ <Text className="text-muted-foreground">Server</Text>
+ <Text className="text-sm text-muted-foreground">
+ {isSettingsLoading ? "Loading..." : settings.address}
+ </Text>
+ </View>
+ <Divider orientation="horizontal" className="mx-6 my-1" />
+ <View className="flex flex-row items-center justify-between px-4 py-1">
+ <Text className="text-muted-foreground">App Version</Text>
+ <Text className="text-sm text-muted-foreground">
+ {Constants.expoConfig?.version ?? "unknown"}
+ </Text>
+ </View>
+ <Divider orientation="horizontal" className="mx-6 my-1" />
+ <View className="flex flex-row items-center justify-between px-4 py-1">
+ <Text className="text-muted-foreground">Server Version</Text>
+ <Text className="text-sm text-muted-foreground">
+ {isServerVersionLoading
+ ? "Loading..."
+ : serverVersionError
+ ? "unavailable"
+ : (serverVersion ?? "unknown")}
+ </Text>
+ </View>
+ </View>
+ </ScrollView>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx
new file mode 100644
index 00000000..3b56548f
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(tags)/_layout.tsx
@@ -0,0 +1,18 @@
+import { Stack } from "expo-router/stack";
+
+export default function Layout() {
+ return (
+ <Stack
+ screenOptions={{
+ headerLargeTitle: true,
+ headerTransparent: true,
+ headerBlurEffect: "systemMaterial",
+ headerShadowVisible: false,
+ headerLargeTitleShadowVisible: false,
+ headerLargeStyle: { backgroundColor: "transparent" },
+ }}
+ >
+ <Stack.Screen name="index" options={{ title: "Tags" }} />
+ </Stack>
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx b/apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx
new file mode 100644
index 00000000..4903d681
--- /dev/null
+++ b/apps/mobile/app/dashboard/(tabs)/(tags)/index.tsx
@@ -0,0 +1,140 @@
+import { useEffect, useState } from "react";
+import { FlatList, Pressable, View } from "react-native";
+import { Link } from "expo-router";
+import FullPageError from "@/components/FullPageError";
+import ChevronRight from "@/components/ui/ChevronRight";
+import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { SearchInput } from "@/components/ui/SearchInput";
+import { Text } from "@/components/ui/Text";
+import { useQueryClient } from "@tanstack/react-query";
+
+import { usePaginatedSearchTags } from "@karakeep/shared-react/hooks/tags";
+import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+
+interface TagItem {
+ id: string;
+ name: string;
+ numBookmarks: number;
+ href: string;
+}
+
+export default function Tags() {
+ const [refreshing, setRefreshing] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const api = useTRPC();
+ const queryClient = useQueryClient();
+
+ // Debounce search query to avoid too many API calls
+ const debouncedSearch = useDebounce(searchQuery, 300);
+
+ // Fetch tags sorted by usage (most used first)
+ const {
+ data,
+ isPending,
+ error,
+ refetch,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = usePaginatedSearchTags({
+ limit: 50,
+ sortBy: debouncedSearch ? "relevance" : "usage",
+ nameContains: debouncedSearch,
+ });
+
+ useEffect(() => {
+ setRefreshing(isPending);
+ }, [isPending]);
+
+ if (error) {
+ return <FullPageError error={error.message} onRetry={() => refetch()} />;
+ }
+
+ if (!data) {
+ return <FullPageSpinner />;
+ }
+
+ const onRefresh = () => {
+ queryClient.invalidateQueries(api.tags.list.pathFilter());
+ };
+
+ const tags: TagItem[] = data.tags.map((tag) => ({
+ id: tag.id,
+ name: tag.name,
+ numBookmarks: tag.numBookmarks,
+ href: `/dashboard/tags/${tag.id}`,
+ }));
+
+ const handleLoadMore = () => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ };
+
+ return (
+ <FlatList
+ className="h-full"
+ contentInsetAdjustmentBehavior="automatic"
+ ListHeaderComponent={
+ <SearchInput
+ containerClassName="mx-2 mb-2"
+ placeholder="Search tags..."
+ value={searchQuery}
+ onChangeText={setSearchQuery}
+ />
+ }
+ contentContainerStyle={{
+ gap: 6,
+ paddingBottom: 20,
+ }}
+ renderItem={(item) => (
+ <View
+ className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2"
+ style={{ borderCurve: "continuous" }}
+ >
+ <Link
+ asChild
+ key={item.item.id}
+ href={item.item.href}
+ className="flex-1"
+ >
+ <Pressable className="flex flex-row items-center justify-between">
+ <View className="flex-1">
+ <Text className="font-medium">{item.item.name}</Text>
+ <Text className="text-sm text-muted-foreground">
+ {item.item.numBookmarks}{" "}
+ {item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"}
+ </Text>
+ </View>
+ <ChevronRight />
+ </Pressable>
+ </Link>
+ </View>
+ )}
+ data={tags}
+ refreshing={refreshing}
+ onRefresh={onRefresh}
+ onEndReached={handleLoadMore}
+ onEndReachedThreshold={0.5}
+ ListFooterComponent={
+ isFetchingNextPage ? (
+ <View className="py-4">
+ <Text className="text-center text-muted-foreground">
+ Loading more...
+ </Text>
+ </View>
+ ) : null
+ }
+ ListEmptyComponent={
+ !isPending ? (
+ <View className="py-8">
+ <Text className="text-center text-muted-foreground">
+ No tags yet
+ </Text>
+ </View>
+ ) : null
+ }
+ />
+ );
+}
diff --git a/apps/mobile/app/dashboard/(tabs)/_layout.tsx b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
index 5cc6aa92..fd5798b9 100644
--- a/apps/mobile/app/dashboard/(tabs)/_layout.tsx
+++ b/apps/mobile/app/dashboard/(tabs)/_layout.tsx
@@ -1,69 +1,62 @@
-import React, { useLayoutEffect } from "react";
-import { Tabs, useNavigation } from "expo-router";
-import { StyledTabs } from "@/components/navigation/tabs";
-import { useColorScheme } from "@/lib/useColorScheme";
+import React from "react";
import {
- ClipboardList,
- Highlighter,
- Home,
- Settings,
- Tag,
-} from "lucide-react-native";
+ Icon,
+ Label,
+ NativeTabs,
+ VectorIcon,
+} from "expo-router/unstable-native-tabs";
+import { useColorScheme } from "@/lib/useColorScheme";
+import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
export default function TabLayout() {
const { colors } = useColorScheme();
- const navigation = useNavigation();
- // Hide the header on the parent screen
- useLayoutEffect(() => {
- navigation.setOptions({
- headerShown: false,
- });
- }, [navigation]);
-
return (
- <StyledTabs
- tabBarClassName="bg-gray-100 dark:bg-background"
- sceneClassName="bg-gray-100 dark:bg-background"
- screenOptions={{
- headerShown: false,
- tabBarActiveTintColor: colors.foreground,
- }}
- >
- <Tabs.Screen
- name="index"
- options={{
- title: "Home",
- tabBarIcon: ({ color }) => <Home color={color} />,
- }}
- />
- <Tabs.Screen
- name="lists"
- options={{
- title: "Lists",
- tabBarIcon: ({ color }) => <ClipboardList color={color} />,
- }}
- />
- <Tabs.Screen
- name="tags"
- options={{
- title: "Tags",
- tabBarIcon: ({ color }) => <Tag color={color} />,
- }}
- />
- <Tabs.Screen
- name="highlights"
- options={{
- title: "Highlights",
- tabBarIcon: ({ color }) => <Highlighter color={color} />,
- }}
- />
- <Tabs.Screen
- name="settings"
- options={{
- title: "Settings",
- tabBarIcon: ({ color }) => <Settings color={color} />,
- }}
- />
- </StyledTabs>
+ <NativeTabs backgroundColor={colors.grey6} minimizeBehavior="onScrollDown">
+ <NativeTabs.Trigger name="(home)">
+ <Icon
+ sf="house.fill"
+ androidSrc={
+ <VectorIcon family={MaterialCommunityIcons} name="home" />
+ }
+ />
+ <Label>Home</Label>
+ </NativeTabs.Trigger>
+
+ <NativeTabs.Trigger name="(lists)">
+ <Icon
+ sf="list.clipboard.fill"
+ androidSrc={
+ <VectorIcon family={MaterialCommunityIcons} name="clipboard-list" />
+ }
+ />
+ <Label>Lists</Label>
+ </NativeTabs.Trigger>
+
+ <NativeTabs.Trigger name="(tags)">
+ <Icon
+ sf="tag.fill"
+ androidSrc={<VectorIcon family={MaterialCommunityIcons} name="tag" />}
+ />
+ <Label>Tags</Label>
+ </NativeTabs.Trigger>
+
+ <NativeTabs.Trigger name="(highlights)">
+ <Icon
+ sf="highlighter"
+ androidSrc={
+ <VectorIcon family={MaterialCommunityIcons} name="marker" />
+ }
+ />
+ <Label>Highlights</Label>
+ </NativeTabs.Trigger>
+
+ <NativeTabs.Trigger name="(settings)">
+ <Icon
+ sf="gearshape.fill"
+ androidSrc={<VectorIcon family={MaterialCommunityIcons} name="cog" />}
+ />
+ <Label>Settings</Label>
+ </NativeTabs.Trigger>
+ </NativeTabs>
);
}
diff --git a/apps/mobile/app/dashboard/(tabs)/highlights.tsx b/apps/mobile/app/dashboard/(tabs)/highlights.tsx
deleted file mode 100644
index 7879081b..00000000
--- a/apps/mobile/app/dashboard/(tabs)/highlights.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { View } from "react-native";
-import FullPageError from "@/components/FullPageError";
-import HighlightList from "@/components/highlights/HighlightList";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
-import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import PageTitle from "@/components/ui/PageTitle";
-
-import { api } from "@karakeep/shared-react/trpc";
-
-export default function Highlights() {
- const apiUtils = api.useUtils();
- const {
- data,
- isPending,
- isPlaceholderData,
- error,
- fetchNextPage,
- isFetchingNextPage,
- refetch,
- } = api.highlights.getAll.useInfiniteQuery(
- {},
- {
- initialCursor: null,
- getNextPageParam: (lastPage) => lastPage.nextCursor,
- },
- );
-
- if (error) {
- return <FullPageError error={error.message} onRetry={() => refetch()} />;
- }
-
- if (isPending || !data) {
- return <FullPageSpinner />;
- }
-
- const onRefresh = () => {
- apiUtils.highlights.getAll.invalidate();
- };
-
- return (
- <CustomSafeAreaView>
- <HighlightList
- highlights={data.pages.flatMap((p) => p.highlights)}
- header={
- <View className="flex flex-row justify-between">
- <PageTitle title="Highlights" />
- </View>
- }
- onRefresh={onRefresh}
- fetchNextPage={fetchNextPage}
- isFetchingNextPage={isFetchingNextPage}
- isRefreshing={isPending || isPlaceholderData}
- />
- </CustomSafeAreaView>
- );
-}
diff --git a/apps/mobile/app/dashboard/(tabs)/lists.tsx b/apps/mobile/app/dashboard/(tabs)/lists.tsx
deleted file mode 100644
index e40be1a5..00000000
--- a/apps/mobile/app/dashboard/(tabs)/lists.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-import { useEffect, useState } from "react";
-import { FlatList, Pressable, View } from "react-native";
-import * as Haptics from "expo-haptics";
-import { Link, router } from "expo-router";
-import FullPageError from "@/components/FullPageError";
-import ChevronRight from "@/components/ui/ChevronRight";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
-import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import PageTitle from "@/components/ui/PageTitle";
-import { Text } from "@/components/ui/Text";
-import { api } from "@/lib/trpc";
-import { useColorScheme } from "@/lib/useColorScheme";
-import { condProps } from "@/lib/utils";
-import { Plus } from "lucide-react-native";
-
-import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
-import { ZBookmarkListTreeNode } from "@karakeep/shared/utils/listUtils";
-
-function HeaderRight({ openNewListModal }: { openNewListModal: () => void }) {
- return (
- <Pressable
- className="my-auto px-4"
- onPress={() => {
- Haptics.selectionAsync();
- openNewListModal();
- }}
- >
- <Plus color="rgb(0, 122, 255)" />
- </Pressable>
- );
-}
-
-interface ListLink {
- id: string;
- logo: string;
- name: string;
- href: string;
- level: number;
- parent?: string;
- numChildren: number;
- collapsed: boolean;
-}
-
-function traverseTree(
- node: ZBookmarkListTreeNode,
- links: ListLink[],
- showChildrenOf: Record<string, boolean>,
- parent?: string,
- level = 0,
-) {
- links.push({
- id: node.item.id,
- logo: node.item.icon,
- name: node.item.name,
- href: `/dashboard/lists/${node.item.id}`,
- level,
- parent,
- numChildren: node.children?.length ?? 0,
- collapsed: !showChildrenOf[node.item.id],
- });
-
- if (node.children && showChildrenOf[node.item.id]) {
- node.children.forEach((child) =>
- traverseTree(child, links, showChildrenOf, node.item.id, level + 1),
- );
- }
-}
-
-export default function Lists() {
- const { colors } = useColorScheme();
- const [refreshing, setRefreshing] = useState(false);
- const { data: lists, isPending, error, refetch } = useBookmarkLists();
- const [showChildrenOf, setShowChildrenOf] = useState<Record<string, boolean>>(
- {},
- );
- const apiUtils = api.useUtils();
-
- useEffect(() => {
- setRefreshing(isPending);
- }, [isPending]);
-
- if (error) {
- return <FullPageError error={error.message} onRetry={() => refetch()} />;
- }
-
- if (!lists) {
- return <FullPageSpinner />;
- }
-
- const onRefresh = () => {
- apiUtils.lists.list.invalidate();
- };
-
- const links: ListLink[] = [
- {
- id: "fav",
- logo: "⭐️",
- name: "Favourites",
- href: "/dashboard/favourites",
- level: 0,
- numChildren: 0,
- collapsed: false,
- },
- {
- id: "arch",
- logo: "🗄️",
- name: "Archive",
- href: "/dashboard/archive",
- level: 0,
- numChildren: 0,
- collapsed: false,
- },
- ];
-
- Object.values(lists.root).forEach((list) =>
- traverseTree(list, links, showChildrenOf),
- );
-
- return (
- <CustomSafeAreaView>
- <FlatList
- className="h-full"
- ListHeaderComponent={
- <View className="flex flex-row justify-between">
- <PageTitle title="Lists" />
- <HeaderRight
- openNewListModal={() => router.push("/dashboard/lists/new")}
- />
- </View>
- }
- contentContainerStyle={{
- gap: 5,
- }}
- renderItem={(l) => (
- <View
- className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2"
- style={condProps({
- condition: l.item.level > 0,
- props: { marginLeft: l.item.level * 20 },
- })}
- >
- {l.item.numChildren > 0 && (
- <Pressable
- className="pr-2"
- onPress={() => {
- setShowChildrenOf((prev) => ({
- ...prev,
- [l.item.id]: !prev[l.item.id],
- }));
- }}
- >
- <ChevronRight
- color={colors.foreground}
- style={{
- transform: [
- { rotate: l.item.collapsed ? "0deg" : "90deg" },
- ],
- }}
- />
- </Pressable>
- )}
-
- <Link asChild key={l.item.id} href={l.item.href} className="flex-1">
- <Pressable className="flex flex-row items-center justify-between">
- <Text>
- {l.item.logo} {l.item.name}
- </Text>
- <ChevronRight />
- </Pressable>
- </Link>
- </View>
- )}
- data={links}
- refreshing={refreshing}
- onRefresh={onRefresh}
- />
- </CustomSafeAreaView>
- );
-}
diff --git a/apps/mobile/app/dashboard/(tabs)/settings.tsx b/apps/mobile/app/dashboard/(tabs)/settings.tsx
deleted file mode 100644
index 76216e00..00000000
--- a/apps/mobile/app/dashboard/(tabs)/settings.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import { useEffect } from "react";
-import { ActivityIndicator, Pressable, Switch, View } from "react-native";
-import { Slider } from "react-native-awesome-slider";
-import { useSharedValue } from "react-native-reanimated";
-import { Link } from "expo-router";
-import { Button } from "@/components/ui/Button";
-import ChevronRight from "@/components/ui/ChevronRight";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
-import { Divider } from "@/components/ui/Divider";
-import PageTitle from "@/components/ui/PageTitle";
-import { Text } from "@/components/ui/Text";
-import { useSession } from "@/lib/session";
-import useAppSettings from "@/lib/settings";
-import { api } from "@/lib/trpc";
-
-export default function Dashboard() {
- const { logout } = useSession();
- const {
- settings,
- setSettings,
- isLoading: isSettingsLoading,
- } = useAppSettings();
-
- const imageQuality = useSharedValue(0);
- const imageQualityMin = useSharedValue(0);
- const imageQualityMax = useSharedValue(100);
-
- useEffect(() => {
- imageQuality.value = settings.imageQuality * 100;
- }, [settings]);
-
- const { data, error, isLoading } = api.users.whoami.useQuery();
-
- if (error?.data?.code === "UNAUTHORIZED") {
- logout();
- }
-
- return (
- <CustomSafeAreaView>
- <PageTitle title="Settings" />
- <View className="flex h-full w-full items-center gap-3 px-4 py-2">
- <View className="flex w-full gap-3 rounded-lg bg-card px-4 py-2">
- <Text>{isSettingsLoading ? "Loading ..." : settings.address}</Text>
- <Divider orientation="horizontal" />
- <Text>{isLoading ? "Loading ..." : data?.email}</Text>
- </View>
- <Text className="w-full p-1 text-2xl font-bold text-foreground">
- App Settings
- </Text>
- <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2">
- <Link asChild href="/dashboard/settings/theme" className="flex-1">
- <Pressable className="flex flex-row justify-between">
- <Text>Theme</Text>
- <View className="flex flex-row items-center gap-2">
- <Text className="text-muted-foreground">
- {
- { light: "Light", dark: "Dark", system: "System" }[
- settings.theme
- ]
- }
- </Text>
- <ChevronRight />
- </View>
- </Pressable>
- </Link>
- </View>
- <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2">
- <Link
- asChild
- href="/dashboard/settings/bookmark-default-view"
- className="flex-1"
- >
- <Pressable className="flex flex-row justify-between">
- <Text>Default Bookmark View</Text>
- <View className="flex flex-row items-center gap-2">
- {isSettingsLoading ? (
- <ActivityIndicator size="small" />
- ) : (
- <Text className="text-muted-foreground">
- {settings.defaultBookmarkView === "reader"
- ? "Reader"
- : "Browser"}
- </Text>
- )}
- <ChevronRight />
- </View>
- </Pressable>
- </Link>
- </View>
- <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2">
- <Text>Show note preview in bookmark</Text>
- <Switch
- value={settings.showNotes}
- onValueChange={(value) =>
- setSettings({
- ...settings,
- showNotes: value,
- })
- }
- />
- </View>
- <Text className="w-full p-1 text-2xl font-bold text-foreground">
- Upload Settings
- </Text>
- <View className="flex w-full flex-row items-center justify-between gap-8 rounded-lg bg-card px-4 py-2">
- <Text>Image Quality</Text>
- <View className="flex flex-1 flex-row items-center justify-center gap-2">
- <Text className="text-foreground">
- {Math.round(settings.imageQuality * 100)}%
- </Text>
- <Slider
- onSlidingComplete={(value) =>
- setSettings({
- ...settings,
- imageQuality: Math.round(value) / 100,
- })
- }
- progress={imageQuality}
- minimumValue={imageQualityMin}
- maximumValue={imageQualityMax}
- />
- </View>
- </View>
- <Divider orientation="horizontal" />
- <Button
- androidRootClassName="w-full"
- onPress={logout}
- variant="destructive"
- >
- <Text>Log Out</Text>
- </Button>
- </View>
- </CustomSafeAreaView>
- );
-}
diff --git a/apps/mobile/app/dashboard/(tabs)/tags.tsx b/apps/mobile/app/dashboard/(tabs)/tags.tsx
deleted file mode 100644
index 7f3e4ac7..00000000
--- a/apps/mobile/app/dashboard/(tabs)/tags.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import { useEffect, useState } from "react";
-import { FlatList, Pressable, View } from "react-native";
-import { Link } from "expo-router";
-import FullPageError from "@/components/FullPageError";
-import ChevronRight from "@/components/ui/ChevronRight";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
-import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import PageTitle from "@/components/ui/PageTitle";
-import { SearchInput } from "@/components/ui/SearchInput";
-import { Text } from "@/components/ui/Text";
-import { api } from "@/lib/trpc";
-
-import { usePaginatedSearchTags } from "@karakeep/shared-react/hooks/tags";
-import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
-
-interface TagItem {
- id: string;
- name: string;
- numBookmarks: number;
- href: string;
-}
-
-export default function Tags() {
- const [refreshing, setRefreshing] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const apiUtils = api.useUtils();
-
- // Debounce search query to avoid too many API calls
- const debouncedSearch = useDebounce(searchQuery, 300);
-
- // Fetch tags sorted by usage (most used first)
- const {
- data,
- isPending,
- error,
- refetch,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = usePaginatedSearchTags({
- limit: 50,
- sortBy: debouncedSearch ? "relevance" : "usage",
- nameContains: debouncedSearch,
- });
-
- useEffect(() => {
- setRefreshing(isPending);
- }, [isPending]);
-
- if (error) {
- return <FullPageError error={error.message} onRetry={() => refetch()} />;
- }
-
- if (!data) {
- return <FullPageSpinner />;
- }
-
- const onRefresh = () => {
- apiUtils.tags.list.invalidate();
- };
-
- const tags: TagItem[] = data.tags.map((tag) => ({
- id: tag.id,
- name: tag.name,
- numBookmarks: tag.numBookmarks,
- href: `/dashboard/tags/${tag.id}`,
- }));
-
- const handleLoadMore = () => {
- if (hasNextPage && !isFetchingNextPage) {
- fetchNextPage();
- }
- };
-
- return (
- <CustomSafeAreaView>
- <FlatList
- className="h-full"
- ListHeaderComponent={
- <View>
- <PageTitle title="Tags" />
- <SearchInput
- containerClassName="mx-2 mb-2"
- placeholder="Search tags..."
- value={searchQuery}
- onChangeText={setSearchQuery}
- />
- </View>
- }
- contentContainerStyle={{
- gap: 5,
- }}
- renderItem={(item) => (
- <View className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2">
- <Link
- asChild
- key={item.item.id}
- href={item.item.href}
- className="flex-1"
- >
- <Pressable className="flex flex-row justify-between">
- <View className="flex-1">
- <Text className="font-medium">{item.item.name}</Text>
- <Text className="text-sm text-muted-foreground">
- {item.item.numBookmarks}{" "}
- {item.item.numBookmarks === 1 ? "bookmark" : "bookmarks"}
- </Text>
- </View>
- <ChevronRight />
- </Pressable>
- </Link>
- </View>
- )}
- data={tags}
- refreshing={refreshing}
- onRefresh={onRefresh}
- onEndReached={handleLoadMore}
- onEndReachedThreshold={0.5}
- ListFooterComponent={
- isFetchingNextPage ? (
- <View className="py-4">
- <Text className="text-center text-muted-foreground">
- Loading more...
- </Text>
- </View>
- ) : null
- }
- ListEmptyComponent={
- !isPending ? (
- <View className="py-8">
- <Text className="text-center text-muted-foreground">
- No tags yet
- </Text>
- </View>
- ) : null
- }
- />
- </CustomSafeAreaView>
- );
-}
diff --git a/apps/mobile/app/dashboard/_layout.tsx b/apps/mobile/app/dashboard/_layout.tsx
index eb1cbe4b..78fd7c60 100644
--- a/apps/mobile/app/dashboard/_layout.tsx
+++ b/apps/mobile/app/dashboard/_layout.tsx
@@ -70,8 +70,10 @@ export default function Dashboard() {
options={{
headerTitle: "New Bookmark",
headerBackTitle: "Back",
- headerTransparent: true,
- presentation: "modal",
+ headerTransparent: false,
+ presentation: "formSheet",
+ sheetGrabberVisible: true,
+ sheetAllowedDetents: [0.35, 0.7],
}}
/>
<Stack.Screen
@@ -110,6 +112,15 @@ export default function Dashboard() {
}}
/>
<Stack.Screen
+ name="lists/[slug]/edit"
+ options={{
+ headerTitle: "Edit List",
+ headerBackTitle: "Back",
+ headerTransparent: true,
+ presentation: "modal",
+ }}
+ />
+ <Stack.Screen
name="archive"
options={{
headerTitle: "",
@@ -144,6 +155,14 @@ export default function Dashboard() {
headerBackTitle: "Back",
}}
/>
+ <Stack.Screen
+ name="settings/reader-settings"
+ options={{
+ title: "Reader Settings",
+ headerTitle: "Reader Settings",
+ headerBackTitle: "Back",
+ }}
+ />
</StyledStack>
);
}
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx
index 7bf0f118..efb82b1e 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/index.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
-import { KeyboardAvoidingView } from "react-native";
+import { KeyboardAvoidingView, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { Stack, useLocalSearchParams } from "expo-router";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import BookmarkAssetView from "@/components/bookmarks/BookmarkAssetView";
import BookmarkLinkTypeSelector, {
BookmarkLinkType,
@@ -12,17 +12,21 @@ import BottomActions from "@/components/bookmarks/BottomActions";
import FullPageError from "@/components/FullPageError";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
import useAppSettings from "@/lib/settings";
-import { api } from "@/lib/trpc";
+import { useQuery } from "@tanstack/react-query";
+import { Settings } from "lucide-react-native";
import { useColorScheme } from "nativewind";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
export default function BookmarkView() {
const insets = useSafeAreaInsets();
+ const router = useRouter();
const { slug } = useLocalSearchParams();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const { settings } = useAppSettings();
+ const api = useTRPC();
const [bookmarkLinkType, setBookmarkLinkType] = useState<BookmarkLinkType>(
settings.defaultBookmarkView,
@@ -36,10 +40,12 @@ export default function BookmarkView() {
data: bookmark,
error,
refetch,
- } = api.bookmarks.getBookmark.useQuery({
- bookmarkId: slug,
- includeContent: false,
- });
+ } = useQuery(
+ api.bookmarks.getBookmark.queryOptions({
+ bookmarkId: slug,
+ includeContent: false,
+ }),
+ );
if (error) {
return <FullPageError error={error.message} onRetry={refetch} />;
@@ -87,11 +93,22 @@ export default function BookmarkView() {
headerTintColor: isDark ? "#fff" : "#000",
headerRight: () =>
bookmark.content.type === BookmarkTypes.LINK ? (
- <BookmarkLinkTypeSelector
- type={bookmarkLinkType}
- onChange={(type) => setBookmarkLinkType(type)}
- bookmark={bookmark}
- />
+ <View className="flex-row items-center gap-3">
+ {bookmarkLinkType === "reader" && (
+ <Pressable
+ onPress={() =>
+ router.push("/dashboard/settings/reader-settings")
+ }
+ >
+ <Settings size={20} color="gray" />
+ </Pressable>
+ )}
+ <BookmarkLinkTypeSelector
+ type={bookmarkLinkType}
+ onChange={(type) => setBookmarkLinkType(type)}
+ bookmark={bookmark}
+ />
+ </View>
) : undefined,
}}
/>
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
index c4b76aef..744b7f7d 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/info.tsx
@@ -477,14 +477,14 @@ const ViewBookmarkPage = () => {
</Button>
</View>
)}
- <View className="gap-2">
- <Text className="items-center text-center">
+ <View className="gap-1">
+ <Text className="text-center text-xs text-muted-foreground">
Created {bookmark.createdAt.toLocaleString()}
</Text>
{bookmark.modifiedAt &&
bookmark.modifiedAt.getTime() !==
bookmark.createdAt.getTime() && (
- <Text className="items-center text-center">
+ <Text className="text-center text-xs text-muted-foreground">
Modified {bookmark.modifiedAt.toLocaleString()}
</Text>
)}
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx
index c502c07f..1070207b 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_lists.tsx
@@ -1,19 +1,22 @@
import React from "react";
-import { FlatList, Pressable, View } from "react-native";
+import { ActivityIndicator, FlatList, Pressable, View } from "react-native";
import Checkbox from "expo-checkbox";
import { useLocalSearchParams } from "expo-router";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
+import { useQuery } from "@tanstack/react-query";
+import type { ZBookmarkList } from "@karakeep/shared/types/lists";
import {
useAddBookmarkToList,
useBookmarkLists,
useRemoveBookmarkFromList,
} from "@karakeep/shared-react/hooks/lists";
-import { api } from "@karakeep/shared-react/trpc";
+import { useTRPC } from "@karakeep/shared-react/trpc";
const ListPickerPage = () => {
+ const api = useTRPC();
const { slug: bookmarkId } = useLocalSearchParams();
if (typeof bookmarkId !== "string") {
throw new Error("Unexpected param type");
@@ -26,17 +29,24 @@ const ListPickerPage = () => {
showProgress: false,
});
};
- const { data: existingLists } = api.lists.getListsOfBookmark.useQuery(
- {
- bookmarkId,
- },
- {
- select: (data) => new Set(data.lists.map((l) => l.id)),
- },
+ const { data: existingLists } = useQuery(
+ api.lists.getListsOfBookmark.queryOptions(
+ {
+ bookmarkId,
+ },
+ {
+ select: (data: { lists: ZBookmarkList[] }) =>
+ new Set(data.lists.map((l) => l.id)),
+ },
+ ),
);
const { data } = useBookmarkLists();
- const { mutate: addToList } = useAddBookmarkToList({
+ const {
+ mutate: addToList,
+ isPending: isAddingToList,
+ variables: addVariables,
+ } = useAddBookmarkToList({
onSuccess: () => {
toast({
message: `The bookmark has been added to the list!`,
@@ -46,7 +56,11 @@ const ListPickerPage = () => {
onError,
});
- const { mutate: removeToList } = useRemoveBookmarkFromList({
+ const {
+ mutate: removeToList,
+ isPending: isRemovingFromList,
+ variables: removeVariables,
+ } = useRemoveBookmarkFromList({
onSuccess: () => {
toast({
message: `The bookmark has been removed from the list!`,
@@ -67,6 +81,13 @@ const ListPickerPage = () => {
}
};
+ const isListLoading = (listId: string) => {
+ return (
+ (isAddingToList && addVariables?.listId === listId) ||
+ (isRemovingFromList && removeVariables?.listId === listId)
+ );
+ };
+
const { allPaths } = data ?? {};
// Filter out lists where user is a viewer (can't add/remove bookmarks)
const filteredPaths = allPaths?.filter(
@@ -77,30 +98,41 @@ const ListPickerPage = () => {
<FlatList
className="h-full"
contentContainerStyle={{
- gap: 5,
+ gap: 6,
+ }}
+ renderItem={(l) => {
+ const listId = l.item[l.item.length - 1].id;
+ const isLoading = isListLoading(listId);
+ const isChecked = existingLists && existingLists.has(listId);
+
+ return (
+ <View className="mx-2 flex flex-row items-center rounded-xl bg-card px-4 py-2">
+ <Pressable
+ key={listId}
+ onPress={() => !isLoading && toggleList(listId)}
+ disabled={isLoading}
+ className="flex w-full flex-row items-center justify-between"
+ >
+ <Text className="shrink">
+ {l.item
+ .map((item) => `${item.icon} ${item.name}`)
+ .join(" / ")}
+ </Text>
+ {isLoading ? (
+ <ActivityIndicator size="small" />
+ ) : (
+ <Checkbox
+ value={isChecked}
+ onValueChange={() => {
+ toggleList(listId);
+ }}
+ disabled={isLoading}
+ />
+ )}
+ </Pressable>
+ </View>
+ );
}}
- renderItem={(l) => (
- <View className="mx-2 flex flex-row items-center rounded-xl border border-input bg-card px-4 py-2">
- <Pressable
- key={l.item[l.item.length - 1].id}
- onPress={() => toggleList(l.item[l.item.length - 1].id)}
- className="flex w-full flex-row justify-between"
- >
- <Text>
- {l.item.map((item) => `${item.icon} ${item.name}`).join(" / ")}
- </Text>
- <Checkbox
- value={
- existingLists &&
- existingLists.has(l.item[l.item.length - 1].id)
- }
- onValueChange={() => {
- toggleList(l.item[l.item.length - 1].id);
- }}
- />
- </Pressable>
- </View>
- )}
data={filteredPaths}
/>
</CustomSafeAreaView>
diff --git a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx
index a4575b27..64d057f2 100644
--- a/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/[slug]/manage_tags.tsx
@@ -6,17 +6,19 @@ import FullPageSpinner from "@/components/ui/FullPageSpinner";
import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
import { useColorScheme } from "@/lib/useColorScheme";
+import { useQuery } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react-native";
import {
useAutoRefreshingBookmarkQuery,
useUpdateBookmarkTags,
} from "@karakeep/shared-react/hooks/bookmarks";
-import { api } from "@karakeep/shared-react/trpc";
+import { useTRPC } from "@karakeep/shared-react/trpc";
const NEW_TAG_ID = "new-tag";
const ListPickerPage = () => {
+ const api = useTRPC();
const { colors } = useColorScheme();
const { slug: bookmarkId } = useLocalSearchParams();
@@ -34,22 +36,24 @@ const ListPickerPage = () => {
});
};
- const { data: allTags, isPending: isAllTagsPending } = api.tags.list.useQuery(
- {},
- {
- select: React.useCallback(
- (data: { tags: { id: string; name: string }[] }) => {
- return data.tags
- .map((t) => ({
- id: t.id,
- name: t.name,
- lowered: t.name.toLowerCase(),
- }))
- .sort((a, b) => a.lowered.localeCompare(b.lowered));
- },
- [],
- ),
- },
+ const { data: allTags, isPending: isAllTagsPending } = useQuery(
+ api.tags.list.queryOptions(
+ {},
+ {
+ select: React.useCallback(
+ (data: { tags: { id: string; name: string }[] }) => {
+ return data.tags
+ .map((t) => ({
+ id: t.id,
+ name: t.name,
+ lowered: t.name.toLowerCase(),
+ }))
+ .sort((a, b) => a.lowered.localeCompare(b.lowered));
+ },
+ [],
+ ),
+ },
+ ),
);
const { data: existingTags } = useAutoRefreshingBookmarkQuery({
bookmarkId,
@@ -165,7 +169,7 @@ const ListPickerPage = () => {
contentInsetAdjustmentBehavior="automatic"
keyExtractor={(t) => t.id}
contentContainerStyle={{
- gap: 5,
+ gap: 6,
}}
SectionSeparatorComponent={() => <View className="h-1" />}
sections={[
@@ -207,7 +211,7 @@ const ListPickerPage = () => {
})
}
>
- <View className="mx-2 flex flex-row items-center gap-2 rounded-xl border border-input bg-card px-4 py-2">
+ <View className="mx-2 flex flex-row items-center gap-2 rounded-xl bg-card px-4 py-2">
{t.section.title == "Existing Tags" && (
<Check color={colors.foreground} />
)}
diff --git a/apps/mobile/app/dashboard/bookmarks/new.tsx b/apps/mobile/app/dashboard/bookmarks/new.tsx
index 25882d7f..f7be22e1 100644
--- a/apps/mobile/app/dashboard/bookmarks/new.tsx
+++ b/apps/mobile/app/dashboard/bookmarks/new.tsx
@@ -2,7 +2,6 @@ import React, { useState } from "react";
import { View } from "react-native";
import { router } from "expo-router";
import { Button } from "@/components/ui/Button";
-import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import { Input } from "@/components/ui/Input";
import { Text } from "@/components/ui/Text";
import { useToast } from "@/components/ui/Toast";
@@ -59,25 +58,23 @@ const NoteEditorPage = () => {
};
return (
- <CustomSafeAreaView>
- <View className="gap-2 px-4">
- {error && (
- <Text className="w-full text-center text-red-500">{error}</Text>
- )}
- <Input
- onChangeText={setText}
- className="bg-card"
- multiline
- placeholder="What's on your mind?"
- autoFocus
- autoCapitalize={"none"}
- textAlignVertical="top"
- />
- <Button onPress={onSubmit} disabled={isPending}>
- <Text>Save</Text>
- </Button>
- </View>
- </CustomSafeAreaView>
+ <View className="flex-1 gap-2 px-4 pt-4">
+ {error && (
+ <Text className="w-full text-center text-red-500">{error}</Text>
+ )}
+ <Input
+ onChangeText={setText}
+ className="bg-card"
+ multiline
+ placeholder="What's on your mind?"
+ autoFocus
+ autoCapitalize={"none"}
+ textAlignVertical="top"
+ />
+ <Button onPress={onSubmit} disabled={isPending}>
+ <Text>Save</Text>
+ </Button>
+ </View>
);
};
diff --git a/apps/mobile/app/dashboard/lists/[slug]/edit.tsx b/apps/mobile/app/dashboard/lists/[slug]/edit.tsx
new file mode 100644
index 00000000..c1103b4d
--- /dev/null
+++ b/apps/mobile/app/dashboard/lists/[slug]/edit.tsx
@@ -0,0 +1,156 @@
+import { useEffect, useState } from "react";
+import { View } from "react-native";
+import { router, useLocalSearchParams } from "expo-router";
+import { Button } from "@/components/ui/Button";
+import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
+import FullPageSpinner from "@/components/ui/FullPageSpinner";
+import { Input } from "@/components/ui/Input";
+import { Text } from "@/components/ui/Text";
+import { useToast } from "@/components/ui/Toast";
+import { useQuery } from "@tanstack/react-query";
+
+import { useEditBookmarkList } from "@karakeep/shared-react/hooks/lists";
+import { useTRPC } from "@karakeep/shared-react/trpc";
+
+const EditListPage = () => {
+ const { slug: listId } = useLocalSearchParams<{ slug?: string | string[] }>();
+ const [text, setText] = useState("");
+ const [query, setQuery] = useState("");
+ const { toast } = useToast();
+ const api = useTRPC();
+ const { mutate, isPending: editIsPending } = useEditBookmarkList({
+ onSuccess: () => {
+ dismiss();
+ },
+ onError: (error) => {
+ // Extract error message from the error object
+ let errorMessage = "Something went wrong";
+ if (error.data?.zodError) {
+ errorMessage = Object.values(error.data.zodError.fieldErrors)
+ .flat()
+ .join("\n");
+ } else if (error.message) {
+ errorMessage = error.message;
+ }
+ toast({
+ message: errorMessage,
+ variant: "destructive",
+ });
+ },
+ });
+
+ if (typeof listId !== "string") {
+ throw new Error("Unexpected param type");
+ }
+
+ const { data: list, isLoading: fetchIsPending } = useQuery(
+ api.lists.get.queryOptions({
+ listId,
+ }),
+ );
+
+ const dismiss = () => {
+ router.back();
+ };
+
+ useEffect(() => {
+ if (!list) return;
+ setText(list.name ?? "");
+ setQuery(list.query ?? "");
+ }, [list?.id, list?.query, list?.name]);
+
+ const onSubmit = () => {
+ if (!text.trim()) {
+ toast({ message: "List name can't be empty", variant: "destructive" });
+ return;
+ }
+
+ if (list?.type === "smart" && !query.trim()) {
+ toast({
+ message: "Smart lists must have a search query",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ mutate({
+ listId,
+ name: text.trim(),
+ query: list?.type === "smart" ? query.trim() : undefined,
+ });
+ };
+
+ const isPending = fetchIsPending || editIsPending;
+
+ return (
+ <CustomSafeAreaView>
+ {isPending ? (
+ <FullPageSpinner />
+ ) : (
+ <View className="gap-3 px-4">
+ {/* List Type Info - not editable */}
+ <View className="gap-2">
+ <Text className="text-sm text-muted-foreground">List Type</Text>
+ <View className="flex flex-row gap-2">
+ <View className="flex-1">
+ <Button
+ variant={list?.type === "manual" ? "primary" : "secondary"}
+ disabled
+ >
+ <Text>Manual</Text>
+ </Button>
+ </View>
+ <View className="flex-1">
+ <Button
+ variant={list?.type === "smart" ? "primary" : "secondary"}
+ disabled
+ >
+ <Text>Smart</Text>
+ </Button>
+ </View>
+ </View>
+ </View>
+
+ {/* List Name */}
+ <View className="flex flex-row items-center gap-1">
+ <Text className="shrink p-2">{list?.icon || "🚀"}</Text>
+ <Input
+ className="flex-1 bg-card"
+ onChangeText={setText}
+ value={text}
+ placeholder="List Name"
+ autoFocus
+ autoCapitalize={"none"}
+ />
+ </View>
+
+ {/* Smart List Query Input */}
+ {list?.type === "smart" && (
+ <View className="gap-2">
+ <Text className="text-sm text-muted-foreground">
+ Search Query
+ </Text>
+ <Input
+ className="bg-card"
+ onChangeText={setQuery}
+ value={query}
+ placeholder="e.g., #important OR list:work"
+ autoCapitalize={"none"}
+ />
+ <Text className="text-xs italic text-muted-foreground">
+ Smart lists automatically show bookmarks matching your search
+ query
+ </Text>
+ </View>
+ )}
+
+ <Button disabled={isPending} onPress={onSubmit}>
+ <Text>Save</Text>
+ </Button>
+ </View>
+ )}
+ </CustomSafeAreaView>
+ );
+};
+
+export default EditListPage;
diff --git a/apps/mobile/app/dashboard/lists/[slug].tsx b/apps/mobile/app/dashboard/lists/[slug]/index.tsx
index e7aab443..763df65e 100644
--- a/apps/mobile/app/dashboard/lists/[slug].tsx
+++ b/apps/mobile/app/dashboard/lists/[slug]/index.tsx
@@ -5,14 +5,16 @@ import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList";
import FullPageError from "@/components/FullPageError";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import { api } from "@/lib/trpc";
import { MenuView } from "@react-native-menu/menu";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { Ellipsis } from "lucide-react-native";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { ZBookmarkList } from "@karakeep/shared/types/lists";
export default function ListView() {
const { slug } = useLocalSearchParams();
+ const api = useTRPC();
if (typeof slug !== "string") {
throw new Error("Unexpected param type");
}
@@ -20,7 +22,7 @@ export default function ListView() {
data: list,
error,
refetch,
- } = api.lists.get.useQuery({ listId: slug });
+ } = useQuery(api.lists.get.queryOptions({ listId: slug }));
return (
<CustomSafeAreaView>
@@ -58,17 +60,22 @@ function ListActionsMenu({
listId: string;
role: ZBookmarkList["userRole"];
}) {
- const { mutate: deleteList } = api.lists.delete.useMutation({
- onSuccess: () => {
- router.replace("/dashboard/lists");
- },
- });
+ const api = useTRPC();
+ const { mutate: deleteList } = useMutation(
+ api.lists.delete.mutationOptions({
+ onSuccess: () => {
+ router.replace("/dashboard/lists");
+ },
+ }),
+ );
- const { mutate: leaveList } = api.lists.leaveList.useMutation({
- onSuccess: () => {
- router.replace("/dashboard/lists");
- },
- });
+ const { mutate: leaveList } = useMutation(
+ api.lists.leaveList.mutationOptions({
+ onSuccess: () => {
+ router.replace("/dashboard/lists");
+ },
+ }),
+ );
const handleDelete = () => {
Alert.alert("Delete List", "Are you sure you want to delete this list?", [
@@ -96,10 +103,24 @@ function ListActionsMenu({
]);
};
+ const handleEdit = () => {
+ router.push({
+ pathname: "/dashboard/lists/[slug]/edit",
+ params: { slug: listId },
+ });
+ };
+
return (
<MenuView
actions={[
{
+ id: "edit",
+ title: "Edit List",
+ attributes: {
+ hidden: role !== "owner",
+ },
+ },
+ {
id: "delete",
title: "Delete List",
attributes: {
@@ -122,9 +143,10 @@ function ListActionsMenu({
onPressAction={({ nativeEvent }) => {
if (nativeEvent.event === "delete") {
handleDelete();
- }
- if (nativeEvent.event === "leave") {
+ } else if (nativeEvent.event === "leave") {
handleLeave();
+ } else if (nativeEvent.event === "edit") {
+ handleEdit();
}
}}
shouldOpenOnLongPress={false}
diff --git a/apps/mobile/app/dashboard/lists/new.tsx b/apps/mobile/app/dashboard/lists/new.tsx
index af51ed15..bada46f2 100644
--- a/apps/mobile/app/dashboard/lists/new.tsx
+++ b/apps/mobile/app/dashboard/lists/new.tsx
@@ -66,20 +66,22 @@ const NewListPage = () => {
<View className="gap-2">
<Text className="text-sm text-muted-foreground">List Type</Text>
<View className="flex flex-row gap-2">
- <Button
- variant={listType === "manual" ? "primary" : "secondary"}
- onPress={() => setListType("manual")}
- className="flex-1"
- >
- <Text>Manual</Text>
- </Button>
- <Button
- variant={listType === "smart" ? "primary" : "secondary"}
- onPress={() => setListType("smart")}
- className="flex-1"
- >
- <Text>Smart</Text>
- </Button>
+ <View className="flex-1">
+ <Button
+ variant={listType === "manual" ? "primary" : "secondary"}
+ onPress={() => setListType("manual")}
+ >
+ <Text>Manual</Text>
+ </Button>
+ </View>
+ <View className="flex-1">
+ <Button
+ variant={listType === "smart" ? "primary" : "secondary"}
+ onPress={() => setListType("smart")}
+ >
+ <Text>Smart</Text>
+ </Button>
+ </View>
</View>
</View>
diff --git a/apps/mobile/app/dashboard/search.tsx b/apps/mobile/app/dashboard/search.tsx
index ab89ce8d..d43f1aef 100644
--- a/apps/mobile/app/dashboard/search.tsx
+++ b/apps/mobile/app/dashboard/search.tsx
@@ -7,12 +7,16 @@ import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
import { SearchInput } from "@/components/ui/SearchInput";
import { Text } from "@/components/ui/Text";
-import { api } from "@/lib/trpc";
import AsyncStorage from "@react-native-async-storage/async-storage";
-import { keepPreviousData } from "@tanstack/react-query";
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
import { useSearchHistory } from "@karakeep/shared-react/hooks/search-history";
import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
+import { useTRPC } from "@karakeep/shared-react/trpc";
const MAX_DISPLAY_SUGGESTIONS = 5;
@@ -29,7 +33,12 @@ export default function Search() {
removeItem: (k: string) => AsyncStorage.removeItem(k),
});
- const onRefresh = api.useUtils().bookmarks.searchBookmarks.invalidate;
+ const api = useTRPC();
+ const queryClient = useQueryClient();
+
+ const onRefresh = () => {
+ queryClient.invalidateQueries(api.bookmarks.searchBookmarks.pathFilter());
+ };
const {
data,
@@ -39,14 +48,16 @@ export default function Search() {
isFetching,
fetchNextPage,
isFetchingNextPage,
- } = api.bookmarks.searchBookmarks.useInfiniteQuery(
- { text: query },
- {
- placeholderData: keepPreviousData,
- gcTime: 0,
- initialCursor: null,
- getNextPageParam: (lastPage) => lastPage.nextCursor,
- },
+ } = useInfiniteQuery(
+ api.bookmarks.searchBookmarks.infiniteQueryOptions(
+ { text: query },
+ {
+ placeholderData: keepPreviousData,
+ gcTime: 0,
+ initialCursor: null,
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ ),
);
const filteredHistory = useMemo(() => {
diff --git a/apps/mobile/app/dashboard/settings/reader-settings.tsx b/apps/mobile/app/dashboard/settings/reader-settings.tsx
new file mode 100644
index 00000000..30ad54b9
--- /dev/null
+++ b/apps/mobile/app/dashboard/settings/reader-settings.tsx
@@ -0,0 +1,301 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Pressable, ScrollView, View } from "react-native";
+import { Slider } from "react-native-awesome-slider";
+import { runOnJS, useSharedValue } from "react-native-reanimated";
+import {
+ ReaderPreview,
+ ReaderPreviewRef,
+} from "@/components/reader/ReaderPreview";
+import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
+import { Divider } from "@/components/ui/Divider";
+import { Text } from "@/components/ui/Text";
+import { MOBILE_FONT_FAMILIES, useReaderSettings } from "@/lib/readerSettings";
+import { useColorScheme } from "@/lib/useColorScheme";
+import { Check, RotateCcw } from "lucide-react-native";
+
+import {
+ formatFontFamily,
+ formatFontSize,
+ formatLineHeight,
+ READER_SETTING_CONSTRAINTS,
+} from "@karakeep/shared/types/readers";
+import { ZReaderFontFamily } from "@karakeep/shared/types/users";
+
+export default function ReaderSettingsPage() {
+ const { isDarkColorScheme: isDark } = useColorScheme();
+
+ const {
+ settings,
+ localOverrides,
+ hasLocalOverrides,
+ hasServerDefaults,
+ updateLocal,
+ clearAllLocal,
+ saveAsDefault,
+ clearAllDefaults,
+ } = useReaderSettings();
+
+ const {
+ fontSize: effectiveFontSize,
+ lineHeight: effectiveLineHeight,
+ fontFamily: effectiveFontFamily,
+ } = settings;
+
+ // Shared values for sliders
+ const fontSizeProgress = useSharedValue<number>(effectiveFontSize);
+ const fontSizeMin = useSharedValue<number>(
+ READER_SETTING_CONSTRAINTS.fontSize.min,
+ );
+ const fontSizeMax = useSharedValue<number>(
+ READER_SETTING_CONSTRAINTS.fontSize.max,
+ );
+
+ const lineHeightProgress = useSharedValue<number>(effectiveLineHeight);
+ const lineHeightMin = useSharedValue<number>(
+ READER_SETTING_CONSTRAINTS.lineHeight.min,
+ );
+ const lineHeightMax = useSharedValue<number>(
+ READER_SETTING_CONSTRAINTS.lineHeight.max,
+ );
+
+ // Display values for showing rounded values while dragging
+ const [displayFontSize, setDisplayFontSize] = useState(effectiveFontSize);
+ const [displayLineHeight, setDisplayLineHeight] =
+ useState(effectiveLineHeight);
+
+ // Refs to track latest display values (avoids stale closures in callbacks)
+ const displayFontSizeRef = useRef(displayFontSize);
+ displayFontSizeRef.current = displayFontSize;
+ const displayLineHeightRef = useRef(displayLineHeight);
+ displayLineHeightRef.current = displayLineHeight;
+
+ // Ref for the WebView preview component
+ const previewRef = useRef<ReaderPreviewRef>(null);
+
+ // Functions to update preview styles via IPC (called from worklets via runOnJS)
+ const updatePreviewFontSize = useCallback(
+ (fontSize: number) => {
+ setDisplayFontSize(fontSize);
+ previewRef.current?.updateStyles(
+ effectiveFontFamily,
+ fontSize,
+ displayLineHeightRef.current,
+ );
+ },
+ [effectiveFontFamily],
+ );
+
+ const updatePreviewLineHeight = useCallback(
+ (lineHeight: number) => {
+ setDisplayLineHeight(lineHeight);
+ previewRef.current?.updateStyles(
+ effectiveFontFamily,
+ displayFontSizeRef.current,
+ lineHeight,
+ );
+ },
+ [effectiveFontFamily],
+ );
+
+ // Sync slider progress and display values with effective settings
+ useEffect(() => {
+ fontSizeProgress.value = effectiveFontSize;
+ setDisplayFontSize(effectiveFontSize);
+ }, [effectiveFontSize]);
+
+ useEffect(() => {
+ lineHeightProgress.value = effectiveLineHeight;
+ setDisplayLineHeight(effectiveLineHeight);
+ }, [effectiveLineHeight]);
+
+ const handleFontFamilyChange = (fontFamily: ZReaderFontFamily) => {
+ updateLocal({ fontFamily });
+ // Update preview immediately with new font family
+ previewRef.current?.updateStyles(
+ fontFamily,
+ displayFontSize,
+ displayLineHeight,
+ );
+ };
+
+ const handleFontSizeChange = (value: number) => {
+ updateLocal({ fontSize: Math.round(value) });
+ };
+
+ const handleLineHeightChange = (value: number) => {
+ updateLocal({ lineHeight: Math.round(value * 10) / 10 });
+ };
+
+ const handleSaveAsDefault = () => {
+ saveAsDefault();
+ // Note: clearAllLocal is called automatically in the shared hook's onSuccess
+ };
+
+ const handleClearLocalOverrides = () => {
+ clearAllLocal();
+ };
+
+ const handleClearServerDefaults = () => {
+ clearAllDefaults();
+ };
+
+ const fontFamilyOptions: ZReaderFontFamily[] = ["serif", "sans", "mono"];
+
+ return (
+ <CustomSafeAreaView>
+ <ScrollView
+ className="w-full"
+ contentContainerClassName="items-center gap-4 px-4 py-2"
+ >
+ {/* Font Family Selection */}
+ <View className="w-full">
+ <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground">
+ Font Family
+ {localOverrides.fontFamily !== undefined && (
+ <Text className="text-blue-500"> (local)</Text>
+ )}
+ </Text>
+ <View className="w-full rounded-lg bg-card px-4 py-2">
+ {fontFamilyOptions.map((fontFamily, index) => {
+ const isChecked = effectiveFontFamily === fontFamily;
+ return (
+ <View key={fontFamily}>
+ <Pressable
+ onPress={() => handleFontFamilyChange(fontFamily)}
+ className="flex flex-row items-center justify-between py-2"
+ >
+ <Text
+ style={{
+ fontFamily: MOBILE_FONT_FAMILIES[fontFamily],
+ }}
+ >
+ {formatFontFamily(fontFamily)}
+ </Text>
+ {isChecked && <Check color="rgb(0, 122, 255)" />}
+ </Pressable>
+ {index < fontFamilyOptions.length - 1 && (
+ <Divider orientation="horizontal" className="h-0.5" />
+ )}
+ </View>
+ );
+ })}
+ </View>
+ </View>
+
+ {/* Font Size */}
+ <View className="w-full">
+ <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground">
+ Font Size ({formatFontSize(displayFontSize)})
+ {localOverrides.fontSize !== undefined && (
+ <Text className="text-blue-500"> (local)</Text>
+ )}
+ </Text>
+ <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3">
+ <Text className="text-muted-foreground">
+ {READER_SETTING_CONSTRAINTS.fontSize.min}
+ </Text>
+ <View className="flex-1">
+ <Slider
+ progress={fontSizeProgress}
+ minimumValue={fontSizeMin}
+ maximumValue={fontSizeMax}
+ renderBubble={() => null}
+ onValueChange={(value) => {
+ "worklet";
+ runOnJS(updatePreviewFontSize)(Math.round(value));
+ }}
+ onSlidingComplete={(value) =>
+ handleFontSizeChange(Math.round(value))
+ }
+ />
+ </View>
+ <Text className="text-muted-foreground">
+ {READER_SETTING_CONSTRAINTS.fontSize.max}
+ </Text>
+ </View>
+ </View>
+
+ {/* Line Height */}
+ <View className="w-full">
+ <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground">
+ Line Height ({formatLineHeight(displayLineHeight)})
+ {localOverrides.lineHeight !== undefined && (
+ <Text className="text-blue-500"> (local)</Text>
+ )}
+ </Text>
+ <View className="flex w-full flex-row items-center gap-3 rounded-lg bg-card px-4 py-3">
+ <Text className="text-muted-foreground">
+ {READER_SETTING_CONSTRAINTS.lineHeight.min}
+ </Text>
+ <View className="flex-1">
+ <Slider
+ progress={lineHeightProgress}
+ minimumValue={lineHeightMin}
+ maximumValue={lineHeightMax}
+ renderBubble={() => null}
+ onValueChange={(value) => {
+ "worklet";
+ runOnJS(updatePreviewLineHeight)(Math.round(value * 10) / 10);
+ }}
+ onSlidingComplete={handleLineHeightChange}
+ />
+ </View>
+ <Text className="text-muted-foreground">
+ {READER_SETTING_CONSTRAINTS.lineHeight.max}
+ </Text>
+ </View>
+ </View>
+
+ {/* Preview */}
+ <View className="w-full">
+ <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground">
+ Preview
+ </Text>
+ <ReaderPreview
+ ref={previewRef}
+ initialFontFamily={effectiveFontFamily}
+ initialFontSize={effectiveFontSize}
+ initialLineHeight={effectiveLineHeight}
+ />
+ </View>
+
+ <Divider orientation="horizontal" className="my-2 w-full" />
+
+ {/* Save as Default */}
+ <Pressable
+ onPress={handleSaveAsDefault}
+ disabled={!hasLocalOverrides}
+ className="w-full rounded-lg bg-card px-4 py-3"
+ >
+ <Text
+ className={`text-center ${hasLocalOverrides ? "text-blue-500" : "text-muted-foreground"}`}
+ >
+ Save as Default (All Devices)
+ </Text>
+ </Pressable>
+
+ {/* Clear Local */}
+ {hasLocalOverrides && (
+ <Pressable
+ onPress={handleClearLocalOverrides}
+ className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3"
+ >
+ <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} />
+ <Text className="text-muted-foreground">Clear Local Overrides</Text>
+ </Pressable>
+ )}
+
+ {/* Clear Server */}
+ {hasServerDefaults && (
+ <Pressable
+ onPress={handleClearServerDefaults}
+ className="flex w-full flex-row items-center justify-center gap-2 rounded-lg bg-card px-4 py-3"
+ >
+ <RotateCcw size={16} color={isDark ? "#9ca3af" : "#6b7280"} />
+ <Text className="text-muted-foreground">Clear Server Defaults</Text>
+ </Pressable>
+ )}
+ </ScrollView>
+ </CustomSafeAreaView>
+ );
+}
diff --git a/apps/mobile/app/dashboard/tags/[slug].tsx b/apps/mobile/app/dashboard/tags/[slug].tsx
index 3f294328..328c65d0 100644
--- a/apps/mobile/app/dashboard/tags/[slug].tsx
+++ b/apps/mobile/app/dashboard/tags/[slug].tsx
@@ -4,15 +4,22 @@ import UpdatingBookmarkList from "@/components/bookmarks/UpdatingBookmarkList";
import FullPageError from "@/components/FullPageError";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import { api } from "@/lib/trpc";
+import { useQuery } from "@tanstack/react-query";
+
+import { useTRPC } from "@karakeep/shared-react/trpc";
export default function TagView() {
const { slug } = useLocalSearchParams();
+ const api = useTRPC();
if (typeof slug !== "string") {
throw new Error("Unexpected param type");
}
- const { data: tag, error, refetch } = api.tags.get.useQuery({ tagId: slug });
+ const {
+ data: tag,
+ error,
+ refetch,
+ } = useQuery(api.tags.get.queryOptions({ tagId: slug }));
return (
<CustomSafeAreaView>
diff --git a/apps/mobile/app/server-address.tsx b/apps/mobile/app/server-address.tsx
new file mode 100644
index 00000000..3b7b01d4
--- /dev/null
+++ b/apps/mobile/app/server-address.tsx
@@ -0,0 +1,231 @@
+import { useState } from "react";
+import { Pressable, View } from "react-native";
+import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
+import { Stack, useRouter } from "expo-router";
+import { Button } from "@/components/ui/Button";
+import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
+import { Input } from "@/components/ui/Input";
+import PageTitle from "@/components/ui/PageTitle";
+import { Text } from "@/components/ui/Text";
+import useAppSettings from "@/lib/settings";
+import { Plus, Trash2 } from "lucide-react-native";
+import { useColorScheme } from "nativewind";
+
+export default function ServerAddress() {
+ const router = useRouter();
+ const { colorScheme } = useColorScheme();
+ const iconColor = colorScheme === "dark" ? "#d1d5db" : "#374151";
+ const { settings, setSettings } = useAppSettings();
+ const [address, setAddress] = useState(
+ settings.address ?? "https://cloud.karakeep.app",
+ );
+ const [error, setError] = useState<string | undefined>();
+
+ // Custom headers state
+ const [headers, setHeaders] = useState<{ key: string; value: string }[]>(
+ Object.entries(settings.customHeaders || {}).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ );
+ const [newHeaderKey, setNewHeaderKey] = useState("");
+ const [newHeaderValue, setNewHeaderValue] = useState("");
+
+ const handleAddHeader = () => {
+ if (!newHeaderKey.trim() || !newHeaderValue.trim()) {
+ return;
+ }
+
+ // Check if header already exists
+ const existingIndex = headers.findIndex((h) => h.key === newHeaderKey);
+ if (existingIndex >= 0) {
+ // Update existing header
+ const updatedHeaders = [...headers];
+ updatedHeaders[existingIndex].value = newHeaderValue;
+ setHeaders(updatedHeaders);
+ } else {
+ // Add new header
+ setHeaders([...headers, { key: newHeaderKey, value: newHeaderValue }]);
+ }
+
+ setNewHeaderKey("");
+ setNewHeaderValue("");
+ };
+
+ const handleRemoveHeader = (index: number) => {
+ setHeaders(headers.filter((_, i) => i !== index));
+ };
+
+ const handleSave = () => {
+ // Validate the address
+ if (!address.trim()) {
+ setError("Server address is required");
+ return;
+ }
+
+ if (!address.startsWith("http://") && !address.startsWith("https://")) {
+ setError("Server address must start with http:// or https://");
+ return;
+ }
+
+ // Convert headers array to object
+ const headersObject = headers.reduce(
+ (acc, { key, value }) => {
+ if (key.trim() && value.trim()) {
+ acc[key] = value;
+ }
+ return acc;
+ },
+ {} as Record<string, string>,
+ );
+
+ // Remove trailing slash and save
+ const cleanedAddress = address.trim().replace(/\/$/, "");
+ setSettings({
+ ...settings,
+ address: cleanedAddress,
+ customHeaders: headersObject,
+ });
+ router.back();
+ };
+
+ return (
+ <CustomSafeAreaView>
+ <Stack.Screen
+ options={{
+ title: "Server Address",
+ headerTransparent: true,
+ }}
+ />
+ <PageTitle title="Server Address" />
+ <KeyboardAwareScrollView
+ className="w-full flex-1"
+ contentContainerClassName="items-center gap-4 px-4 py-4"
+ bottomOffset={20}
+ keyboardShouldPersistTaps="handled"
+ >
+ {/* Error Message */}
+ {error && (
+ <View className="w-full rounded-lg bg-red-50 p-3 dark:bg-red-950">
+ <Text className="text-center text-sm text-red-600 dark:text-red-400">
+ {error}
+ </Text>
+ </View>
+ )}
+
+ {/* Server Address Section */}
+ <View className="w-full">
+ <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground">
+ Server URL
+ </Text>
+ <View className="w-full gap-3 rounded-lg bg-card px-4 py-4">
+ <Text className="text-sm text-muted-foreground">
+ Enter the URL of your Karakeep server
+ </Text>
+ <Input
+ placeholder="https://cloud.karakeep.app"
+ value={address}
+ onChangeText={(text) => {
+ setAddress(text);
+ setError(undefined);
+ }}
+ autoCapitalize="none"
+ keyboardType="url"
+ autoFocus
+ inputClasses="bg-background"
+ />
+ <Text className="text-xs text-muted-foreground">
+ Must start with http:// or https://
+ </Text>
+ </View>
+ </View>
+
+ {/* Custom Headers Section */}
+ <View className="w-full">
+ <Text className="mb-2 px-1 text-sm font-medium text-muted-foreground">
+ Custom Headers
+ {headers.length > 0 && (
+ <Text className="text-muted-foreground"> ({headers.length})</Text>
+ )}
+ </Text>
+ <View className="w-full gap-3 rounded-lg bg-card px-4 py-4">
+ <Text className="text-sm text-muted-foreground">
+ Add custom HTTP headers for API requests
+ </Text>
+
+ {/* Existing Headers List */}
+ {headers.length === 0 ? (
+ <View className="py-4">
+ <Text className="text-center text-sm text-muted-foreground">
+ No custom headers configured
+ </Text>
+ </View>
+ ) : (
+ <View className="gap-2">
+ {headers.map((header, index) => (
+ <View
+ key={index}
+ className="flex-row items-center gap-3 rounded-lg border border-border bg-background p-3"
+ >
+ <View className="flex-1 gap-1">
+ <Text className="text-sm font-semibold">
+ {header.key}
+ </Text>
+ <Text
+ className="text-xs text-muted-foreground"
+ numberOfLines={1}
+ >
+ {header.value}
+ </Text>
+ </View>
+ <Pressable
+ onPress={() => handleRemoveHeader(index)}
+ className="rounded-md p-2"
+ hitSlop={8}
+ >
+ <Trash2 size={18} color="#ef4444" />
+ </Pressable>
+ </View>
+ ))}
+ </View>
+ )}
+
+ {/* Add New Header Form */}
+ <View className="gap-2 border-t border-border pt-4">
+ <Text className="text-sm font-medium">Add New Header</Text>
+ <Input
+ placeholder="Header Name (e.g., X-Custom-Header)"
+ value={newHeaderKey}
+ onChangeText={setNewHeaderKey}
+ autoCapitalize="none"
+ inputClasses="bg-background"
+ />
+ <Input
+ placeholder="Header Value"
+ value={newHeaderValue}
+ onChangeText={setNewHeaderValue}
+ autoCapitalize="none"
+ inputClasses="bg-background"
+ />
+ <Button
+ variant="secondary"
+ onPress={handleAddHeader}
+ disabled={!newHeaderKey.trim() || !newHeaderValue.trim()}
+ >
+ <Plus size={16} color={iconColor} />
+ <Text className="text-sm">Add Header</Text>
+ </Button>
+ </View>
+ </View>
+ </View>
+ </KeyboardAwareScrollView>
+
+ {/* Fixed Save Button */}
+ <View className="border-t border-border bg-background px-4 py-3">
+ <Button onPress={handleSave} className="w-full">
+ <Text className="font-semibold">Save</Text>
+ </Button>
+ </View>
+ </CustomSafeAreaView>
+ );
+}
diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx
index 3e2b6bfb..355f32ef 100644
--- a/apps/mobile/app/sharing.tsx
+++ b/apps/mobile/app/sharing.tsx
@@ -1,14 +1,19 @@
import { useEffect, useRef, useState } from "react";
-import { ActivityIndicator, Pressable, View } from "react-native";
+import { Pressable, View } from "react-native";
+import Animated, { FadeIn } from "react-native-reanimated";
import { useRouter } from "expo-router";
import { useShareIntentContext } from "expo-share-intent";
+import ErrorAnimation from "@/components/sharing/ErrorAnimation";
+import LoadingAnimation from "@/components/sharing/LoadingAnimation";
+import SuccessAnimation from "@/components/sharing/SuccessAnimation";
import { Button } from "@/components/ui/Button";
import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
-import { api } from "@/lib/trpc";
import { useUploadAsset } from "@/lib/upload";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
type Mode =
@@ -18,8 +23,11 @@ type Mode =
| { type: "error" };
function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) {
+ const api = useTRPC();
+ const queryClient = useQueryClient();
+
const onSaved = (d: ZBookmark & { alreadyExists: boolean }) => {
- invalidateAllBookmarks();
+ queryClient.invalidateQueries(api.bookmarks.getBookmarks.pathFilter());
setMode({
type: d.alreadyExists ? "alreadyExists" : "success",
bookmarkId: d.id,
@@ -36,9 +44,6 @@ function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) {
},
});
- const invalidateAllBookmarks =
- api.useUtils().bookmarks.getBookmarks.invalidate;
-
useEffect(() => {
if (isLoading) {
return;
@@ -77,62 +82,23 @@ function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) {
}
}, [isLoading]);
- const { mutate, isPending } = api.bookmarks.createBookmark.useMutation({
- onSuccess: onSaved,
- onError: () => {
- setMode({ type: "error" });
- },
- });
-
- return (
- <View className="flex flex-row gap-3">
- <Text variant="largeTitle">Hoarding</Text>
- <ActivityIndicator />
- </View>
+ const { mutate, isPending } = useMutation(
+ api.bookmarks.createBookmark.mutationOptions({
+ onSuccess: onSaved,
+ onError: () => {
+ setMode({ type: "error" });
+ },
+ }),
);
+
+ return null;
}
export default function Sharing() {
const router = useRouter();
const [mode, setMode] = useState<Mode>({ type: "idle" });
- const autoCloseTimeoutId = useRef<number | null>(null);
-
- let comp;
- switch (mode.type) {
- case "idle": {
- comp = <SaveBookmark setMode={setMode} />;
- break;
- }
- case "alreadyExists":
- case "success": {
- comp = (
- <View className="items-center gap-4">
- <Text variant="largeTitle">
- {mode.type === "alreadyExists" ? "Already Hoarded!" : "Hoarded!"}
- </Text>
- <Button
- onPress={() => {
- router.replace(`/dashboard/bookmarks/${mode.bookmarkId}/info`);
- if (autoCloseTimeoutId.current) {
- clearTimeout(autoCloseTimeoutId.current);
- }
- }}
- >
- <Text>Manage</Text>
- </Button>
- <Pressable onPress={() => router.replace("dashboard")}>
- <Text className="text-muted-foreground">Dismiss</Text>
- </Pressable>
- </View>
- );
- break;
- }
- case "error": {
- comp = <Text variant="largeTitle">Error!</Text>;
- break;
- }
- }
+ const autoCloseTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto dismiss the modal after saving.
useEffect(() => {
@@ -140,14 +106,118 @@ export default function Sharing() {
return;
}
- autoCloseTimeoutId.current = setTimeout(() => {
- router.replace("dashboard");
- }, 2000);
+ autoCloseTimeoutId.current = setTimeout(
+ () => {
+ router.replace("dashboard");
+ },
+ mode.type === "error" ? 3000 : 2500,
+ );
- return () => clearTimeout(autoCloseTimeoutId.current!);
+ return () => {
+ if (autoCloseTimeoutId.current) {
+ clearTimeout(autoCloseTimeoutId.current);
+ }
+ };
}, [mode.type]);
+ const handleManage = () => {
+ if (mode.type === "success" || mode.type === "alreadyExists") {
+ router.replace(`/dashboard/bookmarks/${mode.bookmarkId}/info`);
+ if (autoCloseTimeoutId.current) {
+ clearTimeout(autoCloseTimeoutId.current);
+ }
+ }
+ };
+
+ const handleDismiss = () => {
+ if (autoCloseTimeoutId.current) {
+ clearTimeout(autoCloseTimeoutId.current);
+ }
+ router.replace("dashboard");
+ };
+
return (
- <View className="flex-1 items-center justify-center gap-4">{comp}</View>
+ <View className="flex-1 items-center justify-center bg-background">
+ {/* Hidden component that handles the save logic */}
+ {mode.type === "idle" && <SaveBookmark setMode={setMode} />}
+
+ {/* Loading State */}
+ {mode.type === "idle" && <LoadingAnimation />}
+
+ {/* Success State */}
+ {(mode.type === "success" || mode.type === "alreadyExists") && (
+ <Animated.View
+ entering={FadeIn.duration(200)}
+ className="items-center gap-6"
+ >
+ <SuccessAnimation isAlreadyExists={mode.type === "alreadyExists"} />
+
+ <Animated.View
+ entering={FadeIn.delay(400).duration(300)}
+ className="items-center gap-2"
+ >
+ <Text variant="title1" className="font-semibold text-foreground">
+ {mode.type === "alreadyExists" ? "Already Hoarded!" : "Hoarded!"}
+ </Text>
+ <Text variant="body" className="text-muted-foreground">
+ {mode.type === "alreadyExists"
+ ? "This item was saved before"
+ : "Saved to your collection"}
+ </Text>
+ </Animated.View>
+
+ <Animated.View
+ entering={FadeIn.delay(600).duration(300)}
+ className="items-center gap-3 pt-2"
+ >
+ <Button onPress={handleManage} variant="primary" size="lg">
+ <Text className="font-medium text-primary-foreground">
+ Manage
+ </Text>
+ </Button>
+ <Pressable
+ onPress={handleDismiss}
+ className="px-4 py-2 active:opacity-60"
+ >
+ <Text className="text-muted-foreground">Dismiss</Text>
+ </Pressable>
+ </Animated.View>
+ </Animated.View>
+ )}
+
+ {/* Error State */}
+ {mode.type === "error" && (
+ <Animated.View
+ entering={FadeIn.duration(200)}
+ className="items-center gap-6"
+ >
+ <ErrorAnimation />
+
+ <Animated.View
+ entering={FadeIn.delay(300).duration(300)}
+ className="items-center gap-2"
+ >
+ <Text variant="title1" className="font-semibold text-foreground">
+ Oops!
+ </Text>
+ <Text variant="body" className="text-muted-foreground">
+ Something went wrong
+ </Text>
+ </Animated.View>
+
+ <Animated.View
+ entering={FadeIn.delay(500).duration(300)}
+ className="items-center gap-3 pt-2"
+ >
+ <Pressable
+ onPress={handleDismiss}
+ className="px-4 py-2 active:opacity-60"
+ >
+ <Text className="text-muted-foreground">Dismiss</Text>
+ </Pressable>
+ </Animated.View>
+ </Animated.View>
+ )}
+ </View>
);
}
diff --git a/apps/mobile/app/signin.tsx b/apps/mobile/app/signin.tsx
index 6a554f89..94a57822 100644
--- a/apps/mobile/app/signin.tsx
+++ b/apps/mobile/app/signin.tsx
@@ -7,15 +7,17 @@ import {
View,
} from "react-native";
import { Redirect, useRouter } from "expo-router";
-import { CustomHeadersModal } from "@/components/CustomHeadersModal";
+import * as WebBrowser from "expo-web-browser";
import Logo from "@/components/Logo";
import { TailwindResolver } from "@/components/TailwindResolver";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
-import { api } from "@/lib/trpc";
-import { Bug, Check, Edit3 } from "lucide-react-native";
+import { useMutation } from "@tanstack/react-query";
+import { Bug, Edit3 } from "lucide-react-native";
+
+import { useTRPC } from "@karakeep/shared-react/trpc";
enum LoginType {
Password,
@@ -25,15 +27,9 @@ enum LoginType {
export default function Signin() {
const { settings, setSettings } = useAppSettings();
const router = useRouter();
-
+ const api = useTRPC();
const [error, setError] = useState<string | undefined>();
const [loginType, setLoginType] = useState<LoginType>(LoginType.Password);
- const [isEditingServerAddress, setIsEditingServerAddress] = useState(false);
- const [tempServerAddress, setTempServerAddress] = useState(
- settings.address ?? "https://cloud.karakeep.app",
- );
- const [isCustomHeadersModalVisible, setIsCustomHeadersModalVisible] =
- useState(false);
const emailRef = useRef<string>("");
const passwordRef = useRef<string>("");
@@ -50,51 +46,58 @@ export default function Signin() {
};
const { mutate: login, isPending: userNamePasswordRequestIsPending } =
- api.apiKeys.exchange.useMutation({
- onSuccess: (resp) => {
- setSettings({ ...settings, apiKey: resp.key, apiKeyId: resp.id });
- },
- onError: (e) => {
- if (e.data?.code === "UNAUTHORIZED") {
- setError("Wrong username or password");
- } else {
- setError(`${e.message}`);
- }
- },
- });
+ useMutation(
+ api.apiKeys.exchange.mutationOptions({
+ onSuccess: (resp) => {
+ setSettings({ ...settings, apiKey: resp.key, apiKeyId: resp.id });
+ },
+ onError: (e) => {
+ if (e.data?.code === "UNAUTHORIZED") {
+ setError("Wrong username or password");
+ } else {
+ setError(`${e.message}`);
+ }
+ },
+ }),
+ );
const { mutate: validateApiKey, isPending: apiKeyValueRequestIsPending } =
- api.apiKeys.validate.useMutation({
- onSuccess: () => {
- const apiKey = apiKeyRef.current;
- setSettings({ ...settings, apiKey: apiKey });
- },
- onError: (e) => {
- if (e.data?.code === "UNAUTHORIZED") {
- setError("Invalid API key");
- } else {
- setError(`${e.message}`);
- }
- },
- });
+ useMutation(
+ api.apiKeys.validate.mutationOptions({
+ onSuccess: () => {
+ const apiKey = apiKeyRef.current;
+ setSettings({ ...settings, apiKey: apiKey });
+ },
+ onError: (e) => {
+ if (e.data?.code === "UNAUTHORIZED") {
+ setError("Invalid API key");
+ } else {
+ setError(`${e.message}`);
+ }
+ },
+ }),
+ );
if (settings.apiKey) {
return <Redirect href="dashboard" />;
}
- const handleSaveCustomHeaders = (headers: Record<string, string>) => {
- setSettings({ ...settings, customHeaders: headers });
+ const onSignUp = async () => {
+ const serverAddress = settings.address ?? "https://cloud.karakeep.app";
+ const signupUrl = `${serverAddress}/signup?redirectUrl=${encodeURIComponent("karakeep://signin")}`;
+
+ await WebBrowser.openAuthSessionAsync(signupUrl, "karakeep://signin");
};
const onSignin = () => {
- if (!tempServerAddress) {
+ if (!settings.address) {
setError("Server address is required");
return;
}
if (
- !tempServerAddress.startsWith("http://") &&
- !tempServerAddress.startsWith("https://")
+ !settings.address.startsWith("http://") &&
+ !settings.address.startsWith("https://")
) {
setError("Server address must start with http:// or https://");
return;
@@ -137,71 +140,23 @@ export default function Signin() {
)}
<View className="gap-2">
<Text className="font-bold">Server Address</Text>
- {!isEditingServerAddress ? (
- <View className="flex-row items-center gap-2">
- <View className="flex-1 rounded-md border border-border bg-card px-3 py-2">
- <Text>{tempServerAddress}</Text>
- </View>
- <Button
- size="icon"
- variant="secondary"
- onPress={() => {
- setIsEditingServerAddress(true);
- }}
- >
- <TailwindResolver
- comp={(styles) => (
- <Edit3 size={16} color={styles?.color?.toString()} />
- )}
- className="color-foreground"
- />
- </Button>
+ <View className="flex-row items-center gap-2">
+ <View className="flex-1 rounded-md border border-border bg-card px-3 py-2">
+ <Text>{settings.address ?? "https://cloud.karakeep.app"}</Text>
</View>
- ) : (
- <View className="flex-row items-center gap-2">
- <Input
- className="flex-1"
- inputClasses="bg-card"
- placeholder="Server Address"
- value={tempServerAddress}
- autoCapitalize="none"
- keyboardType="url"
- onChangeText={setTempServerAddress}
- autoFocus
+ <Button
+ size="icon"
+ variant="secondary"
+ onPress={() => router.push("/server-address")}
+ >
+ <TailwindResolver
+ comp={(styles) => (
+ <Edit3 size={16} color={styles?.color?.toString()} />
+ )}
+ className="color-foreground"
/>
- <Button
- size="icon"
- variant="primary"
- onPress={() => {
- if (tempServerAddress.trim()) {
- setSettings({
- ...settings,
- address: tempServerAddress.trim().replace(/\/$/, ""),
- });
- }
- setIsEditingServerAddress(false);
- }}
- >
- <TailwindResolver
- comp={(styles) => (
- <Check size={16} color={styles?.color?.toString()} />
- )}
- className="text-white"
- />
- </Button>
- </View>
- )}
- <Pressable
- onPress={() => setIsCustomHeadersModalVisible(true)}
- className="mt-1"
- >
- <Text className="text-xs text-gray-500 underline">
- Configure Custom Headers{" "}
- {settings.customHeaders &&
- Object.keys(settings.customHeaders).length > 0 &&
- `(${Object.keys(settings.customHeaders).length})`}
- </Text>
- </Pressable>
+ </Button>
+ </View>
</View>
{loginType === LoginType.Password && (
<>
@@ -280,14 +235,14 @@ export default function Signin() {
: "Use password instead?"}
</Text>
</Pressable>
+ <Pressable onPress={onSignUp}>
+ <Text className="mt-4 text-center text-gray-500">
+ Don&apos;t have an account?{" "}
+ <Text className="text-foreground underline">Sign Up</Text>
+ </Text>
+ </Pressable>
</View>
</TouchableWithoutFeedback>
- <CustomHeadersModal
- visible={isCustomHeadersModalVisible}
- customHeaders={settings.customHeaders || {}}
- onClose={() => setIsCustomHeadersModalVisible(false)}
- onSave={handleSaveCustomHeaders}
- />
</KeyboardAvoidingView>
);
}
diff --git a/apps/mobile/app/test-connection.tsx b/apps/mobile/app/test-connection.tsx
index 4cf69fcf..7e1d5779 100644
--- a/apps/mobile/app/test-connection.tsx
+++ b/apps/mobile/app/test-connection.tsx
@@ -1,9 +1,8 @@
import React from "react";
-import { Platform, View } from "react-native";
+import { Platform, ScrollView, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { Button } from "@/components/ui/Button";
import CustomSafeAreaView from "@/components/ui/CustomSafeAreaView";
-import { Input } from "@/components/ui/Input";
import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
import { buildApiHeaders, cn } from "@/lib/utils";
@@ -81,7 +80,7 @@ export default function TestConnection() {
return (
<CustomSafeAreaView>
- <View className="m-4 flex flex-col gap-2 p-2">
+ <View className="m-4 flex flex-1 flex-col gap-2 p-2">
<Button
className="w-full"
onPress={async () => {
@@ -121,17 +120,15 @@ export default function TestConnection() {
{status === "error" && "Connection test failed"}
</Text>
</View>
- <Input
- className="h-fit leading-6"
- style={{
- fontFamily: Platform.OS === "ios" ? "Courier New" : "monospace",
- }}
- multiline={true}
- scrollEnabled={true}
- value={text}
- onChangeText={setText}
- editable={false}
- />
+ <ScrollView className="border-1 border-md h-64 flex-1 border-border bg-input p-2 leading-6">
+ <Text
+ style={{
+ fontFamily: Platform.OS === "ios" ? "Courier New" : "monospace",
+ }}
+ >
+ {text}
+ </Text>
+ </ScrollView>
</View>
</CustomSafeAreaView>
);
diff --git a/apps/mobile/components/SplashScreenController.tsx b/apps/mobile/components/SplashScreenController.tsx
new file mode 100644
index 00000000..52c80415
--- /dev/null
+++ b/apps/mobile/components/SplashScreenController.tsx
@@ -0,0 +1,14 @@
+import { SplashScreen } from "expo-router";
+import useAppSettings from "@/lib/settings";
+
+SplashScreen.preventAutoHideAsync();
+
+export default function SplashScreenController() {
+ const { isLoading } = useAppSettings();
+
+ if (!isLoading) {
+ SplashScreen.hide();
+ }
+
+ return null;
+}
diff --git a/apps/mobile/components/bookmarks/BookmarkAssetImage.tsx b/apps/mobile/components/bookmarks/BookmarkAssetImage.tsx
index 8fa88c8b..35726e4b 100644
--- a/apps/mobile/components/bookmarks/BookmarkAssetImage.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkAssetImage.tsx
@@ -1,14 +1,25 @@
-import { Image } from "react-native";
+import { View } from "react-native";
+import { Image, ImageContentFit } from "expo-image";
import { useAssetUrl } from "@/lib/hooks";
export default function BookmarkAssetImage({
assetId,
className,
+ contentFit = "cover",
}: {
assetId: string;
className: string;
+ contentFit?: ImageContentFit;
}) {
const assetSource = useAssetUrl(assetId);
- return <Image source={assetSource} className={className} />;
+ return (
+ <View className={className}>
+ <Image
+ source={assetSource}
+ style={{ width: "100%", height: "100%" }}
+ contentFit={contentFit}
+ />
+ </View>
+ );
}
diff --git a/apps/mobile/components/bookmarks/BookmarkAssetView.tsx b/apps/mobile/components/bookmarks/BookmarkAssetView.tsx
index 5fe2f470..e009a027 100644
--- a/apps/mobile/components/bookmarks/BookmarkAssetView.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkAssetView.tsx
@@ -48,7 +48,7 @@ export default function BookmarkAssetView({
<Pressable onPress={() => setImageZoom(true)}>
<BookmarkAssetImage
assetId={bookmark.content.assetId}
- className="h-56 min-h-56 w-full object-cover"
+ className="h-56 min-h-56 w-full"
/>
</Pressable>
</View>
diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx
index 922951e5..060aada9 100644
--- a/apps/mobile/components/bookmarks/BookmarkCard.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx
@@ -1,7 +1,6 @@
import {
ActivityIndicator,
Alert,
- Image,
Platform,
Pressable,
ScrollView,
@@ -9,14 +8,16 @@ import {
View,
} from "react-native";
import * as Clipboard from "expo-clipboard";
-import * as FileSystem from "expo-file-system";
+import * as FileSystem from "expo-file-system/legacy";
import * as Haptics from "expo-haptics";
+import { Image } from "expo-image";
import { router, useRouter } from "expo-router";
import * as Sharing from "expo-sharing";
import { Text } from "@/components/ui/Text";
import useAppSettings from "@/lib/settings";
-import { api } from "@/lib/trpc";
+import { buildApiHeaders } from "@/lib/utils";
import { MenuView } from "@react-native-menu/menu";
+import { useQuery } from "@tanstack/react-query";
import { Ellipsis, ShareIcon, Star } from "lucide-react-native";
import type { ZBookmark } from "@karakeep/shared/types/bookmarks";
@@ -25,6 +26,7 @@ import {
useUpdateBookmark,
} from "@karakeep/shared-react/hooks/bookmarks";
import { useWhoAmI } from "@karakeep/shared-react/hooks/users";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import {
getBookmarkLinkImageUrl,
@@ -124,9 +126,10 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) {
assetUrl,
fileUri,
{
- headers: {
- Authorization: `Bearer ${settings.apiKey}`,
- },
+ headers: buildApiHeaders(
+ settings.apiKey,
+ settings.customHeaders,
+ ),
},
);
@@ -314,29 +317,36 @@ function LinkCard({
let imageComp;
if (imageUrl) {
imageComp = (
- <Image
- source={
- imageUrl.localAsset
- ? {
- uri: `${settings.address}${imageUrl.url}`,
- headers: {
- Authorization: `Bearer ${settings.apiKey}`,
- },
- }
- : {
- uri: imageUrl.url,
- }
- }
- className="h-56 min-h-56 w-full object-cover"
- />
+ <View className="h-56 min-h-56 w-full">
+ <Image
+ source={
+ imageUrl.localAsset
+ ? {
+ uri: `${settings.address}${imageUrl.url}`,
+ headers: buildApiHeaders(
+ settings.apiKey,
+ settings.customHeaders,
+ ),
+ }
+ : {
+ uri: imageUrl.url,
+ }
+ }
+ style={{ width: "100%", height: "100%" }}
+ contentFit="cover"
+ />
+ </View>
);
} else {
imageComp = (
- <Image
- // oxlint-disable-next-line no-require-imports
- source={require("@/assets/blur.jpeg")}
- className="h-56 w-full rounded-t-lg"
- />
+ <View className="h-56 w-full overflow-hidden rounded-t-lg">
+ <Image
+ // oxlint-disable-next-line no-require-imports
+ source={require("@/assets/blur.jpeg")}
+ style={{ width: "100%", height: "100%" }}
+ contentFit="cover"
+ />
+ </View>
);
}
@@ -345,7 +355,8 @@ function LinkCard({
<Pressable onPress={onOpenBookmark}>{imageComp}</Pressable>
<View className="flex gap-2 p-2">
<Text
- className="line-clamp-2 text-xl font-bold text-foreground"
+ className="text-xl font-bold text-foreground"
+ numberOfLines={2}
onPress={onOpenBookmark}
>
{bookmark.title ?? bookmark.content.title ?? parsedUrl.host}
@@ -360,7 +371,9 @@ function LinkCard({
<TagList bookmark={bookmark} />
<Divider orientation="vertical" className="mt-2 h-0.5 w-full" />
<View className="mt-2 flex flex-row justify-between px-2 pb-2">
- <Text className="my-auto line-clamp-1">{parsedUrl.host}</Text>
+ <Text className="my-auto" numberOfLines={1}>
+ {parsedUrl.host}
+ </Text>
<ActionBar bookmark={bookmark} />
</View>
</View>
@@ -388,7 +401,7 @@ function TextCard({
<View className="flex max-h-96 gap-2 p-2">
<Pressable onPress={onOpenBookmark}>
{bookmark.title && (
- <Text className="line-clamp-2 text-xl font-bold">
+ <Text className="text-xl font-bold" numberOfLines={2}>
{bookmark.title}
</Text>
)}
@@ -437,13 +450,15 @@ function AssetCard({
<Pressable onPress={onOpenBookmark}>
<BookmarkAssetImage
assetId={assetImage}
- className="h-56 min-h-56 w-full object-cover"
+ className="h-56 min-h-56 w-full"
/>
</Pressable>
<View className="flex gap-2 p-2">
<Pressable onPress={onOpenBookmark}>
{title && (
- <Text className="line-clamp-2 text-xl font-bold">{title}</Text>
+ <Text numberOfLines={2} className="text-xl font-bold">
+ {title}
+ </Text>
)}
</Pressable>
{note && (
@@ -469,20 +484,23 @@ export default function BookmarkCard({
}: {
bookmark: ZBookmark;
}) {
- 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);
+ },
},
- },
+ ),
);
const router = useRouter();
@@ -521,5 +539,12 @@ export default function BookmarkCard({
break;
}
- return <View className="overflow-hidden rounded-xl bg-card">{comp}</View>;
+ return (
+ <View
+ className="overflow-hidden rounded-xl bg-card"
+ style={{ borderCurve: "continuous" }}
+ >
+ {comp}
+ </View>
+ );
}
diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx
index 730bcd08..57e00c24 100644
--- a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx
@@ -5,14 +5,17 @@ import WebView from "react-native-webview";
import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes";
import { Text } from "@/components/ui/Text";
import { useAssetUrl } from "@/lib/hooks";
-import { api } from "@/lib/trpc";
+import { useReaderSettings, WEBVIEW_FONT_FAMILIES } from "@/lib/readerSettings";
import { useColorScheme } from "@/lib/useColorScheme";
+import { useQuery } from "@tanstack/react-query";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
import FullPageError from "../FullPageError";
import FullPageSpinner from "../ui/FullPageSpinner";
import BookmarkAssetImage from "./BookmarkAssetImage";
+import { PDFViewer } from "./PDFViewer";
export function BookmarkLinkBrowserPreview({
bookmark,
@@ -32,22 +35,50 @@ export function BookmarkLinkBrowserPreview({
);
}
+export function BookmarkLinkPdfPreview({ bookmark }: { bookmark: ZBookmark }) {
+ if (bookmark.content.type !== BookmarkTypes.LINK) {
+ throw new Error("Wrong content type rendered");
+ }
+
+ const asset = bookmark.assets.find((r) => r.assetType == "pdf");
+
+ const assetSource = useAssetUrl(asset?.id ?? "");
+
+ if (!asset) {
+ return (
+ <View className="flex-1 bg-background">
+ <Text>Asset has no PDF</Text>
+ </View>
+ );
+ }
+
+ return (
+ <View className="flex flex-1">
+ <PDFViewer source={assetSource.uri ?? ""} headers={assetSource.headers} />
+ </View>
+ );
+}
+
export function BookmarkLinkReaderPreview({
bookmark,
}: {
bookmark: ZBookmark;
}) {
const { isDarkColorScheme: isDark } = useColorScheme();
+ const { settings: readerSettings } = useReaderSettings();
+ const api = useTRPC();
const {
data: bookmarkWithContent,
error,
isLoading,
refetch,
- } = api.bookmarks.getBookmark.useQuery({
- bookmarkId: bookmark.id,
- includeContent: true,
- });
+ } = useQuery(
+ api.bookmarks.getBookmark.queryOptions({
+ bookmarkId: bookmark.id,
+ includeContent: true,
+ }),
+ );
if (isLoading) {
return <FullPageSpinner />;
@@ -61,6 +92,10 @@ export function BookmarkLinkReaderPreview({
throw new Error("Wrong content type rendered");
}
+ const fontFamily = WEBVIEW_FONT_FAMILIES[readerSettings.fontFamily];
+ const fontSize = readerSettings.fontSize;
+ const lineHeight = readerSettings.lineHeight;
+
return (
<View className="flex-1 bg-background">
<WebView
@@ -73,8 +108,9 @@ export function BookmarkLinkReaderPreview({
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
- line-height: 1.6;
+ font-family: ${fontFamily};
+ font-size: ${fontSize}px;
+ line-height: ${lineHeight};
color: ${isDark ? "#e5e7eb" : "#374151"};
margin: 0;
padding: 16px;
@@ -85,17 +121,29 @@ export function BookmarkLinkReaderPreview({
img { max-width: 100%; height: auto; border-radius: 8px; }
a { color: #3b82f6; text-decoration: none; }
a:hover { text-decoration: underline; }
- blockquote {
- border-left: 4px solid ${isDark ? "#374151" : "#e5e7eb"};
- margin: 1em 0;
- padding-left: 1em;
- color: ${isDark ? "#9ca3af" : "#6b7280"};
+ blockquote {
+ border-left: 4px solid ${isDark ? "#374151" : "#e5e7eb"};
+ margin: 1em 0;
+ padding-left: 1em;
+ color: ${isDark ? "#9ca3af" : "#6b7280"};
+ }
+ pre, code {
+ font-family: ui-monospace, Menlo, Monaco, 'Courier New', monospace;
+ background: ${isDark ? "#1f2937" : "#f3f4f6"};
+ }
+ pre {
+ padding: 1em;
+ border-radius: 6px;
+ overflow-x: auto;
+ }
+ code {
+ padding: 0.2em 0.4em;
+ border-radius: 3px;
+ font-size: 0.9em;
}
- pre {
- background: ${isDark ? "#1f2937" : "#f3f4f6"};
- padding: 1em;
- border-radius: 6px;
- overflow-x: auto;
+ pre code {
+ padding: 0;
+ background: none;
}
</style>
</head>
@@ -180,7 +228,8 @@ export function BookmarkLinkScreenshotPreview({
<Pressable onPress={() => setImageZoom(true)}>
<BookmarkAssetImage
assetId={asset.id}
- className="h-full w-full object-contain"
+ className="h-full w-full"
+ contentFit="contain"
/>
</Pressable>
</View>
diff --git a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx
index 58cbcc8d..5c9955bd 100644
--- a/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkLinkTypeSelector.tsx
@@ -4,7 +4,12 @@ import { ChevronDown } from "lucide-react-native";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
-export type BookmarkLinkType = "browser" | "reader" | "screenshot" | "archive";
+export type BookmarkLinkType =
+ | "browser"
+ | "reader"
+ | "screenshot"
+ | "archive"
+ | "pdf";
function getAvailableViewTypes(bookmark: ZBookmark): BookmarkLinkType[] {
if (bookmark.content.type !== BookmarkTypes.LINK) {
@@ -26,6 +31,9 @@ function getAvailableViewTypes(bookmark: ZBookmark): BookmarkLinkType[] {
) {
availableTypes.push("archive");
}
+ if (bookmark.assets.some((asset) => asset.assetType === "pdf")) {
+ availableTypes.push("pdf");
+ }
return availableTypes;
}
@@ -43,7 +51,7 @@ export default function BookmarkLinkTypeSelector({
}: BookmarkLinkTypeSelectorProps) {
const availableTypes = getAvailableViewTypes(bookmark);
- const allActions = [
+ const viewActions = [
{
id: "reader" as const,
title: "Reader View",
@@ -64,9 +72,14 @@ export default function BookmarkLinkTypeSelector({
title: "Archived Page",
state: type === "archive" ? ("on" as const) : undefined,
},
+ {
+ id: "pdf" as const,
+ title: "PDF",
+ state: type === "pdf" ? ("on" as const) : undefined,
+ },
];
- const availableActions = allActions.filter((action) =>
+ const availableViewActions = viewActions.filter((action) =>
availableTypes.includes(action.id),
);
@@ -76,7 +89,7 @@ export default function BookmarkLinkTypeSelector({
Haptics.selectionAsync();
onChange(nativeEvent.event as BookmarkLinkType);
}}
- actions={availableActions}
+ actions={availableViewActions}
shouldOpenOnLongPress={false}
>
<ChevronDown onPress={() => Haptics.selectionAsync()} color="gray" />
diff --git a/apps/mobile/components/bookmarks/BookmarkLinkView.tsx b/apps/mobile/components/bookmarks/BookmarkLinkView.tsx
index e8a78029..ba4d5b0c 100644
--- a/apps/mobile/components/bookmarks/BookmarkLinkView.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkLinkView.tsx
@@ -1,6 +1,7 @@
import {
BookmarkLinkArchivePreview,
BookmarkLinkBrowserPreview,
+ BookmarkLinkPdfPreview,
BookmarkLinkReaderPreview,
BookmarkLinkScreenshotPreview,
} from "@/components/bookmarks/BookmarkLinkPreview";
@@ -31,5 +32,7 @@ export default function BookmarkLinkView({
return <BookmarkLinkScreenshotPreview bookmark={bookmark} />;
case "archive":
return <BookmarkLinkArchivePreview bookmark={bookmark} />;
+ case "pdf":
+ return <BookmarkLinkPdfPreview bookmark={bookmark} />;
}
}
diff --git a/apps/mobile/components/bookmarks/BookmarkList.tsx b/apps/mobile/components/bookmarks/BookmarkList.tsx
index adcf12e0..b3ac13e0 100644
--- a/apps/mobile/components/bookmarks/BookmarkList.tsx
+++ b/apps/mobile/components/bookmarks/BookmarkList.tsx
@@ -30,6 +30,7 @@ export default function BookmarkList({
<Animated.FlatList
ref={flatListRef}
itemLayoutAnimation={LinearTransition}
+ contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={header}
contentContainerStyle={{
gap: 15,
diff --git a/apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx b/apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx
index e627ee16..25be7c2d 100644
--- a/apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx
+++ b/apps/mobile/components/bookmarks/UpdatingBookmarkList.tsx
@@ -1,6 +1,7 @@
-import { api } from "@/lib/trpc";
+import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import type { ZGetBookmarksRequest } from "@karakeep/shared/types/bookmarks";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import FullPageError from "../FullPageError";
@@ -14,7 +15,8 @@ export default function UpdatingBookmarkList({
query: Omit<ZGetBookmarksRequest, "sortOrder" | "includeContent">; // Sort order is not supported in mobile yet
header?: React.ReactElement;
}) {
- const apiUtils = api.useUtils();
+ const api = useTRPC();
+ const queryClient = useQueryClient();
const {
data,
isPending,
@@ -23,12 +25,14 @@ export default function UpdatingBookmarkList({
fetchNextPage,
isFetchingNextPage,
refetch,
- } = api.bookmarks.getBookmarks.useInfiniteQuery(
- { ...query, useCursorV2: true, includeContent: false },
- {
- initialCursor: null,
- getNextPageParam: (lastPage) => lastPage.nextCursor,
- },
+ } = useInfiniteQuery(
+ api.bookmarks.getBookmarks.infiniteQueryOptions(
+ { ...query, useCursorV2: true, includeContent: false },
+ {
+ initialCursor: null,
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ ),
);
if (error) {
@@ -40,8 +44,8 @@ export default function UpdatingBookmarkList({
}
const onRefresh = () => {
- apiUtils.bookmarks.getBookmarks.invalidate();
- apiUtils.bookmarks.getBookmark.invalidate();
+ queryClient.invalidateQueries(api.bookmarks.getBookmarks.pathFilter());
+ queryClient.invalidateQueries(api.bookmarks.getBookmark.pathFilter());
};
return (
diff --git a/apps/mobile/components/highlights/HighlightCard.tsx b/apps/mobile/components/highlights/HighlightCard.tsx
index 7e0b4a2b..ec4278c5 100644
--- a/apps/mobile/components/highlights/HighlightCard.tsx
+++ b/apps/mobile/components/highlights/HighlightCard.tsx
@@ -2,18 +2,16 @@ import { ActivityIndicator, Alert, Pressable, View } from "react-native";
import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import { Text } from "@/components/ui/Text";
-import { api } from "@/lib/trpc";
-import dayjs from "dayjs";
-import relativeTime from "dayjs/plugin/relativeTime";
+import { useQuery } from "@tanstack/react-query";
+import { formatDistanceToNow } from "date-fns";
import { ExternalLink, Trash2 } from "lucide-react-native";
import type { ZHighlight } from "@karakeep/shared/types/highlights";
import { useDeleteHighlight } from "@karakeep/shared-react/hooks/highlights";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { useToast } from "../ui/Toast";
-dayjs.extend(relativeTime);
-
// Color map for highlights (mapped to Tailwind CSS classes used in NativeWind)
const HIGHLIGHT_COLOR_MAP = {
red: "#fecaca", // bg-red-200
@@ -29,6 +27,7 @@ export default function HighlightCard({
}) {
const { toast } = useToast();
const router = useRouter();
+ const api = useTRPC();
const onError = () => {
toast({
@@ -64,13 +63,15 @@ export default function HighlightCard({
],
);
- const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
- {
- bookmarkId: highlight.bookmarkId,
- },
- {
- retry: false,
- },
+ const { data: bookmark } = useQuery(
+ api.bookmarks.getBookmark.queryOptions(
+ {
+ bookmarkId: highlight.bookmarkId,
+ },
+ {
+ retry: false,
+ },
+ ),
);
const handleBookmarkPress = () => {
@@ -79,7 +80,10 @@ export default function HighlightCard({
};
return (
- <View className="overflow-hidden rounded-xl bg-card p-4">
+ <View
+ className="overflow-hidden rounded-xl bg-card p-4"
+ style={{ borderCurve: "continuous" }}
+ >
<View className="flex gap-3">
{/* Highlight text with colored border */}
<View
@@ -104,7 +108,7 @@ export default function HighlightCard({
<View className="flex flex-row items-center justify-between">
<View className="flex flex-row items-center gap-2">
<Text className="text-xs text-muted-foreground">
- {dayjs(highlight.createdAt).fromNow()}
+ {formatDistanceToNow(highlight.createdAt, { addSuffix: true })}
</Text>
{bookmark && (
<>
diff --git a/apps/mobile/components/highlights/HighlightList.tsx b/apps/mobile/components/highlights/HighlightList.tsx
index 865add2a..7d7bb1d4 100644
--- a/apps/mobile/components/highlights/HighlightList.tsx
+++ b/apps/mobile/components/highlights/HighlightList.tsx
@@ -30,6 +30,7 @@ export default function HighlightList({
<Animated.FlatList
ref={flatListRef}
itemLayoutAnimation={LinearTransition}
+ contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={header}
contentContainerStyle={{
gap: 15,
diff --git a/apps/mobile/components/navigation/stack.tsx b/apps/mobile/components/navigation/stack.tsx
index f53b3652..145c591f 100644
--- a/apps/mobile/components/navigation/stack.tsx
+++ b/apps/mobile/components/navigation/stack.tsx
@@ -1,4 +1,4 @@
-import { TextStyle, ViewStyle } from "react-native";
+import { Platform, TextStyle, ViewStyle } from "react-native";
import { Stack } from "expo-router/stack";
import { cssInterop } from "nativewind";
@@ -14,7 +14,10 @@ function StackImpl({ contentStyle, headerStyle, ...props }: StackProps) {
headerStyle: {
backgroundColor: headerStyle?.backgroundColor?.toString(),
},
- navigationBarColor: contentStyle?.backgroundColor?.toString(),
+ navigationBarColor:
+ Platform.OS === "android"
+ ? undefined
+ : contentStyle?.backgroundColor?.toString(),
headerTintColor: headerStyle?.color?.toString(),
};
return <Stack {...props} />;
diff --git a/apps/mobile/components/navigation/tabs.tsx b/apps/mobile/components/navigation/tabs.tsx
deleted file mode 100644
index 83b1c6a7..00000000
--- a/apps/mobile/components/navigation/tabs.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { ViewStyle } from "react-native";
-import { Tabs } from "expo-router";
-import { cssInterop } from "nativewind";
-
-function StyledTabsImpl({
- tabBarStyle,
- headerStyle,
- sceneStyle,
- ...props
-}: React.ComponentProps<typeof Tabs> & {
- tabBarStyle?: ViewStyle;
- headerStyle?: ViewStyle;
- sceneStyle?: ViewStyle;
-}) {
- props.screenOptions = {
- ...props.screenOptions,
- tabBarStyle,
- headerStyle,
- sceneStyle,
- };
- return <Tabs {...props} />;
-}
-
-export const StyledTabs = cssInterop(StyledTabsImpl, {
- tabBarClassName: "tabBarStyle",
- headerClassName: "headerStyle",
- sceneClassName: "sceneStyle",
-});
diff --git a/apps/mobile/components/reader/ReaderPreview.tsx b/apps/mobile/components/reader/ReaderPreview.tsx
new file mode 100644
index 00000000..c091bdbc
--- /dev/null
+++ b/apps/mobile/components/reader/ReaderPreview.tsx
@@ -0,0 +1,117 @@
+import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
+import { View } from "react-native";
+import WebView from "react-native-webview";
+import { WEBVIEW_FONT_FAMILIES } from "@/lib/readerSettings";
+import { useColorScheme } from "@/lib/useColorScheme";
+
+import { ZReaderFontFamily } from "@karakeep/shared/types/users";
+
+const PREVIEW_TEXT =
+ "The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump!";
+
+export interface ReaderPreviewRef {
+ updateStyles: (
+ fontFamily: ZReaderFontFamily,
+ fontSize: number,
+ lineHeight: number,
+ ) => void;
+}
+
+interface ReaderPreviewProps {
+ initialFontFamily: ZReaderFontFamily;
+ initialFontSize: number;
+ initialLineHeight: number;
+}
+
+export const ReaderPreview = forwardRef<ReaderPreviewRef, ReaderPreviewProps>(
+ ({ initialFontFamily, initialFontSize, initialLineHeight }, ref) => {
+ const webViewRef = useRef<WebView>(null);
+ const { isDarkColorScheme: isDark } = useColorScheme();
+
+ const fontFamily = WEBVIEW_FONT_FAMILIES[initialFontFamily];
+ const textColor = isDark ? "#e5e7eb" : "#374151";
+ const bgColor = isDark ? "#000000" : "#ffffff";
+
+ useImperativeHandle(ref, () => ({
+ updateStyles: (
+ newFontFamily: ZReaderFontFamily,
+ newFontSize: number,
+ newLineHeight: number,
+ ) => {
+ const cssFontFamily = WEBVIEW_FONT_FAMILIES[newFontFamily];
+ webViewRef.current?.injectJavaScript(`
+ window.updateStyles("${cssFontFamily}", ${newFontSize}, ${newLineHeight});
+ true;
+ `);
+ },
+ }));
+
+ // Update colors when theme changes
+ useEffect(() => {
+ webViewRef.current?.injectJavaScript(`
+ document.body.style.color = "${textColor}";
+ document.body.style.background = "${bgColor}";
+ true;
+ `);
+ }, [isDark, textColor, bgColor]);
+
+ const html = `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <style>
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+ html, body {
+ height: 100%;
+ overflow: hidden;
+ }
+ body {
+ font-family: ${fontFamily};
+ font-size: ${initialFontSize}px;
+ line-height: ${initialLineHeight};
+ color: ${textColor};
+ background: ${bgColor};
+ padding: 16px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ }
+ </style>
+ <script>
+ window.updateStyles = function(fontFamily, fontSize, lineHeight) {
+ document.body.style.fontFamily = fontFamily;
+ document.body.style.fontSize = fontSize + 'px';
+ document.body.style.lineHeight = lineHeight;
+ };
+ </script>
+ </head>
+ <body>
+ ${PREVIEW_TEXT}
+ </body>
+ </html>
+ `;
+
+ return (
+ <View className="h-32 w-full overflow-hidden rounded-lg">
+ <WebView
+ ref={webViewRef}
+ originWhitelist={["*"]}
+ source={{ html }}
+ style={{
+ flex: 1,
+ backgroundColor: bgColor,
+ }}
+ scrollEnabled={false}
+ showsVerticalScrollIndicator={false}
+ showsHorizontalScrollIndicator={false}
+ />
+ </View>
+ );
+ },
+);
+
+ReaderPreview.displayName = "ReaderPreview";
diff --git a/apps/mobile/components/settings/UserProfileHeader.tsx b/apps/mobile/components/settings/UserProfileHeader.tsx
new file mode 100644
index 00000000..6e389877
--- /dev/null
+++ b/apps/mobile/components/settings/UserProfileHeader.tsx
@@ -0,0 +1,27 @@
+import { View } from "react-native";
+import { Avatar } from "@/components/ui/Avatar";
+import { Text } from "@/components/ui/Text";
+
+interface UserProfileHeaderProps {
+ image?: string | null;
+ name?: string | null;
+ email?: string | null;
+}
+
+export function UserProfileHeader({
+ image,
+ name,
+ email,
+}: UserProfileHeaderProps) {
+ return (
+ <View className="w-full items-center gap-2 py-6">
+ <Avatar image={image} name={name} size={88} />
+ <View className="items-center gap-1">
+ <Text className="text-xl font-semibold">{name || "User"}</Text>
+ {email && (
+ <Text className="text-sm text-muted-foreground">{email}</Text>
+ )}
+ </View>
+ </View>
+ );
+}
diff --git a/apps/mobile/components/sharing/ErrorAnimation.tsx b/apps/mobile/components/sharing/ErrorAnimation.tsx
new file mode 100644
index 00000000..c5cc743a
--- /dev/null
+++ b/apps/mobile/components/sharing/ErrorAnimation.tsx
@@ -0,0 +1,41 @@
+import { useEffect } from "react";
+import { View } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withSequence,
+ withSpring,
+ withTiming,
+} from "react-native-reanimated";
+import * as Haptics from "expo-haptics";
+import { AlertCircle } from "lucide-react-native";
+
+export default function ErrorAnimation() {
+ const scale = useSharedValue(0);
+ const shake = useSharedValue(0);
+
+ useEffect(() => {
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
+
+ scale.value = withSpring(1, { damping: 12, stiffness: 200 });
+ shake.value = withSequence(
+ withTiming(-10, { duration: 50 }),
+ withTiming(10, { duration: 100 }),
+ withTiming(-10, { duration: 100 }),
+ withTiming(10, { duration: 100 }),
+ withTiming(0, { duration: 50 }),
+ );
+ }, []);
+
+ const style = useAnimatedStyle(() => ({
+ transform: [{ scale: scale.value }, { translateX: shake.value }],
+ }));
+
+ return (
+ <Animated.View style={style} className="items-center gap-4">
+ <View className="h-24 w-24 items-center justify-center rounded-full bg-destructive">
+ <AlertCircle size={48} color="white" strokeWidth={2} />
+ </View>
+ </Animated.View>
+ );
+}
diff --git a/apps/mobile/components/sharing/LoadingAnimation.tsx b/apps/mobile/components/sharing/LoadingAnimation.tsx
new file mode 100644
index 00000000..a8838915
--- /dev/null
+++ b/apps/mobile/components/sharing/LoadingAnimation.tsx
@@ -0,0 +1,120 @@
+import { useEffect } from "react";
+import { View } from "react-native";
+import Animated, {
+ Easing,
+ FadeIn,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withRepeat,
+ withSequence,
+ withTiming,
+} from "react-native-reanimated";
+import { Text } from "@/components/ui/Text";
+import { Archive } from "lucide-react-native";
+
+export default function LoadingAnimation() {
+ const scale = useSharedValue(1);
+ const rotation = useSharedValue(0);
+ const opacity = useSharedValue(0.6);
+ const dotOpacity1 = useSharedValue(0);
+ const dotOpacity2 = useSharedValue(0);
+ const dotOpacity3 = useSharedValue(0);
+
+ useEffect(() => {
+ scale.value = withRepeat(
+ withSequence(
+ withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
+ withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
+ ),
+ -1,
+ false,
+ );
+
+ rotation.value = withRepeat(
+ withSequence(
+ withTiming(-5, { duration: 400, easing: Easing.inOut(Easing.ease) }),
+ withTiming(5, { duration: 800, easing: Easing.inOut(Easing.ease) }),
+ withTiming(0, { duration: 400, easing: Easing.inOut(Easing.ease) }),
+ ),
+ -1,
+ false,
+ );
+
+ opacity.value = withRepeat(
+ withSequence(
+ withTiming(1, { duration: 800 }),
+ withTiming(0.6, { duration: 800 }),
+ ),
+ -1,
+ false,
+ );
+
+ dotOpacity1.value = withRepeat(
+ withSequence(
+ withTiming(1, { duration: 300 }),
+ withDelay(900, withTiming(0, { duration: 0 })),
+ ),
+ -1,
+ );
+ dotOpacity2.value = withDelay(
+ 300,
+ withRepeat(
+ withSequence(
+ withTiming(1, { duration: 300 }),
+ withDelay(600, withTiming(0, { duration: 0 })),
+ ),
+ -1,
+ ),
+ );
+ dotOpacity3.value = withDelay(
+ 600,
+ withRepeat(
+ withSequence(
+ withTiming(1, { duration: 300 }),
+ withDelay(300, withTiming(0, { duration: 0 })),
+ ),
+ -1,
+ ),
+ );
+ }, []);
+
+ const iconStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: scale.value }, { rotate: `${rotation.value}deg` }],
+ opacity: opacity.value,
+ }));
+
+ const dot1Style = useAnimatedStyle(() => ({ opacity: dotOpacity1.value }));
+ const dot2Style = useAnimatedStyle(() => ({ opacity: dotOpacity2.value }));
+ const dot3Style = useAnimatedStyle(() => ({ opacity: dotOpacity3.value }));
+
+ return (
+ <Animated.View
+ entering={FadeIn.duration(300)}
+ className="items-center gap-6"
+ >
+ <Animated.View
+ style={iconStyle}
+ className="h-24 w-24 items-center justify-center rounded-full bg-primary/10"
+ >
+ <Archive size={48} className="text-primary" strokeWidth={1.5} />
+ </Animated.View>
+ <View className="flex-row items-baseline">
+ <Text variant="title1" className="font-semibold text-foreground">
+ Hoarding
+ </Text>
+ <View className="w-8 flex-row">
+ <Animated.Text style={dot1Style} className="text-xl text-foreground">
+ .
+ </Animated.Text>
+ <Animated.Text style={dot2Style} className="text-xl text-foreground">
+ .
+ </Animated.Text>
+ <Animated.Text style={dot3Style} className="text-xl text-foreground">
+ .
+ </Animated.Text>
+ </View>
+ </View>
+ </Animated.View>
+ );
+}
diff --git a/apps/mobile/components/sharing/SuccessAnimation.tsx b/apps/mobile/components/sharing/SuccessAnimation.tsx
new file mode 100644
index 00000000..fa0aaf3a
--- /dev/null
+++ b/apps/mobile/components/sharing/SuccessAnimation.tsx
@@ -0,0 +1,140 @@
+import { useEffect } from "react";
+import { View } from "react-native";
+import Animated, {
+ Easing,
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withSequence,
+ withSpring,
+ withTiming,
+} from "react-native-reanimated";
+import * as Haptics from "expo-haptics";
+import { Check } from "lucide-react-native";
+
+interface ParticleProps {
+ angle: number;
+ delay: number;
+ color: string;
+}
+
+function Particle({ angle, delay, color }: ParticleProps) {
+ const progress = useSharedValue(0);
+
+ useEffect(() => {
+ progress.value = withDelay(
+ 200 + delay,
+ withSequence(
+ withTiming(1, { duration: 400, easing: Easing.out(Easing.ease) }),
+ withTiming(0, { duration: 300 }),
+ ),
+ );
+ }, []);
+
+ const particleStyle = useAnimatedStyle(() => {
+ const distance = interpolate(progress.value, [0, 1], [0, 60]);
+ const opacity = interpolate(progress.value, [0, 0.5, 1], [0, 1, 0]);
+ const scale = interpolate(progress.value, [0, 0.5, 1], [0, 1, 0]);
+ const angleRad = (angle * Math.PI) / 180;
+
+ return {
+ position: "absolute" as const,
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: color,
+ opacity,
+ transform: [
+ { translateX: Math.cos(angleRad) * distance },
+ { translateY: Math.sin(angleRad) * distance },
+ { scale },
+ ],
+ };
+ });
+
+ return <Animated.View style={particleStyle} />;
+}
+
+interface SuccessAnimationProps {
+ isAlreadyExists: boolean;
+}
+
+export default function SuccessAnimation({
+ isAlreadyExists,
+}: SuccessAnimationProps) {
+ const checkScale = useSharedValue(0);
+ const checkOpacity = useSharedValue(0);
+ const ringScale = useSharedValue(0.8);
+ const ringOpacity = useSharedValue(0);
+
+ const particleColor = isAlreadyExists
+ ? "rgb(255, 180, 0)"
+ : "rgb(0, 200, 100)";
+
+ useEffect(() => {
+ Haptics.notificationAsync(
+ isAlreadyExists
+ ? Haptics.NotificationFeedbackType.Warning
+ : Haptics.NotificationFeedbackType.Success,
+ );
+
+ ringScale.value = withSequence(
+ withTiming(1.2, { duration: 400, easing: Easing.out(Easing.ease) }),
+ withTiming(1, { duration: 200 }),
+ );
+ ringOpacity.value = withSequence(
+ withTiming(1, { duration: 200 }),
+ withDelay(300, withTiming(0.3, { duration: 300 })),
+ );
+
+ checkScale.value = withDelay(
+ 150,
+ withSpring(1, {
+ damping: 12,
+ stiffness: 200,
+ mass: 0.8,
+ }),
+ );
+ checkOpacity.value = withDelay(150, withTiming(1, { duration: 200 }));
+ }, [isAlreadyExists]);
+
+ const ringStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: ringScale.value }],
+ opacity: ringOpacity.value,
+ }));
+
+ const checkStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: checkScale.value }],
+ opacity: checkOpacity.value,
+ }));
+
+ return (
+ <View className="items-center justify-center">
+ {Array.from({ length: 8 }, (_, i) => (
+ <Particle
+ key={i}
+ angle={(i * 360) / 8}
+ delay={i * 50}
+ color={particleColor}
+ />
+ ))}
+
+ <Animated.View
+ style={ringStyle}
+ className={`absolute h-28 w-28 rounded-full ${
+ isAlreadyExists ? "bg-yellow-500/20" : "bg-green-500/20"
+ }`}
+ />
+
+ <Animated.View
+ style={checkStyle}
+ className={`h-24 w-24 items-center justify-center rounded-full ${
+ isAlreadyExists ? "bg-yellow-500" : "bg-green-500"
+ }`}
+ >
+ <Check size={48} color="white" strokeWidth={3} />
+ </Animated.View>
+ </View>
+ );
+}
diff --git a/apps/mobile/components/ui/Avatar.tsx b/apps/mobile/components/ui/Avatar.tsx
new file mode 100644
index 00000000..239eaba8
--- /dev/null
+++ b/apps/mobile/components/ui/Avatar.tsx
@@ -0,0 +1,112 @@
+import * as React from "react";
+import { View } from "react-native";
+import { Image } from "expo-image";
+import { Text } from "@/components/ui/Text";
+import { useAssetUrl } from "@/lib/hooks";
+import { cn } from "@/lib/utils";
+
+interface AvatarProps {
+ image?: string | null;
+ name?: string | null;
+ size?: number;
+ className?: string;
+ fallbackClassName?: string;
+}
+
+const AVATAR_COLORS = [
+ "#f87171", // red-400
+ "#fb923c", // orange-400
+ "#fbbf24", // amber-400
+ "#a3e635", // lime-400
+ "#34d399", // emerald-400
+ "#22d3ee", // cyan-400
+ "#60a5fa", // blue-400
+ "#818cf8", // indigo-400
+ "#a78bfa", // violet-400
+ "#e879f9", // fuchsia-400
+];
+
+function nameToColor(name: string | null | undefined): string {
+ if (!name) return AVATAR_COLORS[0];
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) {
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
+}
+
+function isExternalUrl(url: string) {
+ return url.startsWith("http://") || url.startsWith("https://");
+}
+
+export function Avatar({
+ image,
+ name,
+ size = 40,
+ className,
+ fallbackClassName,
+}: AvatarProps) {
+ const [imageError, setImageError] = React.useState(false);
+ const assetUrl = useAssetUrl(image ?? "");
+
+ const imageUrl = React.useMemo(() => {
+ if (!image) return null;
+ return isExternalUrl(image)
+ ? {
+ uri: image,
+ }
+ : assetUrl;
+ }, [image]);
+
+ React.useEffect(() => {
+ setImageError(false);
+ }, [image]);
+
+ const initials = React.useMemo(() => {
+ if (!name) return "U";
+ return name.charAt(0).toUpperCase();
+ }, [name]);
+
+ const showFallback = !imageUrl || imageError;
+ const avatarColor = nameToColor(name);
+
+ return (
+ <View
+ className={cn("overflow-hidden", className)}
+ style={{
+ width: size,
+ height: size,
+ borderRadius: size / 2,
+ backgroundColor: showFallback ? avatarColor : undefined,
+ }}
+ >
+ {showFallback ? (
+ <View
+ className={cn(
+ "flex h-full w-full items-center justify-center",
+ fallbackClassName,
+ )}
+ style={{ backgroundColor: avatarColor }}
+ >
+ <Text
+ className="text-white"
+ style={{
+ fontSize: size * 0.4,
+ lineHeight: size * 0.4,
+ textAlign: "center",
+ }}
+ >
+ {initials}
+ </Text>
+ </View>
+ ) : (
+ <Image
+ source={imageUrl}
+ style={{ width: "100%", height: "100%" }}
+ contentFit="cover"
+ onError={() => setImageError(true)}
+ />
+ )}
+ </View>
+ );
+}
diff --git a/apps/mobile/components/ui/CustomSafeAreaView.tsx b/apps/mobile/components/ui/CustomSafeAreaView.tsx
index fdf6520d..8e7755c2 100644
--- a/apps/mobile/components/ui/CustomSafeAreaView.tsx
+++ b/apps/mobile/components/ui/CustomSafeAreaView.tsx
@@ -1,5 +1,5 @@
-import { Platform, SafeAreaView } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useColorScheme } from "@/lib/useColorScheme";
import { useHeaderHeight } from "@react-navigation/elements";
export default function CustomSafeAreaView({
@@ -9,20 +9,19 @@ export default function CustomSafeAreaView({
children: React.ReactNode;
edges?: ("top" | "bottom")[];
}) {
- const insets = useSafeAreaInsets();
const headerHeight = useHeaderHeight();
+ const { colors } = useColorScheme();
return (
<SafeAreaView
style={{
- paddingTop:
- // Some ugly hacks to make the app look the same on both android and ios
- Platform.OS == "android" && edges.includes("top")
- ? headerHeight > 0
- ? headerHeight
- : insets.top
- : undefined,
- paddingBottom: edges.includes("bottom") ? insets.bottom : undefined,
+ flex: 1,
+ backgroundColor: colors.background,
+ paddingTop: edges.includes("top")
+ ? headerHeight > 0
+ ? headerHeight
+ : undefined
+ : undefined,
}}
>
{children}
diff --git a/apps/mobile/components/ui/List.tsx b/apps/mobile/components/ui/List.tsx
deleted file mode 100644
index 52ff5779..00000000
--- a/apps/mobile/components/ui/List.tsx
+++ /dev/null
@@ -1,469 +0,0 @@
-import type {
- FlashListProps,
- ListRenderItem as FlashListRenderItem,
- ListRenderItemInfo,
-} from "@shopify/flash-list";
-import * as React from "react";
-import {
- Platform,
- PressableProps,
- StyleProp,
- TextStyle,
- View,
- ViewProps,
- ViewStyle,
-} from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { Button } from "@/components/ui/Button";
-import { Text, TextClassContext } from "@/components/ui/Text";
-import { cn } from "@/lib/utils";
-import { FlashList } from "@shopify/flash-list";
-import { cva } from "class-variance-authority";
-import { cssInterop } from "nativewind";
-
-cssInterop(FlashList, {
- className: "style",
- contentContainerClassName: "contentContainerStyle",
-});
-
-type ListDataItem = string | { title: string; subTitle?: string };
-type ListVariant = "insets" | "full-width";
-
-type ListRef<T extends ListDataItem> = React.Ref<typeof FlashList<T>>;
-
-type ListRenderItemProps<T extends ListDataItem> = ListRenderItemInfo<T> & {
- variant?: ListVariant;
- isFirstInSection?: boolean;
- isLastInSection?: boolean;
- sectionHeaderAsGap?: boolean;
-};
-
-type ListProps<T extends ListDataItem> = Omit<
- FlashListProps<T>,
- "renderItem"
-> & {
- renderItem?: ListRenderItem<T>;
- variant?: ListVariant;
- sectionHeaderAsGap?: boolean;
- rootClassName?: string;
- rootStyle?: StyleProp<ViewStyle>;
-};
-type ListRenderItem<T extends ListDataItem> = (
- props: ListRenderItemProps<T>,
-) => ReturnType<FlashListRenderItem<T>>;
-
-const rootVariants = cva("min-h-2 flex-1", {
- variants: {
- variant: {
- insets: "ios:px-4",
- "full-width": "ios:bg-card ios:dark:bg-background",
- },
- sectionHeaderAsGap: {
- true: "",
- false: "",
- },
- },
- compoundVariants: [
- {
- variant: "full-width",
- sectionHeaderAsGap: true,
- className: "bg-card dark:bg-background",
- },
- ],
- defaultVariants: {
- variant: "full-width",
- sectionHeaderAsGap: false,
- },
-});
-
-function ListComponent<T extends ListDataItem>({
- variant = "full-width",
- rootClassName,
- rootStyle,
- contentContainerClassName,
- renderItem,
- data,
- sectionHeaderAsGap = false,
- contentInsetAdjustmentBehavior = "automatic",
- ...props
-}: ListProps<T>) {
- const insets = useSafeAreaInsets();
- return (
- <View
- className={cn(
- rootVariants({
- variant,
- sectionHeaderAsGap,
- }),
- rootClassName,
- )}
- style={rootStyle}
- >
- <FlashList
- data={data}
- contentInsetAdjustmentBehavior={contentInsetAdjustmentBehavior}
- renderItem={renderItemWithVariant(
- renderItem,
- variant,
- data,
- sectionHeaderAsGap,
- )}
- contentContainerClassName={cn(
- variant === "insets" &&
- (!data || (typeof data?.[0] !== "string" && "pt-4")),
- contentContainerClassName,
- )}
- contentContainerStyle={{
- paddingBottom: Platform.select({
- ios:
- !contentInsetAdjustmentBehavior ||
- contentInsetAdjustmentBehavior === "never"
- ? insets.bottom + 16
- : 0,
- default: insets.bottom,
- }),
- }}
- getItemType={getItemType}
- showsVerticalScrollIndicator={false}
- {...props}
- />
- </View>
- );
-}
-
-function getItemType<T>(item: T) {
- return typeof item === "string" ? "sectioHeader" : "row";
-}
-
-function renderItemWithVariant<T extends ListDataItem>(
- renderItem: ListRenderItem<T> | null | undefined,
- variant: ListVariant,
- data: readonly T[] | null | undefined,
- sectionHeaderAsGap?: boolean,
-) {
- return (args: ListRenderItemProps<T>) => {
- const previousItem = data?.[args.index - 1];
- const nextItem = data?.[args.index + 1];
- return renderItem
- ? renderItem({
- ...args,
- variant,
- isFirstInSection: !previousItem || typeof previousItem === "string",
- isLastInSection: !nextItem || typeof nextItem === "string",
- sectionHeaderAsGap,
- })
- : null;
- };
-}
-
-const List = React.forwardRef(ListComponent) as <T extends ListDataItem>(
- props: ListProps<T> & { ref?: ListRef<T> },
-) => React.ReactElement;
-
-function isPressable(props: PressableProps) {
- return (
- ("onPress" in props && props.onPress) ||
- ("onLongPress" in props && props.onLongPress) ||
- ("onPressIn" in props && props.onPressIn) ||
- ("onPressOut" in props && props.onPressOut) ||
- ("onLongPress" in props && props.onLongPress)
- );
-}
-
-type ListItemProps<T extends ListDataItem> = PressableProps &
- ListRenderItemProps<T> & {
- androidRootClassName?: string;
- titleClassName?: string;
- titleStyle?: StyleProp<TextStyle>;
- textNumberOfLines?: number;
- subTitleClassName?: string;
- subTitleStyle?: StyleProp<TextStyle>;
- subTitleNumberOfLines?: number;
- textContentClassName?: string;
- leftView?: React.ReactNode;
- rightView?: React.ReactNode;
- removeSeparator?: boolean;
- };
-type ListItemRef = React.Ref<View>;
-
-const itemVariants = cva("ios:gap-0 flex-row gap-0 bg-card", {
- variants: {
- variant: {
- insets: "ios:bg-card bg-card/70",
- "full-width": "bg-card dark:bg-background",
- },
- sectionHeaderAsGap: {
- true: "",
- false: "",
- },
- isFirstItem: {
- true: "",
- false: "",
- },
- isFirstInSection: {
- true: "",
- false: "",
- },
- removeSeparator: {
- true: "",
- false: "",
- },
- isLastInSection: {
- true: "",
- false: "",
- },
- disabled: {
- true: "opacity-70",
- false: "opacity-100",
- },
- },
- compoundVariants: [
- {
- variant: "insets",
- sectionHeaderAsGap: true,
- className: "ios:dark:bg-card dark:bg-card/70",
- },
- {
- variant: "insets",
- isFirstInSection: true,
- className: "ios:rounded-t-[10px]",
- },
- {
- variant: "insets",
- isLastInSection: true,
- className: "ios:rounded-b-[10px]",
- },
- {
- removeSeparator: false,
- isLastInSection: true,
- className:
- "ios:border-b-0 border-b border-border/25 dark:border-border/80",
- },
- {
- variant: "insets",
- isFirstItem: true,
- className: "border-t border-border/40",
- },
- ],
- defaultVariants: {
- variant: "insets",
- sectionHeaderAsGap: false,
- isFirstInSection: false,
- isLastInSection: false,
- disabled: false,
- },
-});
-
-function ListItemComponent<T extends ListDataItem>(
- {
- item,
- isFirstInSection,
- isLastInSection,
- index: _index,
- variant,
- className,
- androidRootClassName,
- titleClassName,
- titleStyle,
- textNumberOfLines,
- subTitleStyle,
- subTitleClassName,
- subTitleNumberOfLines,
- textContentClassName,
- sectionHeaderAsGap,
- removeSeparator = false,
- leftView,
- rightView,
- disabled,
- ...props
- }: ListItemProps<T>,
- ref: ListItemRef,
-) {
- if (typeof item === "string") {
- console.log(
- "List.tsx",
- "ListItemComponent",
- "Invalid item of type 'string' was provided. Use ListSectionHeader instead.",
- );
- return null;
- }
- return (
- <>
- <Button
- disabled={disabled || !isPressable(props)}
- variant="plain"
- size="none"
- unstable_pressDelay={100}
- androidRootClassName={androidRootClassName}
- className={itemVariants({
- variant,
- sectionHeaderAsGap,
- isFirstInSection,
- isLastInSection,
- disabled,
- className,
- removeSeparator,
- })}
- {...props}
- ref={ref}
- >
- <TextClassContext.Provider value="font-normal leading-5">
- {!!leftView && <View>{leftView}</View>}
- <View
- className={cn(
- "h-full flex-1 flex-row",
- !item.subTitle ? "ios:py-3 py-[18px]" : "ios:py-2 py-2",
- !leftView && "ml-4",
- !rightView && "pr-4",
- !removeSeparator &&
- (!isLastInSection || variant === "full-width") &&
- "ios:border-b ios:border-border/80",
- !removeSeparator &&
- isFirstInSection &&
- variant === "full-width" &&
- "ios:border-t ios:border-border/80",
- )}
- >
- <View className={cn("flex-1", textContentClassName)}>
- <Text
- numberOfLines={textNumberOfLines}
- style={titleStyle}
- className={titleClassName}
- >
- {item.title}
- </Text>
- {!!item.subTitle && (
- <Text
- numberOfLines={subTitleNumberOfLines}
- variant="subhead"
- style={subTitleStyle}
- className={cn("text-muted-foreground", subTitleClassName)}
- >
- {item.subTitle}
- </Text>
- )}
- </View>
- {!!rightView && <View>{rightView}</View>}
- </View>
- </TextClassContext.Provider>
- </Button>
- {!removeSeparator && Platform.OS !== "ios" && !isLastInSection && (
- <View className={cn(variant === "insets" && "px-4")}>
- <View className="h-px bg-border/25 dark:bg-border/80" />
- </View>
- )}
- </>
- );
-}
-
-const ListItem = React.forwardRef(ListItemComponent) as <
- T extends ListDataItem,
->(
- props: ListItemProps<T> & { ref?: ListItemRef },
-) => React.ReactElement;
-
-type ListSectionHeaderProps<T extends ListDataItem> = ViewProps &
- ListRenderItemProps<T> & {
- textClassName?: string;
- };
-type ListSectionHeaderRef = React.Ref<View>;
-
-function ListSectionHeaderComponent<T extends ListDataItem>(
- {
- item,
- isFirstInSection: _isFirstInSection,
- isLastInSection: _isLastInSection,
- index: _index,
- variant,
- className,
- textClassName,
- sectionHeaderAsGap,
- ...props
- }: ListSectionHeaderProps<T>,
- ref: ListSectionHeaderRef,
-) {
- if (typeof item !== "string") {
- console.log(
- "List.tsx",
- "ListSectionHeaderComponent",
- "Invalid item provided. Expected type 'string'. Use ListItem instead.",
- );
- return null;
- }
-
- if (sectionHeaderAsGap) {
- return (
- <View
- className={cn(
- "bg-background",
- Platform.OS !== "ios" &&
- "border-b border-border/25 dark:border-border/80",
- className,
- )}
- {...props}
- ref={ref}
- >
- <View className="h-8" />
- </View>
- );
- }
- return (
- <View
- className={cn(
- "ios:pb-1 pb-4 pl-4 pt-4",
- Platform.OS !== "ios" &&
- "border-b border-border/25 dark:border-border/80",
- variant === "full-width"
- ? "bg-card dark:bg-background"
- : "bg-background",
- className,
- )}
- {...props}
- ref={ref}
- >
- <Text
- variant={Platform.select({ ios: "footnote", default: "body" })}
- className={cn("ios:uppercase ios:text-muted-foreground", textClassName)}
- >
- {item}
- </Text>
- </View>
- );
-}
-
-const ListSectionHeader = React.forwardRef(ListSectionHeaderComponent) as <
- T extends ListDataItem,
->(
- props: ListSectionHeaderProps<T> & { ref?: ListSectionHeaderRef },
-) => React.ReactElement;
-
-const ESTIMATED_ITEM_HEIGHT = {
- titleOnly: Platform.select({ ios: 45, default: 57 }),
- withSubTitle: 56,
-};
-
-function getStickyHeaderIndices<T extends ListDataItem>(data: T[]) {
- if (!data) return [];
- const indices: number[] = [];
- for (let i = 0; i < data.length; i++) {
- if (typeof data[i] === "string") {
- indices.push(i);
- }
- }
- return indices;
-}
-
-export {
- ESTIMATED_ITEM_HEIGHT,
- List,
- ListItem,
- ListSectionHeader,
- getStickyHeaderIndices,
-};
-export type {
- ListDataItem,
- ListItemProps,
- ListProps,
- ListRenderItemInfo,
- ListSectionHeaderProps,
-};
diff --git a/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx b/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx
index 0b1dd76c..1a767675 100644
--- a/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx
+++ b/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx
@@ -1,7 +1,3 @@
-import type {
- NativeSyntheticEvent,
- TextInputFocusEventData,
-} from "react-native";
import * as React from "react";
import { Pressable, TextInput, View, ViewStyle } from "react-native";
import Animated, {
@@ -119,7 +115,7 @@ const SearchInput = React.forwardRef<
onChangeText("");
}
- function onFocus(e: NativeSyntheticEvent<TextInputFocusEventData>) {
+ function onFocus(e: Parameters<NonNullable<typeof onFocusProp>>[0]) {
setShowCancel(true);
onFocusProp?.(e);
}
diff --git a/apps/mobile/components/ui/Toast.tsx b/apps/mobile/components/ui/Toast.tsx
index fd122c25..722c93ab 100644
--- a/apps/mobile/components/ui/Toast.tsx
+++ b/apps/mobile/components/ui/Toast.tsx
@@ -1,7 +1,4 @@
-import { createContext, useContext, useEffect, useRef, useState } from "react";
-import { Animated, View } from "react-native";
-import { Text } from "@/components/ui/Text";
-import { cn } from "@/lib/utils";
+import { toast as sonnerToast } from "sonner-native";
const toastVariants = {
default: "bg-foreground",
@@ -10,174 +7,41 @@ const toastVariants = {
info: "bg-blue-500",
};
-interface ToastProps {
- id: number;
- message: string;
- onHide: (id: number) => void;
- variant?: keyof typeof toastVariants;
- duration?: number;
- showProgress?: boolean;
-}
-function Toast({
- id,
- message,
- onHide,
- variant = "default",
- duration = 3000,
- showProgress = true,
-}: ToastProps) {
- const opacity = useRef(new Animated.Value(0)).current;
- const progress = useRef(new Animated.Value(0)).current;
-
- useEffect(() => {
- Animated.sequence([
- Animated.timing(opacity, {
- toValue: 1,
- duration: 500,
- useNativeDriver: true,
- }),
- Animated.timing(progress, {
- toValue: 1,
- duration: duration - 1000,
- useNativeDriver: false,
- }),
- Animated.timing(opacity, {
- toValue: 0,
- duration: 500,
- useNativeDriver: true,
- }),
- ]).start(() => onHide(id));
- }, [duration]);
-
- return (
- <Animated.View
- className={`
- ${toastVariants[variant]}
- m-2 mb-1 transform rounded-lg p-4 transition-all
- `}
- style={{
- opacity,
- transform: [
- {
- translateY: opacity.interpolate({
- inputRange: [0, 1],
- outputRange: [-20, 0],
- }),
- },
- ],
- }}
- >
- <Text className="text-left font-semibold text-background">{message}</Text>
- {showProgress && (
- <View className="mt-2 rounded">
- <Animated.View
- className="h-2 rounded bg-white opacity-30 dark:bg-black"
- style={{
- width: progress.interpolate({
- inputRange: [0, 1],
- outputRange: ["0%", "100%"],
- }),
- }}
- />
- </View>
- )}
- </Animated.View>
- );
-}
-
type ToastVariant = keyof typeof toastVariants;
-interface ToastMessage {
- id: number;
- text: string;
- variant: ToastVariant;
- duration?: number;
- position?: string;
- showProgress?: boolean;
-}
-interface ToastContextProps {
- toast: (t: {
- message: string;
- variant?: keyof typeof toastVariants;
- duration?: number;
- position?: "top" | "bottom";
- showProgress?: boolean;
- }) => void;
- removeToast: (id: number) => void;
-}
-const ToastContext = createContext<ToastContextProps | undefined>(undefined);
-
-// TODO: refactor to pass position to Toast instead of ToastProvider
-function ToastProvider({
- children,
- position = "top",
-}: {
- children: React.ReactNode;
- position?: "top" | "bottom";
-}) {
- const [messages, setMessages] = useState<ToastMessage[]>([]);
-
- const toast: ToastContextProps["toast"] = ({
- message,
- variant = "default",
- duration = 3000,
- position = "top",
- showProgress = true,
- }: {
- message: string;
- variant?: ToastVariant;
- duration?: number;
- position?: "top" | "bottom";
- showProgress?: boolean;
- }) => {
- setMessages((prev) => [
- ...prev,
- {
- id: Date.now(),
- text: message,
- variant,
- duration,
- position,
- showProgress,
- },
- ]);
- };
-
- const removeToast = (id: number) => {
- setMessages((prev) => prev.filter((message) => message.id !== id));
- };
-
- return (
- <ToastContext.Provider value={{ toast, removeToast }}>
- {children}
- <View
- className={cn("absolute left-0 right-0", {
- "top-[45px]": position === "top",
- "bottom-0": position === "bottom",
- })}
- >
- {messages.map((message) => (
- <Toast
- key={message.id}
- id={message.id}
- message={message.text}
- variant={message.variant}
- duration={message.duration}
- showProgress={message.showProgress}
- onHide={removeToast}
- />
- ))}
- </View>
- </ToastContext.Provider>
- );
-}
-
+// Compatibility wrapper for sonner-native
function useToast() {
- const context = useContext(ToastContext);
- if (!context) {
- throw new Error("useToast must be used within ToastProvider");
- }
- return context;
+ return {
+ toast: ({
+ message,
+ variant = "default",
+ duration = 3000,
+ }: {
+ message: string;
+ variant?: ToastVariant;
+ duration?: number;
+ position?: "top" | "bottom";
+ showProgress?: boolean;
+ }) => {
+ // Map variants to sonner-native methods
+ switch (variant) {
+ case "success":
+ sonnerToast.success(message, { duration });
+ break;
+ case "destructive":
+ sonnerToast.error(message, { duration });
+ break;
+ case "info":
+ sonnerToast.info(message, { duration });
+ break;
+ default:
+ sonnerToast(message, { duration });
+ }
+ },
+ removeToast: () => {
+ // sonner-native handles dismissal automatically
+ },
+ };
}
-export { ToastProvider, ToastVariant, Toast, toastVariants, useToast };
+export { ToastVariant, toastVariants, useToast };
diff --git a/apps/mobile/globals.css b/apps/mobile/globals.css
index 992b92cd..82fa9eab 100644
--- a/apps/mobile/globals.css
+++ b/apps/mobile/globals.css
@@ -23,46 +23,6 @@
--border: 230 230 235;
--input: 210 210 215;
--ring: 230 230 235;
-
- --android-background: 250 252 255;
- --android-foreground: 27 28 29;
- --android-card: 255 255 255;
- --android-card-foreground: 24 28 35;
- --android-popover: 215 217 228;
- --android-popover-foreground: 0 0 0;
- --android-primary: 0 112 233;
- --android-primary-foreground: 255 255 255;
- --android-secondary: 176 201 255;
- --android-secondary-foreground: 28 60 114;
- --android-muted: 176 176 181;
- --android-muted-foreground: 102 102 102;
- --android-accent: 169 73 204;
- --android-accent-foreground: 255 255 255;
- --android-destructive: 186 26 26;
- --android-destructive-foreground: 255 255 255;
- --android-border: 118 122 127;
- --android-input: 197 201 206;
- --android-ring: 118 122 127;
-
- --web-background: 250 252 255;
- --web-foreground: 27 28 29;
- --web-card: 255 255 255;
- --web-card-foreground: 24 28 35;
- --web-popover: 215 217 228;
- --web-popover-foreground: 0 0 0;
- --web-primary: 0 112 233;
- --web-primary-foreground: 255 255 255;
- --web-secondary: 176 201 255;
- --web-secondary-foreground: 28 60 114;
- --web-muted: 216 226 255;
- --web-muted-foreground: 0 26 65;
- --web-accent: 169 73 204;
- --web-accent-foreground: 255 255 255;
- --web-destructive: 186 26 26;
- --web-destructive-foreground: 255 255 255;
- --web-border: 118 122 127;
- --web-input: 197 201 206;
- --web-ring: 118 122 127;
}
@media (prefers-color-scheme: dark) {
@@ -86,46 +46,6 @@
--border: 40 40 40;
--input: 51 51 51;
--ring: 40 40 40;
-
- --android-background: 24 28 32;
- --android-foreground: 221 227 233;
- --android-card: 36 40 44;
- --android-card-foreground: 197 201 206;
- --android-popover: 70 74 78;
- --android-popover-foreground: 197 201 206;
- --android-primary: 0 69 148;
- --android-primary-foreground: 214 224 255;
- --android-secondary: 28 60 114;
- --android-secondary-foreground: 255 255 255;
- --android-muted: 112 112 115;
- --android-muted-foreground: 226 226 231;
- --android-accent: 83 0 111;
- --android-accent-foreground: 255 255 255;
- --android-destructive: 147 0 10;
- --android-destructive-foreground: 255 255 255;
- --android-border: 143 148 153;
- --android-input: 70 74 78;
- --android-ring: 143 148 153;
-
- --web-background: 24 28 32;
- --web-foreground: 221 227 233;
- --web-card: 70 74 78;
- --web-card-foreground: 197 201 206;
- --web-popover: 70 74 78;
- --web-popover-foreground: 197 201 206;
- --web-primary: 0 69 148;
- --web-primary-foreground: 214 224 255;
- --web-secondary: 28 60 114;
- --web-secondary-foreground: 255 255 255;
- --web-muted: 29 27 29;
- --web-muted-foreground: 230 224 228;
- --web-accent: 83 0 111;
- --web-accent-foreground: 255 255 255;
- --web-destructive: 147 0 10;
- --web-destructive-foreground: 255 255 255;
- --web-border: 143 148 153;
- --web-input: 70 74 78;
- --web-ring: 143 148 153;
}
}
}
diff --git a/apps/mobile/lib/hooks.ts b/apps/mobile/lib/hooks.ts
index 38ecebea..c3cb9d22 100644
--- a/apps/mobile/lib/hooks.ts
+++ b/apps/mobile/lib/hooks.ts
@@ -1,12 +1,39 @@
-import { ImageURISource } from "react-native";
+import { useQuery } from "@tanstack/react-query";
import useAppSettings from "./settings";
import { buildApiHeaders } from "./utils";
-export function useAssetUrl(assetId: string): ImageURISource {
+interface AssetSource {
+ uri: string;
+ headers: Record<string, string>;
+}
+
+export function useAssetUrl(assetId: string): AssetSource {
const { settings } = useAppSettings();
return {
uri: `${settings.address}/api/assets/${assetId}`,
headers: buildApiHeaders(settings.apiKey, settings.customHeaders),
};
}
+
+export function useServerVersion() {
+ const { settings } = useAppSettings();
+
+ return useQuery({
+ queryKey: ["serverVersion", settings.address],
+ queryFn: async () => {
+ const response = await fetch(`${settings.address}/api/version`, {
+ headers: buildApiHeaders(settings.apiKey, settings.customHeaders),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch server version: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data.version as string;
+ },
+ enabled: !!settings.address,
+ staleTime: 1000 * 60 * 5, // Cache for 5 minutes
+ });
+}
diff --git a/apps/mobile/lib/providers.tsx b/apps/mobile/lib/providers.tsx
index 938b8aeb..4a7def1d 100644
--- a/apps/mobile/lib/providers.tsx
+++ b/apps/mobile/lib/providers.tsx
@@ -1,9 +1,10 @@
import { useEffect } from "react";
import FullPageSpinner from "@/components/ui/FullPageSpinner";
-import { ToastProvider } from "@/components/ui/Toast";
+import { Toaster } from "sonner-native";
-import { TRPCProvider } from "@karakeep/shared-react/providers/trpc-provider";
+import { TRPCSettingsProvider } from "@karakeep/shared-react/providers/trpc-provider";
+import { ReaderSettingsProvider } from "./readerSettings";
import useAppSettings from "./settings";
export function Providers({ children }: { children: React.ReactNode }) {
@@ -19,8 +20,11 @@ export function Providers({ children }: { children: React.ReactNode }) {
}
return (
- <TRPCProvider settings={settings}>
- <ToastProvider>{children}</ToastProvider>
- </TRPCProvider>
+ <TRPCSettingsProvider settings={settings}>
+ <ReaderSettingsProvider>
+ {children}
+ <Toaster />
+ </ReaderSettingsProvider>
+ </TRPCSettingsProvider>
);
}
diff --git a/apps/mobile/lib/readerSettings.tsx b/apps/mobile/lib/readerSettings.tsx
new file mode 100644
index 00000000..9a3fc835
--- /dev/null
+++ b/apps/mobile/lib/readerSettings.tsx
@@ -0,0 +1,93 @@
+import { ReactNode, useCallback } from "react";
+import { Platform } from "react-native";
+
+import {
+ ReaderSettingsProvider as BaseReaderSettingsProvider,
+ useReaderSettingsContext,
+} from "@karakeep/shared-react/hooks/reader-settings";
+import { ReaderSettingsPartial } from "@karakeep/shared/types/readers";
+import { ZReaderFontFamily } from "@karakeep/shared/types/users";
+
+import { useSettings } from "./settings";
+
+// Mobile-specific font families for native Text components
+// On Android, use generic font family names: "serif", "sans-serif", "monospace"
+// On iOS, use specific font names like "Georgia" and "Courier"
+// Note: undefined means use the system default font
+export const MOBILE_FONT_FAMILIES: Record<
+ ZReaderFontFamily,
+ string | undefined
+> = Platform.select({
+ android: {
+ serif: "serif",
+ sans: undefined,
+ mono: "monospace",
+ },
+ default: {
+ serif: "Georgia",
+ sans: undefined,
+ mono: "Courier",
+ },
+})!;
+
+// Font families for WebView HTML content (CSS font stacks)
+export const WEBVIEW_FONT_FAMILIES: Record<ZReaderFontFamily, string> = {
+ serif: "Georgia, 'Times New Roman', serif",
+ sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
+ mono: "ui-monospace, Menlo, Monaco, 'Courier New', monospace",
+} as const;
+
+/**
+ * Mobile-specific provider for reader settings.
+ * Wraps the shared provider with mobile storage callbacks.
+ */
+export function ReaderSettingsProvider({ children }: { children: ReactNode }) {
+ // Read from zustand store directly to keep callback stable (empty deps).
+ const getLocalOverrides = useCallback((): ReaderSettingsPartial => {
+ const currentSettings = useSettings.getState().settings.settings;
+ return {
+ fontSize: currentSettings.readerFontSize,
+ lineHeight: currentSettings.readerLineHeight,
+ fontFamily: currentSettings.readerFontFamily,
+ };
+ }, []);
+
+ const saveLocalOverrides = useCallback((overrides: ReaderSettingsPartial) => {
+ const currentSettings = useSettings.getState().settings.settings;
+ // Remove reader settings keys first, then add back only defined ones
+ const {
+ readerFontSize: _fs,
+ readerLineHeight: _lh,
+ readerFontFamily: _ff,
+ ...rest
+ } = currentSettings;
+
+ const newSettings = { ...rest };
+ if (overrides.fontSize !== undefined) {
+ (newSettings as typeof currentSettings).readerFontSize =
+ overrides.fontSize;
+ }
+ if (overrides.lineHeight !== undefined) {
+ (newSettings as typeof currentSettings).readerLineHeight =
+ overrides.lineHeight;
+ }
+ if (overrides.fontFamily !== undefined) {
+ (newSettings as typeof currentSettings).readerFontFamily =
+ overrides.fontFamily;
+ }
+
+ useSettings.getState().setSettings(newSettings);
+ }, []);
+
+ return (
+ <BaseReaderSettingsProvider
+ getLocalOverrides={getLocalOverrides}
+ saveLocalOverrides={saveLocalOverrides}
+ >
+ {children}
+ </BaseReaderSettingsProvider>
+ );
+}
+
+// Re-export the context hook as useReaderSettings for mobile consumers
+export { useReaderSettingsContext as useReaderSettings };
diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts
index 8eb646cb..d6470145 100644
--- a/apps/mobile/lib/session.ts
+++ b/apps/mobile/lib/session.ts
@@ -1,12 +1,17 @@
import { useCallback } from "react";
+import { useMutation } from "@tanstack/react-query";
+
+import { useTRPC } from "@karakeep/shared-react/trpc";
import useAppSettings from "./settings";
-import { api } from "./trpc";
export function useSession() {
const { settings, setSettings } = useAppSettings();
+ const api = useTRPC();
- const { mutate: deleteKey } = api.apiKeys.revoke.useMutation();
+ const { mutate: deleteKey } = useMutation(
+ api.apiKeys.revoke.mutationOptions(),
+ );
const logout = useCallback(() => {
if (settings.apiKeyId) {
diff --git a/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts
index 40a33976..8da1d33d 100644
--- a/apps/mobile/lib/settings.ts
+++ b/apps/mobile/lib/settings.ts
@@ -1,7 +1,10 @@
+import { useEffect } from "react";
import * as SecureStore from "expo-secure-store";
import { z } from "zod";
import { create } from "zustand";
+import { zReaderFontFamilySchema } from "@karakeep/shared/types/users";
+
const SETTING_NAME = "settings";
const zSettingsSchema = z.object({
@@ -16,6 +19,10 @@ const zSettingsSchema = z.object({
.default("reader"),
showNotes: z.boolean().optional().default(false),
customHeaders: z.record(z.string(), z.string()).optional().default({}),
+ // Reader settings (local device overrides)
+ readerFontSize: z.number().int().min(12).max(24).optional(),
+ readerLineHeight: z.number().min(1.2).max(2.5).optional(),
+ readerFontFamily: zReaderFontFamilySchema.optional(),
});
export type Settings = z.infer<typeof zSettingsSchema>;
@@ -71,5 +78,13 @@ const useSettings = create<AppSettingsState>((set, get) => ({
export default function useAppSettings() {
const { settings, setSettings, load } = useSettings();
+ useEffect(() => {
+ if (settings.isLoading) {
+ load();
+ }
+ }, [load, settings.isLoading]);
+
return { ...settings, setSettings, load };
}
+
+export { useSettings };
diff --git a/apps/mobile/lib/trpc.ts b/apps/mobile/lib/trpc.ts
deleted file mode 100644
index e56968b8..00000000
--- a/apps/mobile/lib/trpc.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createTRPCReact } from "@trpc/react-query";
-
-import type { AppRouter } from "@karakeep/trpc/routers/_app";
-
-export const api = createTRPCReact<AppRouter>();
diff --git a/apps/mobile/lib/upload.ts b/apps/mobile/lib/upload.ts
index 06f007f7..2f323ddb 100644
--- a/apps/mobile/lib/upload.ts
+++ b/apps/mobile/lib/upload.ts
@@ -1,6 +1,7 @@
import ReactNativeBlobUtil from "react-native-blob-util";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTRPC } from "@karakeep/shared-react/trpc";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
import {
zUploadErrorSchema,
@@ -8,7 +9,6 @@ import {
} from "@karakeep/shared/types/uploads";
import type { Settings } from "./settings";
-import { api } from "./trpc";
import { buildApiHeaders } from "./utils";
export function useUploadAsset(
@@ -18,13 +18,13 @@ export function useUploadAsset(
onError?: (e: string) => void;
},
) {
- const invalidateAllBookmarks =
- api.useUtils().bookmarks.getBookmarks.invalidate;
+ const api = useTRPC();
+ const queryClient = useQueryClient();
- const { mutate: createBookmark, isPending: isCreatingBookmark } =
- api.bookmarks.createBookmark.useMutation({
+ const { mutate: createBookmark, isPending: isCreatingBookmark } = useMutation(
+ api.bookmarks.createBookmark.mutationOptions({
onSuccess: (d) => {
- invalidateAllBookmarks();
+ queryClient.invalidateQueries(api.bookmarks.getBookmarks.pathFilter());
if (options.onSuccess) {
options.onSuccess(d);
}
@@ -34,7 +34,8 @@ export function useUploadAsset(
options.onError(e.message);
}
},
- });
+ }),
+ );
const { mutate: uploadAsset, isPending: isUploading } = useMutation({
mutationFn: async (file: { type: string; name: string; uri: string }) => {
diff --git a/apps/mobile/lib/useColorScheme.tsx b/apps/mobile/lib/useColorScheme.tsx
index a00a445d..40e7ad53 100644
--- a/apps/mobile/lib/useColorScheme.tsx
+++ b/apps/mobile/lib/useColorScheme.tsx
@@ -46,13 +46,7 @@ function useInitialAndroidBarSync() {
export { useColorScheme, useInitialAndroidBarSync };
function setNavigationBar(colorScheme: "light" | "dark") {
- return Promise.all([
- NavigationBar.setButtonStyleAsync(
- colorScheme === "dark" ? "light" : "dark",
- ),
- NavigationBar.setPositionAsync("absolute"),
- NavigationBar.setBackgroundColorAsync(
- colorScheme === "dark" ? "#00000030" : "#ffffff80",
- ),
- ]);
+ return NavigationBar.setButtonStyleAsync(
+ colorScheme === "dark" ? "light" : "dark",
+ );
}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index f826300d..7f85a2f7 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -15,52 +15,59 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@expo/metro-runtime": "~6.1.2",
+ "@expo/vector-icons": "^15.0.3",
"@karakeep/shared": "workspace:^0.1.0",
"@karakeep/shared-react": "workspace:^0.1.0",
"@karakeep/trpc": "workspace:^0.1.0",
- "@react-native-async-storage/async-storage": "1.23.1",
- "@react-native-menu/menu": "^1.2.4",
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "@react-native-menu/menu": "^2.0.0",
+ "@react-navigation/native": "^7.1.8",
"@rn-primitives/hooks": "^1.3.0",
"@rn-primitives/slot": "^1.2.0",
- "@shopify/flash-list": "^2.0.3",
+ "@shopify/flash-list": "2.0.2",
"@tanstack/react-query": "5.90.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
- "expo": "~53.0.19",
- "expo-build-properties": "^0.14.6",
- "expo-checkbox": "^4.1.4",
- "expo-clipboard": "^7.1.4",
- "expo-constants": "~17.1.6",
- "expo-dev-client": "^5.2.0",
- "expo-file-system": "~18.1.11",
- "expo-haptics": "^14.1.4",
- "expo-image": "^2.4.0",
- "expo-image-picker": "^16.1.4",
- "expo-linking": "~7.1.5",
- "expo-navigation-bar": "^4.2.5",
- "expo-router": "~5.0.7",
- "expo-secure-store": "^14.2.3",
- "expo-share-intent": "^4.0.0",
- "expo-sharing": "~13.0.1",
- "expo-status-bar": "~2.2.3",
- "expo-system-ui": "^5.0.8",
- "expo-web-browser": "^14.1.6",
+ "date-fns": "^3.6.0",
+ "expo": "~54.0.31",
+ "expo-build-properties": "~1.0.10",
+ "expo-checkbox": "~5.0.8",
+ "expo-clipboard": "~8.0.8",
+ "expo-constants": "~18.0.13",
+ "expo-dev-client": "~6.0.20",
+ "expo-file-system": "~19.0.21",
+ "expo-haptics": "~15.0.8",
+ "expo-image": "~3.0.11",
+ "expo-image-picker": "~17.0.10",
+ "expo-linking": "~8.0.11",
+ "expo-navigation-bar": "~5.0.10",
+ "expo-router": "~6.0.21",
+ "expo-secure-store": "~15.0.8",
+ "expo-share-intent": "^5.1.1",
+ "expo-sharing": "~14.0.8",
+ "expo-status-bar": "~3.0.9",
+ "expo-system-ui": "~6.0.9",
+ "expo-web-browser": "~15.0.10",
"lucide-react-native": "^0.513.0",
- "nativewind": "^4.1.23",
- "react": "^19.1.0",
- "react-native": "0.79.5",
+ "nativewind": "^4.2.1",
+ "react": "^19.2.1",
+ "react-native": "0.81.5",
"react-native-awesome-slider": "^2.5.3",
"react-native-blob-util": "^0.21.2",
- "react-native-gesture-handler": "~2.24.0",
+ "react-native-css-interop": "0.2.1",
+ "react-native-gesture-handler": "~2.28.0",
"react-native-image-viewing": "^0.2.2",
"react-native-keyboard-controller": "^1.18.5",
"react-native-markdown-display": "^7.0.2",
"react-native-pdf": "7.0.3",
- "react-native-reanimated": "^3.17.5",
- "react-native-safe-area-context": "5.4.0",
- "react-native-screens": "~4.11.1",
- "react-native-svg": "^15.11.2",
- "react-native-webview": "^13.13.5",
+ "react-native-reanimated": "~4.1.1",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.16.0",
+ "react-native-svg": "15.12.1",
+ "react-native-webview": "13.15.0",
+ "react-native-worklets": "0.5.1",
+ "sonner-native": "^0.22.2",
"tailwind-merge": "^2.2.1",
"zod": "^3.24.2",
"zustand": "^5.0.5"
diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js
index 74a9f30a..ee6214f0 100644
--- a/apps/mobile/tailwind.config.js
+++ b/apps/mobile/tailwind.config.js
@@ -1,4 +1,4 @@
-const { hairlineWidth, platformSelect } = require("nativewind/theme");
+const { hairlineWidth } = require("nativewind/theme");
/** @type {import('tailwindcss').Config} */
module.exports = {
@@ -53,14 +53,8 @@ module.exports = {
function withOpacity(variableName) {
return ({ opacityValue }) => {
if (opacityValue !== undefined) {
- return platformSelect({
- ios: `rgb(var(--${variableName}) / ${opacityValue})`,
- android: `rgb(var(--android-${variableName}) / ${opacityValue})`,
- });
+ return `rgb(var(--${variableName}) / ${opacityValue})`;
}
- return platformSelect({
- ios: `rgb(var(--${variableName}))`,
- android: `rgb(var(--android-${variableName}))`,
- });
+ return `rgb(var(--${variableName}))`;
};
}
diff --git a/apps/mobile/theme/colors.ts b/apps/mobile/theme/colors.ts
index 626bcb99..47c54a52 100644
--- a/apps/mobile/theme/colors.ts
+++ b/apps/mobile/theme/colors.ts
@@ -1,6 +1,4 @@
-import { Platform } from "react-native";
-
-const IOS_SYSTEM_COLORS = {
+const SYSTEM_COLORS = {
white: "rgb(255, 255, 255)",
black: "rgb(0, 0, 0)",
light: {
@@ -33,77 +31,6 @@ const IOS_SYSTEM_COLORS = {
},
} as const;
-const ANDROID_COLORS = {
- white: "rgb(255, 255, 255)",
- black: "rgb(0, 0, 0)",
- light: {
- grey6: "rgb(242, 242, 247)",
- grey5: "rgb(230, 230, 235)",
- grey4: "rgb(210, 210, 215)",
- grey3: "rgb(199, 199, 204)",
- grey2: "rgb(176, 176, 181)",
- grey: "rgb(153, 153, 158)",
- background: "rgb(250, 252, 255)",
- foreground: "rgb(27, 28, 29)",
- root: "rgb(250, 252, 255)",
- card: "rgb(250, 252, 255)",
- destructive: "rgb(186, 26, 26)",
- primary: "rgb(0, 112, 233)",
- },
- dark: {
- grey6: "rgb(21, 21, 24)",
- grey5: "rgb(40, 40, 40)",
- grey4: "rgb(51, 51, 51)",
- grey3: "rgb(70, 70, 70)",
- grey2: "rgb(99, 99, 99)",
- grey: "rgb(158, 158, 158)",
- background: "rgb(24, 28, 32)",
- foreground: "rgb(221, 227, 233)",
- root: "rgb(24, 28, 32)",
- card: "rgb(24, 28, 32)",
- destructive: "rgb(147, 0, 10)",
- primary: "rgb(0, 69, 148)",
- },
-} as const;
-
-const WEB_COLORS = {
- white: "rgb(255, 255, 255)",
- black: "rgb(0, 0, 0)",
- light: {
- grey6: "rgb(250, 252, 255)",
- grey5: "rgb(243, 247, 251)",
- grey4: "rgb(236, 242, 248)",
- grey3: "rgb(233, 239, 247)",
- grey2: "rgb(229, 237, 245)",
- grey: "rgb(226, 234, 243)",
- background: "rgb(250, 252, 255)",
- foreground: "rgb(27, 28, 29)",
- root: "rgb(250, 252, 255)",
- card: "rgb(250, 252, 255)",
- destructive: "rgb(186, 26, 26)",
- primary: "rgb(0, 112, 233)",
- },
- dark: {
- grey6: "rgb(25, 30, 36)",
- grey5: "rgb(31, 38, 45)",
- grey4: "rgb(35, 43, 52)",
- grey3: "rgb(38, 48, 59)",
- grey2: "rgb(40, 51, 62)",
- grey: "rgb(44, 56, 68)",
- background: "rgb(24, 28, 32)",
- foreground: "rgb(221, 227, 233)",
- root: "rgb(24, 28, 32)",
- card: "rgb(24, 28, 32)",
- destructive: "rgb(147, 0, 10)",
- primary: "rgb(0, 69, 148)",
- },
-} as const;
-
-const COLORS =
- Platform.OS === "ios"
- ? IOS_SYSTEM_COLORS
- : Platform.OS === "android"
- ? ANDROID_COLORS
- : WEB_COLORS;
+const COLORS = SYSTEM_COLORS;
export { COLORS };
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",
};
},
diff --git a/apps/workers/index.ts b/apps/workers/index.ts
index b605b50f..c7b9533d 100644
--- a/apps/workers/index.ts
+++ b/apps/workers/index.ts
@@ -3,9 +3,22 @@ import "dotenv/config";
import { buildServer } from "server";
import {
+ AdminMaintenanceQueue,
+ AssetPreprocessingQueue,
+ BackupQueue,
+ FeedQueue,
+ initTracing,
+ LinkCrawlerQueue,
loadAllPlugins,
+ LowPriorityCrawlerQueue,
+ OpenAIQueue,
prepareQueue,
+ RuleEngineQueue,
+ SearchIndexingQueue,
+ shutdownTracing,
startQueue,
+ VideoWorkerQueue,
+ WebhookQueue,
} from "@karakeep/shared-server";
import serverConfig from "@karakeep/shared/config";
import logger from "@karakeep/shared/logger";
@@ -16,6 +29,7 @@ import { AssetPreprocessingWorker } from "./workers/assetPreprocessingWorker";
import { BackupSchedulingWorker, BackupWorker } from "./workers/backupWorker";
import { CrawlerWorker } from "./workers/crawlerWorker";
import { FeedRefreshingWorker, FeedWorker } from "./workers/feedWorker";
+import { ImportWorker } from "./workers/importWorker";
import { OpenAiWorker } from "./workers/inference/inferenceWorker";
import { RuleEngineWorker } from "./workers/ruleEngineWorker";
import { SearchIndexingWorker } from "./workers/searchWorker";
@@ -23,19 +37,53 @@ import { VideoWorker } from "./workers/videoWorker";
import { WebhookWorker } from "./workers/webhookWorker";
const workerBuilders = {
- crawler: () => CrawlerWorker.build(),
- inference: () => OpenAiWorker.build(),
- search: () => SearchIndexingWorker.build(),
- adminMaintenance: () => AdminMaintenanceWorker.build(),
- video: () => VideoWorker.build(),
- feed: () => FeedWorker.build(),
- assetPreprocessing: () => AssetPreprocessingWorker.build(),
- webhook: () => WebhookWorker.build(),
- ruleEngine: () => RuleEngineWorker.build(),
- backup: () => BackupWorker.build(),
+ crawler: async () => {
+ await LinkCrawlerQueue.ensureInit();
+ return CrawlerWorker.build(LinkCrawlerQueue);
+ },
+ lowPriorityCrawler: async () => {
+ await LowPriorityCrawlerQueue.ensureInit();
+ return CrawlerWorker.build(LowPriorityCrawlerQueue);
+ },
+ inference: async () => {
+ await OpenAIQueue.ensureInit();
+ return OpenAiWorker.build();
+ },
+ search: async () => {
+ await SearchIndexingQueue.ensureInit();
+ return SearchIndexingWorker.build();
+ },
+ adminMaintenance: async () => {
+ await AdminMaintenanceQueue.ensureInit();
+ return AdminMaintenanceWorker.build();
+ },
+ video: async () => {
+ await VideoWorkerQueue.ensureInit();
+ return VideoWorker.build();
+ },
+ feed: async () => {
+ await FeedQueue.ensureInit();
+ return FeedWorker.build();
+ },
+ assetPreprocessing: async () => {
+ await AssetPreprocessingQueue.ensureInit();
+ return AssetPreprocessingWorker.build();
+ },
+ webhook: async () => {
+ await WebhookQueue.ensureInit();
+ return WebhookWorker.build();
+ },
+ ruleEngine: async () => {
+ await RuleEngineQueue.ensureInit();
+ return RuleEngineWorker.build();
+ },
+ backup: async () => {
+ await BackupQueue.ensureInit();
+ return BackupWorker.build();
+ },
} as const;
-type WorkerName = keyof typeof workerBuilders;
+type WorkerName = keyof typeof workerBuilders | "import";
const enabledWorkers = new Set(serverConfig.workers.enabledWorkers);
const disabledWorkers = new Set(serverConfig.workers.disabledWorkers);
@@ -51,6 +99,7 @@ function isWorkerEnabled(name: WorkerName) {
async function main() {
await loadAllPlugins();
+ initTracing("workers");
logger.info(`Workers version: ${serverConfig.serverVersion ?? "not set"}`);
await prepareQueue();
@@ -75,10 +124,19 @@ async function main() {
BackupSchedulingWorker.start();
}
+ // Start import polling worker
+ let importWorker: ImportWorker | null = null;
+ let importWorkerPromise: Promise<void> | null = null;
+ if (isWorkerEnabled("import")) {
+ importWorker = new ImportWorker();
+ importWorkerPromise = importWorker.start();
+ }
+
await Promise.any([
Promise.all([
...workers.map(({ worker }) => worker.run()),
httpServer.serve(),
+ ...(importWorkerPromise ? [importWorkerPromise] : []),
]),
shutdownPromise,
]);
@@ -93,10 +151,14 @@ async function main() {
if (workers.some((w) => w.name === "backup")) {
BackupSchedulingWorker.stop();
}
+ if (importWorker) {
+ importWorker.stop();
+ }
for (const { worker } of workers) {
worker.stop();
}
await httpServer.stop();
+ await shutdownTracing();
process.exit(0);
}
diff --git a/apps/workers/metascraper-plugins/metascraper-amazon-improved.ts b/apps/workers/metascraper-plugins/metascraper-amazon-improved.ts
new file mode 100644
index 00000000..ea9bf2e9
--- /dev/null
+++ b/apps/workers/metascraper-plugins/metascraper-amazon-improved.ts
@@ -0,0 +1,77 @@
+import type { Rules } from "metascraper";
+
+/**
+ * Improved Amazon metascraper plugin that fixes image extraction.
+ *
+ * The default metascraper-amazon package uses `.a-dynamic-image` selector
+ * which matches the FIRST element with that class. On amazon.com pages,
+ * this is often the Prime logo instead of the product image.
+ *
+ * This plugin uses more specific selectors to target the actual product
+ * image:
+ * - #landingImage: The main product image ID
+ * - #imgTagWrapperId img: Fallback container for product images
+ * - #imageBlock img: Additional fallback for newer Amazon layouts
+ *
+ * By placing this plugin BEFORE metascraperAmazon() in the plugin chain,
+ * we ensure the correct image is extracted while keeping all other Amazon
+ * metadata (title, brand, description) from the original plugin.
+ */
+
+const REGEX_AMAZON_URL =
+ /https?:\/\/(.*amazon\..*\/.*|.*amzn\..*\/.*|.*a\.co\/.*)/i;
+
+const test = ({ url }: { url: string }): boolean => REGEX_AMAZON_URL.test(url);
+
+const metascraperAmazonImproved = () => {
+ const rules: Rules = {
+ pkgName: "metascraper-amazon-improved",
+ test,
+ image: ({ htmlDom }) => {
+ // Try the main product image ID first (most reliable)
+ // Prefer data-old-hires attribute for high-resolution images
+ const landingImageHires = htmlDom("#landingImage").attr("data-old-hires");
+ if (landingImageHires) {
+ return landingImageHires;
+ }
+
+ const landingImageSrc = htmlDom("#landingImage").attr("src");
+ if (landingImageSrc) {
+ return landingImageSrc;
+ }
+
+ // Fallback to image block container
+ const imgTagHires = htmlDom("#imgTagWrapperId img").attr(
+ "data-old-hires",
+ );
+ if (imgTagHires) {
+ return imgTagHires;
+ }
+
+ const imgTagSrc = htmlDom("#imgTagWrapperId img").attr("src");
+ if (imgTagSrc) {
+ return imgTagSrc;
+ }
+
+ // Additional fallback for newer Amazon layouts
+ const imageBlockHires = htmlDom("#imageBlock img")
+ .first()
+ .attr("data-old-hires");
+ if (imageBlockHires) {
+ return imageBlockHires;
+ }
+
+ const imageBlockSrc = htmlDom("#imageBlock img").first().attr("src");
+ if (imageBlockSrc) {
+ return imageBlockSrc;
+ }
+
+ // Return undefined to allow next plugin to try
+ return undefined;
+ },
+ };
+
+ return rules;
+};
+
+export default metascraperAmazonImproved;
diff --git a/apps/workers/metascraper-plugins/metascraper-reddit.ts b/apps/workers/metascraper-plugins/metascraper-reddit.ts
index 1fbee3ea..a5de5fe3 100644
--- a/apps/workers/metascraper-plugins/metascraper-reddit.ts
+++ b/apps/workers/metascraper-plugins/metascraper-reddit.ts
@@ -1,4 +1,8 @@
-import type { Rules } from "metascraper";
+import type { CheerioAPI } from "cheerio";
+import type { Rules, RulesOptions } from "metascraper";
+import { decode as decodeHtmlEntities } from "html-entities";
+import { fetchWithProxy } from "network";
+import { z } from "zod";
import logger from "@karakeep/shared/logger";
@@ -28,15 +32,267 @@ import logger from "@karakeep/shared/logger";
* will return 'undefined' and the next plugin
* should continue to attempt to extract images.
*
- * Note: there is another way to accomplish this.
- * If '.json' is appended to a Reddit url, the
- * server will provide a JSON document summarizing
- * the post. If there are preview images, they are
- * included in a section of the JSON. To prevent
- * additional server requests, this method is not
- * currently being used.
+ * We also attempt to fetch the Reddit JSON response
+ * (by appending '.json' to the URL) to grab the
+ * title and preview images directly from the API.
**/
+const redditPreviewImageSchema = z.object({
+ source: z.object({ url: z.string().optional() }).optional(),
+ resolutions: z.array(z.object({ url: z.string().optional() })).optional(),
+});
+
+const redditMediaMetadataItemSchema = z.object({
+ s: z.object({ u: z.string().optional() }).optional(),
+ p: z.array(z.object({ u: z.string().optional() })).optional(),
+});
+
+const redditPostSchema = z.object({
+ title: z.string().optional(),
+ preview: z
+ .object({ images: z.array(redditPreviewImageSchema).optional() })
+ .optional(),
+ url_overridden_by_dest: z.string().optional(),
+ url: z.string().optional(),
+ thumbnail: z.string().optional(),
+ media_metadata: z.record(redditMediaMetadataItemSchema).optional(),
+ author: z.string().optional(),
+ created_utc: z.number().optional(),
+ selftext: z.string().nullish(),
+ selftext_html: z.string().nullish(),
+ subreddit_name_prefixed: z.string().optional(),
+});
+
+type RedditPostData = z.infer<typeof redditPostSchema>;
+
+const redditResponseSchema = z.array(
+ z.object({
+ data: z.object({
+ children: z.array(z.object({ data: redditPostSchema })).optional(),
+ }),
+ }),
+);
+
+interface RedditFetchResult {
+ fetched: boolean;
+ post?: RedditPostData;
+}
+
+const REDDIT_CACHE_TTL_MS = 60 * 1000; // 1 minute TTL to avoid stale data
+
+interface RedditCacheEntry {
+ expiresAt: number;
+ promise: Promise<RedditFetchResult>;
+}
+
+const redditJsonCache = new Map<string, RedditCacheEntry>();
+
+const purgeExpiredCacheEntries = (now: number) => {
+ for (const [key, entry] of redditJsonCache.entries()) {
+ if (entry.expiresAt <= now) {
+ redditJsonCache.delete(key);
+ }
+ }
+};
+
+const decodeRedditUrl = (url?: string): string | undefined => {
+ if (!url) {
+ return undefined;
+ }
+ const decoded = decodeHtmlEntities(url);
+ return decoded || undefined;
+};
+
+const buildJsonUrl = (url: string): string => {
+ const urlObj = new URL(url);
+
+ if (!urlObj.pathname.endsWith(".json")) {
+ urlObj.pathname = urlObj.pathname.replace(/\/?$/, ".json");
+ }
+
+ return urlObj.toString();
+};
+
+const extractImageFromMediaMetadata = (
+ media_metadata?: RedditPostData["media_metadata"],
+): string | undefined => {
+ if (!media_metadata) {
+ return undefined;
+ }
+ const firstItem = Object.values(media_metadata)[0];
+ if (!firstItem) {
+ return undefined;
+ }
+
+ return (
+ decodeRedditUrl(firstItem.s?.u) ??
+ decodeRedditUrl(firstItem.p?.[0]?.u) ??
+ undefined
+ );
+};
+
+const isRedditImageHost = (urlCandidate: string): boolean => {
+ try {
+ const hostname = new URL(urlCandidate).hostname;
+ return hostname.includes("redd.it");
+ } catch {
+ return false;
+ }
+};
+
+const extractImageFromPost = (post: RedditPostData): string | undefined => {
+ const previewImage = post.preview?.images?.[0];
+ const previewUrl =
+ decodeRedditUrl(previewImage?.source?.url) ??
+ decodeRedditUrl(previewImage?.resolutions?.[0]?.url);
+ if (previewUrl) {
+ return previewUrl;
+ }
+
+ const mediaUrl = extractImageFromMediaMetadata(post.media_metadata);
+ if (mediaUrl) {
+ return mediaUrl;
+ }
+
+ const directUrl =
+ decodeRedditUrl(post.url_overridden_by_dest) ??
+ decodeRedditUrl(post.url) ??
+ decodeRedditUrl(post.thumbnail);
+
+ if (directUrl && isRedditImageHost(directUrl)) {
+ return directUrl;
+ }
+
+ return undefined;
+};
+
+const extractTitleFromPost = (post: RedditPostData): string | undefined =>
+ post.title?.trim() || undefined;
+
+const extractAuthorFromPost = (post: RedditPostData): string | undefined =>
+ post.author?.trim() || undefined;
+
+const extractDateFromPost = (post: RedditPostData): string | undefined => {
+ if (!post.created_utc) {
+ return undefined;
+ }
+ const date = new Date(post.created_utc * 1000);
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
+};
+
+const extractPublisherFromPost = (post: RedditPostData): string | undefined =>
+ post.subreddit_name_prefixed?.trim() || "Reddit";
+
+const REDDIT_LOGO_URL =
+ "https://www.redditstatic.com/desktop2x/img/favicon/android-icon-192x192.png";
+
+const fallbackDomImage = ({ htmlDom }: { htmlDom: CheerioAPI }) => {
+ // 'preview' subdomain images are more likely to be what we're after
+ // but it could be in the 'i' subdomain.
+ // returns undefined if neither exists
+ const previewImages = htmlDom('img[src*="preview.redd.it"]')
+ .map((_, el) => htmlDom(el).attr("src"))
+ .get();
+ const iImages = htmlDom('img[src*="i.redd.it"]')
+ .map((_, el) => htmlDom(el).attr("src"))
+ .get();
+ return previewImages[0] || iImages[0];
+};
+
+const fallbackDomTitle = ({ htmlDom }: { htmlDom: CheerioAPI }) => {
+ const title: string | undefined = htmlDom("shreddit-title[title]")
+ .first()
+ .attr("title");
+ const postTitle: string | undefined =
+ title ?? htmlDom("shreddit-post[post-title]").first().attr("post-title");
+ return postTitle ? postTitle.trim() : undefined;
+};
+
+const fetchRedditPostData = async (url: string): Promise<RedditFetchResult> => {
+ const cached = redditJsonCache.get(url);
+ const now = Date.now();
+
+ purgeExpiredCacheEntries(now);
+
+ if (cached && cached.expiresAt > now) {
+ return cached.promise;
+ }
+
+ const promise = (async () => {
+ let jsonUrl: string;
+ try {
+ jsonUrl = buildJsonUrl(url);
+ } catch (error) {
+ logger.warn(
+ "[MetascraperReddit] Failed to construct Reddit JSON URL",
+ error,
+ );
+ return { fetched: false };
+ }
+
+ let response;
+ try {
+ response = await fetchWithProxy(jsonUrl, {
+ headers: { accept: "application/json" },
+ });
+ } catch (error) {
+ logger.warn(
+ `[MetascraperReddit] Failed to fetch Reddit JSON for ${jsonUrl}`,
+ error,
+ );
+ return { fetched: false };
+ }
+
+ if (response.status === 403) {
+ // API forbidden; fall back to DOM scraping.
+ return { fetched: false };
+ }
+
+ if (!response.ok) {
+ logger.warn(
+ `[MetascraperReddit] Reddit JSON request failed for ${jsonUrl} with status ${response.status}`,
+ );
+ return { fetched: false };
+ }
+
+ let payload: unknown;
+ try {
+ payload = await response.json();
+ } catch (error) {
+ logger.warn(
+ `[MetascraperReddit] Failed to parse Reddit JSON for ${jsonUrl}`,
+ error,
+ );
+ return { fetched: false };
+ }
+
+ const parsed = redditResponseSchema.safeParse(payload);
+ if (!parsed.success) {
+ logger.warn(
+ "[MetascraperReddit] Reddit JSON schema validation failed",
+ parsed.error,
+ );
+ return { fetched: false };
+ }
+
+ const firstListingWithChildren = parsed.data.find(
+ (listing) => (listing.data.children?.length ?? 0) > 0,
+ );
+
+ return {
+ fetched: true,
+ post: firstListingWithChildren?.data.children?.[0]?.data,
+ };
+ })();
+
+ redditJsonCache.set(url, {
+ promise,
+ expiresAt: now + REDDIT_CACHE_TTL_MS,
+ });
+
+ return promise;
+};
+
const domainFromUrl = (url: string): string => {
/**
* First-party metascraper plugins import metascraper-helpers,
@@ -71,27 +327,71 @@ const metascraperReddit = () => {
const rules: Rules = {
pkgName: "metascraper-reddit",
test,
- image: ({ htmlDom }) => {
- // 'preview' subdomain images are more likely to be what we're after
- // but it could be in the 'i' subdomain.
- // returns undefined if neither exists
- const previewImages = htmlDom('img[src*="preview.redd.it"]')
- .map((i, el) => htmlDom(el).attr("src"))
- .get();
- const iImages = htmlDom('img[src*="i.redd.it"]')
- .map((i, el) => htmlDom(el).attr("src"))
- .get();
- return previewImages[0] || iImages[0];
- },
- title: ({ htmlDom }) => {
- const title: string | undefined = htmlDom("shreddit-title[title]")
- .first()
- .attr("title");
- const postTitle: string | undefined =
- title ??
- htmlDom("shreddit-post[post-title]").first().attr("post-title");
- return postTitle ? postTitle.trim() : undefined;
- },
+ image: (async ({ url, htmlDom }: { url: string; htmlDom: CheerioAPI }) => {
+ const result = await fetchRedditPostData(url);
+ if (result.post) {
+ const redditImage = extractImageFromPost(result.post);
+ if (redditImage) {
+ return redditImage;
+ }
+ }
+
+ // If we successfully fetched JSON but found no Reddit image,
+ // avoid falling back to random DOM images.
+ if (result.fetched) {
+ return undefined;
+ }
+
+ return fallbackDomImage({ htmlDom });
+ }) as unknown as RulesOptions,
+ title: (async ({ url, htmlDom }: { url: string; htmlDom: CheerioAPI }) => {
+ const result = await fetchRedditPostData(url);
+ if (result.post) {
+ const redditTitle = extractTitleFromPost(result.post);
+ if (redditTitle) {
+ return redditTitle;
+ }
+ }
+
+ return fallbackDomTitle({ htmlDom });
+ }) as unknown as RulesOptions,
+ author: (async ({ url }: { url: string }) => {
+ const result = await fetchRedditPostData(url);
+ if (result.post) {
+ return extractAuthorFromPost(result.post);
+ }
+ return undefined;
+ }) as unknown as RulesOptions,
+ datePublished: (async ({ url }: { url: string }) => {
+ const result = await fetchRedditPostData(url);
+ if (result.post) {
+ return extractDateFromPost(result.post);
+ }
+ return undefined;
+ }) as unknown as RulesOptions,
+ publisher: (async ({ url }: { url: string }) => {
+ const result = await fetchRedditPostData(url);
+ if (result.post) {
+ return extractPublisherFromPost(result.post);
+ }
+ return undefined;
+ }) as unknown as RulesOptions,
+ logo: (async ({ url }: { url: string }) => {
+ const result = await fetchRedditPostData(url);
+ if (result.post) {
+ return REDDIT_LOGO_URL;
+ }
+ return undefined;
+ }) as unknown as RulesOptions,
+ readableContentHtml: (async ({ url }: { url: string }) => {
+ const result = await fetchRedditPostData(url);
+ if (result.post) {
+ const decoded = decodeHtmlEntities(result.post.selftext_html ?? "");
+ // The post has no content, return the title
+ return (decoded || result.post.title) ?? null;
+ }
+ return undefined;
+ }) as unknown as RulesOptions,
};
return rules;
diff --git a/apps/workers/metrics.ts b/apps/workers/metrics.ts
index 3dc4d2c0..42b5aa46 100644
--- a/apps/workers/metrics.ts
+++ b/apps/workers/metrics.ts
@@ -1,7 +1,7 @@
import { prometheus } from "@hono/prometheus";
-import { Counter, Registry } from "prom-client";
+import { Counter, Histogram, Registry } from "prom-client";
-const registry = new Registry();
+export const registry = new Registry();
export const { printMetrics } = prometheus({
registry: registry,
@@ -21,5 +21,15 @@ export const crawlerStatusCodeCounter = new Counter({
labelNames: ["status_code"],
});
+export const bookmarkCrawlLatencyHistogram = new Histogram({
+ name: "karakeep_bookmark_crawl_latency_seconds",
+ help: "Latency from bookmark creation to crawl completion (excludes recrawls and imports)",
+ buckets: [
+ 0.1, 0.25, 0.5, 1, 2.5, 5, 7.5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300,
+ 600, 900, 1200,
+ ],
+});
+
registry.registerMetric(workerStatsCounter);
registry.registerMetric(crawlerStatusCodeCounter);
+registry.registerMetric(bookmarkCrawlLatencyHistogram);
diff --git a/apps/workers/network.ts b/apps/workers/network.ts
index 0dc46da4..2ef8483f 100644
--- a/apps/workers/network.ts
+++ b/apps/workers/network.ts
@@ -86,6 +86,15 @@ function isAddressForbidden(address: string): boolean {
return DISALLOWED_IP_RANGES.has(parsed.range());
}
+export function getBookmarkDomain(url?: string | null): string | undefined {
+ if (!url) return undefined;
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return undefined;
+ }
+}
+
export type UrlValidationResult =
| { ok: true; url: URL }
| { ok: false; reason: string };
@@ -163,7 +172,7 @@ export async function validateUrl(
if (isAddressForbidden(hostname)) {
return {
ok: false,
- reason: `Refusing to access disallowed IP address ${hostname} (requested via ${parsedUrl.toString()})`,
+ reason: `Refusing to access disallowed IP address ${hostname} (requested via ${parsedUrl.toString()}). You can use CRAWLER_ALLOWED_INTERNAL_HOSTNAMES to allowlist specific hostnames for internal access.`,
} as const;
}
return { ok: true, url: parsedUrl } as const;
diff --git a/apps/workers/package.json b/apps/workers/package.json
index 7a5a1c81..fdec2ebf 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -22,6 +22,7 @@
"drizzle-orm": "^0.44.2",
"execa": "9.3.1",
"hono": "^4.10.6",
+ "html-entities": "^2.6.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"ipaddr.js": "^2.2.0",
@@ -52,7 +53,7 @@
"prom-client": "^15.1.3",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"rss-parser": "^3.13.0",
- "tesseract.js": "^5.1.1",
+ "tesseract.js": "^7.0.0",
"tsx": "^4.8.1",
"typescript": "^5.9",
"zod": "^3.24.2"
diff --git a/apps/workers/workerTracing.ts b/apps/workers/workerTracing.ts
new file mode 100644
index 00000000..3ff16d1c
--- /dev/null
+++ b/apps/workers/workerTracing.ts
@@ -0,0 +1,43 @@
+import type { DequeuedJob } from "@karakeep/shared/queueing";
+import { getTracer, withSpan } from "@karakeep/shared-server";
+
+const tracer = getTracer("@karakeep/workers");
+
+type WorkerRunFn<TData, TResult = void> = (
+ job: DequeuedJob<TData>,
+) => Promise<TResult>;
+
+/**
+ * Wraps a worker run function with OpenTelemetry tracing.
+ * Creates a span for each job execution and automatically handles error recording.
+ *
+ * @param name - The name of the span (e.g., "feedWorker.run", "crawlerWorker.run")
+ * @param fn - The worker run function to wrap
+ * @returns A wrapped function that executes within a traced span
+ *
+ * @example
+ * ```ts
+ * const run = withWorkerTracing("feedWorker.run", async (job) => {
+ * // Your worker logic here
+ * });
+ * ```
+ */
+export function withWorkerTracing<TData, TResult = void>(
+ name: string,
+ fn: WorkerRunFn<TData, TResult>,
+): WorkerRunFn<TData, TResult> {
+ return async (job: DequeuedJob<TData>): Promise<TResult> => {
+ return await withSpan(
+ tracer,
+ name,
+ {
+ attributes: {
+ "job.id": job.id,
+ "job.priority": job.priority,
+ "job.runNumber": job.runNumber,
+ },
+ },
+ () => fn(job),
+ );
+ };
+}
diff --git a/apps/workers/workerUtils.ts b/apps/workers/workerUtils.ts
index 3eaf5b4b..48e3b277 100644
--- a/apps/workers/workerUtils.ts
+++ b/apps/workers/workerUtils.ts
@@ -31,9 +31,13 @@ export async function getBookmarkDetails(bookmarkId: string) {
return {
url: bookmark.link.url,
userId: bookmark.userId,
+ createdAt: bookmark.createdAt,
+ crawledAt: bookmark.link.crawledAt,
screenshotAssetId: bookmark.assets.find(
(a) => a.assetType == AssetTypes.LINK_SCREENSHOT,
)?.id,
+ pdfAssetId: bookmark.assets.find((a) => a.assetType == AssetTypes.LINK_PDF)
+ ?.id,
imageAssetId: bookmark.assets.find(
(a) => a.assetType == AssetTypes.LINK_BANNER_IMAGE,
)?.id,
diff --git a/apps/workers/workers/adminMaintenanceWorker.ts b/apps/workers/workers/adminMaintenanceWorker.ts
index e5312964..92d52a22 100644
--- a/apps/workers/workers/adminMaintenanceWorker.ts
+++ b/apps/workers/workers/adminMaintenanceWorker.ts
@@ -1,4 +1,5 @@
import { workerStatsCounter } from "metrics";
+import { withWorkerTracing } from "workerTracing";
import {
AdminMaintenanceQueue,
@@ -20,7 +21,10 @@ export class AdminMaintenanceWorker {
(await getQueueClient())!.createRunner<ZAdminMaintenanceTask>(
AdminMaintenanceQueue,
{
- run: runAdminMaintenance,
+ run: withWorkerTracing(
+ "adminMaintenanceWorker.run",
+ runAdminMaintenance,
+ ),
onComplete: (job) => {
workerStatsCounter
.labels(`adminMaintenance:${job.data.type}`, "completed")
diff --git a/apps/workers/workers/assetPreprocessingWorker.ts b/apps/workers/workers/assetPreprocessingWorker.ts
index ff16906d..d12457d3 100644
--- a/apps/workers/workers/assetPreprocessingWorker.ts
+++ b/apps/workers/workers/assetPreprocessingWorker.ts
@@ -4,6 +4,7 @@ import { workerStatsCounter } from "metrics";
import PDFParser from "pdf2json";
import { fromBuffer } from "pdf2pic";
import { createWorker } from "tesseract.js";
+import { withWorkerTracing } from "workerTracing";
import type { AssetPreprocessingRequest } from "@karakeep/shared-server";
import { db } from "@karakeep/db";
@@ -22,7 +23,9 @@ import {
} from "@karakeep/shared-server";
import { newAssetId, readAsset, saveAsset } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
+import { InferenceClientFactory } from "@karakeep/shared/inference";
import logger from "@karakeep/shared/logger";
+import { buildOCRPrompt } from "@karakeep/shared/prompts";
import {
DequeuedJob,
EnqueueOptions,
@@ -36,7 +39,7 @@ export class AssetPreprocessingWorker {
(await getQueueClient())!.createRunner<AssetPreprocessingRequest>(
AssetPreprocessingQueue,
{
- run: run,
+ run: withWorkerTracing("assetPreprocessingWorker.run", run),
onComplete: async (job) => {
workerStatsCounter.labels("assetPreprocessing", "completed").inc();
const jobId = job.id;
@@ -62,7 +65,7 @@ export class AssetPreprocessingWorker {
{
concurrency: serverConfig.assetPreprocessing.numWorkers,
pollIntervalMs: 1000,
- timeoutSecs: 30,
+ timeoutSecs: serverConfig.assetPreprocessing.jobTimeoutSec,
},
);
@@ -88,6 +91,36 @@ async function readImageText(buffer: Buffer) {
}
}
+async function readImageTextWithLLM(
+ buffer: Buffer,
+ contentType: string,
+): Promise<string | null> {
+ const inferenceClient = InferenceClientFactory.build();
+ if (!inferenceClient) {
+ logger.warn(
+ "[assetPreprocessing] LLM OCR is enabled but no inference client is configured. Falling back to Tesseract.",
+ );
+ return readImageText(buffer);
+ }
+
+ const base64 = buffer.toString("base64");
+ const prompt = buildOCRPrompt();
+
+ const response = await inferenceClient.inferFromImage(
+ prompt,
+ contentType,
+ base64,
+ { schema: null },
+ );
+
+ const extractedText = response.response.trim();
+ if (!extractedText) {
+ return null;
+ }
+
+ return extractedText;
+}
+
async function readPDFText(buffer: Buffer): Promise<{
text: string;
metadata: Record<string, object>;
@@ -199,6 +232,7 @@ export async function extractAndSavePDFScreenshot(
async function extractAndSaveImageText(
jobId: string,
asset: Buffer,
+ contentType: string,
bookmark: NonNullable<Awaited<ReturnType<typeof getBookmark>>>,
isFixMode: boolean,
): Promise<boolean> {
@@ -212,16 +246,31 @@ async function extractAndSaveImageText(
}
}
let imageText = null;
- logger.info(
- `[assetPreprocessing][${jobId}] Attempting to extract text from image.`,
- );
- try {
- imageText = await readImageText(asset);
- } catch (e) {
- logger.error(
- `[assetPreprocessing][${jobId}] Failed to read image text: ${e}`,
+
+ if (serverConfig.ocr.useLLM) {
+ logger.info(
+ `[assetPreprocessing][${jobId}] Attempting to extract text from image using LLM OCR.`,
);
+ try {
+ imageText = await readImageTextWithLLM(asset, contentType);
+ } catch (e) {
+ logger.error(
+ `[assetPreprocessing][${jobId}] Failed to read image text with LLM: ${e}`,
+ );
+ }
+ } else {
+ logger.info(
+ `[assetPreprocessing][${jobId}] Attempting to extract text from image using Tesseract.`,
+ );
+ try {
+ imageText = await readImageText(asset);
+ } catch (e) {
+ logger.error(
+ `[assetPreprocessing][${jobId}] Failed to read image text: ${e}`,
+ );
+ }
}
+
if (!imageText) {
return false;
}
@@ -313,7 +362,7 @@ async function run(req: DequeuedJob<AssetPreprocessingRequest>) {
);
}
- const { asset } = await readAsset({
+ const { asset, metadata } = await readAsset({
userId: bookmark.userId,
assetId: bookmark.asset.assetId,
});
@@ -330,6 +379,7 @@ async function run(req: DequeuedJob<AssetPreprocessingRequest>) {
const extractedText = await extractAndSaveImageText(
jobId,
asset,
+ metadata.contentType,
bookmark,
isFixMode,
);
diff --git a/apps/workers/workers/backupWorker.ts b/apps/workers/workers/backupWorker.ts
index c2d1ae5a..01f54b28 100644
--- a/apps/workers/workers/backupWorker.ts
+++ b/apps/workers/workers/backupWorker.ts
@@ -8,6 +8,7 @@ import archiver from "archiver";
import { eq } from "drizzle-orm";
import { workerStatsCounter } from "metrics";
import cron from "node-cron";
+import { withWorkerTracing } from "workerTracing";
import type { ZBackupRequest } from "@karakeep/shared-server";
import { db } from "@karakeep/db";
@@ -107,7 +108,7 @@ export class BackupWorker {
const worker = (await getQueueClient())!.createRunner<ZBackupRequest>(
BackupQueue,
{
- run: run,
+ run: withWorkerTracing("backupWorker.run", run),
onComplete: async (job) => {
workerStatsCounter.labels("backup", "completed").inc();
const jobId = job.id;
diff --git a/apps/workers/workers/crawlerWorker.ts b/apps/workers/workers/crawlerWorker.ts
index 740d5dac..9815571e 100644
--- a/apps/workers/workers/crawlerWorker.ts
+++ b/apps/workers/workers/crawlerWorker.ts
@@ -9,7 +9,7 @@ import { PlaywrightBlocker } from "@ghostery/adblocker-playwright";
import { Readability } from "@mozilla/readability";
import { Mutex } from "async-mutex";
import DOMPurify from "dompurify";
-import { eq } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import { execa } from "execa";
import { exitAbortController } from "exit";
import { HttpProxyAgent } from "http-proxy-agent";
@@ -27,9 +27,14 @@ import metascraperTitle from "metascraper-title";
import metascraperUrl from "metascraper-url";
import metascraperX from "metascraper-x";
import metascraperYoutube from "metascraper-youtube";
-import { crawlerStatusCodeCounter, workerStatsCounter } from "metrics";
+import {
+ bookmarkCrawlLatencyHistogram,
+ crawlerStatusCodeCounter,
+ workerStatsCounter,
+} from "metrics";
import {
fetchWithProxy,
+ getBookmarkDomain,
getRandomProxy,
matchesNoProxy,
validateUrl,
@@ -37,6 +42,7 @@ import {
import { Browser, BrowserContextOptions } from "playwright";
import { chromium } from "playwright-extra";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
+import { withWorkerTracing } from "workerTracing";
import { getBookmarkDetails, updateAsset } from "workerUtils";
import { z } from "zod";
@@ -52,12 +58,14 @@ import {
} from "@karakeep/db/schema";
import {
AssetPreprocessingQueue,
- LinkCrawlerQueue,
+ getTracer,
OpenAIQueue,
QuotaService,
+ setSpanAttributes,
triggerSearchReindex,
triggerWebhook,
VideoWorkerQueue,
+ withSpan,
zCrawlLinkRequestSchema,
} from "@karakeep/shared-server";
import {
@@ -75,15 +83,21 @@ import serverConfig from "@karakeep/shared/config";
import logger from "@karakeep/shared/logger";
import {
DequeuedJob,
+ DequeuedJobError,
EnqueueOptions,
getQueueClient,
+ Queue,
+ QueueRetryAfterError,
} from "@karakeep/shared/queueing";
import { getRateLimitClient } from "@karakeep/shared/ratelimiting";
import { tryCatch } from "@karakeep/shared/tryCatch";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+import metascraperAmazonImproved from "../metascraper-plugins/metascraper-amazon-improved";
import metascraperReddit from "../metascraper-plugins/metascraper-reddit";
+const tracer = getTracer("@karakeep/workers");
+
function abortPromise(signal: AbortSignal): Promise<never> {
if (signal.aborted) {
const p = Promise.reject(signal.reason ?? new Error("AbortError"));
@@ -125,6 +139,7 @@ const metascraperParser = metascraper([
dateModified: true,
datePublished: true,
}),
+ metascraperAmazonImproved(), // Fix image extraction bug - must come before metascraperAmazon()
metascraperAmazon(),
metascraperYoutube({
gotOpts: {
@@ -185,7 +200,7 @@ const cookieSchema = z.object({
const cookiesSchema = z.array(cookieSchema);
interface CrawlerRunResult {
- status: "completed" | "rescheduled";
+ status: "completed";
}
function getPlaywrightProxyConfig(): BrowserContextOptions["proxy"] {
@@ -288,57 +303,68 @@ async function launchBrowser() {
}
export class CrawlerWorker {
- static async build() {
- chromium.use(StealthPlugin());
- if (serverConfig.crawler.enableAdblocker) {
- logger.info("[crawler] Loading adblocker ...");
- const globalBlockerResult = await tryCatch(
- PlaywrightBlocker.fromPrebuiltFull(fetchWithProxy, {
- path: path.join(os.tmpdir(), "karakeep_adblocker.bin"),
- read: fs.readFile,
- write: fs.writeFile,
- }),
- );
- if (globalBlockerResult.error) {
- logger.error(
- `[crawler] Failed to load adblocker. Will not be blocking ads: ${globalBlockerResult.error}`,
- );
- } else {
- globalBlocker = globalBlockerResult.data;
- }
- }
- if (!serverConfig.crawler.browserConnectOnDemand) {
- await launchBrowser();
- } else {
- logger.info(
- "[Crawler] Browser connect on demand is enabled, won't proactively start the browser instance",
- );
+ private static initPromise: Promise<void> | null = null;
+
+ private static ensureInitialized() {
+ if (!CrawlerWorker.initPromise) {
+ CrawlerWorker.initPromise = (async () => {
+ chromium.use(StealthPlugin());
+ if (serverConfig.crawler.enableAdblocker) {
+ logger.info("[crawler] Loading adblocker ...");
+ const globalBlockerResult = await tryCatch(
+ PlaywrightBlocker.fromPrebuiltFull(fetchWithProxy, {
+ path: path.join(os.tmpdir(), "karakeep_adblocker.bin"),
+ read: fs.readFile,
+ write: fs.writeFile,
+ }),
+ );
+ if (globalBlockerResult.error) {
+ logger.error(
+ `[crawler] Failed to load adblocker. Will not be blocking ads: ${globalBlockerResult.error}`,
+ );
+ } else {
+ globalBlocker = globalBlockerResult.data;
+ }
+ }
+ if (!serverConfig.crawler.browserConnectOnDemand) {
+ await launchBrowser();
+ } else {
+ logger.info(
+ "[Crawler] Browser connect on demand is enabled, won't proactively start the browser instance",
+ );
+ }
+ await loadCookiesFromFile();
+ })();
}
+ return CrawlerWorker.initPromise;
+ }
+
+ static async build(queue: Queue<ZCrawlLinkRequest>) {
+ await CrawlerWorker.ensureInitialized();
logger.info("Starting crawler worker ...");
- const worker = (await getQueueClient())!.createRunner<
+ const worker = (await getQueueClient()).createRunner<
ZCrawlLinkRequest,
CrawlerRunResult
>(
- LinkCrawlerQueue,
+ queue,
{
- run: runCrawler,
- onComplete: async (job, result) => {
- if (result.status === "rescheduled") {
- logger.info(
- `[Crawler][${job.id}] Rescheduled due to domain rate limiting`,
- );
- return;
- }
+ run: withWorkerTracing("crawlerWorker.run", runCrawler),
+ onComplete: async (job: DequeuedJob<ZCrawlLinkRequest>) => {
workerStatsCounter.labels("crawler", "completed").inc();
const jobId = job.id;
logger.info(`[Crawler][${jobId}] Completed successfully`);
const bookmarkId = job.data.bookmarkId;
if (bookmarkId) {
- await changeBookmarkStatus(bookmarkId, "success");
+ await db
+ .update(bookmarkLinks)
+ .set({
+ crawlStatus: "success",
+ })
+ .where(eq(bookmarkLinks.id, bookmarkId));
}
},
- onError: async (job) => {
+ onError: async (job: DequeuedJobError<ZCrawlLinkRequest>) => {
workerStatsCounter.labels("crawler", "failed").inc();
if (job.numRetriesLeft == 0) {
workerStatsCounter.labels("crawler", "failed_permanent").inc();
@@ -349,7 +375,36 @@ export class CrawlerWorker {
);
const bookmarkId = job.data?.bookmarkId;
if (bookmarkId && job.numRetriesLeft == 0) {
- await changeBookmarkStatus(bookmarkId, "failure");
+ await db.transaction(async (tx) => {
+ await tx
+ .update(bookmarkLinks)
+ .set({
+ crawlStatus: "failure",
+ })
+ .where(eq(bookmarkLinks.id, bookmarkId));
+ await tx
+ .update(bookmarks)
+ .set({
+ taggingStatus: null,
+ })
+ .where(
+ and(
+ eq(bookmarks.id, bookmarkId),
+ eq(bookmarks.taggingStatus, "pending"),
+ ),
+ );
+ await tx
+ .update(bookmarks)
+ .set({
+ summarizationStatus: null,
+ })
+ .where(
+ and(
+ eq(bookmarks.id, bookmarkId),
+ eq(bookmarks.summarizationStatus, "pending"),
+ ),
+ );
+ });
}
},
},
@@ -360,8 +415,6 @@ export class CrawlerWorker {
},
);
- await loadCookiesFromFile();
-
return worker;
}
}
@@ -391,239 +444,300 @@ async function loadCookiesFromFile(): Promise<void> {
type DBAssetType = typeof assets.$inferInsert;
-async function changeBookmarkStatus(
- bookmarkId: string,
- crawlStatus: "success" | "failure",
-) {
- await db
- .update(bookmarkLinks)
- .set({
- crawlStatus,
- })
- .where(eq(bookmarkLinks.id, bookmarkId));
-}
-
async function browserlessCrawlPage(
jobId: string,
url: string,
abortSignal: AbortSignal,
) {
- logger.info(
- `[Crawler][${jobId}] Running in browserless mode. Will do a plain http request to "${url}". Screenshots will be disabled.`,
- );
- const response = await fetchWithProxy(url, {
- signal: AbortSignal.any([AbortSignal.timeout(5000), abortSignal]),
- });
- logger.info(
- `[Crawler][${jobId}] Successfully fetched the content of "${url}". Status: ${response.status}, Size: ${response.size}`,
+ return await withSpan(
+ tracer,
+ "crawlerWorker.browserlessCrawlPage",
+ {
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ },
+ },
+ async () => {
+ logger.info(
+ `[Crawler][${jobId}] Running in browserless mode. Will do a plain http request to "${url}". Screenshots will be disabled.`,
+ );
+ const response = await fetchWithProxy(url, {
+ signal: AbortSignal.any([AbortSignal.timeout(5000), abortSignal]),
+ });
+ logger.info(
+ `[Crawler][${jobId}] Successfully fetched the content of "${url}". Status: ${response.status}, Size: ${response.size}`,
+ );
+ return {
+ htmlContent: await response.text(),
+ statusCode: response.status,
+ screenshot: undefined,
+ pdf: undefined,
+ url: response.url,
+ };
+ },
);
- return {
- htmlContent: await response.text(),
- statusCode: response.status,
- screenshot: undefined,
- url: response.url,
- };
}
async function crawlPage(
jobId: string,
url: string,
userId: string,
+ forceStorePdf: boolean,
abortSignal: AbortSignal,
): Promise<{
htmlContent: string;
screenshot: Buffer | undefined;
+ pdf: Buffer | undefined;
statusCode: number;
url: string;
}> {
- // Check user's browser crawling setting
- const userData = await db.query.users.findFirst({
- where: eq(users.id, userId),
- columns: { browserCrawlingEnabled: true },
- });
- if (!userData) {
- logger.error(`[Crawler][${jobId}] User ${userId} not found`);
- throw new Error(`User ${userId} not found`);
- }
+ return await withSpan(
+ tracer,
+ "crawlerWorker.crawlPage",
+ {
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ "user.id": userId,
+ "crawler.forceStorePdf": forceStorePdf,
+ },
+ },
+ async () => {
+ // Check user's browser crawling setting
+ const userData = await db.query.users.findFirst({
+ where: eq(users.id, userId),
+ columns: { browserCrawlingEnabled: true },
+ });
+ if (!userData) {
+ logger.error(`[Crawler][${jobId}] User ${userId} not found`);
+ throw new Error(`User ${userId} not found`);
+ }
- const browserCrawlingEnabled = userData.browserCrawlingEnabled;
+ const browserCrawlingEnabled = userData.browserCrawlingEnabled;
- if (browserCrawlingEnabled !== null && !browserCrawlingEnabled) {
- return browserlessCrawlPage(jobId, url, abortSignal);
- }
+ if (browserCrawlingEnabled !== null && !browserCrawlingEnabled) {
+ return browserlessCrawlPage(jobId, url, abortSignal);
+ }
- let browser: Browser | undefined;
- if (serverConfig.crawler.browserConnectOnDemand) {
- browser = await startBrowserInstance();
- } else {
- browser = globalBrowser;
- }
- if (!browser) {
- return browserlessCrawlPage(jobId, url, abortSignal);
- }
+ let browser: Browser | undefined;
+ if (serverConfig.crawler.browserConnectOnDemand) {
+ browser = await startBrowserInstance();
+ } else {
+ browser = globalBrowser;
+ }
+ if (!browser) {
+ return browserlessCrawlPage(jobId, url, abortSignal);
+ }
- const proxyConfig = getPlaywrightProxyConfig();
- const isRunningInProxyContext =
- proxyConfig !== undefined &&
- !matchesNoProxy(url, proxyConfig.bypass?.split(",") ?? []);
- const context = await browser.newContext({
- viewport: { width: 1440, height: 900 },
- userAgent:
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
- proxy: proxyConfig,
- });
+ const proxyConfig = getPlaywrightProxyConfig();
+ const isRunningInProxyContext =
+ proxyConfig !== undefined &&
+ !matchesNoProxy(url, proxyConfig.bypass?.split(",") ?? []);
+ const context = await browser.newContext({
+ viewport: { width: 1440, height: 900 },
+ userAgent:
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
+ proxy: proxyConfig,
+ });
- try {
- if (globalCookies.length > 0) {
- await context.addCookies(globalCookies);
- logger.info(
- `[Crawler][${jobId}] Cookies successfully loaded into browser context`,
- );
- }
+ try {
+ if (globalCookies.length > 0) {
+ await context.addCookies(globalCookies);
+ logger.info(
+ `[Crawler][${jobId}] Cookies successfully loaded into browser context`,
+ );
+ }
- // Create a new page in the context
- const page = await context.newPage();
+ // Create a new page in the context
+ const page = await context.newPage();
- // Apply ad blocking
- if (globalBlocker) {
- await globalBlocker.enableBlockingInPage(page);
- }
+ // Apply ad blocking
+ if (globalBlocker) {
+ await globalBlocker.enableBlockingInPage(page);
+ }
- // Block audio/video resources and disallowed sub-requests
- await page.route("**/*", async (route) => {
- if (abortSignal.aborted) {
- await route.abort("aborted");
- return;
- }
- const request = route.request();
- const resourceType = request.resourceType();
+ // Block audio/video resources and disallowed sub-requests
+ await page.route("**/*", async (route) => {
+ if (abortSignal.aborted) {
+ await route.abort("aborted");
+ return;
+ }
+ const request = route.request();
+ const resourceType = request.resourceType();
- // Block audio/video resources
- if (
- resourceType === "media" ||
- request.headers()["content-type"]?.includes("video/") ||
- request.headers()["content-type"]?.includes("audio/")
- ) {
- await route.abort("aborted");
- return;
- }
+ // Block audio/video resources
+ if (
+ resourceType === "media" ||
+ request.headers()["content-type"]?.includes("video/") ||
+ request.headers()["content-type"]?.includes("audio/")
+ ) {
+ await route.abort("aborted");
+ return;
+ }
- const requestUrl = request.url();
- const requestIsRunningInProxyContext =
- proxyConfig !== undefined &&
- !matchesNoProxy(requestUrl, proxyConfig.bypass?.split(",") ?? []);
- if (
- requestUrl.startsWith("http://") ||
- requestUrl.startsWith("https://")
- ) {
- const validation = await validateUrl(
- requestUrl,
- requestIsRunningInProxyContext,
+ const requestUrl = request.url();
+ const requestIsRunningInProxyContext =
+ proxyConfig !== undefined &&
+ !matchesNoProxy(requestUrl, proxyConfig.bypass?.split(",") ?? []);
+ if (
+ requestUrl.startsWith("http://") ||
+ requestUrl.startsWith("https://")
+ ) {
+ const validation = await validateUrl(
+ requestUrl,
+ requestIsRunningInProxyContext,
+ );
+ if (!validation.ok) {
+ logger.warn(
+ `[Crawler][${jobId}] Blocking sub-request to disallowed URL "${requestUrl}": ${validation.reason}`,
+ );
+ await route.abort("blockedbyclient");
+ return;
+ }
+ }
+
+ // Continue with other requests
+ await route.continue();
+ });
+
+ // Navigate to the target URL
+ const navigationValidation = await validateUrl(
+ url,
+ isRunningInProxyContext,
);
- if (!validation.ok) {
- logger.warn(
- `[Crawler][${jobId}] Blocking sub-request to disallowed URL "${requestUrl}": ${validation.reason}`,
+ if (!navigationValidation.ok) {
+ throw new Error(
+ `Disallowed navigation target "${url}": ${navigationValidation.reason}`,
);
- await route.abort("blockedbyclient");
- return;
}
- }
-
- // Continue with other requests
- await route.continue();
- });
+ const targetUrl = navigationValidation.url.toString();
+ logger.info(`[Crawler][${jobId}] Navigating to "${targetUrl}"`);
+ const response = await Promise.race([
+ page.goto(targetUrl, {
+ timeout: serverConfig.crawler.navigateTimeoutSec * 1000,
+ waitUntil: "domcontentloaded",
+ }),
+ abortPromise(abortSignal).then(() => null),
+ ]);
- // Navigate to the target URL
- const navigationValidation = await validateUrl(
- url,
- isRunningInProxyContext,
- );
- if (!navigationValidation.ok) {
- throw new Error(
- `Disallowed navigation target "${url}": ${navigationValidation.reason}`,
- );
- }
- const targetUrl = navigationValidation.url.toString();
- logger.info(`[Crawler][${jobId}] Navigating to "${targetUrl}"`);
- const response = await Promise.race([
- page.goto(targetUrl, {
- timeout: serverConfig.crawler.navigateTimeoutSec * 1000,
- waitUntil: "domcontentloaded",
- }),
- abortPromise(abortSignal).then(() => null),
- ]);
+ logger.info(
+ `[Crawler][${jobId}] Successfully navigated to "${targetUrl}". Waiting for the page to load ...`,
+ );
- logger.info(
- `[Crawler][${jobId}] Successfully navigated to "${targetUrl}". Waiting for the page to load ...`,
- );
+ // Wait until network is relatively idle or timeout after 5 seconds
+ await Promise.race([
+ page
+ .waitForLoadState("networkidle", { timeout: 5000 })
+ .catch(() => ({})),
+ new Promise((resolve) => setTimeout(resolve, 5000)),
+ abortPromise(abortSignal),
+ ]);
- // Wait until network is relatively idle or timeout after 5 seconds
- await Promise.race([
- page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => ({})),
- new Promise((resolve) => setTimeout(resolve, 5000)),
- abortPromise(abortSignal),
- ]);
+ abortSignal.throwIfAborted();
- abortSignal.throwIfAborted();
+ logger.info(
+ `[Crawler][${jobId}] Finished waiting for the page to load.`,
+ );
- logger.info(`[Crawler][${jobId}] Finished waiting for the page to load.`);
+ // Extract content from the page
+ const htmlContent = await page.content();
- // Extract content from the page
- const htmlContent = await page.content();
+ abortSignal.throwIfAborted();
- abortSignal.throwIfAborted();
+ logger.info(
+ `[Crawler][${jobId}] Successfully fetched the page content.`,
+ );
- logger.info(`[Crawler][${jobId}] Successfully fetched the page content.`);
+ // Take a screenshot if configured
+ let screenshot: Buffer | undefined = undefined;
+ if (serverConfig.crawler.storeScreenshot) {
+ const { data: screenshotData, error: screenshotError } =
+ await tryCatch(
+ Promise.race<Buffer>([
+ page.screenshot({
+ // If you change this, you need to change the asset type in the store function.
+ type: "jpeg",
+ fullPage: serverConfig.crawler.fullPageScreenshot,
+ quality: 80,
+ }),
+ new Promise((_, reject) =>
+ setTimeout(
+ () =>
+ reject(
+ "TIMED_OUT, consider increasing CRAWLER_SCREENSHOT_TIMEOUT_SEC",
+ ),
+ serverConfig.crawler.screenshotTimeoutSec * 1000,
+ ),
+ ),
+ abortPromise(abortSignal).then(() => Buffer.from("")),
+ ]),
+ );
+ abortSignal.throwIfAborted();
+ if (screenshotError) {
+ logger.warn(
+ `[Crawler][${jobId}] Failed to capture the screenshot. Reason: ${screenshotError}`,
+ );
+ } else {
+ logger.info(
+ `[Crawler][${jobId}] Finished capturing page content and a screenshot. FullPageScreenshot: ${serverConfig.crawler.fullPageScreenshot}`,
+ );
+ screenshot = screenshotData;
+ }
+ }
- // Take a screenshot if configured
- let screenshot: Buffer | undefined = undefined;
- if (serverConfig.crawler.storeScreenshot) {
- const { data: screenshotData, error: screenshotError } = await tryCatch(
- Promise.race<Buffer>([
- page.screenshot({
- // If you change this, you need to change the asset type in the store function.
- type: "jpeg",
- fullPage: serverConfig.crawler.fullPageScreenshot,
- quality: 80,
- }),
- new Promise((_, reject) =>
- setTimeout(
- () =>
- reject(
- "TIMED_OUT, consider increasing CRAWLER_SCREENSHOT_TIMEOUT_SEC",
+ // Capture PDF if configured or explicitly requested
+ let pdf: Buffer | undefined = undefined;
+ if (serverConfig.crawler.storePdf || forceStorePdf) {
+ const { data: pdfData, error: pdfError } = await tryCatch(
+ Promise.race<Buffer>([
+ page.pdf({
+ format: "A4",
+ printBackground: true,
+ }),
+ new Promise((_, reject) =>
+ setTimeout(
+ () =>
+ reject(
+ "TIMED_OUT, consider increasing CRAWLER_SCREENSHOT_TIMEOUT_SEC",
+ ),
+ serverConfig.crawler.screenshotTimeoutSec * 1000,
),
- serverConfig.crawler.screenshotTimeoutSec * 1000,
- ),
- ),
- abortPromise(abortSignal).then(() => Buffer.from("")),
- ]),
- );
- abortSignal.throwIfAborted();
- if (screenshotError) {
- logger.warn(
- `[Crawler][${jobId}] Failed to capture the screenshot. Reason: ${screenshotError}`,
- );
- } else {
- logger.info(
- `[Crawler][${jobId}] Finished capturing page content and a screenshot. FullPageScreenshot: ${serverConfig.crawler.fullPageScreenshot}`,
- );
- screenshot = screenshotData;
- }
- }
+ ),
+ abortPromise(abortSignal).then(() => Buffer.from("")),
+ ]),
+ );
+ abortSignal.throwIfAborted();
+ if (pdfError) {
+ logger.warn(
+ `[Crawler][${jobId}] Failed to capture the PDF. Reason: ${pdfError}`,
+ );
+ } else {
+ logger.info(
+ `[Crawler][${jobId}] Finished capturing page content as PDF`,
+ );
+ pdf = pdfData;
+ }
+ }
- return {
- htmlContent,
- statusCode: response?.status() ?? 0,
- screenshot,
- url: page.url(),
- };
- } finally {
- await context.close();
- // Only close the browser if it was created on demand
- if (serverConfig.crawler.browserConnectOnDemand) {
- await browser.close();
- }
- }
+ return {
+ htmlContent,
+ statusCode: response?.status() ?? 0,
+ screenshot,
+ pdf,
+ url: page.url(),
+ };
+ } finally {
+ await context.close();
+ // Only close the browser if it was created on demand
+ if (serverConfig.crawler.browserConnectOnDemand) {
+ await browser.close();
+ }
+ }
+ },
+ );
}
async function extractMetadata(
@@ -631,54 +745,82 @@ async function extractMetadata(
url: string,
jobId: string,
) {
- logger.info(
- `[Crawler][${jobId}] Will attempt to extract metadata from page ...`,
+ return await withSpan(
+ tracer,
+ "crawlerWorker.extractMetadata",
+ {
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ },
+ },
+ async () => {
+ logger.info(
+ `[Crawler][${jobId}] Will attempt to extract metadata from page ...`,
+ );
+ const meta = await metascraperParser({
+ url,
+ html: htmlContent,
+ // We don't want to validate the URL again as we've already done it by visiting the page.
+ // This was added because URL validation fails if the URL ends with a question mark (e.g. empty query params).
+ validateUrl: false,
+ });
+ logger.info(
+ `[Crawler][${jobId}] Done extracting metadata from the page.`,
+ );
+ return meta;
+ },
);
- const meta = await metascraperParser({
- url,
- html: htmlContent,
- // We don't want to validate the URL again as we've already done it by visiting the page.
- // This was added because URL validation fails if the URL ends with a question mark (e.g. empty query params).
- validateUrl: false,
- });
- logger.info(`[Crawler][${jobId}] Done extracting metadata from the page.`);
- return meta;
}
-function extractReadableContent(
+async function extractReadableContent(
htmlContent: string,
url: string,
jobId: string,
) {
- logger.info(
- `[Crawler][${jobId}] Will attempt to extract readable content ...`,
- );
- const virtualConsole = new VirtualConsole();
- const dom = new JSDOM(htmlContent, { url, virtualConsole });
- let result: { content: string } | null = null;
- try {
- const readableContent = new Readability(dom.window.document).parse();
- if (!readableContent || typeof readableContent.content !== "string") {
- return null;
- }
+ return await withSpan(
+ tracer,
+ "crawlerWorker.extractReadableContent",
+ {
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ },
+ },
+ async () => {
+ logger.info(
+ `[Crawler][${jobId}] Will attempt to extract readable content ...`,
+ );
+ const virtualConsole = new VirtualConsole();
+ const dom = new JSDOM(htmlContent, { url, virtualConsole });
+ let result: { content: string } | null = null;
+ try {
+ const readableContent = new Readability(dom.window.document).parse();
+ if (!readableContent || typeof readableContent.content !== "string") {
+ return null;
+ }
- const purifyWindow = new JSDOM("").window;
- try {
- const purify = DOMPurify(purifyWindow);
- const purifiedHTML = purify.sanitize(readableContent.content);
+ const purifyWindow = new JSDOM("").window;
+ try {
+ const purify = DOMPurify(purifyWindow);
+ const purifiedHTML = purify.sanitize(readableContent.content);
- logger.info(`[Crawler][${jobId}] Done extracting readable content.`);
- result = {
- content: purifiedHTML,
- };
- } finally {
- purifyWindow.close();
- }
- } finally {
- dom.window.close();
- }
+ logger.info(`[Crawler][${jobId}] Done extracting readable content.`);
+ result = {
+ content: purifiedHTML,
+ };
+ } finally {
+ purifyWindow.close();
+ }
+ } finally {
+ dom.window.close();
+ }
- return result;
+ return result;
+ },
+ );
}
async function storeScreenshot(
@@ -686,45 +828,111 @@ async function storeScreenshot(
userId: string,
jobId: string,
) {
- if (!serverConfig.crawler.storeScreenshot) {
- logger.info(
- `[Crawler][${jobId}] Skipping storing the screenshot as per the config.`,
- );
- return null;
- }
- if (!screenshot) {
- logger.info(
- `[Crawler][${jobId}] Skipping storing the screenshot as it's empty.`,
- );
- return null;
- }
- const assetId = newAssetId();
- const contentType = "image/jpeg";
- const fileName = "screenshot.jpeg";
+ return await withSpan(
+ tracer,
+ "crawlerWorker.storeScreenshot",
+ {
+ attributes: {
+ "job.id": jobId,
+ "user.id": userId,
+ "asset.size": screenshot?.byteLength ?? 0,
+ },
+ },
+ async () => {
+ if (!serverConfig.crawler.storeScreenshot) {
+ logger.info(
+ `[Crawler][${jobId}] Skipping storing the screenshot as per the config.`,
+ );
+ return null;
+ }
+ if (!screenshot) {
+ logger.info(
+ `[Crawler][${jobId}] Skipping storing the screenshot as it's empty.`,
+ );
+ return null;
+ }
+ const assetId = newAssetId();
+ const contentType = "image/jpeg";
+ const fileName = "screenshot.jpeg";
- // Check storage quota before saving the screenshot
- const { data: quotaApproved, error: quotaError } = await tryCatch(
- QuotaService.checkStorageQuota(db, userId, screenshot.byteLength),
+ // Check storage quota before saving the screenshot
+ const { data: quotaApproved, error: quotaError } = await tryCatch(
+ QuotaService.checkStorageQuota(db, userId, screenshot.byteLength),
+ );
+
+ if (quotaError) {
+ logger.warn(
+ `[Crawler][${jobId}] Skipping screenshot storage due to quota exceeded: ${quotaError.message}`,
+ );
+ return null;
+ }
+
+ await saveAsset({
+ userId,
+ assetId,
+ metadata: { contentType, fileName },
+ asset: screenshot,
+ quotaApproved,
+ });
+ logger.info(
+ `[Crawler][${jobId}] Stored the screenshot as assetId: ${assetId} (${screenshot.byteLength} bytes)`,
+ );
+ return { assetId, contentType, fileName, size: screenshot.byteLength };
+ },
);
+}
- if (quotaError) {
- logger.warn(
- `[Crawler][${jobId}] Skipping screenshot storage due to quota exceeded: ${quotaError.message}`,
- );
- return null;
- }
+async function storePdf(
+ pdf: Buffer | undefined,
+ userId: string,
+ jobId: string,
+) {
+ return await withSpan(
+ tracer,
+ "crawlerWorker.storePdf",
+ {
+ attributes: {
+ "job.id": jobId,
+ "user.id": userId,
+ "asset.size": pdf?.byteLength ?? 0,
+ },
+ },
+ async () => {
+ if (!pdf) {
+ logger.info(
+ `[Crawler][${jobId}] Skipping storing the PDF as it's empty.`,
+ );
+ return null;
+ }
+ const assetId = newAssetId();
+ const contentType = "application/pdf";
+ const fileName = "page.pdf";
- await saveAsset({
- userId,
- assetId,
- metadata: { contentType, fileName },
- asset: screenshot,
- quotaApproved,
- });
- logger.info(
- `[Crawler][${jobId}] Stored the screenshot as assetId: ${assetId} (${screenshot.byteLength} bytes)`,
+ // Check storage quota before saving the PDF
+ const { data: quotaApproved, error: quotaError } = await tryCatch(
+ QuotaService.checkStorageQuota(db, userId, pdf.byteLength),
+ );
+
+ if (quotaError) {
+ logger.warn(
+ `[Crawler][${jobId}] Skipping PDF storage due to quota exceeded: ${quotaError.message}`,
+ );
+ return null;
+ }
+
+ await saveAsset({
+ userId,
+ assetId,
+ metadata: { contentType, fileName },
+ asset: pdf,
+ quotaApproved,
+ });
+ logger.info(
+ `[Crawler][${jobId}] Stored the PDF as assetId: ${assetId} (${pdf.byteLength} bytes)`,
+ );
+ return { assetId, contentType, fileName, size: pdf.byteLength };
+ },
);
- return { assetId, contentType, fileName, size: screenshot.byteLength };
}
async function downloadAndStoreFile(
@@ -734,91 +942,106 @@ async function downloadAndStoreFile(
fileType: string,
abortSignal: AbortSignal,
) {
- let assetPath: string | undefined;
- try {
- logger.info(
- `[Crawler][${jobId}] Downloading ${fileType} from "${url.length > 100 ? url.slice(0, 100) + "..." : url}"`,
- );
- const response = await fetchWithProxy(url, {
- signal: abortSignal,
- });
- if (!response.ok || response.body == null) {
- throw new Error(`Failed to download ${fileType}: ${response.status}`);
- }
+ return await withSpan(
+ tracer,
+ "crawlerWorker.downloadAndStoreFile",
+ {
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ "user.id": userId,
+ "asset.type": fileType,
+ },
+ },
+ async () => {
+ let assetPath: string | undefined;
+ try {
+ logger.info(
+ `[Crawler][${jobId}] Downloading ${fileType} from "${url.length > 100 ? url.slice(0, 100) + "..." : url}"`,
+ );
+ const response = await fetchWithProxy(url, {
+ signal: abortSignal,
+ });
+ if (!response.ok || response.body == null) {
+ throw new Error(`Failed to download ${fileType}: ${response.status}`);
+ }
- const contentType = normalizeContentType(
- response.headers.get("content-type"),
- );
- if (!contentType) {
- throw new Error("No content type in the response");
- }
+ const contentType = normalizeContentType(
+ response.headers.get("content-type"),
+ );
+ if (!contentType) {
+ throw new Error("No content type in the response");
+ }
- const assetId = newAssetId();
- assetPath = path.join(os.tmpdir(), assetId);
+ const assetId = newAssetId();
+ assetPath = path.join(os.tmpdir(), assetId);
- let bytesRead = 0;
- const contentLengthEnforcer = new Transform({
- transform(chunk, _, callback) {
- bytesRead += chunk.length;
+ let bytesRead = 0;
+ const contentLengthEnforcer = new Transform({
+ transform(chunk, _, callback) {
+ bytesRead += chunk.length;
- if (abortSignal.aborted) {
- callback(new Error("AbortError"));
- } else if (bytesRead > serverConfig.maxAssetSizeMb * 1024 * 1024) {
- callback(
- new Error(
- `Content length exceeds maximum allowed size: ${serverConfig.maxAssetSizeMb}MB`,
- ),
- );
- } else {
- callback(null, chunk); // pass data along unchanged
- }
- },
- flush(callback) {
- callback();
- },
- });
+ if (abortSignal.aborted) {
+ callback(new Error("AbortError"));
+ } else if (bytesRead > serverConfig.maxAssetSizeMb * 1024 * 1024) {
+ callback(
+ new Error(
+ `Content length exceeds maximum allowed size: ${serverConfig.maxAssetSizeMb}MB`,
+ ),
+ );
+ } else {
+ callback(null, chunk); // pass data along unchanged
+ }
+ },
+ flush(callback) {
+ callback();
+ },
+ });
- await pipeline(
- response.body,
- contentLengthEnforcer,
- fsSync.createWriteStream(assetPath),
- );
+ await pipeline(
+ response.body,
+ contentLengthEnforcer,
+ fsSync.createWriteStream(assetPath),
+ );
- // Check storage quota before saving the asset
- const { data: quotaApproved, error: quotaError } = await tryCatch(
- QuotaService.checkStorageQuota(db, userId, bytesRead),
- );
+ // Check storage quota before saving the asset
+ const { data: quotaApproved, error: quotaError } = await tryCatch(
+ QuotaService.checkStorageQuota(db, userId, bytesRead),
+ );
- if (quotaError) {
- logger.warn(
- `[Crawler][${jobId}] Skipping ${fileType} storage due to quota exceeded: ${quotaError.message}`,
- );
- return null;
- }
+ if (quotaError) {
+ logger.warn(
+ `[Crawler][${jobId}] Skipping ${fileType} storage due to quota exceeded: ${quotaError.message}`,
+ );
+ return null;
+ }
- await saveAssetFromFile({
- userId,
- assetId,
- metadata: { contentType },
- assetPath,
- quotaApproved,
- });
+ await saveAssetFromFile({
+ userId,
+ assetId,
+ metadata: { contentType },
+ assetPath,
+ quotaApproved,
+ });
- logger.info(
- `[Crawler][${jobId}] Downloaded ${fileType} as assetId: ${assetId} (${bytesRead} bytes)`,
- );
+ logger.info(
+ `[Crawler][${jobId}] Downloaded ${fileType} as assetId: ${assetId} (${bytesRead} bytes)`,
+ );
- return { assetId, userId, contentType, size: bytesRead };
- } catch (e) {
- logger.error(
- `[Crawler][${jobId}] Failed to download and store ${fileType}: ${e}`,
- );
- return null;
- } finally {
- if (assetPath) {
- await tryCatch(fs.unlink(assetPath));
- }
- }
+ return { assetId, userId, contentType, size: bytesRead };
+ } catch (e) {
+ logger.error(
+ `[Crawler][${jobId}] Failed to download and store ${fileType}: ${e}`,
+ );
+ return null;
+ } finally {
+ if (assetPath) {
+ await tryCatch(fs.unlink(assetPath));
+ }
+ }
+ },
+ );
}
async function downloadAndStoreImage(
@@ -843,77 +1066,91 @@ async function archiveWebpage(
jobId: string,
abortSignal: AbortSignal,
) {
- logger.info(`[Crawler][${jobId}] Will attempt to archive page ...`);
- const assetId = newAssetId();
- const assetPath = path.join(os.tmpdir(), assetId);
-
- let res = await execa({
- input: html,
- cancelSignal: abortSignal,
- env: {
- https_proxy: serverConfig.proxy.httpsProxy
- ? getRandomProxy(serverConfig.proxy.httpsProxy)
- : undefined,
- http_proxy: serverConfig.proxy.httpProxy
- ? getRandomProxy(serverConfig.proxy.httpProxy)
- : undefined,
- no_proxy: serverConfig.proxy.noProxy?.join(","),
+ return await withSpan(
+ tracer,
+ "crawlerWorker.archiveWebpage",
+ {
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ "user.id": userId,
+ },
},
- })("monolith", ["-", "-Ije", "-t", "5", "-b", url, "-o", assetPath]);
+ async () => {
+ logger.info(`[Crawler][${jobId}] Will attempt to archive page ...`);
+ const assetId = newAssetId();
+ const assetPath = path.join(os.tmpdir(), assetId);
- if (res.isCanceled) {
- logger.error(
- `[Crawler][${jobId}] Canceled archiving the page as we hit global timeout.`,
- );
- await tryCatch(fs.unlink(assetPath));
- return null;
- }
+ let res = await execa({
+ input: html,
+ cancelSignal: abortSignal,
+ env: {
+ https_proxy: serverConfig.proxy.httpsProxy
+ ? getRandomProxy(serverConfig.proxy.httpsProxy)
+ : undefined,
+ http_proxy: serverConfig.proxy.httpProxy
+ ? getRandomProxy(serverConfig.proxy.httpProxy)
+ : undefined,
+ no_proxy: serverConfig.proxy.noProxy?.join(","),
+ },
+ })("monolith", ["-", "-Ije", "-t", "5", "-b", url, "-o", assetPath]);
- if (res.exitCode !== 0) {
- logger.error(
- `[Crawler][${jobId}] Failed to archive the page as the command exited with code ${res.exitCode}`,
- );
- await tryCatch(fs.unlink(assetPath));
- return null;
- }
+ if (res.isCanceled) {
+ logger.error(
+ `[Crawler][${jobId}] Canceled archiving the page as we hit global timeout.`,
+ );
+ await tryCatch(fs.unlink(assetPath));
+ return null;
+ }
- const contentType = "text/html";
+ if (res.exitCode !== 0) {
+ logger.error(
+ `[Crawler][${jobId}] Failed to archive the page as the command exited with code ${res.exitCode}`,
+ );
+ await tryCatch(fs.unlink(assetPath));
+ return null;
+ }
- // Get file size and check quota before saving
- const stats = await fs.stat(assetPath);
- const fileSize = stats.size;
+ const contentType = "text/html";
- const { data: quotaApproved, error: quotaError } = await tryCatch(
- QuotaService.checkStorageQuota(db, userId, fileSize),
- );
+ // Get file size and check quota before saving
+ const stats = await fs.stat(assetPath);
+ const fileSize = stats.size;
- if (quotaError) {
- logger.warn(
- `[Crawler][${jobId}] Skipping page archive storage due to quota exceeded: ${quotaError.message}`,
- );
- await tryCatch(fs.unlink(assetPath));
- return null;
- }
+ const { data: quotaApproved, error: quotaError } = await tryCatch(
+ QuotaService.checkStorageQuota(db, userId, fileSize),
+ );
- await saveAssetFromFile({
- userId,
- assetId,
- assetPath,
- metadata: {
- contentType,
- },
- quotaApproved,
- });
+ if (quotaError) {
+ logger.warn(
+ `[Crawler][${jobId}] Skipping page archive storage due to quota exceeded: ${quotaError.message}`,
+ );
+ await tryCatch(fs.unlink(assetPath));
+ return null;
+ }
- logger.info(
- `[Crawler][${jobId}] Done archiving the page as assetId: ${assetId}`,
- );
+ await saveAssetFromFile({
+ userId,
+ assetId,
+ assetPath,
+ metadata: {
+ contentType,
+ },
+ quotaApproved,
+ });
- return {
- assetId,
- contentType,
- size: await getAssetSize({ userId, assetId }),
- };
+ logger.info(
+ `[Crawler][${jobId}] Done archiving the page as assetId: ${assetId}`,
+ );
+
+ return {
+ assetId,
+ contentType,
+ size: await getAssetSize({ userId, assetId }),
+ };
+ },
+ );
}
async function getContentType(
@@ -921,26 +1158,45 @@ async function getContentType(
jobId: string,
abortSignal: AbortSignal,
): Promise<string | null> {
- try {
- logger.info(
- `[Crawler][${jobId}] Attempting to determine the content-type for the url ${url}`,
- );
- const response = await fetchWithProxy(url, {
- method: "HEAD",
- signal: AbortSignal.any([AbortSignal.timeout(5000), abortSignal]),
- });
- const rawContentType = response.headers.get("content-type");
- const contentType = normalizeContentType(rawContentType);
- logger.info(
- `[Crawler][${jobId}] Content-type for the url ${url} is "${contentType}"`,
- );
- return contentType;
- } catch (e) {
- logger.error(
- `[Crawler][${jobId}] Failed to determine the content-type for the url ${url}: ${e}`,
- );
- return null;
- }
+ return await withSpan(
+ tracer,
+ "crawlerWorker.getContentType",
+ {
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ },
+ },
+ async () => {
+ try {
+ logger.info(
+ `[Crawler][${jobId}] Attempting to determine the content-type for the url ${url}`,
+ );
+ const response = await fetchWithProxy(url, {
+ method: "GET",
+ signal: AbortSignal.any([AbortSignal.timeout(5000), abortSignal]),
+ });
+ setSpanAttributes({
+ "crawler.getContentType.statusCode": response.status,
+ });
+ const rawContentType = response.headers.get("content-type");
+ const contentType = normalizeContentType(rawContentType);
+ setSpanAttributes({
+ "crawler.contentType": contentType ?? undefined,
+ });
+ logger.info(
+ `[Crawler][${jobId}] Content-type for the url ${url} is "${contentType}"`,
+ );
+ return contentType;
+ } catch (e) {
+ logger.error(
+ `[Crawler][${jobId}] Failed to determine the content-type for the url ${url}: ${e}`,
+ );
+ return null;
+ }
+ },
+ );
}
/**
@@ -959,53 +1215,69 @@ async function handleAsAssetBookmark(
bookmarkId: string,
abortSignal: AbortSignal,
) {
- const downloaded = await downloadAndStoreFile(
- url,
- userId,
- jobId,
- assetType,
- abortSignal,
- );
- if (!downloaded) {
- return;
- }
- const fileName = path.basename(new URL(url).pathname);
- await db.transaction(async (trx) => {
- await updateAsset(
- undefined,
- {
- id: downloaded.assetId,
- bookmarkId,
- userId,
- assetType: AssetTypes.BOOKMARK_ASSET,
- contentType: downloaded.contentType,
- size: downloaded.size,
- fileName,
- },
- trx,
- );
- await trx.insert(bookmarkAssets).values({
- id: bookmarkId,
- assetType,
- assetId: downloaded.assetId,
- content: null,
- fileName,
- sourceUrl: url,
- });
- // Switch the type of the bookmark from LINK to ASSET
- await trx
- .update(bookmarks)
- .set({ type: BookmarkTypes.ASSET })
- .where(eq(bookmarks.id, bookmarkId));
- await trx.delete(bookmarkLinks).where(eq(bookmarkLinks.id, bookmarkId));
- });
- await AssetPreprocessingQueue.enqueue(
+ return await withSpan(
+ tracer,
+ "crawlerWorker.handleAsAssetBookmark",
{
- bookmarkId,
- fixMode: false,
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ "user.id": userId,
+ "bookmark.id": bookmarkId,
+ "asset.type": assetType,
+ },
},
- {
- groupId: userId,
+ async () => {
+ const downloaded = await downloadAndStoreFile(
+ url,
+ userId,
+ jobId,
+ assetType,
+ abortSignal,
+ );
+ if (!downloaded) {
+ return;
+ }
+ const fileName = path.basename(new URL(url).pathname);
+ await db.transaction(async (trx) => {
+ await updateAsset(
+ undefined,
+ {
+ id: downloaded.assetId,
+ bookmarkId,
+ userId,
+ assetType: AssetTypes.BOOKMARK_ASSET,
+ contentType: downloaded.contentType,
+ size: downloaded.size,
+ fileName,
+ },
+ trx,
+ );
+ await trx.insert(bookmarkAssets).values({
+ id: bookmarkId,
+ assetType,
+ assetId: downloaded.assetId,
+ content: null,
+ fileName,
+ sourceUrl: url,
+ });
+ // Switch the type of the bookmark from LINK to ASSET
+ await trx
+ .update(bookmarks)
+ .set({ type: BookmarkTypes.ASSET })
+ .where(eq(bookmarks.id, bookmarkId));
+ await trx.delete(bookmarkLinks).where(eq(bookmarkLinks.id, bookmarkId));
+ });
+ await AssetPreprocessingQueue.enqueue(
+ {
+ bookmarkId,
+ fixMode: false,
+ },
+ {
+ groupId: userId,
+ },
+ );
},
);
}
@@ -1020,60 +1292,75 @@ async function storeHtmlContent(
userId: string,
jobId: string,
): Promise<StoreHtmlResult> {
- if (!htmlContent) {
- return { result: "not_stored" };
- }
+ return await withSpan(
+ tracer,
+ "crawlerWorker.storeHtmlContent",
+ {
+ attributes: {
+ "job.id": jobId,
+ "user.id": userId,
+ "bookmark.content.size": htmlContent
+ ? Buffer.byteLength(htmlContent, "utf8")
+ : 0,
+ },
+ },
+ async () => {
+ if (!htmlContent) {
+ return { result: "not_stored" };
+ }
- const contentSize = Buffer.byteLength(htmlContent, "utf8");
+ const contentSize = Buffer.byteLength(htmlContent, "utf8");
- // Only store in assets if content is >= 50KB
- if (contentSize < serverConfig.crawler.htmlContentSizeThreshold) {
- logger.info(
- `[Crawler][${jobId}] HTML content size (${contentSize} bytes) is below threshold, storing inline`,
- );
- return { result: "store_inline" };
- }
+ // Only store in assets if content is >= 50KB
+ if (contentSize < serverConfig.crawler.htmlContentSizeThreshold) {
+ logger.info(
+ `[Crawler][${jobId}] HTML content size (${contentSize} bytes) is below threshold, storing inline`,
+ );
+ return { result: "store_inline" };
+ }
- const { data: quotaApproved, error: quotaError } = await tryCatch(
- QuotaService.checkStorageQuota(db, userId, contentSize),
- );
- if (quotaError) {
- logger.warn(
- `[Crawler][${jobId}] Skipping HTML content storage due to quota exceeded: ${quotaError.message}`,
- );
- return { result: "not_stored" };
- }
+ const { data: quotaApproved, error: quotaError } = await tryCatch(
+ QuotaService.checkStorageQuota(db, userId, contentSize),
+ );
+ if (quotaError) {
+ logger.warn(
+ `[Crawler][${jobId}] Skipping HTML content storage due to quota exceeded: ${quotaError.message}`,
+ );
+ return { result: "not_stored" };
+ }
- const assetId = newAssetId();
+ const assetId = newAssetId();
- const { error: saveError } = await tryCatch(
- saveAsset({
- userId,
- assetId,
- asset: Buffer.from(htmlContent, "utf8"),
- metadata: {
- contentType: ASSET_TYPES.TEXT_HTML,
- fileName: null,
- },
- quotaApproved,
- }),
- );
- if (saveError) {
- logger.error(
- `[Crawler][${jobId}] Failed to store HTML content as asset: ${saveError}`,
- );
- throw saveError;
- }
+ const { error: saveError } = await tryCatch(
+ saveAsset({
+ userId,
+ assetId,
+ asset: Buffer.from(htmlContent, "utf8"),
+ metadata: {
+ contentType: ASSET_TYPES.TEXT_HTML,
+ fileName: null,
+ },
+ quotaApproved,
+ }),
+ );
+ if (saveError) {
+ logger.error(
+ `[Crawler][${jobId}] Failed to store HTML content as asset: ${saveError}`,
+ );
+ throw saveError;
+ }
- logger.info(
- `[Crawler][${jobId}] Stored large HTML content (${contentSize} bytes) as asset: ${assetId}`,
- );
+ logger.info(
+ `[Crawler][${jobId}] Stored large HTML content (${contentSize} bytes) as asset: ${assetId}`,
+ );
- return {
- result: "stored",
- assetId,
- size: contentSize,
- };
+ return {
+ result: "stored",
+ assetId,
+ size: contentSize,
+ };
+ },
+ );
}
async function crawlAndParseUrl(
@@ -1082,268 +1369,352 @@ async function crawlAndParseUrl(
jobId: string,
bookmarkId: string,
oldScreenshotAssetId: string | undefined,
+ oldPdfAssetId: string | undefined,
oldImageAssetId: string | undefined,
oldFullPageArchiveAssetId: string | undefined,
oldContentAssetId: string | undefined,
precrawledArchiveAssetId: string | undefined,
archiveFullPage: boolean,
+ forceStorePdf: boolean,
abortSignal: AbortSignal,
) {
- let result: {
- htmlContent: string;
- screenshot: Buffer | undefined;
- statusCode: number | null;
- url: string;
- };
-
- if (precrawledArchiveAssetId) {
- logger.info(
- `[Crawler][${jobId}] The page has been precrawled. Will use the precrawled archive instead.`,
- );
- const asset = await readAsset({
- userId,
- assetId: precrawledArchiveAssetId,
- });
- result = {
- htmlContent: asset.asset.toString(),
- screenshot: undefined,
- statusCode: 200,
- url,
- };
- } else {
- result = await crawlPage(jobId, url, userId, abortSignal);
- }
- abortSignal.throwIfAborted();
-
- const { htmlContent, screenshot, statusCode, url: browserUrl } = result;
+ return await withSpan(
+ tracer,
+ "crawlerWorker.crawlAndParseUrl",
+ {
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ "user.id": userId,
+ "bookmark.id": bookmarkId,
+ "crawler.archiveFullPage": archiveFullPage,
+ "crawler.forceStorePdf": forceStorePdf,
+ "crawler.hasPrecrawledArchive": !!precrawledArchiveAssetId,
+ },
+ },
+ async () => {
+ let result: {
+ htmlContent: string;
+ screenshot: Buffer | undefined;
+ pdf: Buffer | undefined;
+ statusCode: number | null;
+ url: string;
+ };
- // Track status code in Prometheus
- if (statusCode !== null) {
- crawlerStatusCodeCounter.labels(statusCode.toString()).inc();
- }
+ if (precrawledArchiveAssetId) {
+ logger.info(
+ `[Crawler][${jobId}] The page has been precrawled. Will use the precrawled archive instead.`,
+ );
+ const asset = await readAsset({
+ userId,
+ assetId: precrawledArchiveAssetId,
+ });
+ result = {
+ htmlContent: asset.asset.toString(),
+ screenshot: undefined,
+ pdf: undefined,
+ statusCode: 200,
+ url,
+ };
+ } else {
+ result = await crawlPage(
+ jobId,
+ url,
+ userId,
+ forceStorePdf,
+ abortSignal,
+ );
+ }
+ abortSignal.throwIfAborted();
- const meta = await Promise.race([
- extractMetadata(htmlContent, browserUrl, jobId),
- abortPromise(abortSignal),
- ]);
- abortSignal.throwIfAborted();
+ const {
+ htmlContent,
+ screenshot,
+ pdf,
+ statusCode,
+ url: browserUrl,
+ } = result;
- let readableContent = await Promise.race([
- extractReadableContent(htmlContent, browserUrl, jobId),
- abortPromise(abortSignal),
- ]);
- abortSignal.throwIfAborted();
+ // Track status code in Prometheus
+ if (statusCode !== null) {
+ crawlerStatusCodeCounter.labels(statusCode.toString()).inc();
+ setSpanAttributes({
+ "crawler.statusCode": statusCode,
+ });
+ }
- const screenshotAssetInfo = await Promise.race([
- storeScreenshot(screenshot, userId, jobId),
- abortPromise(abortSignal),
- ]);
- abortSignal.throwIfAborted();
+ const meta = await Promise.race([
+ extractMetadata(htmlContent, browserUrl, jobId),
+ abortPromise(abortSignal),
+ ]);
+ abortSignal.throwIfAborted();
- const htmlContentAssetInfo = await storeHtmlContent(
- readableContent?.content,
- userId,
- jobId,
- );
- abortSignal.throwIfAborted();
- let imageAssetInfo: DBAssetType | null = null;
- if (meta.image) {
- const downloaded = await downloadAndStoreImage(
- meta.image,
- userId,
- jobId,
- abortSignal,
- );
- if (downloaded) {
- imageAssetInfo = {
- id: downloaded.assetId,
- bookmarkId,
- userId,
- assetType: AssetTypes.LINK_BANNER_IMAGE,
- contentType: downloaded.contentType,
- size: downloaded.size,
+ const parseDate = (date: string | undefined) => {
+ if (!date) {
+ return null;
+ }
+ try {
+ return new Date(date);
+ } catch {
+ return null;
+ }
};
- }
- }
- abortSignal.throwIfAborted();
- const parseDate = (date: string | undefined) => {
- if (!date) {
- return null;
- }
- try {
- return new Date(date);
- } catch {
- return null;
- }
- };
+ // Phase 1: Write metadata immediately for fast user feedback.
+ // Content and asset storage happen later and can be slow (banner
+ // image download, screenshot/pdf upload, etc.).
+ await db
+ .update(bookmarkLinks)
+ .set({
+ title: meta.title,
+ description: meta.description,
+ // Don't store data URIs as they're not valid URLs and are usually quite large
+ imageUrl: meta.image?.startsWith("data:") ? null : meta.image,
+ favicon: meta.logo,
+ crawlStatusCode: statusCode,
+ author: meta.author,
+ publisher: meta.publisher,
+ datePublished: parseDate(meta.datePublished),
+ dateModified: parseDate(meta.dateModified),
+ })
+ .where(eq(bookmarkLinks.id, bookmarkId));
- // TODO(important): Restrict the size of content to store
- const assetDeletionTasks: Promise<void>[] = [];
- const inlineHtmlContent =
- htmlContentAssetInfo.result === "store_inline"
- ? (readableContent?.content ?? null)
- : null;
- readableContent = null;
- await db.transaction(async (txn) => {
- await txn
- .update(bookmarkLinks)
- .set({
- title: meta.title,
- description: meta.description,
- // Don't store data URIs as they're not valid URLs and are usually quite large
- imageUrl: meta.image?.startsWith("data:") ? null : meta.image,
- favicon: meta.logo,
- htmlContent: inlineHtmlContent,
- contentAssetId:
- htmlContentAssetInfo.result === "stored"
- ? htmlContentAssetInfo.assetId
- : null,
- crawledAt: new Date(),
- crawlStatusCode: statusCode,
- author: meta.author,
- publisher: meta.publisher,
- datePublished: parseDate(meta.datePublished),
- dateModified: parseDate(meta.dateModified),
- })
- .where(eq(bookmarkLinks.id, bookmarkId));
+ let readableContent: { content: string } | null = meta.readableContentHtml
+ ? { content: meta.readableContentHtml }
+ : null;
+ if (!readableContent) {
+ readableContent = await Promise.race([
+ extractReadableContent(
+ meta.contentHtml ?? htmlContent,
+ browserUrl,
+ jobId,
+ ),
+ abortPromise(abortSignal),
+ ]);
+ }
+ abortSignal.throwIfAborted();
- if (screenshotAssetInfo) {
- await updateAsset(
- oldScreenshotAssetId,
- {
- id: screenshotAssetInfo.assetId,
- bookmarkId,
- userId,
- assetType: AssetTypes.LINK_SCREENSHOT,
- contentType: screenshotAssetInfo.contentType,
- size: screenshotAssetInfo.size,
- fileName: screenshotAssetInfo.fileName,
- },
- txn,
- );
- assetDeletionTasks.push(silentDeleteAsset(userId, oldScreenshotAssetId));
- }
- if (imageAssetInfo) {
- await updateAsset(oldImageAssetId, imageAssetInfo, txn);
- assetDeletionTasks.push(silentDeleteAsset(userId, oldImageAssetId));
- }
- if (htmlContentAssetInfo.result === "stored") {
- await updateAsset(
- oldContentAssetId,
- {
- id: htmlContentAssetInfo.assetId,
- bookmarkId,
- userId,
- assetType: AssetTypes.LINK_HTML_CONTENT,
- contentType: ASSET_TYPES.TEXT_HTML,
- size: htmlContentAssetInfo.size,
- fileName: null,
- },
- txn,
- );
- assetDeletionTasks.push(silentDeleteAsset(userId, oldContentAssetId));
- } else if (oldContentAssetId) {
- // Unlink the old content asset
- await txn.delete(assets).where(eq(assets.id, oldContentAssetId));
- assetDeletionTasks.push(silentDeleteAsset(userId, oldContentAssetId));
- }
- });
+ const screenshotAssetInfo = await Promise.race([
+ storeScreenshot(screenshot, userId, jobId),
+ abortPromise(abortSignal),
+ ]);
+ abortSignal.throwIfAborted();
- // Delete the old assets if any
- await Promise.all(assetDeletionTasks);
+ const pdfAssetInfo = await Promise.race([
+ storePdf(pdf, userId, jobId),
+ abortPromise(abortSignal),
+ ]);
+ abortSignal.throwIfAborted();
- return async () => {
- if (
- !precrawledArchiveAssetId &&
- (serverConfig.crawler.fullPageArchive || archiveFullPage)
- ) {
- const archiveResult = await archiveWebpage(
- htmlContent,
- browserUrl,
+ const htmlContentAssetInfo = await storeHtmlContent(
+ readableContent?.content,
userId,
jobId,
- abortSignal,
);
+ abortSignal.throwIfAborted();
+ let imageAssetInfo: DBAssetType | null = null;
+ if (meta.image) {
+ const downloaded = await downloadAndStoreImage(
+ meta.image,
+ userId,
+ jobId,
+ abortSignal,
+ );
+ if (downloaded) {
+ imageAssetInfo = {
+ id: downloaded.assetId,
+ bookmarkId,
+ userId,
+ assetType: AssetTypes.LINK_BANNER_IMAGE,
+ contentType: downloaded.contentType,
+ size: downloaded.size,
+ };
+ }
+ }
+ abortSignal.throwIfAborted();
- if (archiveResult) {
- const {
- assetId: fullPageArchiveAssetId,
- size,
- contentType,
- } = archiveResult;
+ // Phase 2: Write content and asset references.
+ // TODO(important): Restrict the size of content to store
+ const assetDeletionTasks: Promise<void>[] = [];
+ const inlineHtmlContent =
+ htmlContentAssetInfo.result === "store_inline"
+ ? (readableContent?.content ?? null)
+ : null;
+ readableContent = null;
+ await db.transaction(async (txn) => {
+ await txn
+ .update(bookmarkLinks)
+ .set({
+ crawledAt: new Date(),
+ htmlContent: inlineHtmlContent,
+ contentAssetId:
+ htmlContentAssetInfo.result === "stored"
+ ? htmlContentAssetInfo.assetId
+ : null,
+ })
+ .where(eq(bookmarkLinks.id, bookmarkId));
- await db.transaction(async (txn) => {
+ if (screenshotAssetInfo) {
await updateAsset(
- oldFullPageArchiveAssetId,
+ oldScreenshotAssetId,
{
- id: fullPageArchiveAssetId,
+ id: screenshotAssetInfo.assetId,
bookmarkId,
userId,
- assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
- contentType,
- size,
+ assetType: AssetTypes.LINK_SCREENSHOT,
+ contentType: screenshotAssetInfo.contentType,
+ size: screenshotAssetInfo.size,
+ fileName: screenshotAssetInfo.fileName,
+ },
+ txn,
+ );
+ assetDeletionTasks.push(
+ silentDeleteAsset(userId, oldScreenshotAssetId),
+ );
+ }
+ if (pdfAssetInfo) {
+ await updateAsset(
+ oldPdfAssetId,
+ {
+ id: pdfAssetInfo.assetId,
+ bookmarkId,
+ userId,
+ assetType: AssetTypes.LINK_PDF,
+ contentType: pdfAssetInfo.contentType,
+ size: pdfAssetInfo.size,
+ fileName: pdfAssetInfo.fileName,
+ },
+ txn,
+ );
+ assetDeletionTasks.push(silentDeleteAsset(userId, oldPdfAssetId));
+ }
+ if (imageAssetInfo) {
+ await updateAsset(oldImageAssetId, imageAssetInfo, txn);
+ assetDeletionTasks.push(silentDeleteAsset(userId, oldImageAssetId));
+ }
+ if (htmlContentAssetInfo.result === "stored") {
+ await updateAsset(
+ oldContentAssetId,
+ {
+ id: htmlContentAssetInfo.assetId,
+ bookmarkId,
+ userId,
+ assetType: AssetTypes.LINK_HTML_CONTENT,
+ contentType: ASSET_TYPES.TEXT_HTML,
+ size: htmlContentAssetInfo.size,
fileName: null,
},
txn,
);
- });
- if (oldFullPageArchiveAssetId) {
- await silentDeleteAsset(userId, oldFullPageArchiveAssetId);
+ assetDeletionTasks.push(silentDeleteAsset(userId, oldContentAssetId));
+ } else if (oldContentAssetId) {
+ // Unlink the old content asset
+ await txn.delete(assets).where(eq(assets.id, oldContentAssetId));
+ assetDeletionTasks.push(silentDeleteAsset(userId, oldContentAssetId));
}
- }
- }
- };
+ });
+
+ // Delete the old assets if any
+ await Promise.all(assetDeletionTasks);
+
+ return async () => {
+ if (
+ !precrawledArchiveAssetId &&
+ (serverConfig.crawler.fullPageArchive || archiveFullPage)
+ ) {
+ const archiveResult = await archiveWebpage(
+ htmlContent,
+ browserUrl,
+ userId,
+ jobId,
+ abortSignal,
+ );
+
+ if (archiveResult) {
+ const {
+ assetId: fullPageArchiveAssetId,
+ size,
+ contentType,
+ } = archiveResult;
+
+ await db.transaction(async (txn) => {
+ await updateAsset(
+ oldFullPageArchiveAssetId,
+ {
+ id: fullPageArchiveAssetId,
+ bookmarkId,
+ userId,
+ assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ contentType,
+ size,
+ fileName: null,
+ },
+ txn,
+ );
+ });
+ if (oldFullPageArchiveAssetId) {
+ await silentDeleteAsset(userId, oldFullPageArchiveAssetId);
+ }
+ }
+ }
+ };
+ },
+ );
}
/**
- * Checks if the domain should be rate limited and reschedules the job if needed.
- * @returns true if the job should continue, false if it was rescheduled
+ * Checks if the domain should be rate limited and throws QueueRetryAfterError if needed.
+ * @throws {QueueRetryAfterError} if the domain is rate limited
*/
-async function checkDomainRateLimit(
- url: string,
- jobId: string,
- jobData: ZCrawlLinkRequest,
- userId: string,
- jobPriority?: number,
-): Promise<boolean> {
- const crawlerDomainRateLimitConfig = serverConfig.crawler.domainRatelimiting;
- if (!crawlerDomainRateLimitConfig) {
- return true;
- }
-
- const rateLimitClient = await getRateLimitClient();
- if (!rateLimitClient) {
- return true;
- }
-
- const hostname = new URL(url).hostname;
- const rateLimitResult = rateLimitClient.checkRateLimit(
+async function checkDomainRateLimit(url: string, jobId: string): Promise<void> {
+ return await withSpan(
+ tracer,
+ "crawlerWorker.checkDomainRateLimit",
{
- name: "domain-ratelimit",
- maxRequests: crawlerDomainRateLimitConfig.maxRequests,
- windowMs: crawlerDomainRateLimitConfig.windowMs,
+ attributes: {
+ "bookmark.url": url,
+ "bookmark.domain": getBookmarkDomain(url),
+ "job.id": jobId,
+ },
},
- hostname,
- );
+ async () => {
+ const crawlerDomainRateLimitConfig =
+ serverConfig.crawler.domainRatelimiting;
+ if (!crawlerDomainRateLimitConfig) {
+ return;
+ }
- if (!rateLimitResult.allowed) {
- const resetInSeconds = rateLimitResult.resetInSeconds;
- // Add jitter to prevent thundering herd: +40% random variation
- const jitterFactor = 1.0 + Math.random() * 0.4; // Random value between 1.0 and 1.4
- const delayMs = Math.floor(resetInSeconds * 1000 * jitterFactor);
- logger.info(
- `[Crawler][${jobId}] Domain "${hostname}" is rate limited. Rescheduling in ${(delayMs / 1000).toFixed(2)} seconds (with jitter).`,
- );
- await LinkCrawlerQueue.enqueue(jobData, {
- priority: jobPriority,
- delayMs,
- groupId: userId,
- });
- return false;
- }
+ const rateLimitClient = await getRateLimitClient();
+ if (!rateLimitClient) {
+ return;
+ }
+
+ const hostname = new URL(url).hostname;
+ const rateLimitResult = rateLimitClient.checkRateLimit(
+ {
+ name: "domain-ratelimit",
+ maxRequests: crawlerDomainRateLimitConfig.maxRequests,
+ windowMs: crawlerDomainRateLimitConfig.windowMs,
+ },
+ hostname,
+ );
- return true;
+ if (!rateLimitResult.allowed) {
+ const resetInSeconds = rateLimitResult.resetInSeconds;
+ // Add jitter to prevent thundering herd: +40% random variation
+ const jitterFactor = 1.0 + Math.random() * 0.4; // Random value between 1.0 and 1.4
+ const delayMs = Math.floor(resetInSeconds * 1000 * jitterFactor);
+ logger.info(
+ `[Crawler][${jobId}] Domain "${hostname}" is rate limited. Will retry in ${(delayMs / 1000).toFixed(2)} seconds (with jitter).`,
+ );
+ throw new QueueRetryAfterError(
+ `Domain "${hostname}" is rate limited`,
+ delayMs,
+ );
+ }
+ },
+ );
}
async function runCrawler(
@@ -1359,28 +1730,21 @@ async function runCrawler(
return { status: "completed" };
}
- const { bookmarkId, archiveFullPage } = request.data;
+ const { bookmarkId, archiveFullPage, storePdf } = request.data;
const {
url,
userId,
+ createdAt,
+ crawledAt,
screenshotAssetId: oldScreenshotAssetId,
+ pdfAssetId: oldPdfAssetId,
imageAssetId: oldImageAssetId,
fullPageArchiveAssetId: oldFullPageArchiveAssetId,
contentAssetId: oldContentAssetId,
precrawledArchiveAssetId,
} = await getBookmarkDetails(bookmarkId);
- const shouldContinue = await checkDomainRateLimit(
- url,
- jobId,
- job.data,
- userId,
- job.priority,
- );
-
- if (!shouldContinue) {
- return { status: "rescheduled" };
- }
+ await checkDomainRateLimit(url, jobId);
logger.info(
`[Crawler][${jobId}] Will crawl "${url}" for link with id "${bookmarkId}"`,
@@ -1421,11 +1785,13 @@ async function runCrawler(
jobId,
bookmarkId,
oldScreenshotAssetId,
+ oldPdfAssetId,
oldImageAssetId,
oldFullPageArchiveAssetId,
oldContentAssetId,
precrawledArchiveAssetId,
archiveFullPage,
+ storePdf ?? false,
job.abortSignal,
);
@@ -1473,5 +1839,13 @@ async function runCrawler(
// Do the archival as a separate last step as it has the potential for failure
await archivalLogic();
}
+
+ // Record the latency from bookmark creation to crawl completion.
+ // Only for first-time, high-priority crawls (excludes recrawls and imports).
+ if (crawledAt === null && job.priority === 0) {
+ const latencySeconds = (Date.now() - createdAt.getTime()) / 1000;
+ bookmarkCrawlLatencyHistogram.observe(latencySeconds);
+ }
+
return { status: "completed" };
}
diff --git a/apps/workers/workers/feedWorker.ts b/apps/workers/workers/feedWorker.ts
index 2a1334a9..eed7ccb1 100644
--- a/apps/workers/workers/feedWorker.ts
+++ b/apps/workers/workers/feedWorker.ts
@@ -4,6 +4,7 @@ import { fetchWithProxy } from "network";
import cron from "node-cron";
import Parser from "rss-parser";
import { buildImpersonatingTRPCClient } from "trpc";
+import { withWorkerTracing } from "workerTracing";
import { z } from "zod";
import type { ZFeedRequestSchema } from "@karakeep/shared-server";
@@ -88,7 +89,7 @@ export class FeedWorker {
const worker = (await getQueueClient())!.createRunner<ZFeedRequestSchema>(
FeedQueue,
{
- run: run,
+ run: withWorkerTracing("feedWorker.run", run),
onComplete: async (job) => {
workerStatsCounter.labels("feed", "completed").inc();
const jobId = job.id;
@@ -155,9 +156,9 @@ async function run(req: DequeuedJob<ZFeedRequestSchema>) {
const response = await fetchWithProxy(feed.url, {
signal: AbortSignal.timeout(5000),
headers: {
- UserAgent:
+ "User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
- Accept: "application/rss+xml",
+ Accept: "application/rss+xml, application/xml;q=0.9, text/xml;q=0.8",
},
});
if (response.status !== 200) {
diff --git a/apps/workers/workers/importWorker.ts b/apps/workers/workers/importWorker.ts
new file mode 100644
index 00000000..e5b5c27e
--- /dev/null
+++ b/apps/workers/workers/importWorker.ts
@@ -0,0 +1,698 @@
+import { TRPCError } from "@trpc/server";
+import {
+ and,
+ count,
+ eq,
+ gt,
+ inArray,
+ isNotNull,
+ isNull,
+ lt,
+ or,
+} from "drizzle-orm";
+import { Counter, Gauge, Histogram } from "prom-client";
+import { buildImpersonatingTRPCClient } from "trpc";
+
+import { db } from "@karakeep/db";
+import {
+ bookmarkLinks,
+ bookmarks,
+ importSessions,
+ importStagingBookmarks,
+} from "@karakeep/db/schema";
+import { LowPriorityCrawlerQueue, OpenAIQueue } from "@karakeep/shared-server";
+import logger, { throttledLogger } from "@karakeep/shared/logger";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+
+import { registry } from "../metrics";
+
+// Prometheus metrics
+const importStagingProcessedCounter = new Counter({
+ name: "karakeep_import_staging_processed_total",
+ help: "Total number of staged items processed",
+ labelNames: ["result"],
+ registers: [registry],
+});
+
+const importStagingStaleResetCounter = new Counter({
+ name: "karakeep_import_staging_stale_reset_total",
+ help: "Total number of stale processing items reset to pending",
+ registers: [registry],
+});
+
+const importStagingInFlightGauge = new Gauge({
+ name: "karakeep_import_staging_in_flight",
+ help: "Current number of in-flight items (processing + recently completed)",
+ registers: [registry],
+});
+
+const importSessionsGauge = new Gauge({
+ name: "karakeep_import_sessions_active",
+ help: "Number of active import sessions by status",
+ labelNames: ["status"],
+ registers: [registry],
+});
+
+const importStagingPendingGauge = new Gauge({
+ name: "karakeep_import_staging_pending_total",
+ help: "Total number of pending items in staging table",
+ registers: [registry],
+});
+
+const importBatchDurationHistogram = new Histogram({
+ name: "karakeep_import_batch_duration_seconds",
+ help: "Time taken to process a batch of staged items",
+ buckets: [0.1, 0.5, 1, 2, 5, 10, 30],
+ registers: [registry],
+});
+
+const backpressureLogger = throttledLogger(60_000);
+
+function sleep(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Extract a safe, user-facing error message from an error.
+ * Avoids leaking internal details like database errors, stack traces, or file paths.
+ */
+function getSafeErrorMessage(error: unknown): string {
+ // TRPCError client errors are designed to be user-facing
+ if (error instanceof TRPCError && error.code !== "INTERNAL_SERVER_ERROR") {
+ return error.message;
+ }
+
+ // Known safe validation errors thrown within the import worker
+ if (error instanceof Error) {
+ const safeMessages = [
+ "URL is required for link bookmarks",
+ "Content is required for text bookmarks",
+ ];
+ if (safeMessages.includes(error.message)) {
+ return error.message;
+ }
+ }
+
+ return "An unexpected error occurred while processing the bookmark";
+}
+
+export class ImportWorker {
+ private running = false;
+ private pollIntervalMs = 5000;
+
+ // Backpressure settings
+ private maxInFlight = 50;
+ private batchSize = 10;
+ private staleThresholdMs = 60 * 60 * 1000; // 1 hour
+
+ async start() {
+ this.running = true;
+ let iterationCount = 0;
+
+ logger.info("[import] Starting import polling worker");
+
+ while (this.running) {
+ try {
+ // Periodically reset stale processing items (every 60 iterations ~= 1 min)
+ if (iterationCount % 60 === 0) {
+ await this.resetStaleProcessingItems();
+ }
+ iterationCount++;
+
+ // Check if any processing items have completed downstream work
+ await this.checkAndCompleteProcessingItems();
+
+ const processed = await this.processBatch();
+ if (processed === 0) {
+ await this.checkAndCompleteIdleSessions();
+ await this.updateGauges();
+ // Nothing to do, wait before polling again
+ await sleep(this.pollIntervalMs);
+ } else {
+ await this.updateGauges();
+ }
+ } catch (error) {
+ logger.error(`[import] Error in polling loop: ${error}`);
+ await sleep(this.pollIntervalMs);
+ }
+ }
+ }
+
+ stop() {
+ logger.info("[import] Stopping import polling worker");
+ this.running = false;
+ }
+
+ private async processBatch(): Promise<number> {
+ const countPendingItems = await this.countPendingItems();
+ importStagingPendingGauge.set(countPendingItems);
+ if (countPendingItems === 0) {
+ // Nothing to do, wait before polling again
+ return 0;
+ }
+
+ // 1. Check backpressure - inflight items + queue sizes
+ const availableCapacity = await this.getAvailableCapacity();
+
+ if (availableCapacity <= 0) {
+ // At capacity, wait before trying again
+ backpressureLogger(
+ "info",
+ `[import] Pending import items: ${countPendingItems}, but current capacity is ${availableCapacity}. Will wait until capacity is available.`,
+ );
+ return 0;
+ }
+
+ logger.debug(
+ `[import] ${countPendingItems} pending items, available capacity: ${availableCapacity}`,
+ );
+
+ // 2. Get candidate IDs with fair scheduling across users
+ const batchLimit = Math.min(this.batchSize, availableCapacity);
+ const candidateIds = await this.getNextBatchFairly(batchLimit);
+
+ if (candidateIds.length === 0) return 0;
+
+ // 3. Atomically claim rows - only rows still pending will be claimed
+ // This prevents race conditions where multiple workers select the same rows
+ const batch = await db
+ .update(importStagingBookmarks)
+ .set({ status: "processing", processingStartedAt: new Date() })
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "pending"),
+ inArray(importStagingBookmarks.id, candidateIds),
+ ),
+ )
+ .returning();
+
+ // If no rows were claimed (another worker got them first), skip processing
+ if (batch.length === 0) return 0;
+
+ const batchTimer = importBatchDurationHistogram.startTimer();
+
+ // 4. Mark session(s) as running (using claimed rows, not candidates)
+ const sessionIds = [...new Set(batch.map((b) => b.importSessionId))];
+ logger.info(
+ `[import] Claimed batch of ${batch.length} items from ${sessionIds.length} session(s): [${sessionIds.join(", ")}]`,
+ );
+ await db
+ .update(importSessions)
+ .set({ status: "running" })
+ .where(
+ and(
+ inArray(importSessions.id, sessionIds),
+ eq(importSessions.status, "pending"),
+ ),
+ );
+
+ // 5. Process in parallel
+ const results = await Promise.allSettled(
+ batch.map((staged) => this.processOneBookmark(staged)),
+ );
+
+ const outcomes: Record<string, number> = {};
+ for (const r of results) {
+ const key = r.status === "fulfilled" ? r.value : "error";
+ outcomes[key] = (outcomes[key] ?? 0) + 1;
+ }
+ logger.debug(
+ `[import] Batch results: ${Object.entries(outcomes)
+ .map(([k, v]) => `${k}=${v}`)
+ .join(", ")}`,
+ );
+
+ // 6. Check if any sessions are now complete
+ await this.checkAndCompleteEmptySessions(sessionIds);
+
+ batchTimer(); // Record batch duration
+
+ return batch.length;
+ }
+
+ private async updateGauges() {
+ // Update active sessions gauge by status
+ const sessions = await db
+ .select({
+ status: importSessions.status,
+ count: count(),
+ })
+ .from(importSessions)
+ .where(
+ inArray(importSessions.status, [
+ "staging",
+ "pending",
+ "running",
+ "paused",
+ ]),
+ )
+ .groupBy(importSessions.status);
+
+ // Reset all status gauges to 0 first
+ for (const status of ["staging", "pending", "running", "paused"]) {
+ importSessionsGauge.set({ status }, 0);
+ }
+
+ // Set actual values
+ for (const s of sessions) {
+ importSessionsGauge.set({ status: s.status }, s.count);
+ }
+ }
+
+ private async checkAndCompleteIdleSessions() {
+ const sessions = await db
+ .select({ id: importSessions.id })
+ .from(importSessions)
+ .where(inArray(importSessions.status, ["pending", "running"]));
+
+ const sessionIds = sessions.map((session) => session.id);
+ if (sessionIds.length === 0) {
+ return;
+ }
+
+ await this.checkAndCompleteEmptySessions(sessionIds);
+ }
+
+ private async countPendingItems(): Promise<number> {
+ const res = await db
+ .select({ count: count() })
+ .from(importStagingBookmarks)
+ .innerJoin(
+ importSessions,
+ eq(importStagingBookmarks.importSessionId, importSessions.id),
+ )
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "pending"),
+ inArray(importSessions.status, ["pending", "running"]),
+ ),
+ );
+ return res[0]?.count ?? 0;
+ }
+
+ private async getNextBatchFairly(limit: number): Promise<string[]> {
+ // Query pending item IDs from active sessions, ordered by:
+ // 1. User's last-served timestamp (fairness)
+ // 2. Staging item creation time (FIFO within user)
+ // Returns only IDs - actual rows will be fetched atomically during claim
+ const results = await db
+ .select({
+ id: importStagingBookmarks.id,
+ })
+ .from(importStagingBookmarks)
+ .innerJoin(
+ importSessions,
+ eq(importStagingBookmarks.importSessionId, importSessions.id),
+ )
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "pending"),
+ inArray(importSessions.status, ["pending", "running"]),
+ ),
+ )
+ .orderBy(importSessions.lastProcessedAt, importStagingBookmarks.createdAt)
+ .limit(limit);
+
+ return results.map((r) => r.id);
+ }
+
+ private async attachBookmarkToLists(
+ caller: Awaited<ReturnType<typeof buildImpersonatingTRPCClient>>,
+ session: typeof importSessions.$inferSelect,
+ staged: typeof importStagingBookmarks.$inferSelect,
+ bookmarkId: string,
+ ): Promise<void> {
+ const listIds = new Set<string>();
+
+ if (session.rootListId) {
+ listIds.add(session.rootListId);
+ }
+
+ if (staged.listIds && staged.listIds.length > 0) {
+ for (const listId of staged.listIds) {
+ listIds.add(listId);
+ }
+ }
+
+ for (const listId of listIds) {
+ try {
+ await caller.lists.addToList({ listId, bookmarkId });
+ } catch (error) {
+ logger.warn(
+ `[import] Failed to add bookmark ${bookmarkId} to list ${listId}: ${error}`,
+ );
+ }
+ }
+ }
+
+ private async processOneBookmark(
+ staged: typeof importStagingBookmarks.$inferSelect,
+ ): Promise<string> {
+ const session = await db.query.importSessions.findFirst({
+ where: eq(importSessions.id, staged.importSessionId),
+ });
+
+ if (!session || session.status === "paused") {
+ // Session paused mid-batch, reset item to pending
+ await db
+ .update(importStagingBookmarks)
+ .set({ status: "pending" })
+ .where(eq(importStagingBookmarks.id, staged.id));
+ return "reset";
+ }
+
+ try {
+ // Use existing tRPC mutation via internal caller
+ // Note: Duplicate detection is handled by createBookmark itself
+ const caller = await buildImpersonatingTRPCClient(session.userId);
+
+ // Build the request based on bookmark type
+ type CreateBookmarkInput = Parameters<
+ typeof caller.bookmarks.createBookmark
+ >[0];
+
+ const baseRequest = {
+ title: staged.title ?? undefined,
+ note: staged.note ?? undefined,
+ createdAt: staged.sourceAddedAt ?? undefined,
+ crawlPriority: "low" as const,
+ };
+
+ let bookmarkRequest: CreateBookmarkInput;
+
+ if (staged.type === "link") {
+ if (!staged.url) {
+ throw new Error("URL is required for link bookmarks");
+ }
+ bookmarkRequest = {
+ ...baseRequest,
+ type: BookmarkTypes.LINK,
+ url: staged.url,
+ };
+ } else if (staged.type === "text") {
+ if (!staged.content) {
+ throw new Error("Content is required for text bookmarks");
+ }
+ bookmarkRequest = {
+ ...baseRequest,
+ type: BookmarkTypes.TEXT,
+ text: staged.content,
+ };
+ } else {
+ // asset type - skip for now as it needs special handling
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ status: "failed",
+ result: "rejected",
+ resultReason: "Asset bookmarks not yet supported",
+ completedAt: new Date(),
+ })
+ .where(eq(importStagingBookmarks.id, staged.id));
+ await this.updateSessionLastProcessedAt(staged.importSessionId);
+ return "unsupported";
+ }
+
+ const result = await caller.bookmarks.createBookmark(bookmarkRequest);
+
+ // Apply tags via existing mutation (for both new and duplicate bookmarks)
+ if (staged.tags && staged.tags.length > 0) {
+ await caller.bookmarks.updateTags({
+ bookmarkId: result.id,
+ attach: staged.tags.map((t) => ({ tagName: t })),
+ detach: [],
+ });
+ }
+
+ // Handle duplicate case (createBookmark returns alreadyExists: true)
+ if (result.alreadyExists) {
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ status: "completed",
+ result: "skipped_duplicate",
+ resultReason: "URL already exists",
+ resultBookmarkId: result.id,
+ completedAt: new Date(),
+ })
+ .where(eq(importStagingBookmarks.id, staged.id));
+
+ importStagingProcessedCounter.inc({ result: "skipped_duplicate" });
+ await this.attachBookmarkToLists(caller, session, staged, result.id);
+ await this.updateSessionLastProcessedAt(staged.importSessionId);
+ return "duplicate";
+ }
+
+ // Mark as accepted but keep in "processing" until crawl/tag is done
+ // The item will be moved to "completed" by checkAndCompleteProcessingItems()
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ result: "accepted",
+ resultBookmarkId: result.id,
+ })
+ .where(eq(importStagingBookmarks.id, staged.id));
+
+ await this.attachBookmarkToLists(caller, session, staged, result.id);
+
+ await this.updateSessionLastProcessedAt(staged.importSessionId);
+ return "accepted";
+ } catch (error) {
+ logger.error(
+ `[import] Error processing staged item ${staged.id}: ${error}`,
+ );
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ status: "failed",
+ result: "rejected",
+ resultReason: getSafeErrorMessage(error),
+ completedAt: new Date(),
+ })
+ .where(eq(importStagingBookmarks.id, staged.id));
+
+ importStagingProcessedCounter.inc({ result: "rejected" });
+ await this.updateSessionLastProcessedAt(staged.importSessionId);
+ return "failed";
+ }
+ }
+
+ private async updateSessionLastProcessedAt(sessionId: string) {
+ await db
+ .update(importSessions)
+ .set({ lastProcessedAt: new Date() })
+ .where(eq(importSessions.id, sessionId));
+ }
+
+ private async checkAndCompleteEmptySessions(sessionIds: string[]) {
+ for (const sessionId of sessionIds) {
+ const remaining = await db
+ .select({ count: count() })
+ .from(importStagingBookmarks)
+ .where(
+ and(
+ eq(importStagingBookmarks.importSessionId, sessionId),
+ inArray(importStagingBookmarks.status, ["pending", "processing"]),
+ ),
+ );
+
+ if (remaining[0]?.count === 0) {
+ logger.info(
+ `[import] Session ${sessionId} completed, all items processed`,
+ );
+ await db
+ .update(importSessions)
+ .set({ status: "completed" })
+ .where(eq(importSessions.id, sessionId));
+ }
+ }
+ }
+
+ /**
+ * Check processing items that have a bookmark created and mark them as completed
+ * once downstream processing (crawling/tagging) is done.
+ */
+ private async checkAndCompleteProcessingItems(): Promise<number> {
+ // Find processing items where:
+ // - A bookmark was created (resultBookmarkId is set)
+ // - Downstream processing is complete (crawl/tag not pending)
+ const completedItems = await db
+ .select({
+ id: importStagingBookmarks.id,
+ importSessionId: importStagingBookmarks.importSessionId,
+ crawlStatus: bookmarkLinks.crawlStatus,
+ taggingStatus: bookmarks.taggingStatus,
+ })
+ .from(importStagingBookmarks)
+ .leftJoin(
+ bookmarks,
+ eq(bookmarks.id, importStagingBookmarks.resultBookmarkId),
+ )
+ .leftJoin(
+ bookmarkLinks,
+ eq(bookmarkLinks.id, importStagingBookmarks.resultBookmarkId),
+ )
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "processing"),
+ isNotNull(importStagingBookmarks.resultBookmarkId),
+ // Crawl is done (not pending) - either success, failure, or null (not a link)
+ or(
+ isNull(bookmarkLinks.crawlStatus),
+ eq(bookmarkLinks.crawlStatus, "success"),
+ eq(bookmarkLinks.crawlStatus, "failure"),
+ ),
+ // Tagging is done (not pending) - either success, failure, or null
+ or(
+ isNull(bookmarks.taggingStatus),
+ eq(bookmarks.taggingStatus, "success"),
+ eq(bookmarks.taggingStatus, "failure"),
+ ),
+ ),
+ );
+
+ if (completedItems.length === 0) {
+ return 0;
+ }
+
+ const succeededItems = completedItems.filter(
+ (i) => i.crawlStatus !== "failure" && i.taggingStatus !== "failure",
+ );
+ const failedItems = completedItems.filter(
+ (i) => i.crawlStatus === "failure" || i.taggingStatus === "failure",
+ );
+
+ logger.debug(
+ `[import] ${completedItems.length} item(s) finished downstream processing (${succeededItems.length} succeeded, ${failedItems.length} failed)`,
+ );
+
+ // Mark succeeded items as completed
+ if (succeededItems.length > 0) {
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ status: "completed",
+ completedAt: new Date(),
+ })
+ .where(
+ inArray(
+ importStagingBookmarks.id,
+ succeededItems.map((i) => i.id),
+ ),
+ );
+
+ importStagingProcessedCounter.inc(
+ { result: "accepted" },
+ succeededItems.length,
+ );
+ }
+
+ // Mark failed items as failed
+ if (failedItems.length > 0) {
+ for (const item of failedItems) {
+ const reason =
+ item.crawlStatus === "failure" ? "Crawl failed" : "Tagging failed";
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ status: "failed",
+ result: "rejected",
+ resultReason: reason,
+ completedAt: new Date(),
+ })
+ .where(eq(importStagingBookmarks.id, item.id));
+ }
+
+ importStagingProcessedCounter.inc(
+ { result: "rejected" },
+ failedItems.length,
+ );
+ }
+
+ // Check if any sessions are now complete
+ const sessionIds = [
+ ...new Set(completedItems.map((i) => i.importSessionId)),
+ ];
+ await this.checkAndCompleteEmptySessions(sessionIds);
+
+ return completedItems.length;
+ }
+
+ /**
+ * Backpressure: Calculate available capacity based on number of items in flight and the health of the import queues.
+ */
+ private async getAvailableCapacity(): Promise<number> {
+ const [processingCount, crawlerQueue, openaiQueue] = await Promise.all([
+ db
+ .select({ count: count() })
+ .from(importStagingBookmarks)
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "processing"),
+ gt(
+ importStagingBookmarks.processingStartedAt,
+ new Date(Date.now() - this.staleThresholdMs),
+ ),
+ ),
+ ),
+ LowPriorityCrawlerQueue.stats(),
+ OpenAIQueue.stats(),
+ ]);
+
+ const crawlerTotal =
+ crawlerQueue.pending + crawlerQueue.running + crawlerQueue.pending_retry;
+ const openaiTotal =
+ openaiQueue.pending + openaiQueue.running + openaiQueue.pending_retry;
+ const processingTotal = processingCount[0]?.count ?? 0;
+
+ const inFlight = Math.max(crawlerTotal, openaiTotal, processingTotal);
+ importStagingInFlightGauge.set(inFlight);
+
+ return this.maxInFlight - inFlight;
+ }
+
+ /**
+ * Reset stale "processing" items back to "pending" so they can be retried.
+ * Called periodically to handle crashed workers or stuck items.
+ *
+ * Only resets items that don't have a resultBookmarkId - those with a bookmark
+ * are waiting for downstream processing (crawl/tag), not stale.
+ */
+ private async resetStaleProcessingItems(): Promise<number> {
+ const staleThreshold = new Date(Date.now() - this.staleThresholdMs);
+
+ const staleItems = await db
+ .select({ id: importStagingBookmarks.id })
+ .from(importStagingBookmarks)
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "processing"),
+ lt(importStagingBookmarks.processingStartedAt, staleThreshold),
+ // Only reset items that haven't created a bookmark yet
+ // Items with a bookmark are waiting for downstream, not stale
+ isNull(importStagingBookmarks.resultBookmarkId),
+ ),
+ );
+
+ if (staleItems.length > 0) {
+ logger.warn(
+ `[import] Resetting ${staleItems.length} stale processing items`,
+ );
+
+ await db
+ .update(importStagingBookmarks)
+ .set({ status: "pending", processingStartedAt: null })
+ .where(
+ inArray(
+ importStagingBookmarks.id,
+ staleItems.map((i) => i.id),
+ ),
+ );
+
+ importStagingStaleResetCounter.inc(staleItems.length);
+ return staleItems.length;
+ }
+
+ return 0;
+ }
+}
diff --git a/apps/workers/workers/inference/inferenceWorker.ts b/apps/workers/workers/inference/inferenceWorker.ts
index eefc1dd8..57ad1a22 100644
--- a/apps/workers/workers/inference/inferenceWorker.ts
+++ b/apps/workers/workers/inference/inferenceWorker.ts
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm";
import { workerStatsCounter } from "metrics";
+import { withWorkerTracing } from "workerTracing";
import type { ZOpenAIRequest } from "@karakeep/shared-server";
import { db } from "@karakeep/db";
@@ -42,7 +43,7 @@ export class OpenAiWorker {
const worker = (await getQueueClient())!.createRunner<ZOpenAIRequest>(
OpenAIQueue,
{
- run: runOpenAI,
+ run: withWorkerTracing("inferenceWorker.run", runOpenAI),
onComplete: async (job) => {
workerStatsCounter.labels("inference", "completed").inc();
const jobId = job.id;
diff --git a/apps/workers/workers/inference/summarize.ts b/apps/workers/workers/inference/summarize.ts
index 23636961..922eb5b7 100644
--- a/apps/workers/workers/inference/summarize.ts
+++ b/apps/workers/workers/inference/summarize.ts
@@ -1,12 +1,17 @@
import { and, eq } from "drizzle-orm";
+import { getBookmarkDomain } from "network";
import { db } from "@karakeep/db";
-import { bookmarks, customPrompts } from "@karakeep/db/schema";
-import { triggerSearchReindex, ZOpenAIRequest } from "@karakeep/shared-server";
+import { bookmarks, customPrompts, users } from "@karakeep/db/schema";
+import {
+ setSpanAttributes,
+ triggerSearchReindex,
+ ZOpenAIRequest,
+} from "@karakeep/shared-server";
import serverConfig from "@karakeep/shared/config";
import { InferenceClient } from "@karakeep/shared/inference";
import logger from "@karakeep/shared/logger";
-import { buildSummaryPrompt } from "@karakeep/shared/prompts";
+import { buildSummaryPrompt } from "@karakeep/shared/prompts.server";
import { DequeuedJob } from "@karakeep/shared/queueing";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import { Bookmark } from "@karakeep/trpc/models/bookmarks";
@@ -22,6 +27,7 @@ async function fetchBookmarkDetailsForSummary(bookmarkId: string) {
description: true,
htmlContent: true,
contentAssetId: true,
+ crawlStatusCode: true,
publisher: true,
author: true,
url: true,
@@ -56,6 +62,33 @@ export async function runSummarization(
const bookmarkData = await fetchBookmarkDetailsForSummary(bookmarkId);
+ // Check user-level preference
+ const userSettings = await db.query.users.findFirst({
+ where: eq(users.id, bookmarkData.userId),
+ columns: {
+ autoSummarizationEnabled: true,
+ inferredTagLang: true,
+ },
+ });
+
+ setSpanAttributes({
+ "user.id": bookmarkData.userId,
+ "bookmark.id": bookmarkData.id,
+ "bookmark.url": bookmarkData.link?.url,
+ "bookmark.domain": getBookmarkDomain(bookmarkData.link?.url),
+ "bookmark.content.type": bookmarkData.type,
+ "crawler.statusCode": bookmarkData.link?.crawlStatusCode ?? undefined,
+ "inference.type": "summarization",
+ "inference.model": serverConfig.inference.textModel,
+ });
+
+ if (userSettings?.autoSummarizationEnabled === false) {
+ logger.debug(
+ `[inference][${jobId}] Skipping summarization job for bookmark with id "${bookmarkId}" because user has disabled auto-summarization.`,
+ );
+ return;
+ }
+
let textToSummarize = "";
if (bookmarkData.type === BookmarkTypes.LINK && bookmarkData.link) {
const link = bookmarkData.link;
@@ -105,13 +138,21 @@ URL: ${link.url ?? ""}
},
});
+ setSpanAttributes({
+ "inference.prompt.customCount": prompts.length,
+ });
+
const summaryPrompt = await buildSummaryPrompt(
- serverConfig.inference.inferredTagLang,
+ userSettings?.inferredTagLang ?? serverConfig.inference.inferredTagLang,
prompts.map((p) => p.text),
textToSummarize,
serverConfig.inference.contextLength,
);
+ setSpanAttributes({
+ "inference.prompt.size": Buffer.byteLength(summaryPrompt, "utf8"),
+ });
+
const summaryResult = await inferenceClient.inferFromText(summaryPrompt, {
schema: null, // Summaries are typically free-form text
abortSignal: job.abortSignal,
@@ -123,6 +164,11 @@ URL: ${link.url ?? ""}
);
}
+ setSpanAttributes({
+ "inference.summary.size": Buffer.byteLength(summaryResult.response, "utf8"),
+ "inference.totalTokens": summaryResult.totalTokens,
+ });
+
logger.info(
`[inference][${jobId}] Generated summary for bookmark "${bookmarkId}" using ${summaryResult.totalTokens} tokens.`,
);
diff --git a/apps/workers/workers/inference/tagging.ts b/apps/workers/workers/inference/tagging.ts
index 5a79fd22..668c1d5e 100644
--- a/apps/workers/workers/inference/tagging.ts
+++ b/apps/workers/workers/inference/tagging.ts
@@ -1,4 +1,5 @@
-import { and, Column, eq, inArray, sql } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
+import { getBookmarkDomain } from "network";
import { buildImpersonatingTRPCClient } from "trpc";
import { z } from "zod";
@@ -7,14 +8,17 @@ import type {
InferenceClient,
InferenceResponse,
} from "@karakeep/shared/inference";
+import type { ZTagStyle } from "@karakeep/shared/types/users";
import { db } from "@karakeep/db";
import {
bookmarks,
bookmarkTags,
customPrompts,
tagsOnBookmarks,
+ users,
} from "@karakeep/db/schema";
import {
+ setSpanAttributes,
triggerRuleEngineOnEvent,
triggerSearchReindex,
triggerWebhook,
@@ -22,7 +26,8 @@ import {
import { ASSET_TYPES, readAsset } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import logger from "@karakeep/shared/logger";
-import { buildImagePrompt, buildTextPrompt } from "@karakeep/shared/prompts";
+import { buildImagePrompt } from "@karakeep/shared/prompts";
+import { buildTextPrompt } from "@karakeep/shared/prompts.server";
import { DequeuedJob, EnqueueOptions } from "@karakeep/shared/queueing";
import { Bookmark } from "@karakeep/trpc/models/bookmarks";
@@ -66,18 +71,21 @@ function parseJsonFromLLMResponse(response: string): unknown {
}
}
-function tagNormalizer(col: Column) {
+function tagNormalizer() {
+ // This function needs to be in sync with the generated normalizedName column in bookmarkTags
function normalizeTag(tag: string) {
return tag.toLowerCase().replace(/[ \-_]/g, "");
}
return {
normalizeTag,
- sql: sql`lower(replace(replace(replace(${col}, ' ', ''), '-', ''), '_', ''))`,
};
}
async function buildPrompt(
bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
+ tagStyle: ZTagStyle,
+ inferredTagLang: string,
+ curatedTags?: string[],
): Promise<string | null> {
const prompts = await fetchCustomPrompts(bookmark.userId, "text");
if (bookmark.link) {
@@ -95,22 +103,26 @@ async function buildPrompt(
return null;
}
return await buildTextPrompt(
- serverConfig.inference.inferredTagLang,
+ inferredTagLang,
prompts,
`URL: ${bookmark.link.url}
Title: ${bookmark.link.title ?? ""}
Description: ${bookmark.link.description ?? ""}
Content: ${content ?? ""}`,
serverConfig.inference.contextLength,
+ tagStyle,
+ curatedTags,
);
}
if (bookmark.text) {
return await buildTextPrompt(
- serverConfig.inference.inferredTagLang,
+ inferredTagLang,
prompts,
bookmark.text.text ?? "",
serverConfig.inference.contextLength,
+ tagStyle,
+ curatedTags,
);
}
@@ -122,6 +134,9 @@ async function inferTagsFromImage(
bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
inferenceClient: InferenceClient,
abortSignal: AbortSignal,
+ tagStyle: ZTagStyle,
+ inferredTagLang: string,
+ curatedTags?: string[],
): Promise<InferenceResponse | null> {
const { asset, metadata } = await readAsset({
userId: bookmark.userId,
@@ -141,10 +156,15 @@ async function inferTagsFromImage(
}
const base64 = asset.toString("base64");
+ setSpanAttributes({
+ "inference.model": serverConfig.inference.imageModel,
+ });
return inferenceClient.inferFromImage(
buildImagePrompt(
- serverConfig.inference.inferredTagLang,
+ inferredTagLang,
await fetchCustomPrompts(bookmark.userId, "images"),
+ tagStyle,
+ curatedTags,
),
metadata.contentType,
base64,
@@ -166,6 +186,10 @@ async function fetchCustomPrompts(
},
});
+ setSpanAttributes({
+ "inference.prompt.customCount": prompts.length,
+ });
+
let promptTexts = prompts.map((p) => p.text);
if (containsTagsPlaceholder(prompts)) {
promptTexts = await replaceTagsPlaceholders(promptTexts, userId);
@@ -214,13 +238,24 @@ async function inferTagsFromPDF(
bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
inferenceClient: InferenceClient,
abortSignal: AbortSignal,
+ tagStyle: ZTagStyle,
+ inferredTagLang: string,
+ curatedTags?: string[],
) {
const prompt = await buildTextPrompt(
- serverConfig.inference.inferredTagLang,
+ inferredTagLang,
await fetchCustomPrompts(bookmark.userId, "text"),
`Content: ${bookmark.asset.content}`,
serverConfig.inference.contextLength,
+ tagStyle,
+ curatedTags,
);
+ setSpanAttributes({
+ "inference.model": serverConfig.inference.textModel,
+ });
+ setSpanAttributes({
+ "inference.prompt.size": Buffer.byteLength(prompt, "utf8"),
+ });
return inferenceClient.inferFromText(prompt, {
schema: openAIResponseSchema,
abortSignal,
@@ -231,11 +266,25 @@ async function inferTagsFromText(
bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
inferenceClient: InferenceClient,
abortSignal: AbortSignal,
+ tagStyle: ZTagStyle,
+ inferredTagLang: string,
+ curatedTags?: string[],
) {
- const prompt = await buildPrompt(bookmark);
+ const prompt = await buildPrompt(
+ bookmark,
+ tagStyle,
+ inferredTagLang,
+ curatedTags,
+ );
if (!prompt) {
return null;
}
+ setSpanAttributes({
+ "inference.model": serverConfig.inference.textModel,
+ });
+ setSpanAttributes({
+ "inference.prompt.size": Buffer.byteLength(prompt, "utf8"),
+ });
return await inferenceClient.inferFromText(prompt, {
schema: openAIResponseSchema,
abortSignal,
@@ -247,10 +296,32 @@ async function inferTags(
bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>,
inferenceClient: InferenceClient,
abortSignal: AbortSignal,
+ tagStyle: ZTagStyle,
+ inferredTagLang: string,
+ curatedTags?: string[],
) {
+ setSpanAttributes({
+ "user.id": bookmark.userId,
+ "bookmark.id": bookmark.id,
+ "bookmark.url": bookmark.link?.url,
+ "bookmark.domain": getBookmarkDomain(bookmark.link?.url),
+ "bookmark.content.type": bookmark.type,
+ "crawler.statusCode": bookmark.link?.crawlStatusCode ?? undefined,
+ "inference.tagging.style": tagStyle,
+ "inference.lang": inferredTagLang,
+ "inference.type": "tagging",
+ });
+
let response: InferenceResponse | null;
if (bookmark.link || bookmark.text) {
- response = await inferTagsFromText(bookmark, inferenceClient, abortSignal);
+ response = await inferTagsFromText(
+ bookmark,
+ inferenceClient,
+ abortSignal,
+ tagStyle,
+ inferredTagLang,
+ curatedTags,
+ );
} else if (bookmark.asset) {
switch (bookmark.asset.assetType) {
case "image":
@@ -259,6 +330,9 @@ async function inferTags(
bookmark,
inferenceClient,
abortSignal,
+ tagStyle,
+ inferredTagLang,
+ curatedTags,
);
break;
case "pdf":
@@ -267,6 +341,9 @@ async function inferTags(
bookmark,
inferenceClient,
abortSignal,
+ tagStyle,
+ inferredTagLang,
+ curatedTags,
);
break;
default:
@@ -298,6 +375,10 @@ async function inferTags(
}
return tag.trim();
});
+ setSpanAttributes({
+ "inference.tagging.numGeneratedTags": tags.length,
+ "inference.totalTokens": response.totalTokens,
+ });
return tags;
} catch (e) {
@@ -317,12 +398,10 @@ async function connectTags(
return;
}
- await db.transaction(async (tx) => {
+ const res = await db.transaction(async (tx) => {
// Attempt to match exiting tags with the new ones
const { matchedTagIds, notFoundTagNames } = await (async () => {
- const { normalizeTag, sql: normalizedTagSql } = tagNormalizer(
- bookmarkTags.name,
- );
+ const { normalizeTag } = tagNormalizer();
const normalizedInferredTags = inferredTags.map((t) => ({
originalTag: t,
normalizedTag: normalizeTag(t),
@@ -332,7 +411,7 @@ async function connectTags(
where: and(
eq(bookmarkTags.userId, userId),
inArray(
- normalizedTagSql,
+ bookmarkTags.normalizedName,
normalizedInferredTags.map((t) => t.normalizedTag),
),
),
@@ -394,17 +473,19 @@ async function connectTags(
.onConflictDoNothing()
.returning();
- await triggerRuleEngineOnEvent(bookmarkId, [
- ...detachedTags.map((t) => ({
- type: "tagRemoved" as const,
- tagId: t.tagId,
- })),
- ...attachedTags.map((t) => ({
- type: "tagAdded" as const,
- tagId: t.tagId,
- })),
- ]);
+ return { detachedTags, attachedTags };
});
+
+ await triggerRuleEngineOnEvent(bookmarkId, [
+ ...res.detachedTags.map((t) => ({
+ type: "tagRemoved" as const,
+ tagId: t.tagId,
+ })),
+ ...res.attachedTags.map((t) => ({
+ type: "tagAdded" as const,
+ tagId: t.tagId,
+ })),
+ ]);
}
async function fetchBookmark(linkId: string) {
@@ -437,6 +518,37 @@ export async function runTagging(
);
}
+ // Check user-level preference
+ const userSettings = await db.query.users.findFirst({
+ where: eq(users.id, bookmark.userId),
+ columns: {
+ autoTaggingEnabled: true,
+ tagStyle: true,
+ curatedTagIds: true,
+ inferredTagLang: true,
+ },
+ });
+
+ if (userSettings?.autoTaggingEnabled === false) {
+ logger.debug(
+ `[inference][${jobId}] Skipping tagging job for bookmark with id "${bookmarkId}" because user has disabled auto-tagging.`,
+ );
+ return;
+ }
+
+ // Resolve curated tag names if configured
+ let curatedTagNames: string[] | undefined;
+ if (userSettings?.curatedTagIds && userSettings.curatedTagIds.length > 0) {
+ const tags = await db.query.bookmarkTags.findMany({
+ where: and(
+ eq(bookmarkTags.userId, bookmark.userId),
+ inArray(bookmarkTags.id, userSettings.curatedTagIds),
+ ),
+ columns: { name: true },
+ });
+ curatedTagNames = tags.map((t) => t.name);
+ }
+
logger.info(
`[inference][${jobId}] Starting an inference job for bookmark with id "${bookmark.id}"`,
);
@@ -446,6 +558,9 @@ export async function runTagging(
bookmark,
inferenceClient,
job.abortSignal,
+ userSettings?.tagStyle ?? "as-generated",
+ userSettings?.inferredTagLang ?? serverConfig.inference.inferredTagLang,
+ curatedTagNames,
);
if (tags === null) {
diff --git a/apps/workers/workers/ruleEngineWorker.ts b/apps/workers/workers/ruleEngineWorker.ts
index 98a9de74..ecf733cd 100644
--- a/apps/workers/workers/ruleEngineWorker.ts
+++ b/apps/workers/workers/ruleEngineWorker.ts
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { workerStatsCounter } from "metrics";
import { buildImpersonatingAuthedContext } from "trpc";
+import { withWorkerTracing } from "workerTracing";
import type { ZRuleEngineRequest } from "@karakeep/shared-server";
import { db } from "@karakeep/db";
@@ -20,7 +21,7 @@ export class RuleEngineWorker {
const worker = (await getQueueClient())!.createRunner<ZRuleEngineRequest>(
RuleEngineQueue,
{
- run: runRuleEngine,
+ run: withWorkerTracing("ruleEngineWorker.run", runRuleEngine),
onComplete: (job) => {
workerStatsCounter.labels("ruleEngine", "completed").inc();
const jobId = job.id;
@@ -66,14 +67,21 @@ async function runRuleEngine(job: DequeuedJob<ZRuleEngineRequest>) {
const bookmark = await getBookmarkUserId(bookmarkId);
if (!bookmark) {
- throw new Error(
- `[ruleEngine][${jobId}] bookmark with id ${bookmarkId} was not found`,
+ logger.info(
+ `[ruleEngine][${jobId}] bookmark with id ${bookmarkId} was not found, skipping`,
);
+ return;
}
const userId = bookmark.userId;
const authedCtx = await buildImpersonatingAuthedContext(userId);
const ruleEngine = await RuleEngine.forBookmark(authedCtx, bookmarkId);
+ if (!ruleEngine) {
+ logger.info(
+ `[ruleEngine][${jobId}] bookmark with id ${bookmarkId} was not found during rule evaluation, skipping`,
+ );
+ return;
+ }
const results = (
await Promise.all(events.map((event) => ruleEngine.onEvent(event)))
diff --git a/apps/workers/workers/searchWorker.ts b/apps/workers/workers/searchWorker.ts
index fed30c9b..b0608dce 100644
--- a/apps/workers/workers/searchWorker.ts
+++ b/apps/workers/workers/searchWorker.ts
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm";
import { workerStatsCounter } from "metrics";
+import { withWorkerTracing } from "workerTracing";
import type { ZSearchIndexingRequest } from "@karakeep/shared-server";
import { db } from "@karakeep/db";
@@ -25,7 +26,7 @@ export class SearchIndexingWorker {
(await getQueueClient())!.createRunner<ZSearchIndexingRequest>(
SearchIndexingQueue,
{
- run: runSearchIndexing,
+ run: withWorkerTracing("searchWorker.run", runSearchIndexing),
onComplete: (job) => {
workerStatsCounter.labels("search", "completed").inc();
const jobId = job.id;
@@ -55,7 +56,11 @@ export class SearchIndexingWorker {
}
}
-async function runIndex(searchClient: SearchIndexClient, bookmarkId: string) {
+async function runIndex(
+ searchClient: SearchIndexClient,
+ bookmarkId: string,
+ batch: boolean,
+) {
const bookmark = await db.query.bookmarks.findFirst({
where: eq(bookmarks.id, bookmarkId),
with: {
@@ -106,11 +111,15 @@ async function runIndex(searchClient: SearchIndexClient, bookmarkId: string) {
tags: bookmark.tagsOnBookmarks.map((t) => t.tag.name),
};
- await searchClient.addDocuments([document]);
+ await searchClient.addDocuments([document], { batch });
}
-async function runDelete(searchClient: SearchIndexClient, bookmarkId: string) {
- await searchClient.deleteDocuments([bookmarkId]);
+async function runDelete(
+ searchClient: SearchIndexClient,
+ bookmarkId: string,
+ batch: boolean,
+) {
+ await searchClient.deleteDocuments([bookmarkId], { batch });
}
async function runSearchIndexing(job: DequeuedJob<ZSearchIndexingRequest>) {
@@ -132,17 +141,20 @@ async function runSearchIndexing(job: DequeuedJob<ZSearchIndexingRequest>) {
}
const bookmarkId = request.data.bookmarkId;
+ // Disable batching on retries (runNumber > 0) for improved reliability
+ const batch = job.runNumber === 0;
+
logger.info(
- `[search][${jobId}] Attempting to index bookmark with id ${bookmarkId} ...`,
+ `[search][${jobId}] Attempting to index bookmark with id ${bookmarkId} (run ${job.runNumber}, batch=${batch}) ...`,
);
switch (request.data.type) {
case "index": {
- await runIndex(searchClient, bookmarkId);
+ await runIndex(searchClient, bookmarkId, batch);
break;
}
case "delete": {
- await runDelete(searchClient, bookmarkId);
+ await runDelete(searchClient, bookmarkId, batch);
break;
}
}
diff --git a/apps/workers/workers/videoWorker.ts b/apps/workers/workers/videoWorker.ts
index 03525fdf..1ffbf674 100644
--- a/apps/workers/workers/videoWorker.ts
+++ b/apps/workers/workers/videoWorker.ts
@@ -4,6 +4,7 @@ import path from "path";
import { execa } from "execa";
import { workerStatsCounter } from "metrics";
import { getProxyAgent, validateUrl } from "network";
+import { withWorkerTracing } from "workerTracing";
import { db } from "@karakeep/db";
import { AssetTypes } from "@karakeep/db/schema";
@@ -35,7 +36,7 @@ export class VideoWorker {
return (await getQueueClient())!.createRunner<ZVideoRequest>(
VideoWorkerQueue,
{
- run: runWorker,
+ run: withWorkerTracing("videoWorker.run", runWorker),
onComplete: async (job) => {
workerStatsCounter.labels("video", "completed").inc();
const jobId = job.id;
diff --git a/apps/workers/workers/webhookWorker.ts b/apps/workers/workers/webhookWorker.ts
index 0d661372..875a0ac6 100644
--- a/apps/workers/workers/webhookWorker.ts
+++ b/apps/workers/workers/webhookWorker.ts
@@ -1,6 +1,7 @@
import { eq } from "drizzle-orm";
import { workerStatsCounter } from "metrics";
import { fetchWithProxy } from "network";
+import { withWorkerTracing } from "workerTracing";
import { db } from "@karakeep/db";
import { bookmarks, webhooksTable } from "@karakeep/db/schema";
@@ -19,7 +20,7 @@ export class WebhookWorker {
const worker = (await getQueueClient())!.createRunner<ZWebhookRequest>(
WebhookQueue,
{
- run: runWebhook,
+ run: withWorkerTracing("webhookWorker.run", runWebhook),
onComplete: async (job) => {
workerStatsCounter.labels("webhook", "completed").inc();
const jobId = job.id;